A conversation about genro-asgi · Part two
If the router doesn't know about HTTP, what does it know — and what do you get back for the inversion?
6 min · previously: Why another ASGI server?
You said the routing engine knows nothing about HTTP. So how does a method become an endpoint?
By being asked for. A class owns exactly one router, created for it automatically. You mark methods with a decorator and they become entries in that router's tree. At that point they're just callable entries with names, signatures and metadata — no verb, no path, no status code anywhere.
So what turns them into a web API?
A transport adapter. The HTTP one is the server: a request arrives, it resolves the path against the tree, calls the entry, turns the return value into a response. The command line adapter does the same job against a terminal. Neither is privileged — HTTP just happens to be the one everyone starts with.
This feels like indirection for its own sake. What do I get that a decorator with a path on it doesn't give me?
Three things you'd otherwise build yourself. The first is that the tree is readable. You can ask it what's in there — entries, signatures, types, the metadata declared on each one — while the program is running.
Introspection. Everyone has that.
Everyone has some. The difference is what's downstream of it. The OpenAPI document isn't a file somebody maintains, it's a translation of that reading. Return type annotations become response schemas. Parameter signatures become request models. Nothing to keep in sync, because there's no second copy.
And the second thing?
MCP — the protocol AI agents use to discover and call tools. Here it's not a bolt-on: the engine speaks it over the same tree. A method you decorated once is a REST endpoint and a tool an agent can invoke, described to that agent by the same schema that documents it for humans.
A lot of people are shipping MCP adapters over existing APIs right now.
They are, and that's the honest comparison. An adapter is a second surface: it needs its own descriptions, and it drifts from the first one the moment somebody changes a signature and forgets. This isn't a second surface. It's the same tree answering a different caller.
Alright. Third thing?
Plugins that hook into the router rather than into your handlers. Validation builds the model from the method signature. Authorisation is a rule declared on the route — a small boolean algebra over the user's tags, so admin|manager means one of the two and staff&!suspended means what it looks like. There's one for environment capabilities, one that makes a route visible from one channel and invisible from another.
Why does it matter whether that lives in the router or in my code?
Because in your code it's invisible from the outside. An if user.is_admin at the top of a function is a fact only that function knows. Declared on the route, it's a property anyone can read — including the thing that generates the documentation.
What the documentation claims and what the server enforces are produced by the same reading. They can't drift apart, because there's nothing to keep aligned.
Give me the failure mode. When does this bite me?
When you want a route whose shape genuinely doesn't fit the tree — some hand-rolled streaming protocol, a webhook with a signature scheme of its own. Then you're writing a handler that mostly ignores the machinery, and the machinery isn't buying you much.
Fair. And routes are static, I assume — you can't add one at three in the morning based on a database row?
Not from data, no. What exists is declared by code — decorated methods, and branches declared on the class. Routing is never used as a mutable registry, so what answers a request was always going to answer it.
So the whole tree gets built when the server starts.
That part isn't true, and it's worth being precise about. Materialisation is lazy. A router discovers its own routes the first time it's actually used, and a branch declared by class is constructed the first time somebody traverses it. Declare a hundred branches and you pay for the ones people visit.
Then you can't know the full surface without walking it.
You can, and that's the part that makes laziness affordable. A declared-but-not-yet-built branch still describes itself — from its class, without being instantiated. So introspection sees the whole shape: the documentation is complete on a server where nobody has touched half the tree yet.
Declared eagerly, built lazily, readable either way.
That's the sentence. The two properties are usually confused, and they're worth keeping apart: what exists is fixed and inspectable, when it comes alive is on demand.
And the second protocol, the socket one?
Same tree again. There's a WebSocket transport that carries HTTP semantics over an open connection, so a message arriving on the socket finds the same routes, the same plugins, the same authorisation as an HTTP request. One dispatch engine, two transports plugged into it.
Why one engine? Two transports each doing their own thing would be simpler to write.
Simpler to write, and we know what it costs — we already paid it once. Two engines diverged, because nothing forced them to stay equal, and every feature added to one had to be rediscovered in the other. Reconciling them cost more than writing one would have. Now a single contract test suite runs against both.
You keep mentioning that open connection like it's the whole reason for this design. Why does one socket change so much?