Skip to content

Context and responses

Handlers receive a single Context (c), Hono-style. c.req wraps the standard Fetch Request (raw object at c.req.raw) and adds routing context; helpers build standard Response objects.

Reading the request

@app.post("/upload")
async def upload(c):
    c.req.method                 # "POST"
    c.req.url.pathname           # WHATWG URL
    c.req.header("content-type") # combined per Fetch
    c.req.cookies                # dict, RFC 6265bis parsing

    data = await c.req.json()    # or .text() / .bytes() / .form_data()

Bodies are one-shot per the Fetch Standard (body_used); c.req.raw.clone() tees the stream when you need to read twice.

Validating request input

validator() accepts any synchronous converter, so msgspec, Pydantic, or an application function can validate all six HTTP input surfaces without becoming a framework dependency:

from hayate import validator

@app.get("/books/:book_id", validator("param", validate_book_path))
async def show_book(c):
    path = c.req.valid("param")
    return c.json({"book_id": path["book_id"]})

The targets are json, form, query, param, header, and cookie. Route parameters are percent-decoded. Header names are lowercase and repeated values are combined according to Fetch semantics. Form and query mappings use the last value for a repeated name. Validation failures are RFC 9457 responses with status 400.

Building responses

return c.json({"ok": True})                  # application/json
return c.text("hello")                       # text/plain;charset=utf-8
return c.html("<h1>hi</h1>")                 # text/html
return c.body(raw_bytes, 200, headers={...}) # anything
return c.redirect("/next", 303)
return Response(None, 204)                   # raw Response always works

Streaming is just an async iterable of bytes:

async def numbers():
    for i in range(5):
        yield f"{i}\n".encode()

return c.body(numbers())

Errors: RFC 9457 everywhere

raise HTTPException(422, title="Unprocessable", detail="year must be > 0",
                    extensions={"field": "year"})

produces application/problem+json. Framework-generated 404/405/500 use the same format. Override with @app.on_error / @app.not_found.

Cross-cutting state and headers

@app.use
async def auth(c, next_):
    c.set("user", await lookup_user(c))   # typed hand-off to handlers
    await next_()
    c.header("x-served-by", "hayate")     # merged into the final response

@app.get("/me")
async def me(c):
    return c.json({"user": c.get("user")})

After the response: c.wait_until

@app.post("/orders")
async def order(c):
    c.wait_until(send_confirmation_email())   # runs after delivery
    return c.json({"ok": True}, 201)

Semantics match the Workers ctx.waitUntil: on ASGI the work is awaited after the response is sent; on Cloudflare Workers it forwards to the platform's own waitUntil.

Cookies

c.set_cookie("sid", token, http_only=True, secure=True, same_site="lax")

__Host-/__Secure- prefix invariants and SameSite=None-requires-Secure are enforced at serialization time. HMAC signing helpers live in hayate.cookies (sign_value / unsign_value).