Skip to content

FAPI001 endpoint-metadata

What it does

Reports a FastAPI path operation (@router.get(...), @app.post(...), @router.api_route(...)) whose OpenAPI entry lacks metadata the project requires. One finding per endpoint, on its decorator, lists everything missing. By default it requires:

  • a summary: summary=, or a description, description= or a docstring (FastAPI reads the docstring when description= is absent, and the summary then falls back to the function name);
  • a response model: response_model=, or a return annotation FastAPI can build a schema from, which Response and its subclasses aren't. A status_code=204 route is exempt;
  • an explicit status_code= on POST and DELETE, since 200 is rarely right for a create or a delete;
  • a description in every entry of responses=.

Tags and operation_id can be required too (see Configuration). Routes with include_in_schema=False, on the decorator or on their APIRouter(...), have no OpenAPI entry and are skipped.

Why is this bad

The OpenAPI schema is the contract a frontend and every generated client are built from, and FastAPI fills it only from what the code declares. An endpoint without a summary shows its function name, one without a response model shows an empty schema, and a create that answers 200 tells the client nothing happened. Agents add endpoints fast and rarely come back for the metadata.

Example

With the three layers from INW001, turn FAPI001 on:

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"]

An agent adds an endpoint that creates an order:

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/

Fixed: the endpoint says what it does, what it returns and which status it answers with.

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

How to fix

  1. Add what the finding lists to the decorator: summary="..." or a docstring, status_code=, "description" in each responses= entry.
  2. Declare the model the endpoint returns, as a return annotation or response_model=. Returning a Response directly gives FastAPI nothing to document.
  3. If the project doesn't want a check, turn it off in [tool.inwards.rules.endpoint-metadata] rather than adding placeholder metadata.

Fix safety

No automatic fix (autofix: false). A summary and a status code are decisions about the API, not text to generate.

Configuration

Opt-in: it reports only when extend-select or select lists FAPI001. Its options, with their defaults:

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, or [] for a list, turns a check off. An unknown key or a wrong value is a config error that names the key. Like every rule, it also takes modules, and like every content rule check-tests, which makes it report in test modules too (options tables). A finding can be suppressed on the decorator's line with # inwards: ignore[FAPI001] reason="...".

Known limitations

  • A decorator with **kwargs is skipped: the metadata may be in there.
  • require-tags counts tags given on an include_router(...) in another file; an inclusion Inwards can't resolve (a router in a loop, say) makes it stay quiet.
  • It checks that a summary exists, not that it is good.
  • A router's include_in_schema=False on an include_router(...) elsewhere is followed only through inclusions Inwards can resolve.
  • Routes added at runtime (add_api_route, loops over config) aren't seen.
  • An app that serves no schema (FastAPI(openapi_url=None)) is skipped with every route it declares, like include_in_schema=False. So is a router that every inclusion the graph can follow puts under such an app, even when another inclusion in the project can't be followed: Polar's back office. openapi_url=None only stops FastAPI serving the schema: app.openapi() and fastapi.openapi.utils.get_openapi still build one from every route with include_in_schema true (applications.py, setup() and openapi()). So an app that adds a route of its own whose path ends in openapi.json (app.add_api_route(f"/{version}/openapi.json", ...), @app.get("/openapi.json")) counts as serving a schema, as Polar's API does (#391). That route can be added on a parameter of a function the app is passed to.
  • Which routers count (#358). A route counts on a name bound to FastAPI's FastAPI(...) or APIRouter(...), and, when the file calls a route decorator, include_router or another router method on the name, also to a first-party router class or factory, which Inwards resolves through the project's imports: a subclass of FastAPI's class; a module-level NAME = factory(...) whose first-party class factory says or shows that it returns a router class (-> type[APIRouter], or a subclass it defines and returns, as Polar's get_api_router_class does); any module-level APIRouter = <call> or FastAPI = <call>; a first-party function that returns a router, one hop deep; and a name returned by a function annotated -> APIRouter (fastapi-clean-example's make_*_router() factories). A callee merely named APIRouter doesn't count: OpenStack projects name their WSGI routers that. A factory's arguments aren't the router's keywords, so a factory-built router's prefix, tags and responses= are unknown, and its routes count as shown. A file that spells none of the pre-filter's words (fastapi, APIRouter, include_router...) isn't read.
  • Route classes (#392). FastAPI builds each route with its router's route_class, and builds it again with the joined tags at each include_router (routing.py, add_api_route and include_router). The route class comes from route_class= or from a first-party router class that sets it (kwargs["route_class"] = route_class in a class factory, as Polar's get_api_router_class). When one __init__ among its first-party classes sets self.include_in_schema from the tags, Inwards runs that code for each route: an if/elif/else chain over <tag> in self.tags (or a local bound to it) and self.include_in_schema, assigning True or False. Tags are strings or first-party enum members holding one (Polar's APITag.private, a StrEnum), from the decorator, the router and every include_router above it. A route is skipped when the class leaves it out, and also when the answer is unknown: a value other than a literal (Polar's settings.is_development() for private routes), a condition or statement it can't read, a tag it can't read, or a route class that writes self.tags. A route class that doesn't touch the schema changes nothing.
  • Route classes. A first-party route_class= that changes the schema isn't read: Polar's keeps routes tagged APITag.private out of its production schema, and FAPI001 still reports them (#392).
  • Measured on the corpus (#182, chapter 6): 22 findings before the openapi_url rule, 21 after, all looked at: 19 true positives (a POST or DELETE with no explicit status code, a route with no summary) and 2 on the tiny apps Polar's tests build, which aren't reported since test modules are skipped (#362). Since #358 the rule also sees Polar's 526 path operations outside tests and fastapi-clean-example's 15. fastapi-clean-example's 11 findings were all routes with description=getdoc(...), which now counts as a docstring, so none are left. Polar's 131 findings, all classified: 96 on routes tagged APITag.private (#392) and 35 on public routes, each read: 25 true (a POST or DELETE with no explicit status code, a responses= entry with no description, a route with no summary) and 10 false (4 CSV exports, and 6 OAuth2 endpoints whose bodies RFCs define). Since #391 follows Polar's add_versioned_routers(app, api_router), its API routes sit under create_app()'s FastAPI(openapi_url=None), and would all be skipped (0 findings) without the openapi.json route above; with it, the same 131 are reported. Since #392 reads Polar's route class, the 101 findings on routes it keeps out of the production schema (private, or private on the decorator under a public router) are gone: 30 findings are left, 20 true and the same 10 false (4 CSV exports, 6 OAuth2 endpoints), 67 % against 19 % before.

References