Routing
Overview
This page covers registration and matching: App, UrlPath, scoped middleware, prefix error handlers, and route parameters. Per-request Context and Request are on Request and context. Responses are on Responses. Process lifecycle is on Runtime.
The HTTP server holds a single App instance. The CLI or Server constructs it; bootstrap(app, span) registers routes on that shared object. For every request the stack invokes await app(c, w), which opens telemetry, resolves a handler from the route trie, runs your code, applies on_error when something raises, and always finishes w.
Import route patterns from the package root; import Router when you need a standalone route table:
from stario import App, Context, Handler, Middleware, Route, Span, UrlPath, Writerfrom stario.http import RouterStario has no @route decorator and no mount(). Declare endpoints as Route values and register them with app.add. Router from stario.http is for isolated route tables and tests; production apps register on App.
HTTP methods
Route.get, Route.query, Route.post, Route.put, Route.patch, Route.delete, Route.head, and Route.options pin one method on a path. Route.query is HTTP QUERY (RFC 10008): safe, idempotent, request body carries the query. Route("PROPFIND", path) covers anything else. Register with app.add(route, handler). Each (method, pattern) pair may be registered only once.
app.get / app.post / app.handle(method, path, handler) still accept a UrlPath or string when you are not using Route.
Shapes you use every day
Handler—async def handler(c: Context, w: Writer) -> None.Middleware—(inner: Handler) -> Handler, composed at registration time.App—Routerpluson_error,create_task, and__call__(c, w). The server awaitsApp.__call__.Route— one HTTP method on one location. The preferred endpoint declaration.UrlPath— method-free location: compose with/, buildhref(), scopeapp.use.
Build links with USER.href(user_id=42) on either type. Optional host= on UrlPath enables host-aware routes (UrlPath("/users", host="api.example.com") or host placeholders such as {tenant}.example.com).
Path and host placeholders are keyword arguments to href(). URL query parameters always use the query= keyword — even when the path contains a segment named {query}.
Resolution rules
Inside App.__call__, route resolution applies these behaviors before your handler runs:
Non-root paths with a trailing slash
308redirect to the slashless URL (query string preserved).Path matches but HTTP method does not →
405with anAllowheader (unless a custom handler is registered).Successful match →
c.routeis aRouteMatch(pattern string plus captured params). On 404 and some 405 paths,c.route.patternis empty — guard before reading route parameters.
Route
A Route is an HTTP request pattern: method plus a final UrlPath. It does not compose with / and does not hold a handler. From Datastar, call at.fetch(SEND, {"room_id": room.id}) — same href() params, method from the route.
from stario import App, Route, Span, UrlPath, Writer ROOMS = UrlPath("/rooms")ROOM_PATH = ROOMS / "{room_id}" LOBBY = Route.get(ROOMS)CREATE_ROOM = Route.post(ROOMS)ROOM = Route.get(ROOM_PATH)SEND = Route.post(ROOM_PATH / "send") async def bootstrap(app: App, span: Span): app.add(LOBBY, lobby) app.add(CREATE_ROOM, create_room) app.add(ROOM, room_page) app.add(SEND, send) yieldSame location, two methods — two Route objects:
PROFILE = UrlPath("/profile")SHOW = Route.get(PROFILE)SAVE = Route.post(PROFILE)class Route(method, path)
One HTTP method on one path template.
HOME = Route.get("/")SEND = Route.post(UrlPath("/rooms") / "{room_id}" / "send") app.add(SEND, send)at.fetch(SEND, {"room_id": room.id})Route.delete(cls, path)
Route.get(cls, path)
Route.head(cls, path)
Route.href(params=None, /, *, query=None, fragment=None, **params_kwargs)
Build the browser URL for this path. Same contract as UrlPath.href().
Route.options(cls, path)
Route.patch(cls, path)
Route.post(cls, path)
Route.put(cls, path)
Route.query(cls, path)
UrlPath
Path-only locations use a leading slash. Host-aware locations pass host= separately instead of embedding the hostname in the path string.
from stario import App, Context, Route, Span, UrlPath, Writer HOME = Route.get("/")USER = Route.get("/users/{user_id}")FILES = Route.get("/files/{path...}")API_USERS = Route.get(UrlPath("/users", host="api.example.com")) async def bootstrap(app: App, span: Span): app.add(HOME, home) app.add(USER, user_detail) app.add(FILES, serve_file) app.add(API_USERS, list_users) yield| Segment | Meaning |
|---|---|
Literal users | That path segment must match exactly. |
{user_id} | Captures one segment (no /). |
{path...} | Catch-all: rest of the path, slashes included. Only allowed as the final path segment. |
c.route.pattern is the canonical pattern string. For path-only routes that is the path template (for example /users/{user_id}). For host-qualified routes it includes the host template (for example api.example.com/users or {tenant}.example.com/dashboard), not the request URL alone.
class UrlPath(path, *, host=None)
Canonical route template for registration and URL building.
Path-only routes use a leading slash: UrlPath("/users"). Host-aware routes pass the host separately: UrlPath("/users", host="api.example.com") or UrlPath("/users", host="{tenant}.example.com").
Compose locations, then pin a method with Route.get(HOME) and register with app.add. Call href() to build a browser URL with path params, query params, or a fragment. Host-aware patterns build network-path hrefs (//host/path).
Host labels are stored left-to-right as authored. The trie walks them right-to-left (DNS-style); use host_trie() for registration and request_host() for matching.
UrlPath.format(values)
Build the href body from parameter values (no query or fragment).
UrlPath.host_trie()
Host segments in trie registration / match order (right-to-left).
UrlPath.href(params=None, /, *, query=None, fragment=None, **params_kwargs)
Build a browser URL from this route pattern.
Path and host placeholders are passed as keyword arguments (or a positional mapping). URL query parameters use the query= keyword only — not a path segment named query.
UrlPath.request_host(host)
Split a request host into trie match order (right-to-left labels).
UrlPath.request_path(path)
Split a canonical request path into trie match segments.
Middleware
Middleware is a handler factory: given the next handler inner, return a new async handler that may run logic before await inner(c, w), after it, or around it. Wrapping happens at registration time, not per request as a separate object.
Register scoped middleware with app.use(pattern, *middleware) on the trie branch for pattern. Register app.use before any app.add / app.get / app.post / … on that prefix branch (including child paths). Late app.use raises StarioError.
Per-route middleware is still available via middleware=(...) on add, get, post, handle, and other verb helpers. Stario composes scoped_middleware + route_middleware and wraps the handler in reverse order: the first middleware in that sequence is outermost (runs first inbound); scoped middleware runs before per-route middleware.
from stario import App, Context, Handler, Route, Span, UrlPath, Writer API = UrlPath("/api")HEALTH = Route.get(API / "health") def with_request_id(inner: Handler) -> Handler: async def wrapped(c: Context, w: Writer) -> None: c.state["request_id"] = c.req.headers.get("x-request-id") or "local" await inner(c, w) return wrapped async def bootstrap(app: App, span: Span): app.use(API, with_request_id) app.add(HEALTH, health) yieldUrlPath / "suffix" joins path segments: API / "users" → UrlPath("/api/users").
notfound and methodnot_allowed
app.not_found(pattern, handler) and app.method_not_allowed(pattern, handler) attach handlers on the trie branch walked for pattern. During a request, the deepest node along the host/path walk with a policy handler wins (prefix-scoped inheritance down the branch).
A trie-level 404 happens before a concrete route handler is chosen, so shared 404 behavior belongs in not_found or an explicit catch-all route. method_not_allowed receives a factory Callable[[frozenset[str]], Handler]; the default implementation sets Allow and returns 405 text.
Catch-all patterns cannot be used as policy prefixes (not_found("/files/{rest...}", …) is rejected). Use a non-catchall prefix such as /api.
import stario.responses as responsesfrom stario import App, Span, UrlPathfrom stario.http.dispatch import default_not_found, method_not_allowed_handler API = UrlPath("/api") async def api_not_found(c, w): responses.json(w, {"error": "not_found"}, status=404) async def bootstrap(app: App, span: Span): app.not_found(API, api_not_found) app.method_not_allowed(API, method_not_allowed_handler) yieldasync default_not_found(_c, w)
method_not_allowed_handler(allowed)
Route parameters
After a successful match, c.route.params is a read-only map from parameter names to strings. Names come from {id} and {rest...} on the path and from host placeholders on host=.
async def user_detail(c: Context, w: Writer) -> None: user_id = c.route.params["user_id"] ... TENANT_DASH = UrlPath("/dashboard", host="{tenant}.example.com")
async def tenant_dashboard(c: Context, w: Writer) -> None: sub = c.route.params["tenant"] ...
Use `dict(c.route.params)` when you need a mutable copy. :::warning Host vs pathPass host routes with `host=` on `UrlPath`, not as a path prefix. `UrlPath("api/users")` without a leading slash is rejected; use `UrlPath("/users", host="api.example.com")`.::: ## Host-qualified routes Host labels are compared case-insensitively. Placeholders work on host segments the same way as on paths, with catch-all host segments restricted to the first label. The matcher compares host labels in reverse order from human-readable form (TLD first). You author `app.example.com`; internally the walker steps `com`, then `example`, then `app`, then the path. | `Host` header | Labels walked (in order) || --- | --- || `app.example.com` | `com` → `example` → `app` || `example.com` | `com` → `example` | Host routes are tried first when present; hostless routes are shared defaults. Dispatch uses two tries (`_hosts` and `_path`) with one resolution algorithm — host and path resolve together, not in two separate passes with fallback between styles. :::info Host-based routingPrefer path-only routes for the whole app, or host-qualified `UrlPath(..., host=…)` everywhere. Mixing both styles on one app can produce partial host matches and unexpected 404s when apex and subdomain names share initial labels.::: ## Router design These notes mirror dispatch in `stario.http.dispatch`. ### Why the router is a trie Routes live in a trie shaped like the URL: optional host segments, then path segments, then HTTP method. Conflicts surface at registration time instead of depending on registration order. ### Why 404 and 405 are separate 404 means no trie leaf matched the host/path shape. 405 means the path matched but the HTTP method did not. That distinction keeps `Allow` headers meaningful. ### Why trailing slashes normalize Stario redirects `/foo/` to `/foo` (308) so each resource has one canonical URL. ## Router and App :::apidocstario.http.Router::: :::apidocstario.App:::