File uploads in OpenAPI: multipart/form-data, raw binary, and multiple files done right
File upload is the endpoint most teams hand-wave in an OpenAPI document. The request works in the one client they tested, so nobody notices that the spec says type: string, omits the multipart encoding, or models three files as a single one. Then generated SDKs send JSON, the mock server cannot accept a body, and a static scan of the code disagrees with what the route actually does.
There are exactly three upload shapes in HTTP. Model the right one and everything downstream works.
The three shapes
| Shape | Content-Type | When you use it | OpenAPI body |
|---|---|---|---|
| Mixed form | multipart/form-data | File plus metadata fields, or several files | object schema, file props are string: binary |
| Raw single file | application/octet-stream (or the real media type) | Only the file, nothing else, often a PUT | string: binary |
| URL / base64 | application/json | Small files or references stored elsewhere | Normal JSON property |
Sending JSON with a base64 blob is a fourth option in practice, but it is just the JSON shape with a 33 percent size penalty. Prefer one of the first two for anything beyond a tiny icon.
multipart/form-data, field by field
An avatar upload that also takes an alt description and an optional make_primary flag is an object. Each part of the multipart body is a property. The file part uses type: string with format: binary; everything else is a normal field.
paths:
/users/me/avatar:
post:
summary: Upload the current user's avatar
operationId: uploadAvatar
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
description: PNG or JPEG, up to 5 MB.
alt:
type: string
maxLength: 200
description: Accessible description. Omit to keep the current one.
make_primary:
type: boolean
default: false
encoding:
file:
contentType: image/png, image/jpeg
responses:
'201':
description: Avatar stored
content:
application/json:
schema:
$ref: '#/components/schemas/Avatar'
'413':
description: File exceeds 5 MB
'422':
description: Unsupported media type or corrupt imageTwo details matter. required: [file] marks the file part itself as mandatory while alt stays optional. The encoding.file.contentType hint tells codegen and docs that this part is an image, not an opaque blob. Do not set contentType: multipart/form-data on the part; that is the wire type of the whole body, not of one part.
Sending a raw binary body
When the URL already identifies the resource and there is no metadata, PUT the bytes directly. The body schema is a single binary string and the media type is the file's real type, or application/octet-stream when it is genuinely arbitrary.
paths:
/objects/{storageKey}:
put:
summary: Store or replace one object
operationId: putObject
parameters:
- $ref: '#/components/parameters/StorageKey'
requestBody:
required: true
content:
application/octet-stream:
schema:
type: string
format: binary
responses:
'201': { description: Object created }
'204': { description: Existing object replaced }Raw binary is simpler for clients (no multipart assembly) and cheaper on the wire, but it cannot carry a second field. The moment you need metadata alongside the file, move to multipart.
Multiple files
An ordered list of files under one field name is an array of binary strings.
paths:
/expenses/{id}/receipts:
post:
summary: Attach one or more receipts
operationId: attachReceipts
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [receipts]
properties:
receipts:
type: array
minItems: 1
maxItems: 10
items:
type: string
format: binary
responses:
'201': { description: Receipts stored }The client sends repeated parts, all named receipts. That is the multipart convention for an array. Distinct named files with different meanings are different properties instead:
properties:
front: { type: string, format: binary }
back: { type: string, format: binary }Use an array for "zero to N of the same thing" and named properties for "this exact file and that exact file."
What to put in the spec, and what not to
OpenAPI describes the contract, not every server setting. Be precise about what is expressible.
| Concern | How to express it |
|---|---|
| File required | required on the property |
| Count of files | minItems / maxItems on the array |
| Accepted media types | encoding.<field>.contentType, plus a 415/422 response |
| Text field encoding | encoding.<field>.contentType: text/plain (defaults to text/plain) |
| JSON part | encoding.<field>.contentType: application/json with an object schema |
| Max size | Not enforceable in JSON Schema; document it and add a 413 response |
| File extension allowlist | Not a schema concern; document and validate server-side |
Do not invent maxFileSize keywords. Validators ignore unknown annotations, so they give false confidence. A documented limit paired with a 413 Payload Too Large response is the honest contract.
What codegen produces
With the shapes above, generators emit code a developer can use directly. A TypeScript generator turns the multipart example into a FormData body:
const form = new FormData();
form.append("file", fileBlob, "avatar.png");
form.append("alt", "Profile photo");
form.append("make_primary", "true");
await sdk.uploadAvatar(form);The raw PUT generates a method that takes a Blob or Buffer. When the spec instead says type: string with no format: binary, generators emit a method expecting a JSON string, and every caller has to fix the SDK by hand.
What static scanners get wrong
When you reverse-engineer OpenAPI from code, upload routes are where naive parsers fail. Express with Multer distinguishes single("file"), array("receipts", 10), and fields([{ name: "front" }, { name: "back" }]); ASP.NET uses [FromForm] alongside IFormFile and List<IFormFile>; Go and Gin bind multipart through distinct tags; Django REST uses FileField and ListSerializer. A parser that only reads the handler signature usually types the body as string or marks it unknown.
A deterministic scanner should trace the actual multipart middleware to recover the field names, single-versus-array shape, and required parts, and report a gap only when the file handling is built dynamically. It should never flatten every upload to application/octet-stream, and it should never guess a 5 MB limit the code does not enforce.
Checklist before you ship the endpoint
- File parts are
type: string, format: binary, never barestringorobject. - Mixed metadata uses
multipart/form-datawith anobjectschema; file-only uploads use raw binary. - Repeated same-kind files are an
arrayof binary; distinct files are named properties. encodingdeclares image or JSON parts; accepted types are paired with415/422.- Size and extension limits are documented with a
413, not hidden in fake keywords. - Generate the client once and confirm it builds a
FormDatabody or sends aBlob.
Get these six right and the same spec drives correct docs, a mock that accepts a real upload, a working SDK, and an honest code scan.
You can model each of these shapes, generate a client, and spin up a mock that accepts a real multipart body in one local-first workspace, right in your browser. If you are recovering upload contracts from an existing codebase, see how deterministic framework tracing handles multipart middleware in the Express and NestJS scan overview.