FastAPI
seampact-fastapi generates the contract’s Python code for FastAPI. You write the handlers and the hooks; the generated boundary checks each request and each reply.
FastAPI 0.162 and later, Python 3.12 or later. See Versions and Supported versions.
Set up
Section titled “Set up”You need:
- Python 3.12 or later;
- FastAPI 0.162 (a later minor is untested, see Versions);
- Pydantic 2.9 or later. Pydantic 1 is refused.
Install seampact from PyPI. Put the generator in your dev dependencies and the runtime in production:
python3 -m venv .venv && . .venv/bin/activatepip install "seampact-fastapi[generate]"uv pip install and uv add --dev take the same requirement. In production, install plain seampact-fastapi. Add seampact-signed-webhooks if a route is signed by a sender.
A path source and the OpenAPI documents need the compiler. Add the extra seampact-ir[compiler], which installs the seampact-compiler wheel for your platform. A bundle-only app with openapi: none does not need it.
In seampact.config.yaml, add one entry per api. Replace the git url, the tag and integrity with your contract’s: integrity is the hash seampact bundle printed when you published it (see Share contracts). To try a contract in a local folder, use path: ./contract instead and install the compiler extra above.
outDir: generatedopenapi: nonesources: notes: git: { url: "https://git.example.com/notes-contract.git", tag: v1.0.0, directory: bundle } integrity: sha256-...targets: - id: notes out: notes use: pypi:seampact-fastapi api: notes options: { rules: seampact-consistent }The request is checked by Pydantic models; see Types and validation.
Then generate:
seampact generateThe code goes into generated/notes/. Import it as generated.notes.fastapi, generated.notes.operations and generated.notes.schemas. Commit the generated files (Generated files). seampact check fails CI when they drift from the contract; Commands per stack shows how to run it.
Write a handler
Section titled “Write a handler”Write one class per resource. It implements the generated <Resource>Endpoints protocol. A factory builds it, and the factory is a FastAPI dependency, so it takes your own dependencies:
from fastapi import Dependsfrom generated.notes.operations import GetNoteErrors, GetNoteRequest, GetNoteResponse200, GetNoteResult
class NotesEndpoints: def __init__(self, notes: NoteRepository) -> None: self.notes = notes
async def get_note(self, request: GetNoteRequest) -> GetNoteResult: note = await self.notes.get(request.input.path.note_id)
if note is None: return GetNoteErrors.api_not_found()
return GetNoteResponse200(note)
def notes_endpoints(notes: NoteRepository = Depends(get_notes)) -> NotesEndpoints: return NotesEndpoints(notes)- A method is a plain
defor anasync def. Adefruns in FastAPI’s thread pool (40 threads by default, shared with your otherdefdependencies). request.inputholds the checked path, query, headers and body, typed. The input is checked before your factory runs, so no session opens for a bad request.- You never write a status or a content type. Return one arm of the result (
GetNoteResponse200), or an error (GetNoteErrors.api_not_found()). You can raise the same error instead. - A reply that breaks the contract answers 500. So does any other exception, and
on_unexpected_errorhears it.
When an operation takes a version, the boundary checks its format only. Compare it with the stored version yourself, and return the operation’s conflict error when they differ:
if request.input.body.expected_version != task.version: return UpdateTaskErrors.state_conflict()A write versioned with header.If-Match answers 428 when the header is missing or empty, before the handler runs. A weak tag (W/"7") is accepted, and the handler receives the version unquoted (7). A stale version is a 412: the handler returns the preconditionFailed error. See Catch lost updates.
A deadline is a declared header. request.deadline is None when the caller sent none. Check request.deadline.has_passed(datetime.now(UTC)) while you work, and answer the operation’s deadline error (GetNoteErrors.api_deadline_exceeded()). budgetMs is the client’s timeout; the boundary does not enforce it.
Who is calling
Section titled “Who is calling”On a route with an identity, request.principal is the caller, typed as the generated <Identity>Principal. It is None for an anonymous caller on an optional-auth route. A public route has no principal.
When the route runs in scopes, request.scopes has one attribute per scope, each the ResolvedScope your resolver returned, with its id and value:
workspace = request.scopes.workspace.valueuser_id = request.principal.user_idNeither is part of the input, and the client cannot set them. Both print as [redacted].
Wire it in
Section titled “Wire it in”bind_notes() is the one call your app makes. Make it last, after your own middleware and exception handlers.
from fastapi import FastAPIfrom fastapi.middleware.cors import CORSMiddlewarefrom generated.notes.fastapi import NotesHandlerFactories, bind_notes
app = FastAPI()app.add_middleware(CORSMiddleware, allow_origins=["https://app.example.com"])
bind_notes( app, handlers=NotesHandlerFactories(notes=notes_endpoints), authenticate={"user": user_hook}, deciders={"notes.update": NotesDecider}, scope_resolvers={"workspace": WorkspaceResolver}, on_unexpected_error=NotesErrors,)- It adds the contract’s routes, the exception handlers and the boundary. The boundary is the outermost middleware of the app.
handlershas one factory per resource.authenticatehas one hook factory per identity,decidersone per right or rule,scope_resolversone per scope. Keys are the contract’s names.- Add
CORSMiddlewarebeforebind_notes(). A preflight request passes the boundary and reaches it. - With two apis, call
bind_<api>()twice. - To use an
APIRouter, pass it as the first argument and addapp=app. Callbind_notes()beforeapp.include_router(router). A prefix on that router is refused: usemounton the entry.
Each route is named seampact.<api>.<operationId>, and its parameters take the input property names. NotesRouteNames holds the names and NotesUrls builds the paths. artefacts: { routeHelpers: false } leaves both out.
Write the hooks
Section titled “Write the hooks”A hook is a class. Its factory is a FastAPI dependency, so it can take Depends(...) and Security(...). FastAPI reads the credential, and the hook decides.
from typing import Annotated
from fastapi import Securityfrom fastapi.security import OAuth2PasswordBearer
from seampact.hooks import AuthenticateRequest, AuthenticateResult, ErrorCodeWithParams, authenticate_result
bearer = OAuth2PasswordBearer(tokenUrl="/token", auto_error=False)
class UserHook: def __init__(self, token: str | None) -> None: self.token = token
async def authenticate(self, request: AuthenticateRequest) -> AuthenticateResult: user_id = None if self.token is None else await tokens.user_of(self.token)
if user_id is None: return authenticate_result.Denied(deny=ErrorCodeWithParams(code="api.unauthenticated"))
return authenticate_result.Authenticated(principal=UserPrincipal(user_id=user_id))
def user_hook(token: Annotated[str | None, Security(bearer)]) -> UserHook: return UserHook(token)auto_error=False hands None to the factory, so a caller with another credential of the identity (a session cookie) still reaches the hook. The credentials the contract declares are in request.credentials.
- A decider has
decide(request)and returnsdecision.Allowed()ordecision.Denied(deny=ErrorCodeWithParams(code=...)). - A scope resolver has
resolve_scope(request)and returnsscope_resolution.Resolved(resolved=ResolvedScope(...))orscope_resolution.Denied(deny=...). - They come from
seampact.hooks. Theparamsof a right or rule in the request is an instance of its generated params class (a Pydantic model), not a dict: for a right declared withparams: { allowed: [...] }, readrequest.params.allowed. - An idempotency store is a factory for an object with
begin,completeandabandon(seampact.hooks.IdempotencyStore). Pass it asidempotency_store=. An idempotent operation needs one. - The unexpected-error listener has
on_unexpected_error(failure). It hears each 500 the api answers. - FastAPI has no CSRF layer. The boundary checks the contract’s CSRF header for a caller with a cookie only.
- To verify a signed webhook, call
seampact-signed-webhooksin the authenticate hook. The raw body isrequest.signature.raw_body;request.signatureisNoneon an unsigned route.TemplateSignatureVerifierchecks a template signature. A signature your application verifies is refused (signature.application.unsupported).
Use FastAPI’s own auth
Section titled “Use FastAPI’s own auth”Your dependencies and middleware work beside the contract. On a contract route, HTTPException and the other errors they raise answer the contract’s error of their status. 401, 403, 404, 413, 429 and 503 map to unauthenticated, forbidden, notFound, payloadTooLarge, rateLimited and serviceUnavailable, with Retry-After kept on 429 and 503. A scheme that raises HTTPException 401 or 403 answers unauthenticated or forbidden.
Without that error, the operation’s one code of that status answers, if it has exactly one. Otherwise the reply is a logged 500. Your own routes keep your handlers.
Options
Section titled “Options”Set under options: on the target entry.
| Option | Default | What it does |
|---|---|---|
rules |
native |
native uses Pydantic’s own value rules. seampact-consistent gives the contract’s exact checks, equal on every target. See Pydantic. |
responses |
strict |
strict answers 500 when your reply breaks the contract. warn logs it and sends the reply. |
bodyValidation |
false |
Checks success bodies against the contract. |
frameworkEtag |
strip |
Drops an ETag or a 304 that the boundary did not write. keep leaves them. |
catchAll |
true |
Unknown paths under the api’s base path answer the contract’s 404. |
Also on the entry: mount (a path prefix), frameworkRoutes (see The boot check), frameworkDocs, allowUntested, and under artefacts: routeHelpers, nativeDocs and bootCheck. Settings set on a group flow down to the routes below it: see Settings flow down the tree. The full list is in the configuration reference.
Change a setting at run time
Section titled “Change a setting at run time”Settings come from your code. Pass responses, body_validation, public_origin and operations (per operation, responses) to bind_notes(): they override the entry’s options. seampact reads no environment variable. Read your own settings and pass them in.
Paging
Section titled “Paging”A paged list reads request.input.query.cursor (None when absent) and request.input.query.limit for the cursor style. The boundary fills in defaultLimit and answers 400 to a limit above maxLimit (overLimit: clamp serves maxLimit). query.limit stays typed int | None: narrow it with assert query.limit is not None.
Return the generated <Operation>Page: items, plus one snake-case attribute per page fact (next_cursor for nextCursor). The boundary writes the body, the headers and the Link relations.
async def list_notes(self, request: ListNotesRequest) -> ListNotesResult: query = request.input.query notes, next_cursor = await self.notes.page(after=query.cursor, limit=query.limit)
return ListNotesResponse200(body=ListNotesPage(items=notes, next_cursor=next_cursor))Import ListNotesPage from generated.notes.operations with the other generated names. See Lists.
FastAPI’s /docs and /openapi.json show your own routes only. To add the contract’s routes, set nativeDocs on the entry:
artefacts: { nativeDocs: true }Each route then appears in FastAPI’s document as the contract describes it. generate writes native_docs.json beside the code, and bind_notes() adds it to app.openapi().
nativeDocsneeds the compiler: installseampact-ir[compiler].- A contract schema named
ValidationErrororHTTPValidationErroris refused (artefacts.nativeDocs.component-clash): FastAPI owns those names. - Run
seampact generateagain when the contract changes. A stalenative_docs.jsonstops the app at boot. frameworkDocs: offon the entry removes/openapi.json,/docsand/redoc.
More in OpenAPI and docs.
Test your routes
Section titled “Test your routes”Go through the boundary with the TestClient. It needs httpx2, which the dev dependency group holds. Use it as a context manager, so the startup runs the boot check:
def test_get_note() -> None: app = create_app(user_hook=lambda: FakeUserHook("alice"))
with TestClient(app) as client: reply = client.get("/v1/notes/n-1", headers={"Authorization": "Bearer test-token"})
assert reply.status_code == 200- Build the app in a function that takes the hook factories, and pass fakes in the test. Replace a decider, a resolver or a store the same way.
- Send the credential the contract requires. The boundary asks for it before your hook runs.
- Never put the generated access or input dependencies in
app.dependency_overrides: the boot check refuses it, since it would reach your handler without any check. Override the handler factory or its own dependencies instead (app.dependency_overrides[get_notes]is allowed). - Without
with, the boot check runs on the first request instead.
To call a handler directly, build its request class from generated.notes.operations yourself. That request skips the boundary, so use it for unit tests only.
Debug locally
Section titled “Debug locally”Under app.debug, an unexpected exception shows Starlette’s debug page, as on any route. A reply that breaks the contract is still a 500. To see it while you work, pass responses="warn" to bind_notes() in your local settings: the reply goes out and the log names the problem.
Deploy
Section titled “Deploy”- Run uvicorn, which gives the raw request path. Where the server gives none, the boundary matches the decoded path and logs which rules it cannot apply.
- To serve the api under a prefix, set
mount: /apion the entry. Page links add the ASGIroot_path(uvicorn’s--root-path) to the contract path, so set it when a proxy strips a prefix before the app. - When the contract writes absolute URLs, pass
public_origin="https://api.example.com"tobind_notes(). The request’sHostis never read. - An ASGI wrapper around the app object (
app = CORSMiddleware(FastAPI()), an error tracker) runs before the boundary and is not seen. Add middleware withapp.add_middleware(). - The outgoing check cannot read a
brerror body: it is a violation.
In Docker, install plain seampact-fastapi. The generated code is committed, so the image needs no compiler.
FROM python:3.12-slimWORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtCOPY . .CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]Read your settings from environment variables in main.py and pass them to bind_notes(). Never bake secrets into the image.
The boot check
Section titled “The boot check”The app refuses to start, and lists every problem. It runs on startup (uvicorn exits with Application startup failed. Exiting.). Each message names the fix; find it in Boot errors.
| Fault | Fix |
|---|---|
| An identity has no authenticate hook, a right or rule no decider, a scope no resolver. | Pass it to bind_notes(). |
| An idempotent operation has no store. | Pass idempotency_store=. |
| An operation has no handler, or its route is missing or served twice. | Give handlers= a factory for each resource; call bind_notes() once. |
A route of yours would take a contract request, or sits under the api’s base path and frameworkRoutes does not allow-list it. |
Move it, or allow-list it. |
The boundary is not the first middleware: a middleware was added after bind_notes(). |
Add your middleware before bind_notes(). |
An exception handler for HTTPException was added after bind_notes(). |
Add it before. |
The contract writes absolute URLs and public_origin is missing, or it writes relative URLs and public_origin is set. |
Pass it, or remove it. |
The FastAPI minor is one that generate did not accept, or Pydantic is 1. |
Run seampact generate again, or use Pydantic 2. |
Allow-list your own routes in the entry, with a reason. FastAPI’s /docs, /redoc and /openapi.json need no entry.
frameworkRoutes: - { path: "/admin/**", reason: "Back office, not part of the api." }artefacts: { bootCheck: false } turns the check off. { frameworkRoutes: false } turns off its route part, and { hooks: off } its hook part. hooks takes error (default) or off; warn is refused (artefacts.bootCheck.hooks.warn.unsupported).
Versions
Section titled “Versions”FastAPI breaks in minors, so seampact tests them one by one. 0.162 is the floor, and an older minor fails with config.matrix.incompatible. A newer minor prints config.matrix.untested. Accept it on the entry with allowUntested: [fastapi@0.163], or make it fail with checks: { matrix: error }. After pip install -U fastapi, run seampact generate again: the boot check refuses a minor that generate did not accept.
Starlette
Section titled “Starlette”seampact accepts every Starlette that FastAPI accepts, from 0.46, and tests 0.46 and the latest release. Old Starlette releases have known security flaws, fixed in later ones, but seampact does not raise its floor for them, the same choice FastAPI makes:
pip installalready picks the latest Starlette. An old one stays only when your project pins it, another package holds it back, or the environment is never updated. A higher floor in seampact changes none of these.- A higher floor would refuse projects that cannot move yet, for a flaw that may not touch them.
So keeping Starlette current is your project’s job. Run pip install -U starlette, and let pip-audit (or your security scanner) tell you when an update fixes a flaw. To enforce it, add your own floor, for example starlette>=1.3.1, to your project’s requirements.
Limits
Section titled “Limits”generaterefuses some forms by name, until an app needs them. The list is in Generate errors; forms refused on every server are under every server.- A sort key restricted by a right is refused (
paging.sortKey.right.unsupported). A query list is read in the defaultrepeatform (tag=a&tag=b) or ascomma. - DRF and Ninja are separate targets: see Django REST Framework and Django Ninja.