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. Each path or query parameter a generated route declares, such as a
document's id and rev or a storage key, carries its own description in the
schema. 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, and a spec declaring hard_delete=False gets no
DELETE route; 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 (only attach_authn_routes can infer it, see
Authn routes). 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",
)
Uploads: the size cap¶
The upload route refuses a file over max_upload_size (default 64 MiB,
DEFAULT_MAX_UPLOAD_SIZE; None disables it) with a 422 coded
upload_too_large, before the operation runs. A Content-Length over the cap is
rejected early, as a shortcut; the guard is the reader, which counts bytes as it
reads the file, 1 MiB at a time, so at most one chunk past the cap is held in
memory. That bounds memory, not disk: before the route runs, the multipart
parser keeps the first 1 MiB of the file in memory and rolls everything past
1 MiB to a temporary file on disk, with no cap — so cap the request body at your
proxy too.
When an upload takes parameters the generated route doesn't — which record the
file attaches to, say — write the route by hand and read the file with
read_upload_capped, the generated route's reader. It refuses with the same 422
status and upload_too_large code:
from typing import Any
from fastapi import APIRouter, Depends, UploadFile
from forze.application.execution.operations import run_operation
from forze_fastapi.routes import DEFAULT_MAX_UPLOAD_SIZE, read_upload_capped
records_router = APIRouter(prefix="/records", tags=["records"])
@records_router.post("/{record_id}/attachments", status_code=201)
async def attach_file(record_id: str, file: UploadFile, ctx=Depends(ctx_dep)) -> Any:
data = await read_upload_capped(file, DEFAULT_MAX_UPLOAD_SIZE)
args = AttachFile(record_id=record_id, filename=file.filename or "upload", data=data)
return await run_operation(registry, "records.attach_file", args, ctx)
The refusal answers 422 only once the app has called
register_exception_handlers(app) (from forze_fastapi.exceptions); without it
the error surfaces as a 500.
A stored-file kit's max_bytes is a backstop, not a memory guard: it is checked
once the whole upload is in memory, so keep the read capped at the route.
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, serve presign_download from a
route of your own (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/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.
presign_download gets no generated route. Its result is a bearer credential, so
it is marked sensitive, and generated routes, MCP, agent tools and forze ops
refuse it; naming it in include is a configuration error. Serve it from your
own route, and guard the operation in the registry as you guard download:
from fastapi import Depends
from forze.application.execution.operations import run_operation
from forze.application.hooks.authn import AuthnRequired
from forze.application.hooks.authz import AuthzBeforeAuthorize
from forze_kits.aggregates.storage import (
PresignDownloadRequestDTO,
PresignedUrlDTO,
StorageKernelOp,
build_storage_registry,
)
ns = storage_spec.default_namespace
storage_registry = (
build_storage_registry(storage_spec)
.bind(ns.key(StorageKernelOp.PRESIGN_DOWNLOAD))
.bind_outer()
.before(
AuthnRequired().to_step(),
AuthzBeforeAuthorize(spec=AUTHZ, action="files:presign_download").to_step(),
)
.finish(deep=True)
.freeze()
)
@files_router.post("/presign/download")
async def presign_download(
body: PresignDownloadRequestDTO, ctx=Depends(runtime.get_context)
) -> PresignedUrlDTO:
return await run_operation(storage_registry, "files.presign_download", body, ctx)
presign_upload and presign_part keep their generated routes. They are command
operations, so read-only defaults never expose them: MCP and forze ops need
include_writes=True, and a route exists only where you attach one. The direct-upload
flow above needs them.
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.
Stored-file downloads¶
The stored-file kit ships no generated JSON routes. It has four read-only
download operations, and each one loads the row first: a row the caller can't
read (another tenant's, or a missing one) answers not-found, and a pending or
failed file is refused before storage is touched. download is always
registered. The other three are opt-in, because an operation runs only the
guards bound on its own key: enable each with
StoredFileKitSpec(stream_downloads=True, range_downloads=True, presign_downloads=True),
then bind your authn and authz on it as you do on download. A
StoredFileFacade method for an operation left off raises a configuration
error.
| Operation | Input | Result |
|---|---|---|
download |
StoredFileIdDTO |
StoredFileDownloadDTO: the whole object in memory, for small files and tests |
download_stream |
StoredFileIdDTO |
StoredFileStream: the row plus a bounded-memory StreamedDownload named after the stored filename |
download_range |
DownloadStoredFileRangeRequestDTO (id, inclusive start, end or None for the end) |
StoredFileRangeDTO: the row plus the window's bytes, content_range and total_size; a start past the end is a precondition error |
presign_download |
PresignStoredFileDownloadRequestDTO (id, expires_in up to 7 days) |
StoredFilePresignedDownloadDTO: the row plus a presigned GET URL |
A hand-written route hands the stream to streamed_download_response, which
builds the generated download route's 200: Content-Disposition, ETag,
Last-Modified, and Content-Length when the size is known. It doesn't answer
Range or conditional requests.
from uuid import UUID
from fastapi import APIRouter, Depends
from forze.application.execution.operations import run_operation
from forze_fastapi.routes import streamed_download_response
from forze_kits.aggregates.stored_file import StoredFileIdDTO
files_router = APIRouter(prefix="/files", tags=["files"])
@files_router.get("/{file_id}/content")
async def file_content(file_id: UUID, ctx=Depends(ctx_dep)):
args = StoredFileIdDTO(id=file_id)
result = await run_operation(registry, "files.download_stream", args, ctx)
return streamed_download_response(result.stream)
attach_stored_file_download_route attaches the full download route instead.
It runs download_stream and download_range, so it needs both enabled, and it
answers like the storage kit's streaming download: 206 for a Range, each
window capped at max_range_bytes (16 MiB by default), 416 past the end,
304 for a matching If-None-Match or If-Modified-Since, and the streamed
200 otherwise. Each request opens the stream first, which reads the object's
head but not its body, so a 304 transfers nothing.
from forze_fastapi.routes import attach_stored_file_download_route
files_router = APIRouter(prefix="/files", tags=["files"])
# GET /files/{id}/content
attach_stored_file_download_route(
files_router,
registry=registry,
ns=kit.document.default_namespace,
ctx_dep=ctx_dep,
)
On a client-side-encrypting route the kit's upload seals each file as one
AEAD payload, which a range can't slice. download_range refuses such a file
with core.storage.range_whole_payload_unsupported, and the route answers a
Range with the whole file and a 200.
The stream reads the object in parts while the response is sent. If the file is
deleted mid-download, the next read fails with not_found. The status is already
sent, so the response is cut short: the client gets fewer bytes than
Content-Length, or a chunked body with no final chunk. A stored file's key is
never reused, so a stream can't mix two files.
presign_download follows the storage kit's presign rules: the URL is a bearer
credential and the storage key's tenant check applies. The URL signs in the
stored filename as the response's Content-Disposition, so the download saves
under that name rather than the object key's.
Because its result is a credential, presign_download is marked sensitive.
MCP tools, agent tools, generated routes and forze ops refuse it, so serve it
from a hand-written route like the one above. An MCP server over a registry that
enables it needs an operations= allowlist that leaves it out, because the
default exposes every read-only operation and the refusal covers the whole
registration. download_stream is not marked sensitive: it returns the same
bytes as download, not a credential.
download_stream returns a byte stream, and download and download_range
return raw bytes, not JSON, so their descriptors set streams=True. JSON can't
carry a binary file: its bytes aren't UTF-8, so encoding them fails. MCP tools
and resource templates, agent tools, forze ops and attach_operation_routes
can't carry these operations. The MCP and forze ops defaults leave them out,
and naming one anywhere is refused. Serve them from a route that calls
streamed_download_response, like the one above.
A client-side-encrypting storage route can't serve presign_download: the
bytes would leave the store without passing through the app, which decrypts
them. The kit resolves that operation's storage as
StorageSpec(direct_transfer=True), and the route refuses such a spec with a
configuration error (storage_direct_transfer_encrypted). So check_wiring
reports the operation before anything runs, and without check_wiring its
first call fails before the handler runs. The kit's other operations keep
working on that route.
Stored-file direct uploads¶
upload takes the whole file in the request body. For large files,
StoredFileKitSpec(direct_uploads=True) registers three commands that keep the
bytes out of the app: the client sends them straight to the bucket through
presigned URLs. Bind authn and authz on each one, as you do on upload.
| Operation | Input | Result |
|---|---|---|
initiate_upload |
InitiateStoredFileUploadRequestDTO (filename, exact size, content_type, multipart, and expires_in for a single PUT: 15 minutes by default, at most 1 hour) |
StoredFileUploadSessionDTO: the pending row plus a presigned PUT (presigned) or a multipart upload_id |
presign_upload_part |
PresignStoredFileUploadPartRequestDTO (id, upload_id, part_number, expires_in with the same bounds) |
PresignedUrlDTO for one part; it touches the row, bumping its revision, and parallel calls retry the touch |
complete_upload |
CompleteStoredFileUploadRequestDTO (id, plus upload_id and parts for a multipart upload) |
the ready row |
initiate_uploadmints the storage key,{tenant}/{file_id}, and writes apendingrow that holds it. The client never names a key. A declared size overmax_bytesis refused before anything is minted.- The client
PUTs the bytes topresigned.urlwith every header inpresigned.headers: the signed content type, and the create-only precondition (If-None-Match: *on S3,x-goog-if-generation-match: 0on GCS). For a multipart upload it askspresign_upload_partfor each part instead, and keeps each part'sETag. S3 needs the ETags, and every part but the last must be at least 5 MiB there. complete_uploadchecks a multipart upload before assembling it: the stored sizes of the named parts must fitmax_bytesand the declared size, or the row is markedfailedand the session aborted. It assembles create-only, then reads the object's head. The object's size must equal the declared size and stay withinmax_bytes, and its content type must equal the declared one. A match marks the rowready. A mismatch marks the rowfailed, then deletes the object, and raisesupload_mismatchorupload_too_large. If nothing has been uploaded yet, it raisesupload_incompleteand the row stayspending.
An uploaded object is create-only: the single PUT URL creates it once and can
never replace it, for as long as the URL stays valid, and a multipart completion
refuses to assemble over an existing object. So the bytes complete_upload
checked are the bytes the file keeps. Two complete_upload calls that race are
settled by the row's revision: the loser raises revision_mismatch, and a loser
that refused the object marks nothing and deletes nothing.
initiate_upload and presign_upload_part return credentials: a bearer write
URL, or a session id that lets its holder add parts. Both are marked sensitive,
so generated surfaces refuse them; serve them from your own routes.
bind_stored_file_writes runs initiate_upload in a transaction, as it does
upload, and leaves complete_upload outside one, so the failed mark
survives the refusal raised after it.
A client-side-encrypting route can't serve the three direct-upload commands:
the app never sees the bytes, so it can't encrypt them. As with
presign_download, the kit resolves their storage with direct_transfer=True,
so check_wiring reports them and a call fails before any row is written.
Sweeping abandoned uploads¶
A client that initiates and never completes leaves a pending row, and
possibly an object or an unfinished multipart upload. A buffered upload
whose process died between its commit and the after-commit stage leaves a
pending row too. stored_file_pending_sweep_lifecycle_step cleans them up
on an interval (10 minutes by default):
from forze_kits.aggregates.stored_file import stored_file_pending_sweep_lifecycle_step
lifecycle_steps.append(stored_file_pending_sweep_lifecycle_step(kit))
A row's age counts from its last update. initiate_upload creates the row,
and each presign_upload_part touches it. Each sweep reads the pending rows
not updated for older_than (24 hours by default), in id order and in
batches. For each row, the sweep:
- Marks the row
failed, with the revision check thatcomplete_uploaduses when it refuses an upload. If acomplete_uploadraces the sweep, one of them wins cleanly: the sweep leaves a completed file alone, and a complete that lost raisesrevision_mismatch. - Aborts every multipart upload still open at the row's key.
- Deletes the object at the key, if one landed.
- Clears the row's
storage_key.
The sweep then repeats steps 2 to 4 for the failed rows not updated for
older_than that still hold a key. Those are rows whose cleanup failed after
the mark, and rows complete_upload refused, whose single PUT URL could
still land an object after the refusal.
older_than must be longer than 1 hour, the longest a direct-upload URL
lives. Every URL is signed before the update that records it:
initiate_upload signs before it creates the row, and presign_upload_part
signs, then touches the row, and hands the URL out only if the touch succeeds.
So every URL handed out expires within 1 hour of the row's last update, and
none still works once the row is past the cutoff. Concurrent sweeps can't fail a row twice, so
run the step on every replica, unwrapped. Pass tenants= for tenant-aware
document and storage routes. A missing route fails startup. Only a wiring
error, such as tenant_global_required, stops the loop. Any other error,
including a configuration error, is logged for its row or tenant, and the row
is retried on a later sweep.
On S3, initiate_upload opens the multipart upload inside its transaction, so
a rollback after that leaves an empty upload with no row for the sweep to
find. Keep a bucket lifecycle rule that aborts incomplete multipart uploads
(AbortIncompleteMultipartUpload). On GCS a rollback leaves nothing, because
opening a session writes nothing.
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, behind admin_guards only,
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). The operations that act on another principal,
deactivate_principal and the admin API-key routes below, are registered only when
build_authn_registry gets admin_guards, and run behind them. The full
wiring (including how the reset token reaches the user via the outbox) is in the
Authn, authz & tenancy recipe.
Unlike the other helpers, attach_authn_routes needs neither ns= nor resource=:
without them it uses the one namespace in the registry that holds password_login or
refresh_tokens under the default . separator, which is spec.default_namespace for
build_authn_registry(spec). It never guesses — several such namespaces, or none, is a
configuration error that asks for ns=.
Cookie mode¶
Browser clients that must not hold a token in JavaScript pass an
AuthnCookieCarrier to attach_authn_routes(cookies=…). Login and refresh then
set and rotate two HttpOnly cookies instead of returning token strings; the
body keeps the scheme and the *_expires_in lifetimes, so a cookie client still
learns how long its credential is valid. Refresh reads the refresh cookie when
the request has no body or an empty JSON object ({}, as fetch wrappers and
generated clients send); a token in the body takes precedence. Logout revokes
the session and expires both cookies idempotently.
from fastapi import APIRouter
from forze_fastapi.security import AuthnCookieCarrier
router = APIRouter(prefix="/api/auth")
cookies = AuthnCookieCarrier(
access_path="/api", # API calls carry the access cookie
refresh_path="/api/auth", # the long-lived one: every authn route, no other API call
)
attach_authn_routes(router, registry=registry, ctx_dep=ctx_dep, cookies=cookies)
Logout revokes what a logout by access token revokes (every session of the
principal). With no identity bound, which is the case once the access cookie has
expired, it runs logout_by_refresh_token with the refresh cookie instead. That
operation has no route of its own. So scope refresh_path to cover both the refresh
and the logout route, as above. No single cookie path covers only /refresh and
/logout: with refresh_path="/api/auth" the browser sends the refresh cookie to
every authn route under that prefix, and only refresh and logout read it.
runtime_lifespan refuses to start an app whose cookie-mode logout route the
refresh cookie's path does not cover (check_authn_cookie_paths, run by
run_startup_checks on the app and every app mounted beneath it).
Every tab and window shares the refresh cookie, so two of them can refresh with the
same token at once. With a reuse grace the losing
refresh answers 401 refresh_superseded instead of revoking the session as reuse. The
client refreshes again once the winner's response has set the new cookie: a retry sent
before then gets refresh_superseded again, so wait for the other refresh or retry after
a short delay.
Point the inbound CookieTokenAuthn's cookie_name at the same
access_cookie, and list the login, refresh and logout paths in the security
middleware's anonymous_paths —
otherwise a stale access cookie 401s the very route that would replace it. A 401'd
logout never runs, so the session stays live and the cookies stay set.
check_authn_cookie_paths also refuses to start an app whose security middleware
leaves one of the three cookie-mode routes out of anonymous_paths.
Cookies are always HttpOnly and Secure by default, with samesite="lax" as
the outbound browser-side default. Inbound, CookieTokenAuthn ships a
server-side CSRF gate on by default (CookieCsrf): a request using either
cookie on an unsafe method must prove a same-host origin via Origin (or
Referer), so the defense holds even when a proxy strips SameSite or a
samesite="none" deployment relies on it. The middleware applies it to the
access cookie. Login, logout and refresh apply the same ingress's gate to every
request, with or without a cookie (the default gate if no CookieTokenAuthn reads
the carrier's access_cookie), so the settings below cover all three. A cross-origin frontend (a SPA on
another host) lists itself in CookieCsrf(allowed_origins={...}) — a dev server
that picks a free port may be named as a range on a loopback host
(http://localhost:5173-5199, or http://localhost:*); non-browser
cookie clients that send neither header opt in with allow_missing_origin=True
or authenticate via a header ingress. csrf=None disables the gate — a declared
decision to bring your own CSRF layer. When several CookieTokenAuthn read one cookie,
every gate they declare applies to it, so it is ungated only when all of them opt out.
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: another principal authentication accepts, and a
registered service principal when AuthnDepsModule(authz_route=...) is set; otherwise
422 delegate_invalid) 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 (204, and 204 again for a key
already revoked; a key the caller does not hold is a 404, as an unknown one is). This is the minting
surface for the MCP API-key flow:
the user issues a key here and pastes it into the agent host.
An administrator finds a principal's keys with GET /admin/principals/{id}/api-keys
(list_principal_api_keys: the same non-secret descriptors, revoked keys included, and
an empty list for a principal with none) and revokes one with DELETE /admin/api-keys/{id}
(revoke_principal_api_key, 204, and 204 again for a key already revoked; an unknown key
is a 404). They exist only when the registry is built with admin_guards, the steps that
decide who may act on another principal; they run behind them, and so does
deactivate_principal, which without them is not registered either. One guard set gates
all three. At least one guard must declare the permission keys it enforces
(permission_keys()), as AuthzBeforeAuthorize does, or the registry is refused when
built (admin_guards_unauthorized): AuthnRequired, TenantRequired or a logging step
let every signed-in principal through. A step of your own counts once it implements
permission_keys(), and its keys then show in the catalog and in MCP tool descriptions; a
functools.partial or __wrapped__ wrapper counts as what it wraps. If one of your guards
authorizes without declaring it, pass trust_admin_guards=True and answer for it. If you
bound your own guards on deactivate_principal, pass them as admin_guards instead: the
same step bound twice fails at freeze() with Step ID … is not unique.
The guards also run on the list, which is a read: there they run read-only, so a guard
that writes (an audit row, say) fails the list with Cannot use command (write) port.
Write such a record from an after-hook or through the outbox instead.
The admin API-key routes are global, like deactivate_principal: API-key accounts are
not tenant-scoped, so they find a principal's keys or a key by id in every tenant, and the
revoke's 404 tells a caller whether an id exists anywhere. An app whose administrators
are scoped to a tenant must not expose these routes, or must guard them with a policy
only platform administrators pass.
A delegated caller (an agent acting for the user) may list the keys but not issue or revoke
them, and may not log out or change the password: only the user manages their own account.
Nor may it act as an administrator, to list or revoke anyone's keys or deactivate a
principal: an agent acting for an administrator is not one. Those routes answer 403 (delegate_denied), and so do switching and leaving a
tenant below.
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). In cookie mode, pass the same carrier as cookies=: /activate
then sets and rotates both cookies and strips the token strings from its body, behind the
gate login applies. It authenticates from the access cookie, so leave it out of
anonymous_paths. The access cookie's access_path must cover the tenancy routes' mount,
or the switch answers 401. The refresh cookie's path need not cover it: the response sets
that cookie at its own path, and the route never reads it. The gate follows the csrf of
the CookieTokenAuthn reading the access cookie: csrf=None on every such ingress turns it
off for the switch too, as for login.
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.
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.
Your own operation routes¶
An operation no attacher ships gets its route from a binding table, not a hand-written
endpoint. attach_operation_routes takes the table keyed by operation suffix under a
namespace; the schema, operation_id (the namespaced key, e.g. stock.add) and dispatch
come from the catalog, as for the generated routes.
from forze.base.primitives import StrKeyNamespace
from forze_fastapi.routes import (
RouteBinding,
attach_operation_routes,
body_endpoint,
id_endpoint,
query_endpoint,
)
attach_operation_routes(
router,
registry=registry,
ns=StrKeyNamespace(prefix="stock"),
ctx_dep=ctx_dep,
bindings={
"levels": RouteBinding(method="GET", path="/levels", build=query_endpoint),
"add": RouteBinding(method="POST", path="/add", build=body_endpoint, status_code=201),
"get": RouteBinding(
method="GET",
path="/{id}",
build=id_endpoint,
param_descriptions={"id": "The stock item's ID."},
),
},
)
A binding whose operation is not registered is refused, so a typo fails at startup rather
than answering 404; pass skip_unregistered=True to skip it instead.
Every routed operation's key lives under the namespace, as <prefix>.<suffix>. A key
registered without one, such as a bare add, cannot be bound: register it as stock.add
instead. The refusal names a bare key it finds, so the cause is not mistaken for a typo.
query_endpoint reads the whole input DTO from query parameters, one per field; a list
field repeats (?tag=a&tag=b). A field a query string cannot carry, such as a nested
model or bytes, is refused when the route is attached. The operation receives the DTO a
body with the same keys would build: only the parameters sent count as set, so a patch built
from it leaves the rest alone, and validators and extras behave as for a body. A parameter
the DTO does not declare is ignored unless the DTO forbids or allows extra fields. A literal
+ in a value, such as a timezone offset, must be sent as %2B: a query string reads + as
a space.
body_endpoint, id_endpoint, id_rev_endpoint and id_rev_body_endpoint are the
builders the document routes use. The id builders take id and rev from the path where it
has their placeholder and from the query otherwise, typed and defaulted as the input DTO
declares them. Each
shipped builder names the placeholders it fills, and a path with any other placeholder is
refused. An EndpointBuilder of your own takes (runner, input_type, op); give it a
path_params frozenset to have its paths checked the same way.
A binding's param_descriptions describe its endpoint's parameters by name. Each becomes
FastAPI's own Path where the path has the placeholder and Query otherwise, so id
reads the same in both route styles. Only a plain path or query parameter takes one. When
the route is attached, an empty description is refused, and so is a name the endpoint does
not take. A body, the request, or a parameter with its own Query, Depends, … is refused
too: describe that one in its own metadata. A query_endpoint parameter takes its
description from the DTO field.
The operation's output type is the route's response model. It need not be a bare model: an
operation that may find nothing declares output_type=Model | None and answers 200 with
null, and MCP returns it as {"result": null}. An operation without one, such as
a federated search, still has a model result written as a typed route's is: fields that are
None are left out unless the attacher passes exclude_none=False, and the OpenAPI schema
documents an untyped body. Any other result, such as a dict or a list, is written as FastAPI
writes a route without a response model. Either way an infinite or NaN float is written as
null.
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", probes=None, probe_timeout=timedelta(seconds=2))— 200 while the runtime is active and not draining, 503 otherwise; excluded from the schema.runtimemay also be a zero-argument callable returning the runtime orNone, asked per request, for an app that builds its runtime in the lifespan.probesmaps a name to the deps key of a client to askhealth(), adding achecksbreakdown and adegraded503 when a dependency is unreachable — see Readiness.attach_liveness_route(router, *, path="/livez")— 200 unconditionally; reaching the handler is the check. Deliberately blind to drain, so a draining pod reads as alive-but-not-ready instead of dead. Excluded from the schema.attach_metrics_route(router, reader, *, path="/metrics")— a Prometheus pull endpoint over an already-registeredPrometheusMetricReader(needsopentelemetry-exporter-prometheus; any other reader is refused). Renders off the event loop, since collection runs every observable callback in the process. Single-process deployments only — see The Grafana stack.
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"),),
)
# ...plus whatever else your middleware takes.
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. The middleware refuses a request no
ingress authenticates outside its anonymous_paths (list the mounted /auth/login and /auth/refresh
there), and per-operation enforcement stays in the engine's AuthnRequired/authz
hooks.
Match OpenAPI to what the app serves¶
FastAPI documents its own 422 body (HTTPValidationError) on every route that
takes input, but the Forze exception handlers answer errors — a failed request
parse included — with the Forze envelope. Descriptions come from docstrings, so
reST such as :class:`~shop.Order` reaches the rendered docs as written. One
call fixes both:
from forze_fastapi import apply_openapi_conventions
# After every router is attached, and after apply_openapi_security:
apply_openapi_conventions(app)
Each FastAPI 422 becomes the ForzeErrorResponse envelope (detail, code, kind,
optional context), and every operation without its own default response gains one in
the same shape. Only detail is listed as required: every error Forze raises carries code
and kind, but an HTTPException from your code or a FastAPI dependency answers with
detail alone.
The X-Error-Code header accompanies the errors that carry a code, so it is
documented as optional. FastAPI's validation schemas are dropped once nothing
references them. A 422 your route declares with its own model is left alone. A
different model of yours already named ForzeErrorResponse is not overwritten:
the schema request fails with a configuration error, since routers can still be
attached after the call. Responses a route sends
with no body at all, such as a storage download's 304 and 416, are not
envelopes; the default entry does not describe them.
Every description and summary then has its reST rendered as Markdown without
losing prose: roles and ``literals`` become code spans, field lists
(:param:, :returns:, …) are dropped, code-block and :: literal blocks
become fenced blocks, and any other directive (note, deprecated,
versionadded, …) becomes a labelled blockquote. Fenced blocks already in
Markdown pass through as written. Call it last: text a later wrapper adds, such as a security scheme's
description, stays as written.
A generated client types a response field optional when its schema does not list it required,
and pydantic leaves every field with a default optional, though the app sends it every time.
apply_openapi_conventions(app, require_sent_fields=True) lists what every route returning a
schema sends. Where only routes that keep nulls (exclude_none=False) return it, every field is
required. Where a route drops nulls, the default for the routes Forze attaches, the fields that
cannot be null are required, and a nullable one, left out when it is null, is not, even
without a default. A schema is one component shared by all the routes that return it, so where
a route that keeps nulls and one that drops them return the same model, its nullable fields
are optional for both. A schema a request also uses is left as it is, and so is one a route
trims some other way (response_model_exclude_unset, include, exclude).
tag_groups groups the sidebar that Scalar and Redoc render, through the top-level
x-tagGroups extension. Groups and their tags keep the order you give:
from forze_fastapi import apply_openapi_conventions
apply_openapi_conventions(
app,
tag_groups={"Content": ["notes", "files"], "Access": ["auth"]},
)
A group without a name, tags given as one string ("notes" for ["notes"]), or a tag
listed twice is a configuration error raised by the call itself. A listed tag that no
operation is tagged with, a typo or a renamed router, is left out of its group with a
warning, and a group left with no tags is dropped. Neither renderer shows a tag that no
group lists, nor the operations under it. The tags you leave out therefore join a group
named Other, in the order operations first use them: last, or where you list Other
yourself. An operation with no tags is in no group: Scalar still lists it, Redoc does not,
so tag every route. A later call with tag_groups replaces the groups; one without leaves
them as they are. register_scalar_docs needs no setting, since Scalar reads the groups
from the schema.
register_scalar_docs points Scalar at the URL the app serves its document at
(app.openapi_url, prefixed with the request's root_path), so a versioned
FastAPI(openapi_url="/v1/openapi.json") needs nothing extra. Pass openapi_url= for a
document another route serves; an app with openapi_url=None must pass one.