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, Writerfrom stario.http import RouterStario 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) yieldSame 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| 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. |
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) yieldA 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, Spanfrom 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) yieldasync 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 header | Labels walked (in order) |
|---|---|
app.example.com | com → example → app |
example.com | com → 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.