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)

Optional: faster {ts:datetime} matching

Installing ciso8601 speeds the datetime converter up by roughly 1.4–1.7x on values without a numeric offset — the common 2026-08-26T12:30:00 and ...Z shapes:

pip install "veloceframework[ciso8601]"

It changes nothing about what matches or what the handler receives. Only the values the two parsers handle identically take the faster path; one carrying a numeric offset (+05:30) keeps the standard-library parse, because ciso8601 would hand back a different tzinfo type for it. Without the package the converter behaves exactly as it does with it.

Use register_converter to add your own, and unregister_converter to remove it again -- the registry is process-global, so a test or a plugin that registers one should take it back out. Routes already registered keep the converter they were built with; only later registrations are affected.

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.

Empty path segments

A request path is matched as written. An empty segment - a leading or interior // - is not collapsed, so it does not reach the route its canonical form would:

@app.get("/admin/users")
async def admin_users(request):
    ...

# GET /admin/users   -> 200
# GET //admin/users  -> 404
# GET /admin//users  -> 404

This matters when authorization is decided by path prefix. A before_request hook testing request.path.startswith("/admin") reads the path the client sent; if the router had collapsed the empty segment, the two would disagree about the same request and the gate would be stepped around. Send the canonical path.

A single trailing slash is unaffected and still follows redirect_slashes.

Changed in version 0.20.0

A path with an empty segment previously matched the route its canonical form would.

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.

Substituted values are percent-encoded, so a value the application treats as opaque - a username, a slug, a filename - cannot escape its path segment:

app.url_for("user-detail", user_id="bob smith")   # "/users/bob%20smith"
app.url_for("user-detail", user_id="a/b")         # "/users/a%2Fb"

A :path converter is the exception, since crossing segments is what it is for: it may emit /, but still not ? or #. : and @ are left readable, being legal in a path segment (RFC 3986 Sec. 3.3).

Changed in version 0.20.0

Values were previously interpolated verbatim, so a ? or # in one could add a query string or a fragment to the URL being built.

Inside a request, the module-level url_for builds the same URL without a reference to the app, and templates receive it automatically:

from veloce import url_for

@app.get("/profile")
async def profile():
    return {"link": url_for("user-detail", user_id=7)}

It raises RuntimeError outside an application context, where there is no app whose routing table could answer. The endpoint name is positional-only on every form, so a route may have a {name} or {endpoint} segment and still be reversed:

@app.get("/files/{name}", name="download")
async def download(name: str):
    ...

app.url_for("download", name="report.pdf")   # "/files/report.pdf"

Inspecting the route table

app.routes is a summary: a list of dicts with path, method, name, summary, tags and deprecated. It is what you want to render a route table and nothing more.

for route in app.routes:
    print(route["method"], route["path"], route["name"])

To inspect the routes themselves - their response models, dependencies, security requirements, and everything else the registration recorded - use app.iter_routes(), which returns the RouteInfo records:

from veloce import Veloce

app = Veloce()

for method, path, info in app.iter_routes():
    if info.response_model is not None:
        print(method, path, "->", info.response_model.__name__)

WebSocket routes and routes registered with include_in_schema=False are left out by default, matching what the OpenAPI document describes. Pass include_hidden=True for the complete table:

for method, path, info in app.iter_routes(include_hidden=True):
    print(method, path)

iter_routes() is defined on Router, so a Blueprint or a stand-alone Router answers the same way about its own routes.

See also