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, Span, UrlPath, Writerfrom stario.http import RouterStario has no @route decorator and no mount() — register full UrlPath patterns on App. Router from stario.http is for isolated route tables and tests; production apps register on App.
HTTP methods
app.get, app.post, app.put, app.delete, app.patch, app.head, and app.options register one method each. app.handle("PROPFIND", path, handler) covers anything else. Each (method, pattern) pair may be registered only once.
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__.UrlPath— typed path templates for registration andhref()link building.
Build links with UrlPath("/users/{user_id}").href(user_id=42) or register the same object: app.get(USERS, handler). 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.
UrlPath
Path-only routes use a leading slash. Host-aware routes pass host= separately instead of embedding the hostname in the path string.
from stario import App, Context, Span, UrlPath, Writer HOME = UrlPath("/")USER = UrlPath("/users/{user_id}")FILES = UrlPath("/files/{path...}")API_USERS = UrlPath("/users", host="api.example.com") async def bootstrap(app: App, span: Span): app.get(HOME, home) app.get(USER, user_detail) app.get(FILES, serve_file) app.get(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").
Pass the object to route registration (app.get(HOME, …)). 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.get / app.post / … on that prefix branch (including child paths). Late app.use raises StarioError.
Per-route middleware is still available via middleware=(...) on 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, Span, UrlPath, Writer API = UrlPath("/api") 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.get(API / "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:::