FastAPI route generators
The attach_*_routes helpers project a frozen operation registry onto a FastAPI
router: request and response schemas come from the operation descriptors, and
each route's operationId is the registry operation key verbatim (notes.get),
so the HTTP surface, MCP tool names, and the operation catalog share one
identity. Setup (lifespan, middleware, error mapping) lives on the
FastAPI integration page; this is the catalog of
what each generator produces.
Document routes¶
attach_document_routes projects the document operations of a frozen registry
(built with build_document_registry) onto a router you own:
from fastapi import APIRouter
from forze_fastapi.routes import attach_document_routes
router = APIRouter(prefix="/notes", tags=["notes"])
attach_document_routes(
router,
registry=registry, # build_document_registry(spec, dtos).freeze()
ns=spec.default_namespace,
ctx_dep=runtime.get_context,
style="rest",
)
app.include_router(router)
Only operations the registry actually holds are attached, so a read-only spec
yields a read-only router; narrow further with include={"get", "list"}. A
plan-declared deadline surfaces on each generated
route as an x-deadline-seconds OpenAPI extension and a "Time budget" line in
its description, so API clients can set their own timeouts. Merging a
soft-deletion registry (build_soft_deletion_registry) into the document
registry adds its delete/restore operations to the same router automatically.
Instead of ns=spec.default_namespace you can pass resource="notes" — the
attacher builds the namespace for you (StrKeyNamespace(prefix="notes")). It
must equal the prefix the operations were registered under (the kit builders
default to spec.default_namespace); ns= and resource= are mutually
exclusive, so provide exactly one. To move a single operation off its default
path, pass path_overrides={DocumentKernelOp.GET: "/by-id/{id}"} (keyed like
include=). Only the path changes — the operationId stays the verbatim catalog
key, so the HTTP/MCP/catalog identity is preserved; an override that drops a
placeholder the default path binds (e.g. {id}) is a configuration error rather
than a silent demotion to a query parameter. Both knobs are available on every
attach_*_routes helper.
Styles: rest vs rpc¶
Both styles use the same REST verbs; they differ only in how a resource is addressed — REST puts the id in the path, RPC keeps one operation-named path per operation (mirroring the catalog one-to-one) and puts the id in a query parameter:
"rest"— resource paths:POST /notes(201),GET /notes/{id},PATCH /notes/{id}?rev=with the patch DTO as body,DELETE /notes/{id}(204, hard delete). Soft deletion surfaces as action sub-paths —POST /notes/{id}/delete?rev=andPOST /notes/{id}/restore?rev=. List operations keepPOST /notes/list-style paths since their filter bodies have no natural REST verb."rpc"—GET /notes/get?id=,PATCH /notes/update?id=&rev=with the patch DTO as body,DELETE /notes/kill?id=(204), andPATCH /notes/delete?id=&rev=/PATCH /notes/restore?id=&rev=for soft deletion.createand the list operations keepPOST /notes/<op>with the input DTO as body (a new entity or a filter payload has no id to address). So an RPC read is a plain linkable, cacheableGET, not an opaquePOST.
Search routes¶
attach_search_routes does the same for a search registry
(build_search_registry or its hub/federated siblings). Search requests are
filter/query bodies with no natural REST verb, so there is no style choice —
every operation is POST /<op>:
from forze_fastapi.routes import attach_search_routes
search_router = APIRouter(prefix="/notes/search", tags=["notes"])
attach_search_routes(
search_router,
registry=search_registry, # build_search_registry(search_spec).freeze()
ns=search_spec.default_namespace,
ctx_dep=runtime.get_context,
)
Storage routes¶
attach_storage_routes covers a storage registry (build_storage_registry)
and takes the same explicit style. Transport shapes are fixed by the
payloads — multipart upload (the file plus optional description/prefix
form fields), the listing DTO as JSON body, a streamed download with content
type and a Content-Disposition filename, a void delete (204); keys may
contain slashes. The style only decides paths and verbs:
"rest"—POST /files(201),POST /files/list,GET /files/{key}(with aHEADsibling on the same path),DELETE /files/{key}."rpc"—POST /files/upload,POST /files/list,GET /files/download/{key}(+HEAD),DELETE /files/delete/{key}. The key rides the path tail in both styles since it is slash-bearing, not a JSON field.
from forze_fastapi.routes import attach_storage_routes
files_router = APIRouter(prefix="/files", tags=["files"])
attach_storage_routes(
files_router,
registry=storage_registry, # build_storage_registry(storage_spec).freeze()
ns=storage_spec.default_namespace,
ctx_dep=runtime.get_context,
style="rest",
)
Downloads: streaming, ranges & caching¶
The GET download route streams by default (stream=True): the body is a
StreamingResponse consumed chunk by chunk, so the process never buffers a
whole object and a large download can't OOM it. It is backed by three read-only
query operations the registry carries (build_storage_registry registers them;
they acquire only the storage query port): head, download_stream, and
download_range. A plain unconditional GET runs a single download_stream
operation — the cache validators ride along on its result, no separate head
round-trip; a conditional or Range request runs head first.
The route is a full HTTP range/cache citizen:
| Request | Response |
|---|---|
plain GET |
200, streamed body, Accept-Ranges: bytes |
If-None-Match / If-Modified-Since match |
304, no body, validators echoed (If-None-Match takes precedence per RFC 7232) |
Range: bytes=… within the object |
206 from a real backend-ranged fetch, with Content-Range |
well-formed Range beyond the object |
416 with Content-Range: bytes */total |
malformed / non-bytes / multi-range |
header ignored per RFC 7233 — full 200 stream |
- Range cap. A single
Rangewindow is buffered, so it is capped —max_range_bytes, default 16 MiB. A wider request is served truncated to the cap with a206whoseContent-Rangereports the bytes actually returned — a valid partial the client simply re-requests from. Only explicit range windows are bounded; a plain download always streams. - ETag is the backend's.
ETag/Last-Modifiedcome from the backend head, not from hashing the body. For a client-side-encrypted object the ETag is over the stored ciphertext — an opaque but stable validator, exactly what conditional requests need. - Encrypted objects stream too. A client-side-encrypted object is decrypted
chunk by chunk on the way out; its plaintext size isn't known up front, so the
response carries no
Content-Length(chunked transfer). Ranged reads still work over chunked-AEAD objects — only the chunks the window covers are fetched and decrypted; a legacy whole-payload envelope can't be sliced, so aRangerequest against one falls back to the full streamed body rather than erroring. HEADmirrors theGETon the same path:Content-Type,Content-Length,ETag,Last-Modified,Accept-Ranges, no body. ItsContent-Lengthis the stored size (for an encrypted object, the ciphertext length — the streamedGETdecrypts and uses chunked transfer instead).
stream=False opts back into the legacy fully-buffered download: the whole
object in memory, an ETag hashed from the body bytes, and Range served by
slicing the buffer instead of a backend-ranged fetch. Prefer the default; if you
must keep large objects out of the app entirely, expose presign_download (see
below) so the client fetches straight from the backend. When a registry predates
the three streaming operations, the attacher quietly keeps the buffered
endpoint, so enabling stream is safe on any registry.
Direct & resumable uploads¶
For large or direct uploads the application stays out of the data path: the
browser (or an Uppy client) transfers bytes straight to/from the object store
through short-lived presigned URLs, and the app only mints URLs and orchestrates
the session. build_storage_registry already wires these ops; the same
attach_storage_routes call projects them when they are registered (JSON-body
POSTs; rest paths shown):
POST /files/presign/download→ a presignedGETurl ({key, expires_in}).POST /files/presign/upload→ a presignedPUTurl + headers ({key, expires_in, content_type?}).POST /files/uploads(201) → a multipart session{key, upload_id, ...}.POST /files/uploads/parts/url→ a presignedPUTurl for one part ({session, part_number, expires_in}).POST /files/uploads/parts→ the parts already uploaded ({session}) — the resume primitive.POST /files/uploads/complete→ the assembled object's head ({session, parts}).POST /files/uploads/abort(204) → discard an unfinished session.
The browser flow for a resumable multipart upload:
- begin —
POST /files/uploadswith the target key; keep the returnedupload_id(it is the resume/complete handle — round-trip it on every later call). - request part URLs — for each part,
POST /files/uploads/parts/urlwith the session and a 1-indexedpart_number. - PUT parts — the browser
PUTs each part's bytes directly and in parallel to its presigned URL (the app never sees the bytes). To resume an interrupted upload,POST /files/uploads/partsto learn which parts landed, then presign only the missing ones. - complete —
POST /files/uploads/completewith the session and the part list (each part carries theetagthe client got back from itsPUT); the response is the assembled object's head.
Guard these endpoints — and never log the URL
Minting an upload URL (or beginning a multipart session) grants write to a
key, so these endpoints should sit behind authn/authz — treat them like the
deactivate route. presign_upload and every multipart-session op are
command ops, so you can bind AuthnRequired + authz hooks on them in your
registry (they then show up protected under apply_openapi_security). The
minted URL is a bearer credential: it appears in the response body the
client needs but is never logged (the access-log middleware logs only request
path/status/duration, never the response body) — prefer short expires_in
windows.
A presign/multipart op on a client-side-encrypting route is refused by the adapter (the app never sees the bytes, so it cannot encrypt them) and the error propagates to a normal error status. Server-side (SSE/CMEK) encryption is transparent and does not refuse.
Authn routes¶
attach_authn_routes projects an authn registry (build_authn_registry) the
same way. Authn flows are RPC-shaped with one natural surface each, so there is
no style choice — fixed action paths: POST /login and POST /refresh (200,
token response), POST /logout (204, no body), POST /change-password (204),
POST /password-reset/request (202, uniform ack — never the token) and
POST /password-reset/confirm (204), and POST /deactivate (204). Login,
refresh, and the password-reset pair are meant to be reachable without a bearer
token (the operations authenticate via their bodies, or deliberately not at all
for the reset request); logout and change-password declare AuthnRequired (so
they 401 without a bound identity and show up protected under
apply_openapi_security), while deactivate_principal ships unguarded — bind
AuthnRequired + authz hooks on it or exclude it via include=. The full
wiring (including how the reset token reaches the user via the outbox) is in the
Authn, authz & tenancy recipe.
API-key management¶
attach_authn_routes also generates self-service API-key management as a
resource collection (all AuthnRequired): POST /api-keys issues a key for the
caller — the raw secret is in the response once, optionally a user→agent
delegation key (actor_principal_id) with a human label — GET /api-keys
lists the caller's keys as non-secret descriptors (a hint like ab12…wxyz,
never the secret), and DELETE /api-keys/{id} revokes one. This is the minting
surface for the MCP API-key flow:
the user issues a key here and pastes it into the agent host.
Identity, invocation metadata, and error mapping stay with the middlewares and exception handlers from the integration setup — generated routes only validate the input DTO and run the operation through the normal pipeline.
Tenancy routes¶
attach_tenancy_routes projects the self-service tenant-selector registry — fixed
paths, no style choice: GET /tenants (the caller's tenants), POST
/tenants/{id}/activate (switch the active tenant), DELETE /tenants/{id} (204,
leave). attach_tenancy_admin_routes projects the admin plane: POST /tenants
(201, create), GET /tenants/{id}/members, POST /tenants/{id}/deactivate (204),
POST /memberships (204, invite) and DELETE /memberships (204, remove). Admin
operations ship without hooks bound — guard them with AuthnRequired + authz hooks
in the registry, like deactivate.
Aggregate routes¶
attach_aggregate_routes(router, kit, *, ctx_dep, style="rest", tx_route="default",
storage_prefix="/blobs") is the composite: given an AggregateKit it attaches the
kit's document (+ soft-deletion), search, and — when the kit declares storage —
blob routes (under storage_prefix) onto one router with a single call, each
sub-surface exactly as its dedicated attacher would. tx_route must match the
transaction route the deps module registers.
Infrastructure routes¶
attach_jwks_route(router, jwks_provider, *, path="/.well-known/jwks.json", cache_max_age=300)— the JWKS document for token verification; excluded from the OpenAPI schema.attach_readiness_route(router, runtime, *, path="/readyz")— 200 while the runtime is active and not draining, 503 otherwise; excluded from the schema.
Document auth in OpenAPI¶
SecurityContextMiddleware extracts identity; it doesn't tell the schema. By
default the generated OpenAPI (and the Scalar/Swagger UI) shows every endpoint as
open — no Authorize button. apply_openapi_security closes that gap from the
same AuthnRequirement you handed the middleware, so the scheme is declared
once:
from forze.application.contracts.authn import AuthnSpec
from forze_fastapi.security import (
AuthnRequirement,
HeaderTokenAuthn,
apply_openapi_security,
)
# Your authn aggregate's spec — the same one your routes and the engine resolve.
API = AuthnSpec(name="api", enabled_methods=frozenset({"token"}))
requirement = AuthnRequirement(
ingress=(HeaderTokenAuthn(authn_spec=API, header_name="Authorization"),),
)
app.add_middleware(SecurityContextMiddleware, ctx_dep=runtime.get_context, authn=requirement, ...)
# After every router is attached:
apply_openapi_security(app, requirement)
It registers one securityScheme per ingress (bearer for a token on
Authorization; apiKey in header or cookie otherwise) and attaches a security
requirement — the ingress methods as alternatives — to exactly the operations the
catalog flagged as needing a bound principal. That flag (requires_authn) is
derived at freeze from the plan's AuthnRequired or authz hooks, so protected
routes advertise the scheme while token-minting routes (/login, /refresh) stay
open. Use exclude={"orders.deactivate", ...} to leave a flagged operation open.
This documents auth; it doesn't enforce it — enforcement stays in the engine
(the AuthnRequired/authz hooks) and identity extraction in the middleware.