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, Route, Span, UrlPath, Writer
from stario.http import Router

Stario 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

  • 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__.

  • Route — one HTTP method on one location. The preferred endpoint declaration.

  • UrlPath — method-free location: compose with /, build href(), scope app.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 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.

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.

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

Same location, two methods — two Route objects:

python
PROFILE = UrlPath("/profile")
SHOW = Route.get(PROFILE)
SAVE = Route.post(PROFILE)

class Route(method, path)

One HTTP method on one path template.

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

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

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.

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