Skip to content

Forms & uploads

For multipart/form-data bodies, take a form argument annotated with a Struct. Each field is one form part; its type decides how the part is decoded. jero buffers and parses the body once, at the start of the request.

from typing import Literal

from msgspec import Struct

from jero import BaseApp, Endpoint, FilePart, FormPart


class JobConfig(Struct):
    dpi: int


class CreateJob(Struct):
    job_type: Literal["export-text", "export-images"]   # a scalar part
    count: int                                          # a scalar part
    config: JobConfig                                   # a JSON part -> Struct
    document: FilePart                                  # a file upload
    attachments: list[FilePart]                         # repeated file parts
    note: FormPart[str] | None = None                   # optional part with metadata


class JobAccepted(Struct):
    filename: str
    size: int


class UploadEndpoint(Endpoint, path="/jobs"):
    async def post(self, form: CreateJob) -> JobAccepted:
        dpi = form.config.dpi
        upload = form.document            # a FilePart
        return JobAccepted(filename=upload.filename, size=len(upload.data))


class App(BaseApp):
    async def _wire(self) -> None:
        self._include_endpoint(UploadEndpoint())


app = App()

Field types

A form field can be:

  • A scalar (str, int, float, bool, Enum, Literal) — decoded from the part's text.
  • A Struct — the part body is decoded as JSON.
  • bytes — the raw part body.
  • FormPart[T] / FilePart — the part plus its envelope metadata (below).
  • list[...] of any of the above — repeated parts under the same name.
  • Any of the above wrapped in | None — an optional part.

Envelope metadata — FormPart and FilePart

Plain field types give you just the value. When you need a part's content_type, per-part headers, or (for files) the filename, wrap the type:

class FormPart[T, H: Struct | None = None](Struct):
    data: T
    content_type: str | None
    headers: H
    raw_headers: RawHeaders

class FilePart[H: Struct | None = None](FormPart[bytes, H]):
    filename: str           # required; a file part without one is a 422
class Upload(Struct):
    document: FilePart                       # bytes data + filename + content_type
    config: FormPart[JobConfig]              # JSON data + content_type

Typed part headers

Parts can carry their own headers. Type them by parameterizing the wrapper; they're bound (and validated) just like request headers:

class Checksum(Struct):
    x_checksum: str


class Upload(Struct):
    document: FilePart[Checksum]             # part headers -> Checksum
    blob: FormPart[bytes, Checksum]

None is the default when a part declares no typed headers. Every part also exposes raw_headers — the part headers exactly as sent, including original casing and repeats — regardless of whether you typed them:

digest = form.document.headers.x_checksum              # typed and validated
repeats = form.blob.raw_headers.getlist("X-Checksum")  # exact, as sent

Error semantics

  • A non-multipart body where a form is expected → 415.
  • A malformed multipart body → 400.
  • A missing required part, or a file part without a filename → 422.

Like json and content, form is a request body — mutually exclusive with them, and rejected on GET/DELETE.