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 whendescription=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, whichResponseand its subclasses aren't. Astatus_code=204route is exempt; - an explicit
status_code=onPOSTandDELETE, since 200 is rarely right for a create or a delete; - a
descriptionin every entry ofresponses=.
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:
[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:
from fastapi import APIRouter
router = APIRouter(prefix="/orders", tags=["orders"])
@router.post("/")
async def create_order(body: OrderIn):
...
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.
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)
How to fix¶
- Add what the finding lists to the decorator:
summary="..."or a docstring,status_code=,"description"in eachresponses=entry. - Declare the model the endpoint returns, as a return annotation or
response_model=. Returning aResponsedirectly gives FastAPI nothing to document. - 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:
[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
**kwargsis skipped: the metadata may be in there. require-tagscounts tags given on aninclude_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=Falseon aninclude_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, likeinclude_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=Noneonly stops FastAPI serving the schema:app.openapi()andfastapi.openapi.utils.get_openapistill build one from every route withinclude_in_schematrue (applications.py,setup()andopenapi()). So an app that adds a route of its own whose path ends inopenapi.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(...)orAPIRouter(...), and, when the file calls a route decorator,include_routeror 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-levelNAME = 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'sget_api_router_classdoes); any module-levelAPIRouter = <call>orFastAPI = <call>; a first-party function that returns a router, one hop deep; and a name returned by a function annotated-> APIRouter(fastapi-clean-example'smake_*_router()factories). A callee merely namedAPIRouterdoesn'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 andresponses=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 eachinclude_router(routing.py,add_api_routeandinclude_router). The route class comes fromroute_class=or from a first-party router class that sets it (kwargs["route_class"] = route_classin a class factory, as Polar'sget_api_router_class). When one__init__among its first-party classes setsself.include_in_schemafrom the tags, Inwards runs that code for each route: anif/elif/elsechain over<tag> in self.tags(or a local bound to it) andself.include_in_schema, assigningTrueorFalse. Tags are strings or first-party enum members holding one (Polar'sAPITag.private, aStrEnum), from the decorator, the router and everyinclude_routerabove 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'ssettings.is_development()for private routes), a condition or statement it can't read, a tag it can't read, or a route class that writesself.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 taggedAPITag.privateout of its production schema, and FAPI001 still reports them (#392). - Measured on the corpus (#182, chapter 6): 22 findings before the
openapi_urlrule, 21 after, all looked at: 19 true positives (aPOSTorDELETEwith 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 withdescription=getdoc(...), which now counts as a docstring, so none are left. Polar's 131 findings, all classified: 96 on routes taggedAPITag.private(#392) and 35 on public routes, each read: 25 true (aPOSTorDELETEwith no explicit status code, aresponses=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'sadd_versioned_routers(app, api_router), its API routes sit undercreate_app()'sFastAPI(openapi_url=None), and would all be skipped (0 findings) without theopenapi.jsonroute 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¶
- ADR-037: Framework rule families, opt-in, with their own prefix
- FastAPI: Path Operation Configuration, Response Model
- Ruff's
FAST001reports a redundantresponse_model; FAPI001 reports a missing one.