Min 09-10 — The Request 📨¶
req is everything the client sent, already parsed. It is the read half of a handler.
async def handler(req, res, ctx):
...
Where the data lives¶
| You want | Read it from | Type |
|---|---|---|
/users/42 → 42 | req.params.get('id') | str, or the declared type |
?page=3 | req.queries.get('page') | str, or coerced |
| A JSON body | req.json | dict / list |
| A validated body | req.data | dict (see Schemas) |
| A form or upload | req.form.get('field') | str or File |
| Raw bytes | req.body | bytes |
| Headers | req.headers.get('authorization') | str |
| Cookies | req.cookies.get('session') | str |
Path parameters¶
app.GET('/users/:id/orders/:order_id', handler)
async def handler(req, res, ctx):
req.params.get('id') # '42'
req.params.get('order_id') # '7'
Add a type to get it converted instead of a string:
app.GET('/users/:id:int', handler) # req.params.get('id') -> 42
app.GET('/report/:day:date', handler) # req.params.get('day') -> date(2026, 8, 1)
app.GET('/item/:sku:uuid', handler) # req.params.get('sku') -> UUID(...)
Path segments and query strings accept the same seven names: :int, :float, :bool, :str, :date, :datetime, :uuid. See the router for the full table.
Paths and query strings differ on bad input
A path segment that cannot convert makes the route not match, so the request falls through to another route or 404 and your handler never sees an unconverted value. A query string is more forgiving and hands you the raw string instead, because a bad ?page= should not hide the whole endpoint.
Query strings¶
Read them straight off req.queries:
# GET /search?q=heaven&page=3
req.queries.get('q') # 'heaven'
req.queries.get('page') # '3' -> a string, by default
Declare types in the route to have them coerced:
app.GET('/search?page:int&since:date&exact:bool', search)
req.queries.get('page') # 3 int
req.queries.get('since') # date(2026, 1, 1) date
req.queries.get('exact') # True bool
Supported: :int, :float, :bool, :str, :date, :datetime, :uuid, in any case. The same names work in path segments, and a query parameter is declared, refused and documented by the same rules as a path one — anything else raises UrlError when you register the route. Declaring them also documents the endpoint: one parse of the declaration drives both the coercion and the in: query parameters in the generated spec.
Bad input does not raise
?page=banana gives you the string 'banana', not a 422. A :bool that cannot be read is the one exception and comes back False. Check anything you rely on.
JSON bodies¶
req.json decodes the raw body with orjson every time you touch it:
async def create(req, res, ctx):
payload = req.json # {'name': 'Ada'}
req.data is the better habit. With a schema registered it holds the validated body; without one it falls back to req.json.
app.schema.POST('/users', expects=User)
async def create(req, res, ctx):
user = req.data # guaranteed to satisfy User
res.body = {'name': user['name']}
Type-safe req.data¶
Request is generic — annotate it and your editor autocompletes the payload:
from heaven import Request, Response, Context
async def create_user(req: Request[User], res: Response, ctx: Context):
user = req.data # your IDE knows the shape
This is a convention for your type checker; the runtime doesn't verify the annotation matches what you registered.
Forms and uploads¶
req.form parses application/x-www-form-urlencoded and multipart/form-data.
async def upload(req, res, ctx):
form = req.form
username = form.get('username') # str
avatar = form.get('avatar') # a File object
avatar.filename # 'photo.png'
avatar.content # bytes
req.form is None when the request has no form content type — check before using it.
A File also carries .content_type, .size, .save(path) to copy it somewhere in chunks, and .file, a file object for handing to anything that reads files.
On a buffered route, req.form holds the whole upload in memory
Parsing a buffered form keeps the request body and the parsed parts side by side, so peak memory is a multiple of the upload size. That is fine for an avatar and wrong for a video.
Two things to reach for, covered in Serving Files:
App(max_body_size=...)caps what any buffered route will accept, answering413and stopping the read rather than accumulating the rest.stream=Trueon the route leaves the body unread. Consume it raw withreq.stream(), or parse it incrementally withawait req.form.
On a route registered with stream=True, the form must be awaited, and large file parts spill to a temporary file on disk instead of accumulating in memory:
app.POST('/upload', upload, stream=True)
async def upload(req, res, ctx):
form = await req.form # parses the body as it arrives
form.get('title') # fields work as usual
form.get('video').save(destination) # the file never sat in memory
Awaiting req.form on a buffered route is harmless, so the same handler body works on both kinds of route. Reading fields on a streaming route without awaiting first raises RuntimeError. The details (spill threshold, per-field ceilings, error draining) are in Serving Files.
Metadata¶
req.method # 'POST'
req.url # '/users/1?active=true'
req.route # '/users/:id' — the pattern that matched
req.subdomain # 'api'
req.host # 'api.example.com'
req.scheme # 'http'
req.ip.address # '203.0.113.9'
req.ip.port # 54123
req.app # the Router — use req.app.peek('db')
req.app is how you reach shared resources
Anything you stored at startup with app.keep() is one hop away:
async def list_users(req, res, ctx):
db = req.app.peek('db')
res.body = await db.fetch('SELECT * FROM users')
Next: You've heard them. Now answer → Min 11-12 — The Response