Genro Routes for Dummies

Transport-agnostic routing for Python — define your logic once, expose it anywhere

The Problem Nobody Talks About

You write an API. Your framework forces you to think in HTTP from day one: paths, verbs, request objects, response objects. Your business logic is buried inside framework-specific handlers.

Then someone asks: "Can we expose this via CLI too?" Or: "Can the MCP bridge call these methods directly?" Or: "Can I test this without spinning up a server?"

And you realize your "API" is not an API — it's an HTTP handler collection glued to a framework.

Genro Routes separates what your code does from how it's exposed. Your handlers are plain Python methods. The transport layer (HTTP, CLI, WebSocket, MCP) is a thin adapter on top. This makes everything more natural for GraphQL, tRPC, MCP, direct Python calls, and AI agents.

YOUR APPLICATION RoutingClass + @route + Plugins GENRO ROUTES Router • Plugins • Introspection HTTP / ASGI CLI WebSocket MCP Direct Call

When does this actually matter?

One codebase, many frontends

HTTP, MCP, CLI, WebSocket — same handlers, zero duplication. The transport adapter is a thin, replaceable layer.

Test without infrastructure

router.node("create_order")(payload) — no HTTP client, no server, no mock. Just call the method.

Runtime introspection

router.nodes() returns every handler with metadata, auth rules, capabilities. Build admin UIs or let AI agents discover your API.

Compose services like objects

Attach/detach child services at runtime. Build complex apps from simple, independent modules that know nothing about each other.

Plugins without middleware spaghetti

Auth, validation, logging, OpenAPI — plugins that attach to routers and propagate to children automatically.

Evolve without rewriting

Started as CLI? Add HTTP. Need AI? Add MCP. Your routing layer stays exactly the same.

One name per operation

Each handler has a unique name that is the operation. Not a resource acted upon by a verb. This is how GraphQL, gRPC, tRPC, MCP, and every modern AI agent protocol works. The HTTP verb is inferred automatically when needed.

Traditional RESTGenro Routes
GET /userslist_users
POST /userscreate_user
POST /users/123/suspendsuspend_user

Quick Start

Install

pip install genro-routes          # Core (zero dependencies)
pip install genro-routes[pydantic] # With validation support

Write your service

from genro_routes import RoutingClass, Router, route

class BookStore(RoutingClass):
    def __init__(self):
        self.catalog = [
            {"id": 1, "title": "Dune", "author": "Herbert"},
            {"id": 2, "title": "Neuromancer", "author": "Gibson"},
        ]
        self.api = Router(self, name="api")

    @route("api")
    def list_books(self):
        return self.catalog

    @route("api")
    def find_book(self, book_id: int):
        return next((b for b in self.catalog if b["id"] == book_id), None)

    @route("api")
    def add_book(self, title: str, author: str):
        book = {"id": len(self.catalog) + 1, "title": title, "author": author}
        self.catalog.append(book)
        return book

Call it directly — no server needed

store = BookStore()

store.api.node("list_books")()              # [{"id": 1, ...}, {"id": 2, ...}]
store.api.node("find_book")(book_id=1)      # {"id": 1, "title": "Dune", ...}
store.api.node("add_book")(title="Snow Crash", author="Stephenson")

# Introspect all registered handlers
info = store.api.nodes()
print(info["entries"].keys())  # dict_keys(['list_books', 'find_book', 'add_book'])

Test it — this is your entire test

def test_find_book():
    store = BookStore()
    result = store.api.node("find_book")(book_id=1)
    assert result["title"] == "Dune"

def test_add_book():
    store = BookStore()
    book = store.api.node("add_book")(title="Snow Crash", author="Stephenson")
    assert book["id"] == 3
    assert len(store.api.node("list_books")()) == 3

No HTTP client, no mock server, no fixtures. Your tests call the same methods your HTTP adapter will call. If the test passes, the API works.

Now expose it via HTTP — three lines

# With genro-asgi (the HTTP bridge)
from genro_asgi import GenroASGI

store = BookStore()
app = GenroASGI(store)  # That's it. uvicorn app:app

# GET  /api/list_books     → [{"id": 1, ...}]
# GET  /api/find_book?book_id=1  → {"id": 1, "title": "Dune"}
# POST /api/add_book       → {"id": 3, ...}

Same BookStore, same logic, zero changes. The ASGI adapter maps router entries to HTTP endpoints, infers GET/POST from parameter types, and handles serialization.

The Plugin System

Plugins attach to routers and form a pipeline. Every handler call passes through all attached plugins. They propagate automatically from parent to child routers.

Pydantic Auth Env OpenAPI Logging Plugin pipeline — each handler passes through all attached plugins Attach: Router(self, name="api").plug("pydantic").plug("auth")

Three ways to configure

1. In the decorator — static, per-handler

@route("api", auth_rule="admin", pydantic_disabled=True)

2. On the plugin instance — runtime, global or targeted

router.auth.configure(rule="user")                          # All handlers
router.auth.configure(_target="admin_panel", rule="admin")   # One handler

3. Via the routing proxy — wildcards and introspection

svc.routing.configure("api:auth/admin_*", rule="admin")  # Glob pattern
svc.routing.configure("?")                              # Show full config tree

At a glance

PluginWhat it doesMain config
pydanticValidates inputs from type hints, generates response JSON schemasdisabled
authTag-based access control with boolean rule expressionsrule
openapiControls OpenAPI 3.1 schema generation, infers HTTP methodsmethod, tags, security
envHides handlers when system capabilities are missingrequires
loggingWraps calls with timing and tracingbefore, after

Plugin inheritance: Plug into the parent router once. Every child service attached later inherits the plugin and its configuration automatically.

AuthPlugin

auth

Tag-based access control. Handlers declare required tags, requests declare user tags. The plugin matches them and blocks unauthorized access.

What happens at runtime

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

    @route("api", auth_rule="admin")
    def delete_user(self, user_id: int): ...

    @route("api")                       # No rule = public
    def health(self): ...

svc = API()

# Authorized — works
svc.api.node("delete_user", auth_tags="admin")(user_id=42)

# Wrong tags — raises NotAuthorized (403)
svc.api.node("delete_user", auth_tags="guest")

# No tags at all — raises NotAuthenticated (401)
svc.api.node("delete_user")

# Public handler — always works
svc.api.node("health")()

Filtered introspection

# nodes() shows only what the user can access:
svc.api.nodes(auth_tags="admin")            # Sees: delete_user, health
svc.api.nodes(auth_tags="guest")            # Sees: health only
svc.api.nodes()                            # Sees: health only (no tags = no access)

Rule expressions

@route("api", auth_rule="admin|moderator")     # Admin OR moderator
@route("api", auth_rule="admin&internal")      # Admin AND internal
@route("api", auth_rule="!guest")              # NOT guest
@route("api", auth_rule="(admin|mod)&!banned") # Grouping
OperatorMeaningExample
|ORadmin|moderator
&ANDadmin&internal
!NOT!guest
()Grouping(admin|mod)&!banned

Parameters

ParameterTypeDefaultDescription
rulestr""Tag expression. Empty = public.
enabledboolTrueEnable/disable the plugin.

PydanticPlugin

pydantic

Two jobs: (1) validates inputs against type hints at runtime, (2) generates JSON response schemas from return types so bridges (OpenAPI, MCP) can document your API automatically.

from typing import TypedDict

class BookResponse(TypedDict):
    id: int
    title: str
    author: str

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

    @route("api")
    def get_book(self, book_id: int) -> BookResponse:
        return {"id": book_id, "title": "Dune", "author": "Herbert"}

svc = API()
svc.api.node("get_book")(book_id=1)       # OK → {"id": 1, ...}
svc.api.node("get_book")(book_id="abc")   # ValidationError!

Response schema for free

# The return type annotation auto-generates a JSON Schema:
entry = svc.api._entries["get_book"]
schema = entry.metadata["pydantic"]["response_schema"]
# {"type": "object", "properties": {"id": {"type": "integer"}, ...}}

# OpenAPI and MCP bridges pick this up automatically.

Supported return types: TypedDict, dataclass, dict, list, str, int, bool, Pydantic models — anything Pydantic can serialize.

Skip validation for a handler

@route("api", pydantic_disabled=True)
def raw_handler(self, data): ...  # No validation, no schema

Parameters

ParameterTypeDefaultDescription
disabledboolFalseSkip validation for this handler/router.

OpenAPIPlugin

openapi

Controls OpenAPI 3.1 schema generation. Overrides HTTP methods, adds tags, marks deprecated. Integrates with auth + env + pydantic automatically.

self.api = Router(self, name="api").plug("openapi").plug("auth").plug("pydantic")

@route("api", auth_rule="admin",
       openapi_tags="admin",
       openapi_deprecated=True,
       openapi_method="delete")
def remove_item(self, item_id: int): ...

# Generate full spec:
spec = svc.api.nodes(mode="openapi")
# {"paths": {"/remove_item": {"delete": {"security": [...], ...}}}}

# Or hierarchical (preserves router tree):
spec = svc.api.nodes(mode="h_openapi")

Auto-inference

Without explicit method: GET for scalar-only params (str, int, bool), POST for complex params (dict, list, models).

Cross-plugin integration

If presentOpenAPI generates
auth_rule="admin"security: [{"BearerAuth": []}]
No auth_rulesecurity: [] (public)
env_requires="redis"x-requires: "redis"
Pydantic return typeresponses.200.schema

Parameters

ParameterTypeDefaultDescription
methodstr|NoneautoHTTP method override (get, post, put, delete, patch)
tagsstr|listNoneOpenAPI tags for grouping
summarystrNoneSummary override
descriptionstrNoneDescription override
deprecatedboolFalseMark as deprecated
security_schemestr"BearerAuth"Security scheme name
securitylistNoneExplicit override. [] = public.

EnvPlugin

env

Capability-based filtering. Handlers declare what the system must have (Redis, JWT, Stripe). If requirements aren't met, the handler disappears from introspection and raises NotAvailable on direct call.

from genro_routes.plugins.env import CapabilitiesSet, capability

class SystemCaps(CapabilitiesSet):
    @capability
    def redis(self) -> bool:
        return True

    @capability
    def stripe(self) -> bool:
        return False

class PaymentAPI(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api").plug("env")
        self.capabilities = SystemCaps()

    @route("api", env_requires="redis")
    def cached_lookup(self): ...        # Visible (redis available)

    @route("api", env_requires="stripe|paypal")
    def charge(self): ...                # Hidden (neither available)

svc = PaymentAPI()
svc.api.nodes()  # Shows cached_lookup only. charge is invisible.
svc.api.node("charge")()  # Raises NotAvailable!

# Provide capabilities at request time:
svc.api.node("charge", env_capabilities="stripe")()  # Now works

Same rule syntax as auth: | OR, & AND, ! NOT, () grouping.

Accumulation: Capabilities from parent instances are inherited. Request-time capabilities add to the set.

Parameters

ParameterTypeDefaultDescription
requiresstr""Capability expression. Empty = no requirement.
enabledboolTrueEnable/disable the plugin.

LoggingPlugin

logging

Wraps handler calls with timing. Logs "start" before and "end (X ms)" after. Useful for debugging and monitoring.

self.api = Router(self, name="api").plug("logging")

@route("api")
def slow_op(self):
    time.sleep(0.1)
    return "done"

# Output:
#   slow_op start
#   slow_op end (100.45 ms)

Fine-grained control

@route("api", logging_flags="before,after:off")  # Start only, skip timing
def fast_op(self): ...

# At runtime:
router.logging.configure(enabled=False)                    # Silence all
router.logging.configure(_target="slow_op", after=True)    # Re-enable one
router.logging.configure(print=True)                       # Force print()

Parameters

ParameterTypeDefaultDescription
enabledboolTrueGate the plugin entirely
beforeboolTrueLog "start" before handler
afterboolTrueLog "end (X ms)" after handler
logboolTrueUse logger.info() when available
printboolFalseAlways use print() instead

Flag syntax: "enabled" → True. "before:off" → False. "enabled,after:off" → enabled=True, after=False.

Service Composition

Real applications are built from independent modules. Each module is a RoutingClass with its own router. Connect them with self.attach_instance(child, ...) on the parent RoutingClass.

App Router: api (branch) + auth plugin UsersService list_users, create_user OrdersService list_orders, approve_order NotifyService send_email, send_sms "users" "orders" "notify" Path resolution: app.api.node("users/list_users")() app.api.node("orders/approve_order")(order_id="123")
class App(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api", branch=True).plug("auth")

        self.users = UsersService()
        self.orders = OrdersService()
        self.notify = NotifyService()

        self.attach_instance(self.users, name="users")
        self.attach_instance(self.orders, name="orders")
        self.attach_instance(self.notify, name="notify")

app = App()
app.api.node("users/list_users")()
app.api.node("orders/approve_order")(order_id="123")

# Introspect the entire tree:
tree = app.api.nodes()
print(tree["routers"].keys())  # dict_keys(['users', 'orders', 'notify'])

Auth plugin was plugged once on App.api. All three child services inherit it automatically. UsersService, OrdersService, and NotifyService know nothing about auth — it's managed at the root.

Composition features

  • branch=True — pure namespace, no handlers at root level
  • prefix="handle_" — strip method prefixes (handle_list → entry list)
  • Auto-detach on attribute replacement — swap child services at runtime
  • Dotted path resolution — "users/list_users" walks the tree
  • routing.instance("api/users") — retrieve any attached child instance without storing it as an attribute

Execution Context

Handlers need db, user, session — but must not know which adapter (HTTP, bot, CLI) provides them. RoutingContext is a simple container where the adapter stores whatever the handlers need.

Create and attach attributes freely

from genro_routes import RoutingContext

ctx = RoutingContext()
ctx.db = db_connection
ctx.user = current_user
ctx.locale = "it"

No abstract methods, no required properties. Just set what you need.

Parent chain — layered contexts

Real applications have layers. Each layer adds its own state without copying the parent’s:

server_ctx .server .config app_ctx parent=server_ctx .app request_ctx parent=app_ctx .db .user .session request_ctx.db → local | request_ctx.app → walks up to app_ctx | request_ctx.config → walks up to server_ctx
# Server boot
server_ctx = RoutingContext()
server_ctx.server = server
server_ctx.config = global_config

# App mount
app_ctx = RoutingContext(parent=server_ctx)
app_ctx.app = app

# Per-request
request_ctx = RoutingContext(parent=app_ctx)
request_ctx.db = request.state.db
request_ctx.user = request.state.user

Slot + parent chain — set once, inherit everywhere

The context is stored in a _ctx slot on each instance. Reading self.ctx walks up the _routing_parent chain:

  • Set it on the root — children inherit it automatically via the parent chain
  • Override locally — a child can set its own ctx to shadow the parent's
  • Concurrency isolation is the adapter's job (ContextVar, threading.local, etc.)
# Adapter sets context once per request
svc.ctx = request_ctx

# Every handler reads it (walks parent chain)
@route("api")
def list_orders(self):
    db = self.ctx.db        # from request_ctx (local)
    user = self.ctx.user    # from request_ctx (local)
    config = self.ctx.config  # walked up to server_ctx
    return db.query("SELECT ...")

# Cleanup
svc.ctx = None

No more DbRoutingClass. Database connections live in the context now: self.ctx.db. The parent chain handles propagation — set it once at the top, read it anywhere below.

CLI Adapter

Genro Routes includes a built-in CLI transport adapter. Given any RoutingClass, it generates a full command-line interface with tab completion, help, and typed parameters — automatically.

Install: pip install genro-routes[cli]

Minimal Script

#!/usr/bin/env python
from genro_routes import RoutingClass, Router, route
from genro_routes.cli import RoutingCli

class OrdersAPI(RoutingClass):
    def __init__(self, label: str):
        self.label = label
        self.api = Router(self, name="orders")

    @route("orders")
    def list(self):
        """List all orders."""
        return ["order-1", "order-2"]

    @route("orders")
    def retrieve(self, ident: str):
        """Retrieve a single order."""
        return f"{self.label}:{ident}"

cli = RoutingCli(OrdersAPI("acme"))
cli.run()

What You Get

$ orders --help
Usage: ordersapi [OPTIONS] COMMAND [ARGS]...

Commands:
  list      List all orders.
  retrieve  Retrieve a single order.

$ orders list
["order-1", "order-2"]

$ orders retrieve 42
acme:42

$ orders retrieve --help
Usage: ordersapi retrieve [OPTIONS] IDENT
...

How It Maps

genro-routes conceptCLI equivalent
RouterCommand group (subcommand namespace)
Handler (entry)Command
Parameter without defaultPositional argument
Parameter with default--option
bool parameter--flag/--no-flag
Literal["a","b"]--choice {a,b}
Enum--choice {NAME,...}
list[str]--items a --items b (multiple)
Child router (self.attach_instance)Nested sub-group
Handler docstringCommand help text

Multiple Routers

With a single router, entries are commands at root level. With multiple routers, each becomes a sub-group:

# Single router:
$ myapp list
$ myapp retrieve 42

# Multiple routers:
$ myapp api list
$ myapp admin reset

Tab Completion

Click provides native shell completion. Activate it once:

# bash
eval "$(_MYAPP_COMPLETE=bash_source myapp)"

# zsh
eval "$(_MYAPP_COMPLETE=zsh_source myapp)"

# fish
_MYAPP_COMPLETE=fish_source myapp | source

Then TAB works for commands, sub-commands, options, and choices.

Output Formats

# Default: auto (strings direct, dicts/lists as JSON)
cli = RoutingCli(MyService)

# Force JSON for everything
cli = RoutingCli(MyService, output_format="json")

# Table format (uses rich if installed, otherwise JSON)
cli = RoutingCli(MyService, output_format="table")

# Raw repr()
cli = RoutingCli(MyService, output_format="raw")

Class vs Instance

# Pass a class — instantiated without arguments
cli = RoutingCli(MyService)

# Pass an instance — full control over initialization
svc = MyService(db=connect(), config=load_config())
cli = RoutingCli(svc)

Cheat Sheet

Essentials

I want to...Code
Create a router on my classself.api = Router(self, name="api")
Register a handler@route("api") def my_handler(self): ...
Call a handler by namesvc.api.node("my_handler")(arg1, arg2)
See all available handlerssvc.api.nodes()
Get OpenAPI for the whole routersvc.api.nodes(mode="openapi")

Plugins

I want to...Code
Attach a pluginRouter(self, name="api").plug("auth")
Set auth rule on a handler@route("api", auth_rule="admin")
Validate inputs with type hints.plug("pydantic") + add type annotations
Require a system capability@route("api", env_requires="redis")
Override HTTP method for OpenAPI@route("api", openapi_method="delete")
Configure a plugin globallysvc.api.auth.configure(rule="user")
Configure for one handler onlysvc.api.auth.configure(_target="x", rule="admin")
Configure with wildcard matchingsvc.routing.configure("api:auth/admin_*", ...)
See current plugin configsvc.routing.configure("?")

Context

I want to...Code
Create a contextctx = RoutingContext()
Attach state to the contextctx.db = conn; ctx.user = user
Create a child context (inherits parent)child = RoutingContext(parent=ctx)
Set context on root instancesvc.ctx = ctx
Read context from a handlerself.ctx.db
Clear context (fall through to parent)svc.ctx = None

Composition

I want to...Code
Attach a child service (single router)parent.attach_instance(child, name="x")
Attach with explicit cross-mappingparent.attach_instance(child, router_api="orders:sales")
Retrieve attached child instanceparent.routing.instance("api/x")
Call through hierarchyparent.api.node("x/handler")()
Resolve by endpoint_idparent.api.node("@MY-EP")()
Create a pure namespace (no handlers)Router(self, name="api", branch=True)
Strip method name prefixesRouter(self, name="api", prefix="handle_")
Auto-propagate db to childrenSet ctx.db = conn on RoutingContext — parent chain handles propagation
Filter by user permissionssvc.api.nodes(auth_tags="admin")
Filter by system capabilitiessvc.api.nodes(env_capabilities="redis")

CLI

I want to...Code
Create a CLI from my RoutingClassRoutingCli(MyService).run()
Pass a pre-configured instanceRoutingCli(MyService(config=cfg)).run()
Force JSON outputRoutingCli(MyService, output_format="json")
Enable bash tab completioneval "$(_MYAPP_COMPLETE=bash_source myapp)"
Get the click Group for testingcli.click_group

Exceptions

ExceptionHTTP equiv.Raised when
NotFound404Path/entry does not exist
NotAuthenticated401Auth required, no tags provided
NotAuthorized403Tags don't match the rule
NotAvailable501Capabilities don't meet requirements