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:

# doc-example: fragment (illustrative snippet, not a runnable app)
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 headers — your 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 the server 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.