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 attach_instance.

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.api.attach_instance(self.users, name="users")
        self.api.attach_instance(self.orders, name="orders")
        self.api.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

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("?")

Composition

I want to...Code
Attach a child serviceparent.api.attach_instance(child, name="x")
Call through hierarchyparent.api.node("x/handler")()
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 childrenUse DbRoutingClass instead of RoutingClass
Filter by user permissionssvc.api.nodes(auth_tags="admin")
Filter by system capabilitiessvc.api.nodes(env_capabilities="redis")

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