Skip to content

Spec-First Routing

Summary

The OpenAPI document is the single source of truth for the HTTP contract. Handlers are bound to it by name; there is no decorator router.


The contract

  • openapi.yaml (or .json) declares paths, schemas, parameters, responses, and security.
  • A custom operationId names the handler function.
  • The framework (openapi_first) validates requests/responses against the spec and dispatches to the handler whose name equals operationId.

App bootstrap

# main.py
from openapi_first.app import OpenAPIFirstApp

app = OpenAPIFirstApp(
    openapi_path="openapi.yaml",
    routes_module=interfaces.openapi,   # module whose namespace holds handlers
    title="<App> API",
    version="0.1.0",
    lifespan=lifespan,
)

lifespan calls the composition root:

1
2
3
4
5
@asynccontextmanager
async def lifespan(app):
    app.state.db_resources = await init()
    yield
    await shutdown()

Handler registry

interfaces/openapi.py is a flat re-export of every handler, so the framework can resolve operationId across the whole app:

1
2
3
from interfaces.item.routes import *
from interfaces.wallet.routes import *
from interfaces.progress.sse import *

Handler shape

Handlers are thin and synchronous with respect to business logic:

1
2
3
async def create_item(request: Request, body: dict):
    svc = request.app.state.db_resources["item_svc"]
    return await svc.create(Item.model_validate(body))

Generated stubs. openapi-first routes emits this exact shape (async def handlers taking body) when given --template-style; without that flag the stub is def create_item(payload: ItemInput) — sync, with a payload parameter. Both shapes bind identically: the router resolves handlers by operationId and introspects the FastAPI signature, and it never inspects the request body. Pass --template-style so the generated signatures match the handlers you keep in interfaces/.

Conventions

  • operationId naming: list_<resource>, create_<resource>, get_<resource>, update_<resource>, delete_<resource>.
  • Read-only endpoints may use a cache decorator keyed on path + query + user.
  • Validation errors -> 400; not found -> 404; delete -> 204.
  • Security is declared once in the spec and enforced by the framework; handlers contain no auth code.

Authless apps

Auth is deferred in the standard: the same spec-first app runs with or without it, because security lives entirely in the spec, never in handlers. For an app that needs no auth yet, do not invent per-handler auth checks — just omit the security keys:

  • Remove the top-level security: block.
  • Remove components.securitySchemes.
  • Do not add security: [] to operations. An explicit empty array is a per-operation override that means "public", and it only matters when a global security block exists. Leaving every operation without a security key is the cleanest authless spec.

Profile endpoints (/users/me) and profile headers (x-profile) still exist in an authless app. Define a stand-in identity in the composition root — for example get_current_user returns or lazily creates a fixed "Demo User" — so handlers stay auth-agnostic and only the wiring changes when real auth arrives.

The handlers, services, and composition root are unchanged. When auth is needed later, add the global security block and an introspecting dependency; only handlers that must opt out get security: [].

Codegen

openapi_first scaffolds models and route stubs from the spec (see the Build Recipe, step 3):

1
2
3
openapi-first models openapi.yaml -o _gen/models.py
openapi-first routes openapi.yaml -o _gen/routes \
  --use-models --models-module _gen.models --inject-request --package --template-style

Prefer codegen over hand-writing boilerplate. The result is the wire contract and a set of operationId-named signatures — not the canonical handlers.