Skip to content

Forms and uploads

Three ways to receive something other than JSON:

need use
an HTML form, or a small file upload request.form(), or a model bound with Form()
an upload larger than max_body, or one to refuse before it arrives a BodyStream argument
the raw bytes of an ordinary body request.body

Reading a form

@app.post("/profile")
async def profile(request: Request):
    form = request.form()
    name = form["name"]              # first value of a field
    tags = form.getlist("tag")       # every value of a repeated field
    avatar = form.get("avatar")      # an UploadFile, or None
    return {"name": name, "tags": tags}

request.form() parses application/x-www-form-urlencoded and multipart/form-data. Parsing happens in Rust, on the worker thread, only when it is called; a route that never reads a form pays nothing.

A FormData behaves like a read-only mapping from each name to its first value, and keeps every value in order:

form["name"], form.get("name") the first value
form.getlist("name") every value, in order
form.multi_items() every (name, value) pair
form.files every UploadFile

Text fields are str. Bytes that are not valid UTF-8 become replacement characters, as they do in a query string, rather than failing the request.

Files

A multipart part with a filename is an UploadFile:

filename as the client sent it
content_type as the client sent it, or None
size in bytes
read() the content as bytes
text(encoding="utf-8") the content decoded

A filename is client input

filename may be empty, contain .., or contain slashes. Never join it onto a directory without sanitising it first.

A form is held in memory while it is read, like any other body, and is bounded by max_body. For files larger than that, use a BodyStream.

Binding a form to a model

from oxbrook import Form, UploadFile
from pydantic import BaseModel

class Signup(BaseModel):
    email: str
    age: int
    interests: list[str] = []
    avatar: UploadFile | None = None

@app.post("/signup")
async def signup(_: Request, data: Signup = Form()):
    ...

Fields are validated by pydantic, and a failure is a 422 in the same shape as a JSON body's. A field declared as a list collects every value of a repeated form field; any other field takes the first. A model with an UploadFile field is documented in OpenAPI as multipart/form-data, and without one as application/x-www-form-urlencoded.

A handler takes one body: a JSON model, a Form() model, or a BodyStream.

When a form cannot be read

status
the body is not a form content type 415
malformed multipart, or no boundary 400
more than max_parts fields and files 413

max_parts defaults to 1,000 and bounds how many objects one request can make the worker build: request.form(max_parts=50), or Form(max_parts=50).

Streaming a body

from oxbrook import BodyStream

@app.put("/uploads/{name}")
async def upload(request: Request, name: str, body: BodyStream):
    if not allowed(request):
        raise HTTPError(403)                 # before a byte of the body is read
    size = 0
    with open(storage_path(name), "wb") as out:
        async for chunk in body:
            out.write(chunk)
            size += len(chunk)
    return {"stored": size}

An argument annotated BodyStream makes the route stream: the server hands the request to the handler before reading the body, and chunks arrive as the client sends them. await body.read() collects whatever remains.

Nothing is read until the handler asks. The server does not touch the body until the first chunk is requested, so a client that sent Expect: 100-continue is never told to continue if the handler refuses first. The upload does not happen.

Memory stays flat. About a megabyte waits between the socket and the handler. Past that the server stops reading until the handler catches up, and TCP pushes back on the client. A 200 MB upload to a handler that reads slowly grows the process by a few megabytes, not two hundred.

Limits apply as the body arrives.

the iterator raises
more than max_body bytes, or a declared Content-Length over it HTTPError(413)
the client stops sending for longer than request_timeout HTTPError(408)
the connection ends before the body does HTTPError(400)

Unhandled, each becomes that response. A declared length over the limit is refused without reading.

The timeout follows progress. For a streaming route, request_timeout counts from the last chunk that moved rather than from when the request arrived. An upload that takes ten minutes and keeps moving is not cut off. A handler that stops reading, or reads everything and then never answers, still gets 504.

Read the body before responding. When the response is sent the request is over; a handler still reading gets HTTPError(400).

On a route without a BodyStream, request.stream() yields the already-collected body once, so code that reads a stream works on both. On a streaming route, request.body is empty.

Routes that read a form or stream a body cannot be agent tools: tool arguments arrive as JSON.