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:
Routers are instantiated at runtime:
Router(self, name="api")Methods are marked with
@route("router_name")decoratorEach 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, Router, route
class Service(RoutingClass):
def __init__(self, label: str):
self.label = label
self.api = Router(self, name="api")
@route("api")
def describe(self):
return f"service:{self.label}"
# Each instance is isolated
first = Service("alpha")
second = Service("beta")
assert first.api.node("describe")() == "service:alpha"
assert second.api.node("describe")() == "service:beta"
Key points:
Router(self, name="api")creates instance-scoped router in__init__@route("api")marks method for registrationRoutingClassis required - all classes usingRoutermust inherit from itEach instance has independent routing state
Registering Handlers
Methods are automatically registered when decorated with @route:
class API(RoutingClass):
def __init__(self):
self.routes = Router(self, name="routes")
@route("routes")
def echo(self, value: str):
return value
@route("routes", name="alt_name")
def action(self):
return "executed"
api = API()
# Direct name resolution
assert api.routes.node("echo")("hello") == "hello"
# Custom name resolution
assert api.routes.node("alt_name")() == "executed"
Registration happens automatically when you inherit from RoutingClass and instantiate routers in __init__.
Single Router Default
When a class has exactly one router, @route() without arguments uses it automatically:
class Table(RoutingClass):
def __init__(self):
self.table = Router(self, name="table")
@route() # Uses the only router automatically
def add(self, data):
return f"added:{data}"
@route() # Also uses the single router
def remove(self, id):
return f"removed:{id}"
t = Table()
assert t.table.node("add")("x") == "added:x"
assert t.table.node("remove")(1) == "removed:1"
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, Router, route
class UsersModule(RoutingClass):
def __init__(self):
self.api = Router(self, name="api")
@route("api")
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.
Accessing the Default Router
You can access the default router programmatically via the default_router property:
class SingleAPI(RoutingClass):
def __init__(self):
self.api = Router(self, name="api")
@route()
def ping(self):
return "pong"
svc = SingleAPI()
assert svc.default_router is svc.api # Only one router = default
class MultiAPI(RoutingClass):
def __init__(self):
self.api = Router(self, name="api")
self.admin = Router(self, name="admin")
m = MultiAPI()
assert m.default_router is None # Multiple routers = no default
Calling Handlers
Use node() to retrieve handlers - it returns a callable RouterNode:
class Calculator(RoutingClass):
def __init__(self):
self.ops = Router(self, name="ops")
@route("ops")
def add(self, a: int, b: int):
return a + b
calc = Calculator()
# node() returns a RouterNode which is callable
node = calc.ops.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.routes = Router(self, name="routes", prefix="handle_")
@route("routes")
def handle_list(self):
return f"{self.prefix}:list"
@route("routes", name="detail")
def handle_detail(self, ident: int):
return f"{self.prefix}:detail:{ident}"
sub = SubService("users")
# Prefix stripped: "handle_list" → "list"
assert sub.routes.node("list")() == "users:list"
# Custom name used: "handle_detail" → "detail"
assert sub.routes.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):
def __init__(self):
self.api = Router(self, name="api")
@route("api")
def known_action(self):
return "success"
fb = Fallback()
# Existing handler - no error
node = fb.api.node("known_action")
if not node.error:
assert node() == "success"
# Non-existing - node has error
missing = fb.api.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, Router, 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.api = Router(self, name="api").plug("auth")
@route("api", auth_rule="admin")
def admin_only(self):
return "secret"
api = MyAPI()
# Map error codes to custom exceptions
node = api.api.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 |
Pydantic validation failed |
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 parameter (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.api = Router(self, name="api", default_entry="serve")
@route("api")
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.api.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.routes = Router(self, name="routes", prefix="handle_")
@route("routes")
def handle_list(self):
return f"{self.prefix}:list"
@route("routes", name="detail")
def handle_detail(self, ident: int):
return f"{self.prefix}:detail:{ident}"
class RootAPI(RoutingClass):
def __init__(self):
self.api = Router(self, name="api")
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.api.node("users/list")() == "users:list"
assert root.api.node("products/detail")(5) == "products:detail:5"
Key points:
attach_instanceis a method onRoutingClass, not onRoutername="alias"is a shortcut when the child has a single routerFor multi-router children, use
router_<parent>=...kwargs (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.api = Router(self, name="api")
self.child_service = SubService("child")
self.attach_instance(self.child_service, name="sub")
@route("api")
def action(self):
pass
insp = Inspectable()
# Get metadata (single source: nodes)
info = insp.api.nodes()
assert "action" in info["entries"]
assert "sub" in info["routers"]
# Get nodes starting from a specific path
sub_info = insp.api.nodes(basepath="sub")
assert "list" in sub_info["entries"]
# Use lazy=True for on-demand expansion of children
lazy_info = insp.api.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 recursivelymode: Output format mode (see below)pattern: Regex pattern to filter entry names (only matching entries are included)forbidden: Include blocked entries with their rejection reason (defaultFalse)
Output modes:
None(default): Standard introspection format with entries, routers, plugin_info"openapi": Flat OpenAPI 3.0 schema with all paths merged across the hierarchy"h_openapi": Hierarchical OpenAPI format preserving the router tree structure
# Flat OpenAPI schema (all paths merged)
schema = insp.api.nodes(mode="openapi")
# Hierarchical OpenAPI schema (preserves router structure)
h_schema = insp.api.nodes(mode="h_openapi")
# Filter entries by name pattern
admin_entries = insp.api.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 (with
mode="openapi")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):
def __init__(self):
self.api = Router(self, name="api")
@route("api", meta_mimetype="application/json", meta_deprecated=True)
def get_data(self):
"""Return data in JSON format."""
return {"foo": "bar"}
@route("api", 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.api.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.api.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 the _routing_parent chain), 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, Router, route, is_result_wrapper
class APIService(RoutingClass):
def __init__(self):
self.api = Router(self, name="api")
@route("api")
def render_html(self):
content = "<html><body>Hello</body></html>"
return self.result_wrapper(content, mime_type="text/html")
svc = APIService()
result = svc.api.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