Skip to content

OpenAPI & API Docs

Every arvel app ships an OpenAPI document and an interactive docs UI for free — generated from your routes by Litestar, not hand-written. Type a handler's request body and return value with arvel.Schema and the request/response schemas appear automatically; the whole document is configured from one typed file, config/openapi.py.

Needs the [http] extra

Serving the schema runs on Litestar — uv add 'arvel[http]'.

Where it lives

A new app is scaffolded with config/openapi.py already wired:

from arvel import env

config = {
    "title": env("APP_NAME", "blog"),
    "version": "0.1.0",
    "description": "The blog API.",
    "path": "/schema",   # docs UI here; raw document at /schema/openapi.json
    "ui": "swagger",     # swagger | redoc | scalar | rapidoc | stoplight
}

Boot the app (arvel serve) and visit /schema for the UI, or /schema/openapi.json for the raw document. This dict is a typed view (arvel.http.OpenApiSettings) — keys are validated through msgspec, so a typo or a wrong type is a clear error, not a silent miss.

Exporting the document to a file

To get the spec without running a server — commit it to the repo, diff it in CI, feed it to a client generator — use openapi:export, which renders it from the compiled app and writes JSON:

arvel openapi:export                 # writes openapi.json
arvel openapi:export docs/api.json   # …or a path you choose

A common CI check is to run it and fail if the committed file changed — so a route change that forgot to update the spec is caught in review.

Request & response schemas

Schemas come straight from your handler's type hints. Annotate the return value for a response schema; add a Schema-typed parameter for a request body schema (it's parsed and validated for you, then passed to the handler):

from arvel import Route, Schema


class CreateUser(Schema):
    name: str
    email: str


class UserOut(Schema):
    id: int
    name: str


async def store(request, data: CreateUser) -> UserOut:   # request + response schemas
    user = await User.create(name=data.name, email=data.email)
    return UserOut(id=user.id, name=user.name)


Route.post("/users", store, name="users.store")

The document now references CreateUser (request body) and UserOut (response) under components.schemas, and a malformed body returns 400 before your handler runs. The operation's operationId is the route name (users.store) — clean and stable for client generators.

Configuring the document

Every key below is optional; set what you need in config/openapi.py:

Key What it sets
title, version, description, summary Document identity
terms_of_service Link to your ToS
path Where the UI + document are served (default /schema)
ui The renderer: swagger (default), redoc, scalar, rapidoc, stoplight
use_handler_docstrings Use handler docstrings as operation descriptions (default True)
contact {"name": ..., "email": ..., "url": ...}
license {"name": "MIT"}
servers [{"url": "https://api.example.com", "description": "production"}]
tags [{"name": "auth", "description": "Authentication"}]
external_docs {"url": ..., "description": ...}
security Security schemes — see below

Authentication

Define a security scheme to get the Authorize button in the docs UI (so you can paste a token and call protected endpoints), then mark the routes that require it.

# config/openapi.py
config = {
    # ...
    "security": {"bearer": True},   # HTTP bearer/JWT scheme → the Authorize button
}
# routes/api.py
Route.get("/user", me, name="api.user").secure("bearer")   # shows the lock; requires the scheme

By default the scheme is only advertised — routes opt in with .secure(...), so public endpoints (login, health) stay open. To require it on every route instead, set a default:

"security": {"bearer": {"format": "JWT", "default": True}},

You can also customize the scheme, use an API-key scheme, or an OpenID Connect scheme (for an IdP login like Keycloak — .secure("oidc") on the route):

"security": {"bearer": {"format": "JWT", "description": "Paste your access token"}},
"security": {"api_key": {"name": "X-API-Key", "in": "header"}},
"security": {"oidc": {"openIdConnectUrl": "https://idp/realms/app/.well-known/openid-configuration"}},

Scaffold it

arvel new myapp --auth wires all of this up: a bearer login flow, config/openapi.py with the bearer scheme, and GET /api/user marked .secure("bearer") while /api/login stays public.

.secure() documents the contract — enforcement still lives in your middleware/handler (e.g. the bearer TokenGuard). See Authentication.

Common mistakes & gotchas

  • .secure() doesn't enforce anything. It documents that a route needs a scheme (and adds the lock in the UI). The actual check still lives in your middleware/guard — a route with .secure() but no guard is documented as protected while being wide open.
  • A security scheme that does nothing. Declaring security only advertises the scheme; routes opt in with .secure(...). Nothing is required until a route asks for it (or you set default: True).
  • A request body that never appears in the schema. The body schema comes from a Schema-typed handler parameter. A plain request-only handler has no documented (or validated) body.
  • Unnamed routes → ugly client SDKs. operationId is the route name, so an unnamed route gets a generated id. Name your routes for stable, readable generated clients.
  • Forgetting the [http] extra. The schema and UI serve on Litestar — without arvel[http] there's nothing to serve.

See also

  • Routing — defining and naming the routes the schema is generated from.
  • ValidationSchema and validating request input.
  • Authentication — the bearer-token flow behind a secured route.