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") decorator

  • 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, 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 registration

  • RoutingClass is required - all classes using Router must inherit from it

  • Each 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:

  • RoutingContext has no required properties — attach any attribute freely

  • RoutingContext(parent=...) creates layered contexts; missing attributes walk up the chain

  • svc.ctx = ctx stores the context on the instance slot

  • Child instances walk up the _routing_parent chain to find the context

  • svc.ctx = None clears 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.doc

  • Error handling: check node.error before 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.error is None if path resolved correctly

  • node.error contains error code string (e.g., "not_found") if resolution failed

  • Calling 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).

Exceptions: NotFound, NotAuthenticated, NotAuthorized, NotAvailable

Genro Routes provides exceptions for handling routing errors:

from genro_routes import NotFound, NotAuthenticated, NotAuthorized, NotAvailable

# NotFound - raised when calling node() on non-existent entry
# NotAuthenticated - raised when entry requires auth but none provided (401)
# NotAuthorized - raised when auth provided but doesn't match (403)
# NotAvailable - raised when entry exists but capabilities are missing

Using node() with filters:

from genro_routes import RoutingClass, Router, route, NotFound, NotAuthorized

class SecureAPI(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api").plug("auth")

    @route("api", auth_rule="admin")
    def admin_action(self):
        return "admin only"

    @route("api", auth_rule="public")
    def public_action(self):
        return "public"

api = SecureAPI()

# Entry exists and tag matches - node is callable
node = api.api.node("admin_action", auth_tags="admin")
assert node() == "admin only"

# Entry exists but tag doesn't match - node has error
node = api.api.node("admin_action", auth_tags="public")
assert node.error == "not_authorized"  # Error reason
# Calling raises NotAuthorized
try:
    node()
except NotAuthorized as e:
    print(f"Access denied: {e.selector}")  # "api:admin_action"

# Entry doesn't exist - node has error
node = api.api.node("nonexistent")
# Calling raises NotFound
try:
    node()
except NotFound as e:
    print(f"Not found: {e.selector}")  # "api:nonexistent"

Exception attributes:

  • selector: The full selector in format "router_name:path" (e.g., "api:admin_action")

RouterNode properties:

  • node.error: Error code string (e.g., "not_authorized", "not_available") or None

  • Calling a node with error raises the appropriate exception

Best-match resolution:

The node() method uses best-match resolution - it walks the path as far as possible and passes unconsumed segments as arguments to the handler:

node = router.node("unknown/path")
# If default_entry="index" accepts *args, unconsumed segments
# are passed as positional arguments when calling the handler
result = node()  # calls handler("unknown", "path")

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

not_found

NotFound

404

Path doesn’t resolve

not_authenticated

NotAuthenticated

401

Auth required, none provided

not_authorized

NotAuthorized

403

Auth provided, insufficient

not_available

NotAvailable

501

Capability missing

validation_error

ValidationError

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_entry specifies 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_entry handler doesn’t exist, node.error is 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_instance is a method on RoutingClass, not on Router

  • name="alias" is a shortcut when the child has a single router

  • For 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 hierarchy

  • lazy: Return router references instead of expanding recursively

  • mode: 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 (default False)

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 under metadata["meta"] in the entry

  • node.metadata property returns the meta dict directly (convenience)

  • nodes() returns the full structure with ["metadata"]["meta"] path

  • The meta_ prefix is stripped from the key name

  • Separate 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 a ResultWrapper with arbitrary metadata

  • is_result_wrapper(obj) checks if an object is a ResultWrapper

  • The transport adapter (e.g., genro-asgi) inspects the wrapper to set response headers

  • ResultWrapper.value contains the actual result

  • ResultWrapper.metadata contains the metadata dict

Next Steps

Now that you understand the basics: