Basic Usage
This guide covers Genro Routes’ core features with practical examples derived from the test suite.
Overview
Genro Routes provides instance-scoped routing with hierarchical organization and plugin support. Each router instance is independent with its own plugin state.
Key concepts:
Every
RoutingClassowns exactly one router, auto-created lazily and exposed asself.routeMethods are marked with the
@route()decorator (keyword-only options)Each instance gets isolated routing state
Plugins apply per-instance, not globally
Creating Your First Router
Create a service with instance-scoped routing:
from genro_routes import RoutingClass, route
class Service(RoutingClass):
def __init__(self, label: str):
self.label = label
@route()
def describe(self):
return f"service:{self.label}"
# Each instance is isolated
first = Service("alpha")
second = Service("beta")
assert first.route.node("describe")() == "service:alpha"
assert second.route.node("describe")() == "service:beta"
Key points:
The router is created automatically on first access of
self.route— never callRouter(...)yourself@route()marks a method for registrationRoutingClassis required - only its instances can own a routerEach instance has independent routing state
Registering Handlers
Methods are automatically registered when decorated with @route:
class API(RoutingClass):
@route()
def echo(self, value: str):
return value
@route(name="alt_name")
def action(self):
return "executed"
api = API()
# Direct name resolution
assert api.route.node("echo")("hello") == "hello"
# Custom name resolution
assert api.route.node("alt_name")() == "executed"
Registration happens automatically: the router discovers marked methods lazily on first use — no explicit bind call, and no __init__ needed unless you configure plugins or router options.
One Class, One Router
@route() always registers the method on the class’s single router:
class Table(RoutingClass):
@route()
def add(self, data):
return f"added:{data}"
@route()
def remove(self, id):
return f"removed:{id}"
t = Table()
assert t.route.node("add")("x") == "added:x"
assert t.route.node("remove")(1) == "removed:1"
Router options (description, prefix, default_entry) are set on the existing router in __init__ (binding is lazy, so this is race-free):
class DocumentedTable(RoutingClass):
def __init__(self):
self.route.description = "Table operations"
@route()
def add(self, data):
return f"added:{data}"
Database Access via Context
Handlers access shared state (database, user, session) through self.ctx.
The adapter creates a RoutingContext, attaches what it needs, and sets it
on any RoutingClass instance — all instances in the same task share it
via the _routing_parent chain.
from genro_routes import RoutingClass, RoutingContext, route
class UsersModule(RoutingClass):
@route()
def list_users(self):
return self.ctx.db.execute("SELECT * FROM users")
# Adapter creates a layered context:
server_ctx = RoutingContext()
server_ctx.config = global_config
app_ctx = RoutingContext(parent=server_ctx)
app_ctx.app = app
request_ctx = RoutingContext(parent=app_ctx)
request_ctx.db = db_connection
request_ctx.user = current_user
# Set it — now every handler in this task sees it
svc = UsersModule()
svc.ctx = request_ctx
# Handler reads:
# self.ctx.db → local (request_ctx)
# self.ctx.config → walks up to server_ctx
Key points:
RoutingContexthas no required properties — attach any attribute freelyRoutingContext(parent=...)creates layered contexts; missing attributes walk up the chainsvc.ctx = ctxstores the context on the instance slotChild instances walk up the
_routing_parentchain to find the contextsvc.ctx = Noneclears the local slot (children fall through to parent)
See the Execution Context Guide for the full explanation including adapter patterns and subclassing.
Multiple Surfaces via Composition
A class has exactly one router. When you need distinct surfaces (e.g. a public
API and an admin area), compose separate RoutingClass instances:
class PublicAPI(RoutingClass):
@route()
def ping(self):
return "pong"
class AdminAPI(RoutingClass):
@route()
def reset(self):
return "reset done"
class App(RoutingClass):
def __init__(self):
self.api = PublicAPI()
self.admin = AdminAPI()
self.attach_instance(self.api, name="api")
self.attach_instance(self.admin, name="admin")
app = App()
assert app.route.node("api/ping")() == "pong"
assert app.route.node("admin/reset")() == "reset done"
For a grouping level without a dedicated class, attach a Section (an empty
RoutingClass): app.attach_instance(Section("Admin area"), name="admin").
Calling Handlers
Use node() to retrieve handlers - it returns a callable RouterNode:
class Calculator(RoutingClass):
@route()
def add(self, a: int, b: int):
return a + b
calc = Calculator()
# node() returns a RouterNode which is callable
node = calc.route.node("add")
assert node(2, 3) == 5
# RouterNode also provides metadata access
assert node.path == "add"
assert node.error is None
RouterNode features:
Callable: invoke directly with
node(*args, **kwargs)Metadata: access
node.path,node.metadata,node.docError handling: check
node.errorbefore calling
Using Prefixes and Custom Names
Clean up method names with prefixes and provide alternative names with the name option:
class SubService(RoutingClass):
def __init__(self, prefix: str):
self.prefix = prefix
self.route.prefix = "handle_"
@route()
def handle_list(self):
return f"{self.prefix}:list"
@route(name="detail")
def handle_detail(self, ident: int):
return f"{self.prefix}:detail:{ident}"
sub = SubService("users")
# Prefix stripped: "handle_list" → "list"
assert sub.route.node("list")() == "users:list"
# Custom name used: "handle_detail" → "detail"
assert sub.route.node("detail")(10) == "users:detail:10"
Benefits:
Prefixes keep method names organized in code
Explicit names provide cleaner external APIs
Router resolves both automatically
Checking Node Errors
Use node.error to check if a path resolved correctly:
class Fallback(RoutingClass):
@route()
def known_action(self):
return "success"
fb = Fallback()
# Existing handler - no error
node = fb.route.node("known_action")
if not node.error:
assert node() == "success"
# Non-existing - node has error
missing = fb.route.node("missing")
if missing.error:
print(f"Handler error: {missing.error}")
RouterNode error handling:
node.errorisNoneif path resolved correctlynode.errorcontains error code string (e.g.,"not_found") if resolution failedCalling a node with error raises the appropriate exception
Note: node() always returns a RouterNode. If the path points to a child router without specifying an entry, best-match resolution will use the child’s default_entry (see Hierarchies).
Custom Exception Mapping
Map router error codes to your framework’s exception classes using the errors parameter in node():
from genro_routes import RoutingClass, route
# Define your framework's exceptions
class HTTPNotFound(Exception):
pass
class HTTPForbidden(Exception):
pass
class HTTPUnauthorized(Exception):
pass
class MyAPI(RoutingClass):
def __init__(self):
self.route.plug("auth")
@route(auth_rule="admin")
def admin_only(self):
return "secret"
api = MyAPI()
# Map error codes to custom exceptions
node = api.route.node("admin_only", auth_tags="guest", errors={
"not_found": HTTPNotFound,
"not_authorized": HTTPForbidden,
"not_authenticated": HTTPUnauthorized,
})
# Calling raises your custom exception
try:
node()
except HTTPForbidden:
print("Access denied!") # Your exception type
Available error codes (see RouterNode.ERROR_CODES):
Code |
Default Exception |
HTTP Status |
When |
|---|---|---|---|
|
|
404 |
Path doesn’t resolve |
|
|
401 |
Auth required, none provided |
|
|
403 |
Auth provided, insufficient |
|
|
501 |
Capability missing |
|
|
422 |
Bad arguments: pydantic validation failed or arguments unbindable ( |
Use cases:
Integrate with web frameworks (FastAPI, Starlette, Flask)
Consistent error handling across your application
Custom error responses with framework-specific exception types
Catch-All Routing with default_entry
Routers have a default_entry option (default: "index") that enables catch-all routing patterns via best-match resolution:
class FileServer(RoutingClass):
def __init__(self):
# default_entry="index" is the default, but can be customized
self.route.default_entry = "serve"
@route()
def serve(self, *path_segments):
return f"Serving: {'/'.join(path_segments)}"
server = FileServer()
# node() uses best-match resolution - when path can't be fully resolved,
# unconsumed segments become arguments to the handler
node = server.route.node("docs/api/reference")
assert node() == "Serving: docs/api/reference"
Key behaviors:
default_entryspecifies which handler to use for unresolved paths (default:"index")Best-match resolution walks the path as far as possible
Unconsumed path segments are passed as arguments when calling
node()If
default_entryhandler doesn’t exist,node.erroris set
Use cases:
File servers with arbitrary path depth
Catch-all handlers for dynamic routing
Pass-through routes to external services
Building Hierarchies
Create nested router structures with path-based access:
class SubService(RoutingClass):
def __init__(self, prefix: str):
self.prefix = prefix
self.route.prefix = "handle_"
@route()
def handle_list(self):
return f"{self.prefix}:list"
@route(name="detail")
def handle_detail(self, ident: int):
return f"{self.prefix}:detail:{ident}"
class RootAPI(RoutingClass):
def __init__(self):
self.users = SubService("users")
self.products = SubService("products")
self.attach_instance(self.users, name="users")
self.attach_instance(self.products, name="products")
root = RootAPI()
# Access with path separator
assert root.route.node("users/list")() == "users:list"
assert root.route.node("products/detail")(5) == "products:detail:5"
Key points:
attach_instanceis a method onRoutingClass, not onRoutername="alias"links the child’s router into the parent’s router under that aliasFor grouping levels without handlers, attach a
Section(see Hierarchies Guide)
Hierarchies enable:
Organized service composition
Logical grouping of related handlers
Namespace isolation
Introspection
Inspect router structure and registered handlers:
class Inspectable(RoutingClass):
def __init__(self):
self.child_service = SubService("child")
self.attach_instance(self.child_service, name="sub")
@route()
def action(self):
pass
insp = Inspectable()
# Get metadata (single source: nodes)
info = insp.route.nodes()
assert "action" in info["entries"]
assert "sub" in info["routers"]
# Get nodes starting from a specific path
sub_info = insp.route.nodes(basepath="sub")
assert "list" in sub_info["entries"]
# Use lazy=True for on-demand expansion of children
lazy_info = insp.route.nodes(lazy=True)
sub_router = lazy_info["routers"]["sub"] # Router reference, not expanded
sub_expanded = sub_router.nodes() # Expand on demand
nodes() parameters:
basepath: Start from a specific point in the hierarchylazy: Return router references instead of expanding recursivelypattern: Regex pattern to filter entry names (only matching entries are included)forbidden: Include blocked entries with their rejection reason (defaultFalse)
nodes() always returns the dialect-neutral introspection tree (entries, routers, plugin metadata, and the per-entry result / params blocks). It does not produce OpenAPI or any other dialect: transport adapters (e.g. genro-asgi) read this tree to build OpenAPI/MCP output.
# Filter entries by name pattern
admin_entries = insp.route.nodes(pattern="admin_.*")
Including blocked entries:
Use forbidden=True to include entries that are blocked by plugins (e.g., due to missing capabilities or authorization). Blocked entries have a forbidden field with the rejection reason:
# Include blocked entries for full tree introspection
entries = router.nodes(forbidden=True).get("entries", {})
# {"public": {"name": "public", ...},
# "admin_only": {"name": "admin_only", "forbidden": "not_authorized", ...},
# "needs_redis": {"name": "needs_redis", "forbidden": "not_available", ...}}
Use nodes() to:
Generate API documentation (feed the neutral tree to a transport-layer dialect translator)
Debug routing issues
Validate configuration
Build dynamic UIs that expand on demand (with
lazy=True)Show full tree with blocked entries greyed out (with
forbidden=True)
Custom Metadata with meta_*
Add custom metadata to handlers using the meta_ prefix in @route():
class MetadataAPI(RoutingClass):
@route(meta_mimetype="application/json", meta_deprecated=True)
def get_data(self):
"""Return data in JSON format."""
return {"foo": "bar"}
@route(meta_version="2.0", meta_auth_required=True)
def get_data_v2(self):
return {"foo": "bar", "extra": True}
api = MetadataAPI()
# Access metadata via node() - returns meta dict directly
node = api.route.node("get_data")
assert node.metadata["mimetype"] == "application/json"
assert node.metadata["deprecated"] is True
# Or via nodes() for all entries - uses full path
all_info = api.route.nodes()
entry_meta = all_info["entries"]["get_data_v2"]["metadata"]["meta"]
assert entry_meta["version"] == "2.0"
assert entry_meta["auth_required"] is True
Key behaviors:
meta_*kwargs are stored undermetadata["meta"]in the entrynode.metadataproperty returns the meta dict directly (convenience)nodes()returns the full structure with["metadata"]["meta"]pathThe
meta_prefix is stripped from the key nameSeparate from plugin configuration (which uses
<plugin>_<key>format)
Use cases:
API versioning information
Deprecation markers
Content-type hints
Custom authorization requirements
Any handler-specific metadata not tied to plugins
Execution Context
See the Execution Context Guide for the complete reference.
Quick summary: RoutingContext is an extensible container with parent chain
delegation. Adapters create layered contexts (server → app → request), set
them via svc.ctx = ctx (stored in a _ctx slot on the instance), and handlers read
shared state with self.ctx.db, self.ctx.user, etc. Missing
attributes walk up the parent chain automatically.
Wrapping Handler Results
Use ResultWrapper to return handler results with metadata that the transport layer can use (e.g., for content-type negotiation).
from genro_routes import RoutingClass, route, is_result_wrapper
class APIService(RoutingClass):
@route()
def render_html(self):
content = "<html><body>Hello</body></html>"
return self.result_wrapper(content, mime_type="text/html")
svc = APIService()
result = svc.route.node("render_html")()
# Check if result is wrapped
if is_result_wrapper(result):
print(result.value) # "<html><body>Hello</body></html>"
print(result.metadata) # {"mime_type": "text/html"}
Key points:
self.result_wrapper(value, **metadata)creates aResultWrapperwith arbitrary metadatais_result_wrapper(obj)checks if an object is aResultWrapperThe transport adapter (e.g., genro-asgi) inspects the wrapper to set response headers
ResultWrapper.valuecontains the actual resultResultWrapper.metadatacontains the metadata dict
Next Steps
Now that you understand the basics:
Plugin Guide - Extend functionality with plugins
Hierarchies Guide - Advanced nested routing patterns
Best Practices - Production-ready patterns
API Reference - Complete API documentation