Internal: FastAPI rules (routes from openapi.yaml, Depends, problem+json, login, settings); read by work's executors before FastAPI code.
Resources
3Install
npx skillscat add idontlikedrinkpivo/web-dev-pipeline-codex-plugin/fastapi Install via the SkillsCat registry.
Codex runtime. Before using this skill, read
../executor-catalog/references/codex-runtime.md. It defines tool mappings, named-agent dispatch and stage-chat orchestration; use it for inherited Claude Code terms below.
FastAPI
The project's conventions for the FastAPI web layer: routes, dependencies, request and response models, the
problem+json error handlers, login and settings, tooling. The database layer (models, repositories, sessions,
Alembic) belongs to fastapi-sqlalchemy.
Which layout governs — decide first
The cases (architecture document, Framework-first, existing repo) and the contract's authority are intypescript/references/which-layout-governs.md, shared by the backend and test skills of both stacks — read it
first. FastAPI's placement in them:
- An architecture document exists: routers are factories in
<area>/adapters/http/—build_<area>_router(<use cases>) -> APIRouter— andbootstrap/constructs the use cases, calls the factories and includes the routers (clean-architecture-design, FastAPI row). A handler parses the request, calls one use case or query, and maps the result to the response schema; it holds no rule and builds no use case.Dependsresolves only request-scoped things: the session (handed to a use-case factory frombootstrap/when the use case needs it) and the auth context. - Framework-first: a handler calls the
domain/module and the ORM directly. - Existing repo: the repo's routers and modules; a new rule imports neither FastAPI nor SQLAlchemy.
Use the fastapi CLI
Start the app with fastapi dev locally (through uv run). Prefer declaring the entrypoint (the app object the §5 tree places in bootstrap/, or the repo's existing one such as src.main:app) in pyproject.toml over passing a file path on every call; pass the path only when the config cannot be added or the user asks not to.
The container, and any run the platform stops, starts through the production entry in bootstrap/ (python -m <package>.bootstrap.serve), not fastapi run: the entry configures the JSON logs before uvicorn starts and passes it log_config=None and the graceful-shutdown timeout, which fastapi run does not (references/service-endpoints.md).
Conventions
Parameters and dependencies
Annotatedeverywhere — machine-checked (RuffFAST002,python→ The machine checks); the why: signatures stay callable outside FastAPI and the types stay honest.- Dependency aliases. Give each reusable dependency a type alias named as a noun without suffix —
DbSession = Annotated[AsyncSession, Depends(get_db)],CurrentUser = Annotated[User, Depends(get_current_user)],CurrentAdmin— and use the alias in every signature, unless asked not to. Reuse the names the repo already has; the same names appear infastapi-sqlalchemy. - No class dependencies. Do not pass a class to
Depends(). Write a function dependency that takes the parameters and returns the instance (a dataclass is fine), so the request parameters are explicit. The one exception is FastAPI's ownOAuth2PasswordRequestFormin the login route (Annotated[OAuth2PasswordRequestForm, Depends()],references/jwt-auth.md): it is the framework's parser of the login form, and its fields are fixed by the OAuth2 spec.
Models and responses
- No Ellipsis defaults. Never write
name: str = ...,Field(..., gt=0)orQuery(...). A field or parameter without a default is already required;...is noise. - No
RootModel. Annotate the plain type instead, e.g.items: Annotated[list[int], Field(min_length=1), Body()]. The wrapper class only adds.rootindirection. - Declare the return type (
async def get_item() -> ItemRead:) so the response is validated, filtered and documented. Useresponse_model=on the decorator only when the returned object is not the public schema, e.g. an internal model carrying a secret field. - Response schemas are built, not borrowed. With an architecture document the handler maps the entity or read model to the response schema explicitly; an ORM record is never the response. Brownfield: keep the repo's existing pattern.
- Declare the contract's error statuses on the operation (
responses=from one shared helper that points at the Problem model), so the app's own OpenAPI lists what the contract lists — the contract test compares them.
Routing
- Self-contained routers. Put
prefix,tagsand shareddependencies=[Depends(...)]on theAPIRouteritself, not ininclude_router(), so every place that includes it gets the same prefix and auth. Exceptions are allowed; this is the default. - One function per HTTP operation. Do not branch on
request.methodinside@app.api_route(methods=[...]); separate functions get their own parameters, response model and OpenAPI entry.
Sync vs async
defunless sure. Useasync defonly when everything awaited inside is async and nothing blocks; otherwise use plaindef. The same rule applies to dependencies. Never call blocking code insideasync def— use an async driver,asyncio.to_thread, or a plaindefpath operation: it works but stalls the event loop.
Errors as problem+json
- Exception handlers, registered once in
bootstrap/(brownfield: next to the app object), implement the contract's error answers, all asapplication/problem+json:- a named domain or use-case error → the status and
codethe contract gives it (foundation §4 named-error → HTTP table), in the contract's Problem body; - a request that fails its schema → the contract's validation status,
codeand per-field list, field names in the wire casing. FastAPI's default{"detail": [...]}body is not that answer, so the handler forRequestValidationErrorreplaces it; - a request that cannot be parsed (malformed JSON, an undecodable cursor) → the contract's status for it;
- a serialization failure (
40001) or deadlock (40P01) that is left after the bounded reruns (fastapi-sqlalchemy→ Driver errors and install) → the contract's 503 withRetry-After; - anything unnamed → logged with its cause, the contract's 500 with a generic
detail— never the message, stack or SQL. An unexpected constraint violation is this case: the repository classifies the conflicts it expects into named errors, and a duplicate is never answered as a bare 409 (openapi-spec-generator→ Step 6).
- a named domain or use-case error → the status and
- Routes and use cases raise named errors; they do not build bodies or
raise HTTPException(detail=...). - Brownfield without
openapi.yaml: keep the error format the repo already returns. Never introduce a second one.
Auth and configuration
- Token kind, lifetime, hashing and roles come from architecture §1 «Решения по безопасности»; brownfield: from the existing code or the user. Implementing registration, password login with JWT or the current-user dependencies: read references/jwt-auth.md — libraries, the timing-safe check,
algorithms=[...], 401 withWWW-Authenticate, roles read from the database. - Settings via
pydantic-settings, built inbootstrap/. A secret (a signing key, an API token, a password) has no default, so a missing one stops the start;.env.examplelists every variable. Touch only the settings the task needs: in an existing repo, keep the defaults of settings the task is not about (removingDATABASE_URL's default breaks every command run without.env). CORS lists exact origins, never*with credentials.
Service endpoints, logs and shutdown
The platform the service is handed to probes it, scrapes it, collects its stdout and stops it with SIGTERM. The code for all four is written once, in bootstrap/, from references/service-endpoints.md — read it before touching them.
GET /health/livetouches nothing;GET /health/readychecks the database (2 s) and each store the app cannot serve without, never a partner's API;GET /metricsservesprometheus-client's registry withhttp_request_duration_seconds, which one pure ASGI middleware fills, labelled by the route template. The three sit outside the contract (NOT_IN_CONTRACT,include_in_schema=False) and outside auth.- Logs are stdlib
loggingwith the JSON formatter frombootstrap/, configured before the server starts; uvicorn's loggers go through the same handler. One object per line withtime,level,msg,logger,request_id(a contextvar the middleware sets fromX-Request-Id) anderr. Code logs throughlogging.getLogger(__name__), neverprint. No secret, token or personal data in a line. - Shutdown: uvicorn's
timeout_graceful_shutdownfromSHUTDOWN_TIMEOUT_SEC, the lifespan's shutdown disposes the engine, exit 0. One worker process per container; several workers needprometheus_client's multiprocess mode.
The contract test
tests/contract/test_openapi_contract.py compares the OpenAPI the app builds from its routes (get_openapi) withdocumentation/api/openapi.yaml: every operation the app serves exists there with the same method, path andoperationId, the same error statuses, and the same request and success-response schemas. It is written once
from references/contract-test-template.py (repo-scaffold); a unit that
adds or changes a route keeps it green — a red contract test is a route that differs from the contract, never a
reason to loosen the comparison.
Tooling and libraries
- uv for dependencies, Ruff for lint and format (enable its FastAPI rules), the type checker the quality gates name
(repo-scaffold/references/quality-gates.md). Language rules:python. asyncio.to_threadfor a blocking call that must run insideasync def(Sync vs async above) — the one tool,
the same the conftest template uses, and no extra dependency.- HTTPX for outbound HTTP; prefer it over Requests.
- SQLModel only if the user asks for it, and never beside an existing SQLAlchemy layer: that gives two model systems for one table.
Version-sensitive points — references/version-checks.md. In a work packet their answers come inCURRENT LIB DOCS; where it says none, probe the installed version in the project instead of fetching docs.
Outside work, read the file and check each point you rely on against the installed version.
The reviewer checks
- A handler parses, calls one use case or query, and maps the result — no rule, no use-case construction, no
ORM record as the response. - Errors reach the client only through the handlers in
bootstrap/: noHTTPException(detail=...), no body
built in a route, a validation failure answers the contract's validation status, not FastAPI's default 422 body. - Login is timing-safe and strict — when the app runs its own password login (with SSO the token is the
issuer's, checked by its keys as architecture §1 says, and this check does not apply): tokens through PyJWT and passwords throughpwdlib(Argon2) — notpython-jose,passlibor a barehashlib; an unknown email still verifies against a dummy hash; decoding passesalgorithms=[...]; a bad token is 401 withWWW-Authenticate: Bearer; a role is read from the database on each
request, never from a token claim; login and registration are rate-limited by address and by account where
architecture §1 puts it, answering the contract's 429 withRetry-After. - Secrets have no default and no literal anywhere; CORS names exact origins.
- No blocking call inside
async def(a sync driver,requests, file IO, a password hash) —def, a thread,
or an async library. - A new or changed operation matches the contract — the contract test covers it and passes.
/health/livetouches nothing;/health/readychecks only stores the app cannot serve without, each bounded,
never a partner's API — a partner outage must not take every pod out of rotation.- No metric label or log field comes from raw input:
routeis the template (scope["route"].path), never
the request path; no user id, email, token or body in a label or a log line. A label takes a small fixed set of values (the route template, the method, the status class): an id from the path or a user's value in a label makes a new series per value. - A write takes the request model's fields by name: no
**body.model_dump()into a record or an update, and
a field the client may not set — the owner, a role, a status, a price — is not in the request model; request
models forbid extra fields (extra="forbid"), so a sent one is refused, not stored.