Routing
Status: SOURCE OF TRUTH Last Updated: 2026-06-28 genro-routes version: 0.22.0+
Overview
genro-asgi delegates per-app route resolution to genro-routes, a separate library that provides tree-based routing with introspection capabilities.
Routing in genro-asgi happens in two stages:
Demultiplexing (server level): the
Dispatcherlooks at the first path segment and picks the app mounted under that name. It does not resolve the handler — it only relays the ASGI call to the chosen app.Route resolution (app level): each
AsgiApplicationresolves the rest of the path inside its own router (the lazyrouteproperty), viaself.route.node(...).
This is a relay model: the server owns no global route table; every app does its own routing.
Full documentation: genro-routes
Core Components
Component |
Description |
|---|---|
|
Tree-based router, organizes handlers hierarchically |
|
Base class for classes with routed methods ( |
|
Decorator to register a method as a route on a named router |
|
Protocol for custom routers (e.g. |
Key Methods
Method |
Description |
|---|---|
|
Resolve a path to a |
|
Enumerate nodes (entries + sub-routers) |
|
Attach a routed instance as a child |
|
Register a routing plugin on the router |
|
List the plugins registered with genro-routes |
There is no
router.get(...)method and norouter.openapi()method in genro-routes 0.22.0. Resolution isrouter.node(...); OpenAPI is produced fromrouter.nodes(mode="openapi")(see OpenAPI Generation below).
Path Resolution
Paths use / as separator (URL-style, same as HTTP paths). At the app level the
mount prefix is stripped first, then the remainder is the selector passed to
router.node():
# An app mounted under "docs" sees these app-relative selectors:
"/docs" → "" (app index)
"/docs/api/users" → "api/users"
# The empty-mount app keeps the whole path:
"/" → "/"
"/_server/monitor" → "_server/monitor"
AsgiApplication.path_in_app(path) performs this prefix stripping; the empty
mount (mount_name == "") keeps the full path.
Demultiplexing: the Dispatcher
Dispatcher is the innermost ASGI layer at the server. It does not resolve
handlers; it demultiplexes on the first path segment and relays to the owning
app:
# server/dispatcher.py (essence)
def _resolve_mount(self, path: str) -> str:
stripped = path.strip("/")
return stripped.split("/", 1)[0] if "/" in stripped else stripped
async def __call__(self, scope, receive, send):
path = scope.get("path", "/")
mount = self._resolve_mount(path)
app = self.server.apps.get(mount) or self.server.apps.get("") # "" = fallback app
if app is None:
raise HTTPNotFound(path)
await app(scope, receive, send) # the app is itself an ASGI callable
Each app is invoked as an ASGI callable: it enters its own middleware chain
(if configured) and ends at handle_request. The empty mount ("") is the app
for any path that no other app claims.
Route Resolution: per-app, in handle_request
AsgiApplication.handle_request is the per-app routing engine. It
authenticates, builds the filter set, resolves the handler in self.route, builds
a context, executes the handler and sends the response:
# applications/asgi_application/asgi_application.py (essence)
auth = self.authenticate(scope)
filters = {"auth_tags": auth["tags"] if auth is not None else []}
channel = scope.get("_headers", {}).get("x-channel", "").strip()
if channel:
filters["channel_channel"] = channel # x-channel header → channel filter
node = self.route.node(
self.path_in_app(request.path),
errors=ROUTER_ERRORS,
**filters, # auth_tags, channel_channel, ...
)
result = await smartasync(node)(**dict(request.query))
request.response.set_result(result, node.metadata)
await request.response(scope, receive, send)
Filter kwargs
The filters are plain keyword arguments forwarded to router.node(...) (and
router.nodes(...)); they are consumed by genro-routes’ routing plugins:
Filter kwarg |
Source |
Used by |
|---|---|---|
|
identity from |
auth plugin |
|
|
channel plugin |
|
request environment flags ( |
env plugin |
The header-to-filter mapping is singular: the
x-channelheader populatesfilters["channel_channel"](one channel string), not a plural collection.
Error mapping
Router errors raised during resolution are mapped to HTTP exceptions via the
ROUTER_ERRORS dict passed as errors=:
Router error |
HTTP exception |
Status |
|---|---|---|
|
|
404 |
|
|
403 |
|
|
401 |
|
|
503 |
|
|
400 |
Basic Usage
Routed Methods
from genro_routes import route
from genro_asgi import AsgiApplication
class MyApp(AsgiApplication):
openapi_info = {"title": "My API", "version": "1.0.0"}
@route() # uses the only router (self.route) automatically
def index(self):
return {"status": "ok"}
@route()
def users(self, id: str = None):
if id:
return {"user": id}
return {"users": ["alice", "bob"]}
RoutingClass (genro-routes) provides the lazy route property: the router is
created on first access, no explicit setup needed.
With a single router @route() is enough; with several routers pass the router
name positionally:
class MyApp(AsgiApplication):
def on_init(self, **kwargs):
self.backoffice = Router(self, name="backoffice")
@route("backoffice") # selects the named router
def admin(self):
return "Admin panel"
Mounting Apps on the Server
The server is the demultiplexer; apps are mounted under a name (the first path segment / URL prefix):
docs = DocsApp()
server.mount("docs", docs) # sets docs.mount_name and wires docs.server -> server
# Now /docs → docs.index()
# /docs/api → docs.api()
mount calls attach_instance internally to establish the dual parent-child
relationship (app.server points back at the server). Apps are also mounted
declaratively from a config.py recipe (apps.application(code=..., mount=...)),
which the server materializes in _mount_app.
Attaching a routed instance directly
attach_instance (from RoutingClass) is the low-level primitive; name is
keyword-only:
server.attach_instance(some_routing_instance, name="docs")
Introspection with nodes()
nodes() enumerates the router tree. Its real signature is:
router.nodes(basepath=None, lazy=False, mode=None, pattern=None,
forbidden=False, **filters)
nodes = router.nodes() # default enumeration
nodes = router.nodes(mode="openapi") # OpenAPI-shaped output
nodes = router.nodes(lazy=True) # do not descend into sub-routers eagerly
nodes = router.nodes(pattern="api/*") # restrict by path pattern
Filtering is done through the same **filters kwargs used by node() (e.g.
auth_tags=..., channel_channel=..., env_capabilities=...) — there is no
filter="expr" boolean-expression parameter on nodes().
Routing Plugins
genro-routes ships routing plugins (in genro_routes.plugins): auth,
channel, env, logging, openapi, pydantic. They are what interpret the
filter kwargs and produce OpenAPI metadata. A plugin is attached to a router with
router.plug(...), and the set of plugins known to genro-routes is discoverable
via Router.available_plugins() (surfaced by the plugin configurator section at
/_server/plugins/plugins when server(plugins=True)).
Note:
FilterPluginis not a public symbol of genro-routes 0.22.0 and is not used anywhere in genro-asgi. Tag/capability filtering is performed by passingauth_tags/channel_channel/env_capabilitiestorouter.node()/router.nodes(), which theauth,channelandenvplugins evaluate.
Roadmap: user-based route visibility
Driving route visibility from the current user’s permissions (hiding routes a
user may not see) is a planned use of the same filter mechanism — it is
not implemented today. The building block exists: auth_tags is already
derived from the authenticated identity and passed to the router on every
request.
OpenAPI Generation
The OpenAPI schema is built from the app’s router via nodes(mode="openapi"),
not a router.openapi() call:
# applications/openapi_application/openapi_application.py (essence)
paths_data = self.route.nodes(mode="openapi")
schema = {"openapi": "3.1.0", "info": self.openapi_info, "paths": paths_data}
A single node’s OpenAPI fragment can also be obtained from router.node(path, openapi=True), whose returned RouterNode.openapi carries the schema dict (used
by the genro_api sys-app).
StaticRouter
genro-asgi provides StaticRouter, which implements RouterInterface for
serving files from a storage backend. It is constructed from a StorageNode
(not a bare path string) and resolves paths with node() using a best-match
strategy:
from genro_asgi.routers import StaticRouter
# root is a StorageNode (filesystem, S3, HTTP, ...), not a "./public" string
static = StaticRouter(root, name="assets")
# Resolve a path to a StaticRouterNode
node = static.node("css/style.css")
node() # → the underlying StorageNode (the file)
node.extra_args # unconsumed path segments (best-match remainder)
node.metadata # {"mime_type", "isdir", "isfile"}
StaticRouter.node(path, **kwargs) walks the path segment by segment and returns
the deepest valid node; unconsumed segments are exposed in extra_args.
See routers/static_router.py for details.
Integration with AsgiServer
AsgiServer inherits from RoutingClass (and BasicAuthMixin), but in the
current relay model it does not resolve handlers itself. The Dispatcher
demultiplexes to apps, and each app resolves routes in its own router. To expose
handlers you build an AsgiApplication and mount it:
from genro_routes import route
from genro_asgi import AsgiApplication
class ApiApp(AsgiApplication):
@route()
def index(self):
return {"message": "Hello"}
@route()
def api(self, path: str = None):
return {"api": path}
server.mount("api", ApiApp()) # /api, /api/api, ...
The request flow is:
ASGI request arrives at the
Dispatcherwith a path (e.g./api/users).The
Dispatchertakes the first segment (api), picks the mounted app (falling back to the empty-mount app) and relays the ASGI call.The app’s
handle_requeststrips the mount prefix, callsself.route.node("users", errors=ROUTER_ERRORS, **filters)to resolve the handler, executes it and converts the result to aResponse.
Copyright: Softwell S.r.l. (2025) License: Apache License 2.0