OpenAPI, metadata and docs¶
Veloce generates an OpenAPI 3.1 document
from your registered routes and serves it alongside two interactive explorers.
Veloce exposes the document at /openapi.json,
Swagger UI at /docs, and ReDoc at /redoc by default. The schema is built lazily
on first access and cached on app.openapi_schema.
from veloce import Request, Veloce
app = Veloce(title="Catalog API", version="1.0.0")
@app.get("/ping")
async def ping(request: Request):
return {"pong": True}
if __name__ == "__main__":
app.run(port=8000)
Run the app and visit http://localhost:8000/docs for Swagger UI or
http://localhost:8000/redoc for ReDoc. Both read from /openapi.json.
App metadata¶
Metadata passed to the Veloce constructor populates the OpenAPI info object
and the document root. Only title and version are emitted unconditionally;
the rest appear when set.
from veloce import Request, Veloce
app = Veloce(
title="Catalog API",
version="2.3.1",
summary="Products, carts and orders.",
description="A longer description, **Markdown** is rendered by the docs UIs.",
terms_of_service="https://example.com/terms",
contact={"name": "API Support", "url": "https://example.com/support"},
license_info={"name": "MIT", "url": "https://opensource.org/license/mit"},
servers=[{"url": "https://api.example.com", "description": "Production"}],
openapi_tags=[{"name": "items", "description": "Catalog operations."}],
openapi_external_docs={"url": "https://example.com/docs", "description": "Manual"},
)
@app.get("/items", tags=["items"])
async def list_items(request: Request):
return []
| Constructor argument | Emitted as | Purpose |
|---|---|---|
title |
info.title |
API name shown in the docs UIs. Defaults to "Veloce". |
version |
info.version |
API version string. Defaults to "0.1.0". |
summary |
info.summary |
One-line summary, distinct from description. |
description |
info.description |
Longer Markdown description. |
terms_of_service |
info.termsOfService |
URL to the terms of service. |
contact |
info.contact |
OpenAPI contact object. |
license_info |
info.license |
OpenAPI license object. |
servers |
servers |
Base URLs the API is served from. |
openapi_tags |
tags |
Tag metadata and ordering for the document. |
openapi_external_docs |
externalDocs |
Document-level external documentation object. |
openapi_version on the app is the spec version string emitted as the document's
top-level openapi key; it defaults to "3.1.0" and rarely needs changing.
Docs URLs¶
The three serving paths are constructor arguments. Set any of them to change the
path; set one to None to disable that endpoint.
from veloce import Request, Veloce
app = Veloce(
openapi_url="/api/openapi.json",
docs_url="/api/docs",
redoc_url="/api/redoc",
)
@app.get("/ping")
async def ping(request: Request):
return {"pong": True}
| Argument | Default | Purpose |
|---|---|---|
openapi_url |
/openapi.json |
Path serving the generated OpenAPI JSON document. |
docs_url |
/docs |
Path serving Swagger UI. |
redoc_url |
/redoc |
Path serving ReDoc. |
Note
The /docs and /redoc routes fetch the document from openapi_url. If you
move openapi_url, Veloce wires the UIs to the new path automatically — you
do not adjust them separately.
Conditional OpenAPI¶
Disabling a docs surface is per-URL, and the granularity matters.
docs_url=None/redoc_url=None- removes an interactive UI while the JSON schema endpoint stays up.
openapi_url=None- disables the whole OpenAPI subsystem — no JSON document and, because the UIs
depend on it, no
/docsor/redoc.
import os
from veloce import Request, Veloce
# Serve the machine-readable schema everywhere, but only expose the
# interactive explorers outside production.
production = os.environ.get("ENV") == "production"
app = Veloce(
docs_url=None if production else "/docs",
redoc_url=None if production else "/redoc",
)
@app.get("/ping")
async def ping(request: Request):
return {"pong": True}
Disable with None, not \"\"
The off switch is the value None. An empty string is a real (empty) path,
not a disable signal, and registering a route at "" is invalid. To turn a
docs surface off, pass None.
from veloce import Request, TestClient, Veloce
app = Veloce(docs_url=None, openapi_url="/openapi.json")
@app.get("/ping")
async def ping(request: Request):
return {"pong": True}
client = TestClient(app)
assert client.get("/docs").status_code == 404
assert client.get("/openapi.json").status_code == 200
Extending the schema¶
app.openapi() returns the generated
document, building it on first call and caching it on app.openapi_schema.
The returned dict is the live cached object, so mutating it in place is the supported
way to inject keys the generator does not produce — an info.x-logo, a custom tag
ordering, vendor extensions.
from veloce import Request, Veloce
app = Veloce(title="Catalog API")
@app.get("/items")
async def list_items(request: Request):
return []
# Build once, then patch the cached document in place. The /docs and
# /openapi.json endpoints serve this same mutated object.
schema = app.openapi()
schema["info"]["x-logo"] = {"url": "https://example.com/logo.png"}
To replace the document wholesale and bypass the auto-build, assign your own dict
to app.openapi_schema before the first request lands. app.openapi() returns it
untouched.
from veloce import Request, TestClient, Veloce
app = Veloce()
app.openapi_schema = {
"openapi": "3.1.0",
"info": {"title": "Hand-written", "version": "9.9"},
"paths": {},
}
@app.get("/items")
async def list_items(request: Request):
return []
client = TestClient(app)
assert client.get("/openapi.json").json()["info"]["title"] == "Hand-written"
Note
The generator runs once and caches. If you register routes after the first
call to app.openapi() (or after the first request), reset
app.openapi_schema = None to force a rebuild on next access.
Swagger UI parameters¶
swagger_ui_parameters is a dict of options passed straight into the
SwaggerUIBundle(...) constructor in the /docs page. Use it to tune the
explorer's behaviour.
from veloce import Request, Veloce
app = Veloce(
swagger_ui_parameters={
"docExpansion": "none",
"deepLinking": True,
"displayRequestDuration": True,
},
)
@app.get("/ping")
async def ping(request: Request):
return {"pong": True}
swagger_ui_init_oauth is a separate dict passed to Swagger UI's initOAuth(...),
used to pre-fill the OAuth2 client configuration on the Authorize dialog.
Note
The docs assets (Swagger UI and ReDoc bundles) load from a CDN, pinned to a
fixed version with a Subresource Integrity hash. Veloce does not currently
self-host the assets, so /docs and /redoc need network access to the CDN.
Separate input and output schemas¶
When a model's request shape and response shape diverge — a computed_field, a
write-only or read-only field — Veloce emits a distinct -Output component for the
response so each side documents what is actually sent and received. This is on by
default; pass separate_input_output_schemas=False to reuse one component for both.
from pydantic import BaseModel
from veloce import Veloce
class ItemIn(BaseModel):
name: str
price: float
class ItemOut(BaseModel):
name: str
price: float
id: int
app = Veloce()
@app.post("/items", response_model=ItemOut)
async def create_item(item: ItemIn):
return {"name": item.name, "price": item.price, "id": 1}
The request body references ItemIn; the 200 response references ItemOut.
Note
Models whose validation and serialization JSON Schemas are byte-identical collapse
onto a single component — the -Output variant only appears when the shapes
genuinely differ.
Tip
Declaring distinct …In / …Out models keeps your request and response
contracts explicit. response_model is not inferred from the return
annotation — declare it on the route to document the output schema. See
Requests and responses.
Per-route configuration¶
Every route decorator accepts OpenAPI-specific keywords that shape its operation object.
from pydantic import BaseModel
from veloce import Request, Veloce
class Item(BaseModel):
name: str
app = Veloce()
@app.post(
"/items",
tags=["items"],
summary="Create an item",
operation_id="create_item",
openapi_extra={"x-internal": True},
)
async def create_item(item: Item):
return {"name": item.name}
@app.get("/healthz", include_in_schema=False)
async def healthz(request: Request):
return {"status": "ok"}
| Keyword | Effect |
|---|---|
operation_id |
Sets the operation's operationId. Defaults to <name>_<method>. |
openapi_extra |
A dict deep-merged onto the generated operation (nested dicts merge key-by-key; scalars and lists overwrite). |
include_in_schema |
When False, the route stays reachable but is omitted from the document. |
tags |
OpenAPI tags for the operation, combined with router-level tags. |
summary / description |
Operation summary and description; description defaults to the handler docstring. |
deprecated |
Marks the operation deprecated. |
response_description |
Description of the successful response. |
from veloce import Request, TestClient, Veloce
app = Veloce()
@app.get("/items", operation_id="list_items")
async def list_items(request: Request):
return []
@app.get("/healthz", include_in_schema=False)
async def healthz(request: Request):
return {"status": "ok"}
client = TestClient(app)
schema = client.get("/openapi.json").json()
assert schema["paths"]["/items"]["get"]["operationId"] == "list_items"
assert "/healthz" not in schema["paths"]
assert client.get("/healthz").status_code == 200
operationId disambiguation
Each operationId must be unique in the document. Two handlers sharing a
function name on different paths would collide, so Veloce deterministically
suffixes the auto-generated id with a path-derived segment and logs a warning.
Pin operation_id= to silence it. Disable the suffixing entirely with
Veloce(disambiguate_operation_ids=False).
Callbacks¶
A callbacks map on a route is emitted verbatim into the operation's callbacks
field. It documents requests your API makes back to the caller in response to the
original operation.
from veloce import Request, Veloce
app = Veloce()
invoice_callback = {
"invoicePaid": {
"{$request.body#/callbackUrl}": {
"post": {
"requestBody": {
"content": {
"application/json": {
"schema": {"type": "object"}
}
}
},
"responses": {"200": {"description": "Acknowledged."}},
}
}
}
}
@app.post("/invoices", callbacks=invoice_callback)
async def create_invoice(request: Request):
return {"status": "pending"}
Callbacks are documentation-only and pass through verbatim
Unlike FastAPI, Veloce does not build callback operations from a callback router — there is no callback-router introspection. You supply the callback object yourself as a raw OpenAPI Callback dict, and Veloce copies it into the operation unchanged. Veloce neither validates the structure nor sends the callback requests at runtime; wiring the outbound request is your code's job.
Webhooks¶
app.webhooks is a router whose routes are pure documentation. Register handlers
on it to emit an OpenAPI 3.1 webhooks map describing events an external service
should expect from your API. These routes are never served — they only document
the payload shape.
from pydantic import BaseModel
from veloce import Veloce
class Subscription(BaseModel):
username: str
monthly_fee: float
app = Veloce()
@app.webhooks.post("/new-subscription")
async def new_subscription(body: Subscription):
"""Sent when a new subscription is created."""
The first Pydantic-typed parameter of a webhook handler becomes the documented
request body. The event key is the registered path with surrounding slashes
stripped (new-subscription above).
from pydantic import BaseModel
from veloce import TestClient, Veloce
class Subscription(BaseModel):
username: str
app = Veloce()
@app.webhooks.post("/new-subscription")
async def new_subscription(body: Subscription):
pass
client = TestClient(app)
schema = client.get("/openapi.json").json()
assert "new-subscription" in schema["webhooks"]
assert "post" in schema["webhooks"]["new-subscription"]
Note
Webhook operationIds flow through the same disambiguation pass as regular
routes, so a webhook sharing a handler name with a path operation still emits
a unique operationId.
Validating the document¶
Veloce can run a lightweight structural pass over the assembled document —
checking for malformed operations and dangling $refs — and raise a ValueError
naming the offending path. It is opt-in via validate_openapi, which defaults to
the app's debug flag, so production builds pay nothing.
Note
Veloce builds the document as plain dicts; there are no Pydantic OpenAPI model classes to import. Read and mutate the schema as nested dictionaries.
Next steps¶
- Security schemes — how
Security()schemes populatecomponents.securitySchemes. - Requests and responses —
response_modeland the request/response contract the schema documents. - Parameters — how path, query, header and cookie parameters become OpenAPI parameter objects.
- Routing — the full path-operation decorator surface.
- Full signatures are in the API reference.