Przejdź do treści

FAPI001 endpoint-metadata

Co robi

Zgłasza operację ścieżki FastAPI (@router.get(...), @app.post(...), @router.api_route(...)), której wpis OpenAPI nie ma metadanych wymaganych przez projekt. Jedna diagnostyka na endpoint, na jego dekoratorze, wymienia wszystko, czego brakuje. Domyślnie reguła wymaga:

  • podsumowania: summary= albo opisu, czyli description= lub docstringu (FastAPI czyta docstring, gdy nie ma description=, a podsumowanie wraca wtedy do nazwy funkcji);
  • modelu odpowiedzi: response_model= albo adnotacji typu zwracanego, z której FastAPI zbuduje schemat, czym nie są Response i jego podklasy. Trasa ze status_code=204 jest zwolniona;
  • jawnego status_code= dla POST i DELETE, bo 200 rzadko pasuje do tworzenia albo usuwania;
  • pola description w każdym wpisie responses=.

Można też wymagać tagów i operation_id (zobacz Konfigurację). Trasy z include_in_schema=False, w dekoratorze albo w ich APIRouter(...), nie mają wpisu OpenAPI i są pomijane.

Dlaczego to źle

Schemat OpenAPI to kontrakt, z którego powstają frontend i każdy wygenerowany klient, a FastAPI wypełnia go tylko tym, co kod deklaruje. Endpoint bez podsumowania pokazuje nazwę funkcji, bez modelu odpowiedzi pusty schemat, a tworzenie zasobu, które odpowiada 200, nic klientowi nie mówi. Agenci dodają endpointy szybko i rzadko wracają po metadane.

Przykład

Z trzema warstwami z INW001 włącz FAPI001:

pyproject.toml
[tool.inwards]
layers = [
  { name = "domain", modules = ["shop.domain"] },
  { name = "application", modules = ["shop.application"] },
  { name = "infrastructure", modules = ["shop.infrastructure"] },
]

[tool.inwards.rules]
extend-select = ["FAPI001"]

Agent dodaje endpoint, który tworzy zamówienie:

shop/infrastructure/orders.py
from fastapi import APIRouter

router = APIRouter(prefix="/orders", tags=["orders"])


@router.post("/")
async def create_order(body: OrderIn):
    ...
inwards check
shop/infrastructure/orders.py:6:1: FAPI001 `create_order` (POST /) has no summary, explicit status code or response model in its OpenAPI metadata.
  fix: Declare the missing OpenAPI metadata on `@router.post("/")`.
    1. Add `summary="..."` (or a docstring) and `status_code=201` to `@router.post("/")`, and a return annotation or `response_model=...`.
    2. Write a summary that says what the endpoint does, not the function name; declare the model the endpoint really returns.
    3. If the project doesn't want this metadata, ask the user to change [tool.inwards.rules.endpoint-metadata]; don't edit [tool.inwards] yourself.
  docs: https://sircypkowskyy.github.io/inwards/rules/FAPI001/

Poprawiony: endpoint mówi, co robi, co zwraca i jakim statusem odpowiada.

shop/infrastructure/orders.py
from fastapi import APIRouter
from pydantic import BaseModel

router = APIRouter(prefix="/orders", tags=["orders"])


class OrderIn(BaseModel):
    product_id: int


class OrderOut(BaseModel):
    id: int


@router.post("/", status_code=201, summary="Place an order")
async def create_order(body: OrderIn) -> OrderOut:
    return OrderOut(id=1)
inwards check

Jak naprawić

  1. Dodaj do dekoratora to, co wymienia diagnostyka: summary="..." albo docstring, status_code=, "description" w każdym wpisie responses=.
  2. Zadeklaruj model, który endpoint zwraca, jako adnotację typu zwracanego albo response_model=. Bezpośrednio zwracany Response nie daje FastAPI niczego do udokumentowania.
  3. Jeśli projekt nie chce któregoś sprawdzenia, wyłącz je w [tool.inwards.rules.endpoint-metadata] zamiast dopisywać zastępcze metadane.

Bezpieczeństwo poprawki

Bez automatycznej poprawki (autofix: false). Podsumowanie i kod statusu to decyzje o API, a nie tekst do wygenerowania.

Konfiguracja

Opt-in: reguła zgłasza tylko wtedy, gdy extend-select albo select wymienia FAPI001. Jej opcje z wartościami domyślnymi:

pyproject.toml
[tool.inwards.rules]
extend-select = ["FAPI001"]

[tool.inwards.rules.endpoint-metadata]
require-summary = "summary-or-docstring"  # or "summary" (summary= itself), or false
require-response-model = true             # response_model= or a usable return annotation
require-status-code = ["post", "delete"]  # methods that need an explicit status_code=
require-response-fields = ["description"] # keys every responses= entry needs: description, model, content
require-tags = false                      # tags= on the route, its router, or an include_router above it
require-operation-id = false              # an explicit operation_id=, for generated clients

false, a dla listy [], wyłącza sprawdzenie. Nieznany klucz albo zła wartość to błąd konfiguracji, który podaje klucz. Jak każda reguła, przyjmuje też modules, a jak każda reguła treści także check-tests, które sprawia, że zgłasza też w modułach testowych (tabele opcji). Diagnostykę można wyciszyć w linii dekoratora przez # inwards: ignore[FAPI001] reason="...".

Znane ograniczenia

  • Dekorator z **kwargs jest pomijany: metadane mogą być w środku.
  • require-tags liczy tagi podane w include_router(...) w innym pliku; dołączenie, którego Inwards nie umie rozwiązać (na przykład router w pętli), ucisza sprawdzenie.
  • Reguła sprawdza, czy podsumowanie istnieje, a nie czy jest dobre.
  • include_in_schema=False na include_router(...) gdzie indziej jest brane pod uwagę tylko przez dołączenia, które Inwards umie rozwiązać.
  • Trasy dodawane w czasie działania (add_api_route, pętle po konfiguracji) nie są widoczne.
  • Aplikacja, która nie serwuje schematu (FastAPI(openapi_url=None)), jest pomijana razem ze wszystkimi swoimi trasami, jak przy include_in_schema=False. Tak samo router, który każde dołączenie śledzone przez graf umieszcza pod taką aplikacją, nawet gdy innego dołączenia w projekcie nie da się prześledzić: back office Polara. openapi_url=None wyłącza tylko serwowanie schematu przez FastAPI: app.openapi() i fastapi.openapi.utils.get_openapi nadal budują go ze wszystkich tras z include_in_schema ustawionym na prawdę (applications.py, setup() i openapi()). Dlatego aplikacja, która dodaje własną trasę o ścieżce kończącej się na openapi.json (app.add_api_route(f"/{version}/openapi.json", ...), @app.get("/openapi.json")), liczy się jako serwująca schemat, tak jak API Polara (#391). Taką trasę można dodać na parametrze funkcji, do której trafia aplikacja.
  • Które routery się liczą (#358). Trasa liczy się na nazwie powiązanej z FastAPI(...) albo APIRouter(...) z FastAPI, a gdy plik wywołuje na tej nazwie dekorator trasy, include_router albo inną metodę routera, także z własną klasą albo fabryką routera, którą Inwards rozwiązuje przez importy projektu: podklasą klasy FastAPI; NAZWA = fabryka(...) na poziomie modułu, gdy własna fabryka klas mówi albo pokazuje, że zwraca klasę routera (-> type[APIRouter] albo podklasa, którą definiuje i zwraca, jak get_api_router_class w Polarze); dowolnym APIRouter = <wywołanie> albo FastAPI = <wywołanie> na poziomie modułu; własną funkcją, która zwraca router, jeden krok w głąb; oraz nazwą zwracaną przez funkcję z adnotacją -> APIRouter (fabryki make_*_router() w fastapi-clean-example). Samo wywołanie czegoś, co nazywa się APIRouter, się nie liczy: projekty OpenStack tak nazywają swoje routery WSGI. Argumenty fabryki nie są słowami kluczowymi routera, więc prefiks, tagi i responses= routera z fabryki są nieznane, a jego trasy liczą się jako widoczne w schemacie. Plik, który nie zawiera żadnego słowa z filtra tekstowego (fastapi, APIRouter, include_router...), nie jest czytany.
  • Klasy tras (#392). FastAPI buduje każdą trasę klasą route_class jej routera i buduje ją ponownie, z połączonymi tagami, przy każdym include_router (routing.py, add_api_route i include_router). Klasa tras pochodzi z route_class= albo z własnej klasy routera, która ją ustawia (kwargs["route_class"] = route_class w fabryce klas, jak get_api_router_class Polara). Gdy jeden __init__ wśród jej własnych klas ustawia self.include_in_schema na podstawie tagów, Inwards wykonuje ten kod dla każdej trasy: łańcuch if/elif/else po <tag> in self.tags (albo lokalnej nazwie z nim związanej) i self.include_in_schema, przypisujący True albo False. Tagi to napisy albo elementy własnych enumów przechowujące napis (APITag.private Polara, StrEnum), z dekoratora, routera i każdego include_router nad nim. Trasa jest pomijana, gdy klasa ją wyklucza, a także gdy odpowiedź jest nieznana: wartość inna niż literał (settings.is_development() Polara dla tras prywatnych), warunek albo instrukcja, której Inwards nie umie odczytać, tag, którego nie umie odczytać, albo klasa tras, która zapisuje self.tags. Klasa tras, która nie dotyka schematu, niczego nie zmienia.
  • Klasy tras. Własna klasa route_class=, która zmienia schemat, nie jest czytana: klasa Polara trzyma trasy z tagiem APITag.private poza schematem produkcyjnym, a FAPI001 nadal je zgłasza (#392).
  • Zmierzona na korpusie (#182, rozdział 6): 22 zgłoszenia przed regułą o openapi_url, 21 po niej, wszystkie obejrzane: 19 trafnych (POST albo DELETE bez jawnego kodu statusu, trasa bez podsumowania) i 2 na małych aplikacjach, które budują testy Polara; tych nie zgłasza, odkąd moduły testowe są pomijane (#362). Od #358 reguła widzi też 526 operacji ścieżki Polara poza testami i 15 z fastapi-clean-example. Wszystkie 11 zgłoszeń w fastapi-clean-example dotyczyło tras z description=getdoc(...), które teraz liczy się jak docstring, więc nie zostało żadne. 131 zgłoszeń w Polarze, wszystkie sklasyfikowane: 96 na trasach z tagiem APITag.private (#392) i 35 na trasach publicznych, każde przeczytane: 25 trafnych (POST albo DELETE bez jawnego kodu statusu, wpis responses= bez opisu, trasa bez podsumowania) i 10 fałszywych alarmów (4 eksporty CSV i 6 endpointów OAuth2, których treść odpowiedzi definiują RFC). Odkąd #391 śledzi add_versioned_routers(app, api_router) Polara, jego trasy API leżą pod FastAPI(openapi_url=None) z create_app() i bez opisanej wyżej trasy openapi.json wszystkie byłyby pomijane (0 zgłoszeń); z nią zgłaszanych jest tych samych 131. Odkąd #392 czyta klasę tras Polara, znika 101 zgłoszeń na trasach, które ta klasa wyklucza ze schematu produkcyjnego (prywatnych albo prywatnych w dekoratorze pod publicznym routerem): zostaje 30 zgłoszeń, 20 trafnych i tych samych 10 fałszywych (4 eksporty CSV, 6 endpointów OAuth2), 67 % wobec 19 % wcześniej.

Źródła