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:

  1. Demultiplexing (server level): the Dispatcher looks 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.

  2. Route resolution (app level): each AsgiApplication resolves the rest of the path inside its own router (the lazy route property), via self.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

Router

Tree-based router, organizes handlers hierarchically

RoutingClass

Base class for classes with routed methods (AsgiServer, AsgiApplication)

@route(router=None)

Decorator to register a method as a route on a named router

RouterInterface

Protocol for custom routers (e.g. StaticRouter)


Key Methods

Method

Description

router.node(path, errors=None, openapi=False, **filters)

Resolve a path to a RouterNode

router.nodes(basepath=None, lazy=False, mode=None, pattern=None, **filters)

Enumerate nodes (entries + sub-routers)

parent.attach_instance(obj, *, name=None)

Attach a routed instance as a child

router.plug(plugin)

Register a routing plugin on the router

Router.available_plugins()

List the plugins registered with genro-routes

There is no router.get(...) method and no router.openapi() method in genro-routes 0.22.0. Resolution is router.node(...); OpenAPI is produced from router.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

auth_tags

identity from authenticate(scope)

auth plugin

channel_channel

x-channel request header (when present)

channel plugin

env_capabilities

request environment flags (request.env_capabilities)

env plugin

The header-to-filter mapping is singular: the x-channel header populates filters["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

not_found

HTTPNotFound

404

not_authorized

HTTPForbidden

403

not_authenticated

HTTPUnauthorized

401

not_available

HTTPServiceUnavailable

503

validation_error

HTTPBadRequest

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: FilterPlugin is not a public symbol of genro-routes 0.22.0 and is not used anywhere in genro-asgi. Tag/capability filtering is performed by passing auth_tags / channel_channel / env_capabilities to router.node() / router.nodes(), which the auth, channel and env plugins 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:

  1. ASGI request arrives at the Dispatcher with a path (e.g. /api/users).

  2. The Dispatcher takes the first segment (api), picks the mounted app (falling back to the empty-mount app) and relays the ASGI call.

  3. The app’s handle_request strips the mount prefix, calls self.route.node("users", errors=ROUTER_ERRORS, **filters) to resolve the handler, executes it and converts the result to a Response.


Copyright: Softwell S.r.l. (2025) License: Apache License 2.0