Core Concepts

Status: πŸ”΄ DA REVISIONARE

This page explains the model behind genro-asgi: how a server relates to the applications it serves, how a request finds its handler, and the design principles that make the whole thing predictable. Read it once and the how-to guides will read like footnotes.

The server / application model

genro-asgi separates two roles cleanly:

  • A server owns the runtime: one uvicorn loop, the middleware chain, the request registry, lifespan, and the set of applications it serves.

  • An application owns behaviour: a tree of @route-decorated methods that answer requests. An application knows nothing about ports or middleware.

BaseServer and AsgiServer

BaseServer is the minimal substrate every server shares: the uvicorn loop, a monitored thread pool for blocking work, the primary app, the secondary mounts, lifespan, and the request registry.

AsgiServer is the shipped, batteries-included server. It is a composition of capability mixins stacked over BaseServer in a single MRO β€” communication, auth, session, middleware, plugins, storage, and tasks. Each mixin contributes a feature, and each feature is turned on through a constructor keyword argument:

server = AsgiServer(
    primary=App(),
    auth={...},          # arms the auth middleware
    middleware={...},    # arms other middleware
    tasks=True,          # arms the task backbone
    plugins={...},       # arms OpenAPI / pydantic plugins
)

The mixins exist whether or not you configure them β€” you never subclass to add a capability, you pass config to feed it. This is what β€œobjects always exist, backends come from config” means in practice.

Primary vs mounts

A server always has exactly one primary application (mandatory) and a dictionary of secondary mounts keyed by URL prefix (possibly empty).

  • The primary answers / and everything no mount claims.

  • A secondary mount answers requests whose first path segment matches its mount name.

# Conceptual shape: a primary "shop" plus a secondary "api" mount.
# Requests to /api/... reach the api app; everything else reaches shop.

The one demux rule

There is a single rule for dispatching a request to an application, the same on every server:

First path segment β†’ secondary mount if one exists with that name, otherwise the primary app.

Running with only a primary and no mounts is a usage of this rule, not a different mechanism. There is no second dispatch path to learn.

The automatic _server app

Every server automatically mounts an internal application at /_server/. You do not configure it into existence β€” it is always present. On a public server it is full (login, monitoring, OpenAPI of the system endpoints, task management); its endpoints live under /_server/... and never leak into your own app’s route tree.

Routing with genro-routes

Routing is delegated to genro-routes, a protocol-neutral routing library. This is a deliberate choice: because the route tree does not know whether it is being called over REST, OpenAPI, or MCP, the same tree can be exposed through several transports. It is the reason OpenAPI and MCP applications reuse your ordinary @route methods rather than duplicating them.

@route

Import the decorator from genro_routes:

from genro_routes import route

Key forms you will use across the guides:

  • @route() β€” publish a method as an endpoint; its name is the URL segment.

  • @route(media_type="text/html") β€” respond as HTML instead of JSON.

  • @route(auth_rule="admin") β€” protect the endpoint (see below).

  • @route(channel_channels="mcp") β€” also expose the method as an MCP tool.

  • @route(task="cleanup", task_every="1s") β€” register the method as a scheduled task.

auth_rule and default-deny

A route carrying auth_rule="admin" is protected: the caller’s avatar must carry the matching tag. Protection is default-deny β€” a protected route answers 403 even when no auth middleware is configured, so you never accidentally ship an open endpoint that was meant to be closed. The authentication guide covers the credential side.

Requests and responses

  • A Request object wraps the incoming ASGI scope: query parameters bind to your handler’s signature, and the body is available for parsing.

  • A handler’s return value determines the response: return a dict for JSON, a string with media_type="text/html" for HTML, or an explicit Response object for full control.

  • For long or open-ended bodies, return a StreamingResponse or an SseStream (see the streaming guide).

Both Request and Response are importable from genro_asgi.

Design principles

genro-asgi is a spec-first redesign; its principles are ratified in SPECIFICATION.md. Three of them govern almost every API decision:

  • No globals β€” state lives in instances. There is no module-level server and no ambient request. A server is an object you build; its components reach each other through an explicit parent reference (an application holds self.server, a request holds self.application). Two servers in the same process are fully isolated.

  • Config is data, not structure. You describe what you want with plain data (dicts, config-builder calls) and hand it to objects that already exist. You do not restructure code to switch a backend; you change the data.

  • Objects always exist; backends come from config. A capability is not an X | None that a flag flips on. The session subsystem, the auth subsystem, the task subsystem β€” they are always present. Configuration decides which backend they use. This removes a whole category of β€œis it enabled?” branching.

Two more shape how you extend the framework:

  • Routes are static from boot β€” the route tree is fixed when the server starts; routing is never used as a mutable registry.

  • Extension is by subclassing. You grow an application by subclassing the right base (OpenApiApplication, McpApplication, SessionMixin, and so on), not by patching instances at runtime.

The request flow

Putting it together, here is the path an HTTP request travels:

        HTTP request
             β”‚
             β–Ό
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β”‚ uvicorn β”‚            one loop, owned by the server
        β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜
             β–Ό
      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
      β”‚ AsgiServer β”‚           the ASGI app is the server object itself
      β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
            β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚   middleware chain      β”‚  errors β†’ (wellknown) β†’ (logging) β†’
  β”‚  (outer β†’ inner)        β”‚  (cors) β†’ (session) β†’ (auth)
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β–Ό
     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     β”‚  demux on first   β”‚    segment matches a mount?  β†’ that app
     β”‚  path segment     β”‚    otherwise                β†’ primary app
     β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β–Ό
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚  @route handler(**params)β”‚  query/body bound to the signature, typed
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
               β–Ό
          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
          β”‚ Response β”‚           dict β†’ JSON, str+html β†’ HTML, stream β†’ chunks
          β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
               β–Ό
          ASGI send  ───────────►  HTTP response

The middleware are ordered by priority (lower number = more outer). The always-on error middleware wraps everything, which is why an unmatched path or a raised HTTP exception becomes a clean status code rather than a stack trace. See the middleware guide for the exact chain and how to arm each stage.

Where to go next

  • How-to guides β€” apply these concepts to concrete tasks.

  • Getting started β€” if you skipped the runnable hello-world, start there.

  • SPECIFICATION.md β€” the founding decision log, for the full rationale.