Skip to content

Routing

Veloce matches requests with a radix tree. Routes are registered at import time; path parameters are extracted and type-coerced during the tree traversal, not afterwards.

Route decorators

Every HTTP method has a decorator on the application:

app.py
from veloce import Request, Veloce

app = Veloce()


@app.get("/")
async def index(request: Request):
    return {"ok": True}


@app.post("/items")
async def create_item(request: Request):
    return {"created": True}

get, post, put, patch, delete, head, options, trace, and query are all available.

The QUERY method

@app.query registers a route for the HTTP QUERY method (RFC 10008). QUERY is safe and idempotent like GET but carries a request body like POST — for read-only operations whose parameters are too complex for a URL (search, filtering, paging). The handler reads the body exactly as a POST handler does:

app.py
from pydantic import BaseModel
from veloce import Veloce

app = Veloce()


class Search(BaseModel):
    term: str
    limit: int = 20


@app.query("/search")
async def search(q: Search) -> dict:
    return {"term": q.term, "limit": q.limit}

Practical scope

Browser fetch and many CDNs do not yet speak QUERY, so it is best suited to service-to-service APIs today. QUERY routes are omitted from the generated OpenAPI 3.1 document (the Path Item Object has no query field); native schema support awaits OpenAPI 3.2.

Path parameters

Wrap a segment in braces and annotate the handler parameter — Veloce coerces the value to the annotated type:

@app.get("/users/{user_id}")
async def get_user(user_id: int):
    # user_id is an int; "/users/abc" never reaches this handler
    return {"id": user_id}

Typed converters

A converter can be declared inline. A segment the converter rejects is a route miss (404), not a validation error:

Rule Matches
{id:int} a decimal integer
{ratio:float} a decimal float
{key:uuid} a canonical RFC 4122 UUID
{rest:path} the remainder of the URL (greedy)
{c:any(red,green)} one of a fixed set of values
{d:date} an ISO 8601 date → datetime.date
{t:time} an ISO 8601 time → datetime.time
{ts:datetime} an ISO 8601 datetime → datetime.datetime
{dur:timedelta} an ISO 8601 duration → datetime.timedelta
{amount:decimal} a decimal literal → decimal.Decimal
@app.get("/files/{rest:path}")
async def serve(rest: str):
    return {"path": rest}   # "/files/a/b/c.txt" -> rest == "a/b/c.txt"

The temporal and decimal converters coerce the matched segment to the corresponding Python type, so the handler receives a datetime.date, datetime.datetime, datetime.time, datetime.timedelta, or decimal.Decimal rather than a string. A Z suffix on a datetime/time is accepted (normalised to +00:00). timedelta requires a full ISO 8601 duration with at least one component (P1DT2H); a bare number is a route miss.

@app.get("/reports/{day:date}")
async def report(day):
    return {"weekday": day.weekday()}   # "/reports/2026-06-03" -> date(2026, 6, 3)

Use register_converter to add your own.

Static segments win over parameters

When a static path and a parameterised path overlap at the same position, the static one always matches first. The radix tree tries an exact-segment lookup before scanning parameter children, so registration order is irrelevant:

@app.get("/users/me")
async def me(request: Request):
    return {"user": "current"}


@app.get("/users/{user_id}")
async def get_user(user_id: int):
    return {"id": user_id}

/users/me resolves to me() even though /users/{user_id} could also match, and even if get_user were registered first.

No ordering footgun

Unlike a regex router where the first registered pattern wins, Veloce's match precedence is structural: an exact segment is tried before any parameter child, and the legacy * wildcard is tried last. You do not have to register specific routes before general ones.

Constrained converters

The int, float, and str converters accept arguments, so a bound takes part in matching rather than only producing a handler-layer error. A value outside the bound is a route miss (404), not a 422:

Rule Matches
{n:int(min=1,max=100)} an integer in [1, 100]
{n:int(min=1)} an integer >= 1
{n:int(signed=False)} an unsigned integer (no leading -)
{x:float(min=0.0,max=1.0)} a float in [0.0, 1.0]
{code:str(length=2)} a string of exactly 2 characters
{slug:str(minlength=3,maxlength=64)} a string of 3–64 characters
@app.get("/page/{n:int(min=1,max=100)}")
async def page(n: int):
    return {"page": n}     # "/page/0" and "/page/101" are 404, "/page/50" is 200

The constraints are enforced whether the route resolves on the radix fast path or the regex fallback, and url_for rejects a value the converter would never match. A converter with no arguments behaves exactly as before.

Query parameters

A handler parameter that is not a path parameter — and is not the Request, a dependency, or a request-body model — is read from the query string and coerced to its annotated type. A default value makes the parameter optional; without one it is a required query parameter, and a missing value produces a 422:

@app.get("/search")
async def search(request: Request, q: str = "", page: int = 1, limit: int = 10):
    return {"query": q, "page": page, "limit": limit}

/search?q=veloce&page=2 yields q="veloce", page=2, limit=10.

Grouping routes with a Router

A Router is a self-contained group of routes with a shared prefix and metadata. Mount it onto the app with include_router:

from veloce import Request, Router, Veloce

app = Veloce()
api = Router(prefix="/api/v2", tags=["v2"])


@api.get("/status")
async def status(request: Request):
    return {"status": "ok"}


app.include_router(api)        # now serving GET /api/v2/status

Routers can be nested and carry their own dependencies= and responses=. Scoped before_request / after_request / teardown_request hooks are a Blueprint feature — a plain Router has none.

Duplicate routes

Registering a second handler for the same path and HTTP method raises DuplicateRouteError at registration time, so an accidental route shadowing fails loudly at startup instead of dispatching to the wrong handler. Change the policy with on_duplicate on Veloce(...) or Router(...):

app = Veloce(on_duplicate="error")    # default: raise DuplicateRouteError
app = Veloce(on_duplicate="warn")     # log a warning and replace
app = Veloce(on_duplicate="override") # replace silently

Including the same router twice carries the same handler and is an idempotent re-mount, never reported as a conflict.

Path operation configuration

Every route decorator accepts a set of OpenAPI knobs that shape how the operation appears in the generated schema and the docs UI. They are metadata-only — they do not change how a request is matched or dispatched.

app.py
from veloce import Veloce

app = Veloce()


@app.post(
    "/items",
    tags=["items"],
    summary="Create an item",
    response_description="The created item",
    status_code=201,
)
async def create_item(name: str):
    """Add a new item to the catalogue.

    The full request body is validated before this handler runs.
    """
    return {"name": name}

The handler's docstring becomes the operation description, so an operation's prose lives next to the code it documents.

Option Default Purpose
tags [] OpenAPI tags for the operation, combined with the router-level tags.
summary route name Short one-line summary shown in the docs UI.
description handler docstring Long-form description; falls back to handler.__doc__.
deprecated False Marks the operation deprecated in the schema.
response_description "Successful Response" Description of the 200/status_code response.
status_code 200 Default success status code for the response.
operation_id "{name}_{method}" Explicit OpenAPI operationId; pin it for stable client codegen.
include_in_schema True Register the route but omit it from the OpenAPI document when False.
openapi_extra None Arbitrary dict deep-merged onto this route's operation object.

A docstring is the idiomatic way to document an operation:

@app.get("/health")
async def health():
    """Report whether the service is up.

    Used by the load balancer's health check.
    """
    return {"status": "ok"}

Docstring is used whole — no summary split

Veloce uses the entire docstring as the operation description. It does not treat the first line as summary and the rest as description. Set summary= explicitly when you want a short title; otherwise the summary defaults to the route name, not the docstring's first line.

Deprecating and hiding routes

deprecated=True keeps the route live but flags it in the schema; clients and the docs UI render it struck through. include_in_schema=False keeps the route fully functional but drops it from the OpenAPI document entirely:

@app.get("/legacy", deprecated=True)
async def legacy():
    return {"detail": "use /v2 instead"}


@app.get("/internal/metrics", include_in_schema=False)
async def metrics():
    return {"requests": 1234}

Stable operation IDs

operation_id defaults to "{name}_{method}" (e.g. get_user_get). Duplicate auto-generated IDs are disambiguated with a numeric suffix and a warning. Pin operation_id= to get a stable identifier for generated clients:

@app.get("/users/{user_id}", operation_id="readUser")
async def get_user(user_id: int):
    return {"id": user_id}

Extending the operation object

openapi_extra is deep-merged onto the operation object, so you can attach fields Veloce does not surface directly. Dict keys merge recursively; list values overwrite:

@app.get(
    "/report",
    openapi_extra={"x-internal": True, "responses": {"503": {"description": "Down"}}},
)
async def report():
    return {"ok": True}

Note

These options live on every method decorator (get, post, ...) and on Router routes. Route-level tags are appended to the router's tags; a route-level operation_id or summary overrides any inherited value.

Build the document and inspect the configured operation directly with the in-memory client:

from veloce import TestClient, Veloce

app = Veloce()


@app.post("/items", tags=["items"], summary="Create an item", status_code=201)
async def create_item(name: str):
    """Add a new item to the catalogue."""
    return {"name": name}


client = TestClient(app)

schema = client.get("/openapi.json").json()
op = schema["paths"]["/items"]["post"]
assert op["summary"] == "Create an item"
assert op["tags"] == ["items"]
assert op["description"] == "Add a new item to the catalogue."

Reverse URLs

Build a URL for a named route instead of hard-coding paths:

@app.get("/users/{user_id}", name="user-detail")
async def user_detail(user_id: int):
    ...

url = app.url_for("user-detail", user_id=7)   # "/users/7"

When a route parameter declares a converter, url_for validates the value against that converter before building the URL, so a reversed URL is guaranteed to resolve. A value the matcher would reject raises rather than producing a dead link:

@app.get("/items/{id:int}", name="item")
async def item(id: int):
    ...

app.url_for("item", id=42)      # "/items/42"
app.url_for("item", id="abc")   # raises - "abc" is not a valid int segment

Parameters without a typed converter (a bare {name} or a raw-regex segment like {id:[0-9]+}) accept any stringifiable value.

See also