without-web¶
An opinionated HTTP and WebSocket router for
without-asgi. without-asgi deliberately ships no
router, only the unopinionated boundary (scope/event parsing) and composition
tools (Middleware, stack, wrap, buffered). without-web is the
opinionated layer on top: tuple patterns with typed parameters, typed request
extraction, 405-vs-404, mounting, scoped middleware, exception handlers, reverse
routing (url_for), and OpenAPI. See the
without_web API reference for the full surface.
It snaps onto the boundary through nothing but the existing HttpRouter type:
Router.dispatch is an HttpRouter[T], so make_asgi_app(http=router.dispatch)
just works, and bring-your-own (or no router at all) stays first-class. Neither
package imports the other's routing opinions.
import json
from without_asgi import Response, make_asgi_app
from without_web import INT, Router, get, path_param
uid = path_param("id", INT) # one token: a pattern segment AND a typed read
@get(t"/users/{uid}", uid) # t-string pattern; `@get` returns a Route value
async def show_user(state, user_id: int): # user_id is an int, no `assert isinstance`
body = json.dumps({"id": user_id}).encode()
return Response(status=200, headers=((b"content-type", b"application/json"),), body=body)
router = Router(routes=(show_user,), fallback=not_found)
app = make_asgi_app(lifespan, http=router.dispatch)
Encoding (the serializer, its options, the content type) is the application's
choice, so without-web ships no json_response-style helper: a handler returns
a Response (status, headers, bytes) however it likes. The same stance the
router takes toward schemas (schema_for is injected) it takes toward response
bodies.
A router dispatches on the scope, never the body¶
make_asgi_app types the router as (T, HttpScope) -> HttpHandler. The router
never sees the inbound stream, so it cannot parse a body it never reads.
Dispatch is therefore a total, pure function of method + path. Everything
else (query, headers, body) may be parsed by an endpoint, but parsing it does
not change which handler is selected.
Path parameters are the one thing the router must own: only the route pattern
knows that /users/42 binds id=42.
Patterns: literal strings and t-strings¶
A route pattern is either a plain string for a literal-only path
(@get("/todos"), split into Literal segments) or a t-string (PEP 750,
Python 3.14) interpolating path-param tokens (@get(t"/todos/{todo_id}", ...),
where todo_id is a path_param(...)/catch_all(...) value). A parameter must
occupy a whole segment.
The t-string carries the path structure for matching, but a Template erases
its interpolation types, so the token's type is recovered from the handler's
positional extractor list: the same path_param(...) value is interpolated
into the pattern (where it is the segment, matched and schemed through its
converter) and passed alongside (where it is the typed read), so the name,
converter, schema, and parsed type are declared exactly once. A plain string is
taken verbatim as a literal path, so a path parameter requires the t-string form.
Layering: each layer owns the schema it parses¶
Whichever layer parses or produces a value is the single source of truth for that value's schema. The router owns method, path, and path params; the handler owns query, request body, and responses, each carried by the extractor that parses it. OpenAPI is a merge of those self-descriptions, not a blob declared in one place (see OpenAPI).
Matching: a radix tree (trie)¶
The pattern's literal parts are split on / and folded into one immutable trie
whose nodes map a segment (a literal, a typed param slot, or a catch_all) to
child nodes, terminating in a {method -> endpoint} map. Matching is a pure
walk. Three things fall out of the structure rather than being authored by hand:
- Precedence. At each node, literal children beat typed-param children beat the catch-all. No "order your routes carefully" footgun.
- 405-vs-404. Land on a node whose method map lacks the request method → a
405with anAllowheader. Dead-end the walk → thefallback(typically a404). This is the distinction an exactmethod == m and path == prouter cannot express. - Delegation to an opaque sub-app is a black-box leaf at a prefix node.
(Transparent mounting isn't a router concern at all:
mount(...)bakes the prefix into the routes before they reach the router, see Mounting.)
The one subtlety: a converter can reject (INT against abc), so the walk
backtracks to try sibling branches when a converter rejects, with a defined
resolution order (literal, then typed params, then catch-all).
Converters parse, they don't validate¶
A Converter is a str -> value parser paired with the JSON Schema it parses
into, a plain value carrying its own name. The built-ins are exported as
values: STR, INT, FLOAT, UUID, and PATH (the catch-all). A converter
that raises ValueError rejects the segment, which makes that branch fail to
match and the walk backtrack (ultimately a 404), never a handler-side error. A
token carries its converter value straight into the trie, so an app adds its own
by constructing a Converter and using it in a path_param; there is no
registry to register it in.
Reading the request: extractors¶
An Extractor[C, V] is parsing-as-a-value: a pure C -> V (from the request
context C it reads) paired with the OpenAPI fragment it contributes.
path_param, query_param, header_param, body, catch_all, http_scope,
and websocket_scope build them. An extractor that raises rejects the request,
mapped to a 4xx by the exception handlers; it never decides which handler runs.
A rejecting parse (a ValueError) becomes an ExtractionError the extractor
raises with everything a recover policy needs gathered at the raise site: the
field it came from and the underlying error as a first-class cause, so a
policy answers case ExtractionError(cause=ValidationError()) with a 422 and
case ExtractionError() with a 400 naming the field. Because that boundary is
one matchable type, a plain ValueError raised deeper in a handler stays a 500
instead of masquerading as a client 400. http_scope()/websocket_scope() hand
back the unparsed scope, so "pass the scope down" and "parse parts of it" compose
instead of competing.
The context C is what makes the wrong extractor on the wrong route a static
error rather than a runtime guard. It is a small lattice of request-head types:
the permissive RequestHead (scope is HttpScope | WebsocketScope), which
path_param/query_param/header_param/catch_all read, so those tokens serve
any route; HttpRequestHead (scope narrowed to HttpScope), which http_scope
needs; WebsocketRequestHead (scope is WebsocketScope), which websocket_scope
needs; and BufferedRequest (an HttpRequestHead plus the buffered body), which
body needs. Because an extractor is contravariant in C, a permissive
Extractor[RequestHead, V] slots into any handler, while a body token
(Extractor[BufferedRequest, V]) on a streaming or websocket route, or an
http_scope on a websocket route, is a mypy error at the call, with no runtime
check to fail later.
handle(*extractors, fn=...) ties the extractor types to fn's parameters via
an overload ladder, so a path_param("id", INT) paired with an fn that expects
a str is a mypy error, with no runtime introspection. It buffers the request
input (so a body extractor can read it) but does not force buffered output:
fn is always async (a handler must be able to await I/O), and may be an
async def that returns a Response (buffered, after async work) or an
async def ... yield that streams Outbound events. A single _emit dispatch
relays whichever, so the output mode is just what the handler hands back. A plain
def ... return Response is a type error: there would be no place to await.
handle_stream(*extractors, fn=...) is the streaming-input sibling: it leaves
the inbound stream untouched and hands it to fn as a trailing Stream[Inbound]
argument, so fn is the processor (no inner function), reading the live stream
as it arrives (a streaming upload, a long poll, a loop driven by request chunks).
The same overload ladder ties the extractor types, but the extractors are
scope-only (path_param/query_param/header_param/http_scope, whose context
is the streaming route's HttpRequestHead); a body extractor is a static type
error, since its BufferedRequest context is exactly the buffering a streaming
route avoids. The output is free here too (yield to stream, return or await a
Response to buffer), so the input/output 2×2 is fully covered: input
buffering is the
one build-time axis (handle vs handle_stream), output is always the handler's
return. The inbound stream is deliberately not an extractor: an Extractor
reads the parsed-once RequestHead value, and a live stream is a consume-once
place, so it is passed as an argument rather than smuggled into RequestHead.
into(make, *extractors) combines extractors into one that builds a typed value,
the escape hatch from the per-handler arity ceiling and the way to parse a group
of inputs into one model. Each extractor supplies one positional argument to
make (a constructor or factory), with the types tied the same way; the
constituents' OpenAPI fragments are carried through. It reuses the existing
tokens rather than re-reading the request. A frozen dataclass or NamedTuple
constructor works directly; a pydantic model (keyword-only init plus validators)
is wrapped in a small factory (into(lambda a, b: M(x=a, y=b), ea, eb)), so a
rejecting validator raises for the exception handlers to map.
The method decorators get/post/put/patch/delete/head/options are
handle plus a method and a pattern. @get(pattern, *extractors) co-locates the
route with the handler and returns a Route value: it registers nothing, so
assembly stays the explicit, declarative Router(routes=(...)). The Router
merges Routes that share a pattern, so @get and @post on one path combine
into a single method map.
Each method decorator carries a .stream form for streaming input:
@post.stream(pattern, *extractors) is to handle_stream what @post is to
handle. The handler is the processor, taking the live inbound stream as its
trailing argument, and a body extractor is a static type error. See integration.todos'
POST /todos/import, which folds a newline-delimited stream into the list as it
arrives, acknowledging each line while later chunks are still in flight.
buffered (the (state, match, body) -> Response adapter) and the lower-level
Endpoint[T, S, H] = (T, Match[S]) -> H protocol remain for bring-your-own
handlers; Match carries the scope plus the already-parsed path params.
Mounting¶
Mounting a sub-application has two genuinely different cases, and without-web
keeps them apart rather than folding both into one Mount object.
Transparent — routes you own. mount(prefix, *middleware) returns a transform
that bakes the prefix (and any per-route middleware) into each route you hand it,
returning plain routes whose path already includes the prefix:
api = mount("/api", require_auth) # a reusable mount point
routes = api(list_users, create_user) # -> rebased routes, /api/... behind auth
@mount("/api") # or as a decorator on one route
@get(t"/users/{uid}", uid)
async def show_user(...): ...
Because the prefix is baked into the route's segments, a mounted route is still a
first-class, self-contained value: there is no Mount wrapper in the router,
matching and OpenAPI see the full path directly, and reverse routing (url_for)
needs no router and cannot lose the prefix (this is what dissolves the old
cross-router footgun, see Reverse routing). Nesting
composes: mount("/api")(mount("/v1")(route)) is /api/v1/.... This is also the
clean shape for distributing a route group: a package ships a factory
build(prefix, ...) that constructs its mounted, interlinked routes together, and
the consumer picks the prefix.
Opaque — a sub-app whose routes you cannot see. delegate(prefix, app) mounts
a bring-your-own HttpRouter (a legacy app, a third-party mount) as a black box:
its routes cannot be baked, so it is handed the prefix-trimmed scope (ASGI
root_path semantics) and left alone. A delegate can itself be rebased by
mount (mount("/admin")(delegate("/legacy", app)) sits at /admin/legacy), so a
nested opaque app is trimmed by its full accumulated path.
ws_mount and ws_delegate are the exact WebSocket siblings (see
WebSocket routing).
Middleware: router-wide, per-prefix, or per-route¶
A Router's middleware runs on every dispatch. To scope middleware to part of
an app, two composing tools, no new mechanism:
- A prefix: hand middleware to
mount(prefix, *middleware)(see Mounting). It is baked onto every route placed under the mount and nothing outside it. This is the natural home for cross-cutting concerns like auth on/admin. Seeintegration.todos, whoseadmin = mount("/admin", require_authorization)carries anAuthorization-header gate. (Mount middleware is state-agnosticHttpMiddleware[object], which auth and logging already are; state-specific middleware goes on a route withwith_middleware.) - One route:
with_middleware(endpoint, *middleware)wraps a single endpoint. AnEndpointbuilds the handler and aMiddlewareis(T, H, S) -> H, so this is just composition; the result is a narrowerEndpoint. Use it per method, e.g.route("/admin", get=with_middleware(list_admins, require_auth)).
Both reuse the same Middleware vocabulary (stack, wrap, a plain
(T, handler, scope) -> handler) as the router-wide hook, including the ability to
read connection state and to replace the handler outright (a 401 without calling
the wrapped endpoint).
Exception handlers¶
Exception handling is not a new mechanism: it is a Middleware. catching(recover)
wraps an endpoint, watches its outbound stream, and turns a raised exception into a
Response, reusing the existing stack/wrap/compose machinery. There is no
registry: recover is the app's policy, an ordinary
(Exception) -> Awaitable[Response | None] function. Write it as match exc: and
each case narrows to its real type (no assert isinstance, no cast); return None
to let the exception propagate.
async def recover(exc: Exception) -> Response | None:
match exc:
case TodoNotFound():
return Response(status=404, ...) # exc is TodoNotFound here
case _:
return None # propagate
router = Router(routes=(...), middleware=stack(catching(recover)))
Because it is plain middleware, the app controls where it sits in the stack (an
outer catching handles what an inner one returns None for) and recover has
full control: re-raise, chain, or do async work. catching_websocket is the
sibling that maps to a WebsocketClose.
Honest limitation: once a ResponseStart has been emitted, the status line is
on the wire and cannot be rewritten, so the mapping applies only to exceptions
raised before the first outbound event. After that the exception re-raises.
OpenAPI: recover a description from structure¶
openapi(router, schema_for=...) is a pure transform of the route table. For
each route it merges the router's half (path, methods, path-param schemas, which
it reads from the converters on the segments) with the endpoint's half. The
endpoint's half is recovered from the extractors that parse it: each
query_param/header_param/body carries its own OpenAPI fragment, so the
schema is declared in exactly the one place it is parsed, and handle/@get
assemble those into a self-describing endpoint.
Turning a captured type into JSON Schema is an injected concern, so without-web
stays schema-library agnostic: an extractor or RouteSpec may carry an
already-built JSON Schema mapping, or a type plus a schema_for: type -> schema
function the app supplies (pydantic's model_json_schema, or a dataclass
walker).
A request or response body is a Body(media_type, shape). The shape is
Single(schema) for one whole document or Sequence(item_schema) for a
sequential media type (NDJSON, SSE text/event-stream, application/json-seq,
...): Single renders OpenAPI's schema, Sequence renders 3.2's itemSchema
(one item's shape), and the document is emitted as 3.2.0. This is
documentation only: without-web is agnostic to the framing on the wire, the
media type is the app's string, and the handler emits the bytes. A
streaming-input route has no body extractor to recover an inbound schema from,
so it declares one directly: @post.stream(..., request_body=Body(
"application/x-ndjson", Sequence(...))).
Reverse routing: url_for¶
url_for(route, values) is the inverse of dispatch: given a route and the values
for its path parameters, it renders the concrete path the route would match at, so
a handler or template links to a route by its value rather than hand-assembling a
string that drifts when the path changes. It is a plain function of the route
value, no router involved:
show_user = get(t"/users/{uid}", uid) # a Route value you hold
@post("/users", new_user_body)
async def create_user(state, new):
location = url_for(show_user, {"id": created.id}) # -> "/users/<id>"
return Response(status=201, headers=((b"location", location.encode()),), body=...)
The handler references the show_user value (immutable, like a constant) and
calls a free function: it names no router, so there is no app-level singleton to
depend on and no runtime handler→router cycle. This works because a route is a
self-contained value: mount bakes any prefix into the route's segments (see
Mounting), so the route's own path is its full path. Three things
follow:
- Identity is the value, not a name. You pass the route you already hold; there is no registry of names to keep in sync (the same stance the trie takes, see Matching).
- No router, no cross-router footgun. Because the prefix lives in the route,
not in a router, reversing needs no router and cannot miss a prefix that some
other router knew about. A websocket handler reverses an HTTP
Routeto link to its resource with the same call, since neither is tied to a router. - Values are checked in reverse. Each value is rendered and fed back through
the segment's converter to prove it would parse straight back (parse, don't
validate, in reverse): a value the converter would reject, one that does not
round-trip, or one that spans multiple segments (a
/in a single-segment param) raises, as does a missing or unknown parameter. Acatch_allsegment is the one place/is allowed.url_forreverses aWebsocketRoutethe same way.
See integration.todos: create_todo's 201 body carries the new todo's URL,
reversed from the show_todo route, and the /todos/session websocket puts that
same reversed URL in each reply, an HTTP route linked from a websocket handler
with nothing but the route value.
WebSocket routing¶
WebsocketRouter reuses the same trie machinery with no method layer (so no
405): a connection either matches a path or falls to the fallback. Its
dispatch is a WebsocketRouter[T] for make_asgi_app(websocket=...). Mounting
mirrors HTTP: ws_mount(prefix, *middleware) bakes a prefix into WebSocket routes
and ws_delegate(prefix, app) mounts an opaque WebSocket app as a black box (see
Mounting).
@ws(pattern, *extractors) is the websocket sibling of @get/@post: it ties
typed path_param/query_param/header_param tokens to the handler's arguments
and returns a WebsocketRoute. Its context is WebsocketRequestHead, so a body
(or http_scope) token is a static type error: a handshake carries no body, and
its scope is a WebsocketScope, not an HttpScope. The handler is the frame
processor,
exactly as @post.stream's is: it takes the live inbound frames as a trailing
Stream[WebsocketInbound] argument and yields WebsocketOutbound directly,
rather than returning a processor. Because the handler is the processor, a
websocket connection folds naturally: see integration.todos' /todos/session,
which threads a working TodoList across its inbound frames (a scan over the
connection), the bidirectional, long-lived sibling of the POST /todos/import
fold.
The integration package's todos example is built entirely
on this package; see it for a worked HTTP + WebSocket service.