Routing

A route is one HTTP method on one path. Register it, link to it.

A Route is one HTTP method on one path. You register it with app.add and build links with href().

Import route pieces from the package root; import Router when you need a standalone route table:

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

Stario has no @route decorator and no mount(). Production apps register on App; Router is for isolated route tables and tests.

Route#

A Route takes one string, Route("GET /home"), or a method and a path, Route("POST", ROOM + "/send"). Any HTTP method works, including QUERY (RFC 10008) and PROPFIND. A path starts with /. A host-qualified address starts with //, as in //api.example.com/users. Each method and path pair can be registered once.

A Route does not hold a handler. .pattern is the method plus .target (POST /rooms/{room_id}); .path and .host are the path and host strings. Route parses host and path once at construction, and app.add reuses those segments. Route is immutable.

href() fills {placeholders} and takes query= and fragment= for the URL suffix. Those names are reserved — {query} and {fragment} are not valid placeholders. From Datastar, call at.post(SEND.href(room.id)) or at.get.

from stario import App, Route, Span, Writer
 
ROOMS = "/rooms"
ROOM = ROOMS + "/{room_id}"
 
LOBBY = Route("GET", ROOMS)
CREATE_ROOM = Route("POST", ROOMS)
SHOW = Route("GET", ROOM)
SEND = Route("POST", ROOM + "/send")
 
async def bootstrap(app: App, span: Span):
    app.add(LOBBY, lobby)
    app.add(CREATE_ROOM, create_room)
    app.add(SHOW, room_page)
    app.add(SEND, send)
    yield

Same path, two methods — two Route objects:

PROFILE = "/profile"
SHOW = Route("GET", PROFILE)
SAVE = Route("POST", PROFILE)

class Route(*parts, method=None, host=None, path=None)#

One HTTP method on one path template.

HOME = Route("GET /")
SEND = Route("POST", ROOM + "/send")
 
app.add(SEND, send)
at.post(SEND.href(room.id))

Route.empty(cls)#

Unmatched 404 / 405 sentinel. Do not register it.

Route.href(*args, query=None, fragment=None, **params)#

Browser URL string. Placeholders are strings; all must be provided.

Paths and placeholders#

from stario import App, Context, Route, Span, Writer
 
HOME = Route("GET /")
USER = Route("GET /users/{user_id}")
FILES = Route("GET /files/{path...}")
API_USERS = Route("GET //api.example.com/users")
 
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.

A placeholder must be the whole path segment or host label. users-{id}, pre{id}, {id}.txt, and {path...}.bin are invalid; {tenant}.example.com is valid, api-{tenant}.example.com is not. {{name}} is a literal {name} segment.

Route.target is the URL without the method. For path-only routes it is the path template (/users/{user_id}); for host-qualified routes it includes the host (//api.example.com/users).

Route parameters#

After a successful match, c.match is an immutable hit. c.match.pattern is the route address (GET /users/{user_id}) and c.match.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= or //.

async def user_detail(c: Context, w: Writer) -> None:
    user_id = c.match.params["user_id"]
    ...
TENANT_DASH = Route("GET //{tenant}.example.com/dashboard")
 
async def tenant_dashboard(c: Context, w: Writer) -> None:
    sub = c.match.params["tenant"]
    ...

On 404 and 405, c.match is Match.empty — guard before reading params. Request spans use c.match.pattern when a route matches. Use dict(c.match.params) when you need a mutable copy.

Middleware#

Middleware is a handler factory: given the next handler inner, it returns a new async handler that may run logic before await inner(c, w), after it, or around it.

Register scoped middleware with app.use(pattern, *middleware) on the trie branch for pattern. Registration order does not matter: the router composes middleware into each endpoint when the app compiles, so app.use may come before or after the app.add calls it covers. Scoped middleware also wraps the not_found and method_not_allowed answers under its prefix.

Per-route middleware goes in middleware=(...) on add. Stario composes scoped + route middleware and wraps the handler in reverse order: the first middleware in that sequence is outermost, and scoped middleware runs before per-route middleware.

from stario import App, Context, Handler, Route, Span, Writer
 
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

A shared prefix is a string: API = "/api" then Route("GET", API + "/health").

not_found and method_not_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. 405 means the path matched but the method did not; the default handler sets an Allow header and returns 405 text. method_not_allowed receives a factory Callable[[frozenset[str]], Handler].

Trailing slashes redirect: a non-root path with a trailing slash gets a 308 to the slashless URL, query string preserved.

Catch-all patterns cannot be policy prefixes — not_found("/files/{rest...}", …) is rejected. Use a non-catchall prefix such as /api.

from stario import App, Span
from stario.http.dispatch import default_not_found, method_not_allowed_handler
 
 
async def api_not_found(c, w):
    w.json({"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)#

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 headerLabels walked (in order)
app.example.comcom → example → app
example.comcom → example

Host routes are tried first when present. If the host tree misses, dispatch falls back to the path tree. Mixing host-qualified and hostless routes on one app can produce unexpected 404s when apex and subdomain names share labels — prefer one style per app.

Pass host routes as //host/path or host=, not as a path without a leading slash. Route("GET api/users") is rejected; use Route("GET //api.example.com/users") or Route("GET /users", host="api.example.com").

Router and App#

The HTTP server holds a single App instance. Server constructs it inside serve(); bootstrap(app, span) registers routes on that shared object. For every request the stack opens telemetry, resolves a handler from the compiled route table, runs your code, and always finishes w.

App is Router plus attach_loop, create_task, drain_tasks, shutdown, shutting_down, and signal_shutdown. Prefixes such as app.use("/api", mw) and Files(root, "/media") are strings that start with / or //.

class Router()#

Route table: host routes override hostless defaults when they fully match.

Router.use(pattern, *middleware)#

Router.not_found(pattern, handler)#

Router.method_not_allowed(pattern, handler)#

Router.add(route, handler, *, middleware=())#

Router.compiled()#

The compiled trie, built now if a registration changed it.

Router.find_handler(host, path, method)#

Resolve (host, path, method).

host must already be lowercased (Request.host). path is the request path as sent (Request.raw_path, still percent-encoded): it is split on / first and each segment is decoded on its own, so %2F never adds a segment. Plain paths like /users/42 are the same either way.

class App()#

Route table plus task tracking for graceful shutdown.

Handlers are async def and must write a complete response. Uncaught exceptions are logged; if nothing was sent, the framework writes 500. A response already on the wire is not rewritten. Use catch_errors middleware or write error responses in handlers. Use create_task for work tied to a running server so drain can observe it.

shutdown is a per-loop Future for the calling loop. signal_shutdown() completes it on every attached loop so worker threads wake together. tasks is per-thread so drain only sees work on this loop.

App.create_task(coro, *, loop=None, name=None, eager_start=False)#

Schedule a coroutine on the running loop and retain the task until it completes.

The HTTP protocol schedules each request handler through this method so graceful shutdown can await in-flight work. App code can use the same API for background work; both share tasks until shutdown drain.

  • coro: Coroutine to run.

  • loop: Optional loop to schedule on when the caller already has it.

  • name: Optional task name for debuggers.

  • eager_start: Run immediately until the first suspension.

The new asyncio.Task.

  • StarioError: If no event loop is running (call from async request or app code only).

App.signal_shutdown()#

Complete shutdown on every attached loop.

App.attach_loop(loop=None)#

Register loop (default: running loop) so shutdown reaches it.

async App.drain_tasks()#

Await until every task created with create_task has finished (including nested scheduling).

Useful in tests to wait for background work after the HTTP response has been sent. Do not call from inside a coroutine that is itself tracked in create_task or you risk deadlock.