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).