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:

python
from stario import App, Context, Handler, Middleware, Span, UrlPath, Writer
from stario.http import Router

Stario 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

  • Handlerasync def handler(c: Context, w: Writer) -> None.

  • Middleware(inner: Handler) -> Handler, composed at registration time.

  • AppRouter plus on_error, create_task, and __call__(c, w). The server awaits App.__call__.

  • UrlPath — typed path templates for registration and href() 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 308 redirect to the slashless URL (query string preserved).

  • Path matches but HTTP method does not → 405 with an Allow header (unless a custom handler is registered).

  • Successful match → c.route is a RouteMatch (pattern string plus captured params). On 404 and some 405 paths, c.route.pattern is 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.

python
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
SegmentMeaning
Literal usersThat 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.

python
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)
    yield

UrlPath / "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.

python
import stario.responses as responses
from stario import App, Span, UrlPath
from 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)
    yield

async 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=.

python
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"] ...

text
 
Use `dict(c.route.params)` when you need a mutable copy.
 
:::warning Host vs path
Pass 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 routing
Prefer 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
 
:::apidoc
stario.http.Router
:::
 
:::apidoc
stario.App
:::