Skip to content

Middleware

A jero middleware is a typed, hand-wired object whose hooks are introspected and compiled at wiring (the same pattern as custom exception handlers): no base class, no decorator. It can do three things:

  • intercept — answer a request before routing and auth (gates, preflights).
  • response_headers — add headers to covered responses, constant or computed.
  • observe — see the outcome after the response starts (timing, audit).

Wiring compiles only what a middleware actually defines, and each hook kind has a fixed, known cost. Routes no middleware covers are not wrapped, not branched into, not touched — the uncovered request path is byte-for-byte the one an app without middleware runs.

A first middleware: request timing

import logging

from jero import BaseApp, Endpoint, Request, Struct

logger = logging.getLogger("app.timing")


class Status(Struct):
    ok: bool


class TimingMiddleware:
    def observe(self, request: Request, status: int, duration: float) -> None:
        logger.info("%s %s -> %d in %.1fms", request.method, request.path, status, duration * 1e3)


class HealthEndpoint(Endpoint, path="/healthz"):
    def get(self) -> Status:
        return Status(ok=True)


class App(BaseApp):
    async def wire(self) -> None:
        self._include_middleware(TimingMiddleware())
        self._include_endpoint(HealthEndpoint())


app = App()

duration is measured from dispatch to response-start. observe may be sync or async; either way it runs after the response has left, so it never adds latency a caller sees.

The protocol

A middleware object may define any of these; everything else on it is ignored, and an object defining none of them fails wiring loud:

from typing import ClassVar

from jero import HTTPMethod, JSONResponse, Request, Struct


class SecurityHeaders(Struct):
    x_frame_options: str = "DENY"


class RefusedBody(Struct):
    reason: str


class OriginHeaders(Struct):
    origin: str | None = None


class ExampleMiddleware:
    # EITHER a constant attribute (baked into covered routes at wiring — free) ...
    response_headers: ClassVar[SecurityHeaders] = SecurityHeaders()

    # static scope declaration: intercept compiles into these wire verbs only
    intercept_methods: ClassVar[tuple[HTTPMethod, ...]] = ("GET",)

    # answer instead of routing; None falls through. Sync or async.
    def intercept(self, request: Request[OriginHeaders]) -> JSONResponse[RefusedBody] | None: ...

    # observability: sees the outcome, cannot touch it. Exceptions logged + swallowed.
    def observe(self, request: Request, status: int, duration: float) -> None: ...

(A middleware defines either the constant response_headers attribute or the method form def response_headers(self, request: Request[H]) -> HeadersStruct | None — one name, one meaning per class.)

Request[H] is the read-only view every hook receives: the wire method and path, plus headersyour Struct, bound exactly like auth's headers: wiring compiles a scanner for just the keys your fields name. Annotate a bare Request to bind no headers at all. received_at is time.perf_counter() at dispatch, stamped on routes a dynamic hook covers.

The capability boundary: middleware can answer a request (intercept), add response headers (response_headers), and watch (observe). It can never rewrite a handler's body or status: that requires buffering or wrapping every response — the onion price — and belongs in granian or your reverse proxy (compression, caching, ETags). jero refuses it rather than covering it slowly. Passing state from middleware to handlers (request.state.tenant) is also not offered: the idiom is jero's existing typed sources — bind the header in the handler, or carry it on the auth user.

The tiers and what they cost

hook when it runs per-request cost
response_headers (constant attribute) never — baked into route header blocks at wiring zero
intercept + intercept_methods only on the declared verbs, pre-auth zero off-scope; one table hit + scan on-scope
response_headers(...) (method) every covered response, as it leaves one scan + call + merge
observe(...) after the response starts request view + one call

The tiers order the design: reach for the constant tier when a value is fixed, scope intercepts to the verbs that need them, and treat the method tier as the last resort it is. Every cost is opt-in and per-covered-route — uncovered routes pay nothing. Measured numbers are on the Performance page.

Registration: global and per-include

from typing import ClassVar

from jero import BaseApp, Endpoint, Request, Struct


class ServerHeaders(Struct):
    x_served_by: str = "jero"


class BrandMiddleware:
    response_headers: ClassVar[ServerHeaders] = ServerHeaders()


class AuditMiddleware:
    def observe(self, request: Request, status: int, duration: float) -> None: ...


class Status(Struct):
    ok: bool


class HealthEndpoint(Endpoint, path="/healthz"):
    def get(self) -> Status:
        return Status(ok=True)


class MetricsEndpoint(Endpoint, path="/metrics"):
    def get(self) -> Status:
        return Status(ok=True)


class App(BaseApp):
    async def wire(self) -> None:
        self._include_middleware(BrandMiddleware())      # every route
        self._include_endpoint(HealthEndpoint())         # covered by globals
        self._include_endpoint(
            MetricsEndpoint(),
            middleware=(AuditMiddleware(),),             # + include-scoped
        )


app = App()

middleware= is an include-time keyword beside auth= — middleware scope is deployment policy, not resource identity, so it does not live on the Endpoint/Resource class.

The request path is fixed:

request arrives
  1. global intercepts (verb-scoped, registration order) — first response wins
  2. routing (static hit / dynamic match; 404/405/OPTIONS fallthrough)
  3. include-scoped intercepts, same rule
  4. auth → bind → handler
  5. response_headers merges onto WHATEVER response leaves
     (handler success, problem body, exception-handler response, short-circuit)
  6. observe sees the outcome after the response starts

Two consequences worth spelling out: global intercepts run pre-routing, so they can answer requests no route serves (that's how an OPTIONS-scoped intercept answers preflights for paths that would 404) — and intercepts run before auth (a preflight carries no credentials). Intercept scoping sees the wire method: HEAD is its own entry, even though routing serves it from GET handlers.

An exception inside intercept or response_headers enters the same funnel as handler exceptions — custom handlers get their shot, then the generic 500 problem. observe exceptions are logged and swallowed. Middleware can never emit content-length or content-type (the senders own those), and two middlewares' constant pairs claiming the same header name fail wiring loud; dynamic pairs can't be checked and append per HTTP semantics.

An interception gate

An admin include gated on a header, scoped at the mount — the rest of the app never pays for it:

from typing import ClassVar

from jero import BaseApp, Endpoint, HTTPMethod, JSONResponse, Request, Struct


class GateHeaders(Struct):
    x_admin_key: str | None = None


class Refused(Struct):
    reason: str


class AdminGateMiddleware:
    intercept_methods: ClassVar[tuple[HTTPMethod, ...]] = ("GET", "POST")

    def intercept(self, request: Request[GateHeaders]) -> JSONResponse[Refused] | None:
        if request.headers.x_admin_key != "letmein":
            return JSONResponse(json=Refused(reason="admin key required"), status_code=403)
        return None  # fall through to auth → bind → handler


class Report(Struct):
    widgets: int


class AdminReportEndpoint(Endpoint, path="/admin/report"):
    def get(self) -> Report:
        return Report(widgets=42)


class App(BaseApp):
    async def wire(self) -> None:
        self._include_endpoint(AdminReportEndpoint(), middleware=(AdminGateMiddleware(),))


app = App()

An intercept's return annotation follows handler rules — any buffered response kind, unioned freely, with | None meaning "fall through". A plain Struct return answers with 200; NoContent / Created / Accepted fix their own status; status_code= overrides. Streaming kinds are rejected at wiring: an interception is an answer, not a stream.

Rebuilding CORS yourself

The built-in CORS policy uses exactly this machinery — nothing it does is privileged. A wildcard policy is one constant attribute plus one preflight intercept:

from typing import ClassVar

from jero import BaseApp, Endpoint, HTTPMethod, NoContent, Request, Struct


class WildcardCORSHeaders(Struct):
    access_control_allow_origin: str = "*"


class PreflightHeaders(Struct):
    access_control_request_method: str | None = None


class PreflightResponseHeaders(Struct):
    access_control_allow_origin: str = "*"
    access_control_allow_methods: str = "GET, POST, PUT, PATCH, DELETE"
    access_control_allow_headers: str = "content-type, authorization"
    access_control_max_age: int = 600


class HandRolledCORS:
    """The wildcard tier: constant pairs on every response, preflights answered."""

    response_headers: ClassVar[WildcardCORSHeaders] = WildcardCORSHeaders()

    intercept_methods: ClassVar[tuple[HTTPMethod, ...]] = ("OPTIONS",)

    def intercept(
        self, request: Request[PreflightHeaders]
    ) -> NoContent[PreflightResponseHeaders] | None:
        if request.headers.access_control_request_method is None:
            return None  # a plain OPTIONS, not a preflight — let the Allow answer run
        return NoContent(headers=PreflightResponseHeaders())


class Status(Struct):
    ok: bool


class HealthEndpoint(Endpoint, path="/healthz"):
    def get(self) -> Status:
        return Status(ok=True)


class App(BaseApp):
    async def wire(self) -> None:
        self._include_middleware(HandRolledCORS())
        self._include_endpoint(HealthEndpoint())


app = App()

Registered globally, the intercept answers preflights pre-routing — including for paths that would 404 — and the constant pair rides every response for free. Prefer the built-in (self._include_cors(CORS())): it also handles origin allow-lists, per-route preflight policy, and validation. But when you need a cross-cutting behavior jero doesn't ship, this is the shape it takes.

Why not onion middleware

The classic ASGI middleware shape — an onion of app wrappers, each an async callable wrapping send — costs a coroutine plus allocations per layer per request, on every request, whether or not the layer applies. Measured on jero's hot path, one onion layer that does nothing but append a single constant CORS pair already costs about 1.3× — a third of the entire per-request budget, before it does anything a real middleware does. jero rejects that shape: the compiled tiers above have a floor of zero and a known ceiling, and only covered routes pay. The measured per-tier numbers are on the Performance page.