forze_fastapi connects Forze usecases to HTTP routes. It provides prebuilt routers for document and search operations, a custom ForzeAPIRouter with idempotency support, exception handlers, and OpenAPI docs integration.
Installation
uv add 'forze[fastapi]'
Execution context dependency
All Forze routes resolve ports through ExecutionContext. In FastAPI, provide a callable dependency that returns the current context:
from fastapi import FastAPI
from forze.application.execution import ExecutionRuntime
runtime = ExecutionRuntime(...)
app = FastAPI()
def context_dependency():
return runtime.get_context()
This function is passed as ctx_dep= to prebuilt routers or context_dependency= to ForzeAPIRouter.
Document router
build_document_router wires standard CRUD operations from a UsecaseRegistry and DocumentDTOs. It generates routes that resolve the DocumentUsecasesFacade, build each usecase, and execute it.
Generated endpoints
| Endpoint | Method | Description |
|---|---|---|
/metadata |
GET | Fetch document metadata by ID |
/create |
POST | Create a document (idempotent when configured) |
/update |
PATCH | Partial update with id, rev, and DTO body |
/delete |
PATCH | Soft-delete (when the spec supports it) |
/restore |
PATCH | Restore a previously soft-deleted document |
/kill |
DELETE | Hard-delete a document |
/list |
POST | List documents with typed results (opt-in) |
/raw-list |
POST | List documents with raw results (opt-in) |
List endpoints are disabled by default. Enable them with include_list_endpoints=True.
Setup
from forze.application.composition.document import (
DocumentDTOs,
build_document_registry,
tx_document_plan,
)
from forze_fastapi.routers import build_document_router
project_dtos = DocumentDTOs(
read=ProjectReadModel,
create=CreateProjectCmd,
update=UpdateProjectCmd,
)
registry = build_document_registry(project_spec, project_dtos)
registry.extend_plan(tx_document_plan, inplace=True)
app.include_router(
build_document_router(
prefix="/projects",
tags=["projects"],
registry=registry,
spec=project_spec,
dtos=project_dtos,
ctx_dep=context_dependency,
)
)
The router automatically detects whether the spec supports soft-delete and update operations and only generates applicable endpoints.
Search router
build_search_router exposes typed and raw full-text search endpoints:
| Endpoint | Method | Description |
|---|---|---|
/search |
POST | Typed search with Pydantic model response |
/raw-search |
POST | Raw search returning JSON dicts |
Setup
from forze.application.composition.search import (
SearchDTOs,
build_search_registry,
)
from forze_fastapi.routers import build_search_router
search_dtos = SearchDTOs(read=ProjectReadModel)
search_registry = build_search_registry(project_search_spec, search_dtos)
app.include_router(
build_search_router(
prefix="/projects",
tags=["projects-search"],
registry=search_registry,
spec=project_search_spec,
dtos=search_dtos,
ctx_dep=context_dependency,
)
)
You can also attach search routes to an existing router using attach_search_routes() for a combined endpoint group.
Custom routes with ForzeAPIRouter
When you need custom endpoints that still leverage Forze idempotency behavior, use ForzeAPIRouter:
from fastapi import Body
from pydantic import BaseModel
from forze_fastapi.routing.router import ForzeAPIRouter
class CreatePayload(BaseModel):
title: str
router = ForzeAPIRouter(
prefix="/custom",
tags=["custom"],
context_dependency=context_dependency,
)
@router.post(
"/create",
idempotent=True,
operation_id="custom.create",
idempotency_config={"dto_param": "payload"},
)
async def create(payload: CreatePayload = Body(...)):
ctx = router.resolve_context()
doc = ctx.doc_write(project_spec)
return await doc.create(payload)
ForzeAPIRouter extends FastAPI's APIRouter with:
context_dependency: a callable that returns theExecutionContextidempotentflag on routes for automatic deduplicationidempotency_configfor per-route or router-level idempotency settings
Idempotency
Idempotent POST routes prevent duplicate side effects when clients retry requests. The system requires:
idempotent=Trueon the route decorator- A stable
operation_idfor the route - An idempotency adapter registered in the dependency container (e.g. via
RedisDepsModule) - The client sends an
Idempotency-Keyheader with a unique key per request
When a duplicate request arrives (same operation, same key, same payload hash), the adapter returns the previously stored response instead of re-executing the operation.
How it works
- The route middleware calls
IdempotencyPort.begin()with the operation ID, idempotency key, and a hash of the request payload - If a cached snapshot exists, it is returned immediately
- If no snapshot exists, the route handler runs normally
- After a successful response,
IdempotencyPort.commit()stores the response for future deduplication
Configuration
Router-level defaults:
router = ForzeAPIRouter(
prefix="/api",
context_dependency=context_dependency,
idempotency_config={
"key_header": "Idempotency-Key",
"dto_param": "payload",
},
)
Per-route overrides:
@router.post(
"/create",
idempotent=True,
operation_id="resource.create",
idempotency_config={"dto_param": "body"},
)
async def create(body: CreatePayload = Body(...)):
...
Exception handlers
Register built-in handlers to map Forze errors to appropriate HTTP status codes:
from forze_fastapi.handlers import register_exception_handlers
register_exception_handlers(app)
| Forze error | HTTP status | When |
|---|---|---|
NotFoundError |
404 | Document or resource not found |
ConflictError |
409 | Revision conflict, duplicate key |
ValidationError |
422 | Domain validation failure |
CoreError |
500 | Unexpected framework error |
The response body includes the error message and, when available, a machine-readable code in the X-Error-Code header.
Scalar API reference
Register Scalar docs page for interactive API exploration:
from forze_fastapi.openapi import register_scalar_docs
register_scalar_docs(app, path="/docs", scalar_version="1.41.0")
The page title is derived from app.title. The Scalar docs page replaces the default Swagger UI with a more modern interface.
Route parameters
forze_fastapi provides common parameter helpers used by prebuilt routers:
| Helper | Type | Purpose |
|---|---|---|
UUIDQuery |
UUID |
Document ID query parameter |
RevQuery |
int |
Revision query parameter for optimistic concurrency |
pagination() |
Pagination |
Limit/offset pagination dependency |
These are also available for custom routes when building your own endpoints.
Runtime scope with FastAPI lifespan
Use the runtime scope as a FastAPI lifespan context manager:
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
async with runtime.scope():
yield
app = FastAPI(title="My API", lifespan=lifespan)
This ensures infrastructure clients are connected during the application lifetime and properly shut down when the application stops.
Complete example
Complete example
import asyncio
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
from forze.application.composition.document import (
DocumentDTOs,
build_document_registry,
tx_document_plan,
)
from forze.application.composition.search import (
SearchDTOs,
build_search_registry,
)
from forze.application.execution import Deps, DepsPlan, ExecutionRuntime, LifecyclePlan
from forze_fastapi.handlers import register_exception_handlers
from forze_fastapi.openapi import register_scalar_docs
from forze_fastapi.routers import build_document_router, build_search_router
from forze_postgres import (
PostgresClient,
PostgresConfig,
PostgresDepsModule,
postgres_lifecycle_step,
)
from forze_redis import RedisClient, RedisConfig, RedisDepsModule, redis_lifecycle_step
# Runtime setup
pg = PostgresClient()
redis = RedisClient()
runtime = ExecutionRuntime(
deps=DepsPlan.from_modules(
lambda: Deps.merge(
PostgresDepsModule(client=pg, rev_bump_strategy="database", history_write_strategy="database")(),
RedisDepsModule(client=redis)(),
),
),
lifecycle=LifecyclePlan.from_steps(
postgres_lifecycle_step(dsn="postgresql://app:app@localhost:5432/app", config=PostgresConfig()),
redis_lifecycle_step(dsn="redis://localhost:6379/0", config=RedisConfig()),
),
)
@asynccontextmanager
async def lifespan(app: FastAPI):
async with runtime.scope():
yield
app = FastAPI(title="Projects API", lifespan=lifespan)
register_exception_handlers(app)
register_scalar_docs(app)
ctx_dep = lambda: runtime.get_context()
# Document routes
project_dtos = DocumentDTOs(
read=ProjectReadModel,
create=CreateProjectCmd,
update=UpdateProjectCmd,
)
doc_registry = build_document_registry(project_spec, project_dtos)
doc_registry.extend_plan(tx_document_plan, inplace=True)
app.include_router(
build_document_router(
prefix="/projects",
tags=["projects"],
registry=doc_registry,
spec=project_spec,
dtos=project_dtos,
ctx_dep=ctx_dep,
)
)
# Search routes
search_dtos = SearchDTOs(read=ProjectReadModel)
search_registry = build_search_registry(project_search_spec, search_dtos)
app.include_router(
build_search_router(
prefix="/projects",
tags=["search"],
registry=search_registry,
spec=project_search_spec,
dtos=search_dtos,
ctx_dep=ctx_dep,
)
)
async def main():
server = uvicorn.Server(uvicorn.Config(app, host="0.0.0.0", port=8000))
await server.serve()
if __name__ == "__main__":
asyncio.run(main())