Skip to content

Responses & headers

What a handler returns is part of its type signature, so the response schema is known at startup (and to the coming OpenAPI spec). There are two levels: return a plain value when you just want a body, or a response wrapper when you want to control headers or status.

Plain returns

Return type Sent as
a Struct application/json
list[Struct] application/json (a JSON array)
bytes application/octet-stream
from msgspec import Struct

from jero import BaseApp, Endpoint, Resource


class Widget(Struct):
    id: str
    name: str


class WidgetPath(Struct):
    widget_id: str


class WidgetResource(Resource, path="/widgets"):
    async def read_one(self, path: WidgetPath) -> Widget:      # JSON object
        return Widget(id=path.widget_id, name="gizmo")

    async def read_many(self) -> list[Widget]:                 # JSON array
        return [Widget(id="widget-id", name="gizmo")]


class ExportEndpoint(Endpoint, path="/export"):
    async def get(self) -> bytes:                              # octet-stream
        return b"id,name\n"


class App(BaseApp):
    async def _wire(self) -> None:
        self._include_resource(WidgetResource())
        self._include_endpoint(ExportEndpoint())


app = App()

A JSON body is always a Struct (or a list of them) — never a raw dict. A dict/blob return is a WiringError at startup. That's the rule that gives every endpoint a validated, schema-able contract.

Controlling headers & status

When you need to set headers or override the status, return a wrapper. They're generic so the body and header types are preserved, not erased:

from msgspec import Struct

from jero import BaseApp, JSONResponse, Resource


class Widget(Struct):
    id: str


class WidgetPath(Struct):
    widget_id: str


class WidgetHeaders(Struct):
    x_cache: str
    x_rate_limit: int


class WidgetResource(Resource, path="/widgets"):
    async def read_one(self, path: WidgetPath) -> JSONResponse[Widget, WidgetHeaders]:
        return JSONResponse(
            json=Widget(id=path.widget_id),
            headers=WidgetHeaders(x_cache="hit", x_rate_limit=100),
        )


class App(BaseApp):
    async def _wire(self) -> None:
        self._include_resource(WidgetResource())


app = App()
  • JSONResponse[T: Struct, H: Struct | None = None]json: T, encoded with the same fast msgspec path as a plain return (the wrapper itself is never serialized).
  • BytesResponse[H: Struct | None = None]content: bytes, octet-stream.

The body type is required (JSONResponse[Widget]) — that's the point: reaching for a wrapper never costs you the schema. The header type H defaults to None, so JSONResponse[Widget] is a body with no typed headers.

Headers

Two ways to set response headers, mirroring how a handler receives them.

Typed — headers

A Struct, for the conventional 99%. Field names map to wire names by the inverse of the request mangle (x_trace_idx-trace-id); values are encoded as strings — scalars plainly (booltrue/false), nested Structs/lists as JSON. None-valued optional fields are simply omitted.

class Headers(Struct):
    x_request_id: str
    x_rate_remaining: int
    x_debug: DebugInfo | None = None   # a Struct -> JSON string; None -> omitted


JSONResponse(json=widget, headers=Headers(x_request_id="abc", x_rate_remaining=42))
# X-Request-Id: abc
# X-Rate-Remaining: 42

This is the typed path the OpenAPI spec will describe.

Raw — raw_headers

The escape hatch for exotic names: literal underscores, specific casing, or repeats (e.g. multiple Set-Cookie). A plain mapping, or a RawHeaders (pass a request's straight through to forward it, repeats and all):

from jero import RawHeaders

JSONResponse(
    json=widget,
    raw_headers=RawHeaders([("Set-Cookie", "a=1"), ("Set-Cookie", "b=2")]),
)

When both are given, the typed headers are emitted first, then raw_headers is appended — so its repeats survive. content-type defaults per kind and content-length is always managed by the framework (ignored if you supply it).

The rule of thumb: a typed Struct for the conventional case; drop to raw_headers for exact wire control — casing, underscores, repeats, anything non-conventional.

Status codes

Every wrapper carries status_code: int | None. Leave it None to use the verb's default (201 for create, else 200); set it to override:

from msgspec import Struct

from jero import BaseApp, JSONResponse, Resource


class WidgetIn(Struct):
    name: str


class Widget(WidgetIn):
    id: str


class WidgetResource(Resource, path="/widgets"):
    async def create(self, json: WidgetIn) -> JSONResponse[Widget]:
        widget = Widget(id="widget-id", name=json.name)
        return JSONResponse(json=widget, status_code=202)   # Accepted


class App(BaseApp):
    async def _wire(self) -> None:
        self._include_resource(WidgetResource())


app = App()

status_code is available on BytesResponse and the streaming responses too.

Errors

Raise HTTPError(status, detail) from anywhere in a handler to short-circuit with a JSON error body:

from jero import HTTPError

if widget is None:
    raise HTTPError(404, "widget not found")
# -> 404  {"error": "widget not found"}

See REST & error semantics for the full status-code map.