Skip to main content

Multipart

OPRA supports multipart/form-data and multipart/mixed requests out of the box. MultipartReader streams parts one by one, writing file parts directly to temporary files so your handler is never blocked waiting for the full body to land in memory.


Declaring a multipart operation

Use .MultipartContent() on the operation decorator. The optional configuration callback lets you declare expected fields and files, their types, and whether they are required:

@(HttpOperation.POST()
.MultipartContent({}, config => {
config
.Field('title', { type: 'string', required: true })
.Field('description', { type: 'string' })
.File('cover', { required: true })
.File(/^attachment\d+/); // matches attachment0, attachment1, …
})
.Response(201))
async create(ctx: HttpContext) { ... }

Both .Field() and .File() accept a field name string or a RegExp pattern. A pattern matches all parts whose name satisfies the expression.

.Field() options

OptionTypeDescription
typeType | stringOPRA type used to coerce and validate the field value.
requiredbooleanWhether the field must be present. Validated after all parts are read.
contentTypestring | string[]Accepted MIME type(s) for this part.
descriptionstringSchema description.

.File() options

OptionTypeDescription
requiredbooleanWhether the file must be present.
contentTypestring | string[]Accepted MIME type(s). Requests with a non-matching content type are rejected with 400 Bad Request.
descriptionstringSchema description.

.MultipartContent() size limits

OptionTypeDescription
maxPartsnumberMaximum total number of parts (fields + files).
maxPartSizenumberMaximum size in bytes for any single part.
maxFieldSizenumberMaximum size in bytes for text field parts only.
maxTotalSizenumberMaximum combined size in bytes for all parts.

Reading parts

ctx.getMultipartReader()

async getMultipartReader(): Promise<MultipartReader>

Returns the MultipartReader for the current request. Throws InternalServerError if the request is not multipart, and NotAcceptableError if the operation does not declare a multipart content type.

Check ctx.isMultipart first when the content type may vary:

if (ctx.isMultipart) {
const reader = await ctx.getMultipartReader();
// ...
}

reader.getAll()

async getAll(): Promise<MultipartReader.Item[]>

Reads every part in order and returns them as an array. The simplest approach when all parts must be present before processing can begin:

const reader = await ctx.getMultipartReader();
const parts = await reader.getAll();

for (const part of parts) {
if (part.kind === 'field') {
console.log(part.field, part.value);
}
if (part.kind === 'file') {
const buf = await part.buffer();
console.log(part.field, part.filename, buf.length);
}
}

reader.getNext()

async getNext(): Promise<MultipartReader.Item | undefined>

Reads and returns the next part. Returns undefined when all parts have been read. Use this for streaming processing where you want to act on each part as it arrives rather than waiting for all of them:

const reader = await ctx.getMultipartReader();

let part: MultipartReader.Item | undefined;
while ((part = await reader.getNext())) {
if (part.kind === 'file') {
// process or save part.storedPath before reading the next part
await processFile(part);
}
}

Parts arrive in the order they appear in the HTTP request.

reader.items

readonly items: MultipartReader.Item[]

The list of items received so far. Useful after getAll() to inspect without re-reading, or during streaming to check previously received parts.


Part types

MultipartReader.Field

A text or structured value part.

PropertyTypeDescription
kind'field'Discriminant — use to narrow the union type.
fieldstringThe part name from Content-Disposition.
valueanyThe decoded value, coerced to the type declared in .Field(). Raw string if no type was declared.
mimeTypestring | undefinedPart content type, if the client sent one.
encodingstring | undefinedPart character encoding.
headersRecord<string, string>All raw part headers.

MultipartReader.File

A file upload part. The file is streamed to a temporary file on disk as it arrives. Reading buffer() or text() waits for the write to finish before returning.

PropertyTypeDescription
kind'file'Discriminant.
fieldstringThe part name from Content-Disposition.
filenamestring | undefinedThe original filename the client provided.
storedPathstringAbsolute path of the temporary file on disk.
typestring | undefinedMIME type of the file.
encodingBufferEncodingFile encoding (default 'utf-8').
headersRecord<string, string>All raw part headers.
sizenumberFile size in bytes (synchronous, reads from filesystem).
buffer()() => Promise<Buffer>Reads the file and returns a Buffer. Awaits the write stream to finish.
text()() => Promise<string>Reads the file and returns a string. Awaits the write stream to finish.
delete()() => Promise<void>Deletes the temporary file immediately.
autoDeletebooleanWhen true (default), the file is deleted automatically when the LocalFile object is garbage collected or the process exits.
note

autoDelete is true by default for all uploaded files. OPRA also calls reader.purge() when the request context finishes, which deletes all temp files created during the request. If you need the file to survive beyond the request (e.g. move it to permanent storage), set part.autoDelete = false before the request ends, or copy the file to its final destination during the handler.


Validation

OPRA validates multipart parts during getNext(), immediately after each part is read from the stream:

  • Field value — coerced and validated against the declared type. Throws BadRequestError on failure.
  • File content type — checked against the contentType declared in .File(). Throws BadRequestError if it doesn't match.
  • Unknown fields — if the operation declares any multipart fields, parts with names that don't match any declaration throw BadRequestError.
  • Required fields — checked after all parts have been read (when getNext() returns undefined). Missing required fields throw a ValidationError collected across all missing fields.

Events

MultipartReader extends AsyncEventEmitter. You can listen to events to process parts reactively:

const reader = await ctx.getMultipartReader();

reader.on('item', (item: MultipartReader.Item) => {
// fires for every part, field or file, before validation
});

reader.on('field', (item: MultipartReader.Field) => {
// fires for field parts, after validation
});

reader.on('file', (item: MultipartReader.File) => {
// fires for file parts, after content-type check
});

reader.on('error', (err: Error) => {
// fires on parse errors or validation failures
});

reader.resume(); // start the stream
note

Events fire as parts arrive from the network. 'field' and 'file' fire after validation; 'item' fires before. When using getNext() or getAll(), the reader handles resume()/pause() automatically. Call reader.resume() manually only when using the event-based approach.


File lifecycle

Uploaded files land in os.tmpdir() by default. OPRA names them based on the original filename, adding a random prefix when a collision occurs.

Automatic cleanup happens via two mechanisms:

  1. reader.purge() is called when the HttpContext emits 'finish' — this deletes all temp files created during the request.
  2. LocalFile.autoDelete = true registers with FinalizationRegistry and process.finalization, so files are also cleaned up on GC or process exit.

Keeping a file after the request ends:

const reader = await ctx.getMultipartReader();
const parts = await reader.getAll();

for (const part of parts) {
if (part.kind === 'file') {
part.autoDelete = false; // prevent purge() from deleting it
await fs.rename(part.storedPath, `/data/uploads/${part.filename}`);
}
}

Complete example

import { HttpController, HttpOperation } from '@opra/common';
import { HttpContext } from '@opra/http';
import fs from 'node:fs/promises';

@HttpController({ path: 'articles' })
export class ArticlesController {
@(HttpOperation.POST()
.MultipartContent(
{ maxTotalSize: 50 * 1024 * 1024 }, // 50 MB total
config => {
config
.Field('title', { type: 'string', required: true })
.Field('description', { type: 'string' })
.Field('tags', { type: 'string', required: false })
.File('cover', { required: true, contentType: 'image/*' })
.File(/^attachment/, { contentType: ['image/*', 'application/pdf'] });
},
)
.Response(201))
async create(ctx: HttpContext) {
const reader = await ctx.getMultipartReader();
const parts = await reader.getAll();

const fields: Record<string, any> = {};
const files: MultipartReader.File[] = [];

for (const part of parts) {
if (part.kind === 'field') {
fields[part.field] = part.value;
} else {
part.autoDelete = false; // keep the file after the request finishes
await fs.rename(part.storedPath, `/data/uploads/${part.filename ?? part.field}`);
files.push(part);
}
}

return this.service.create({ ...fields, files });
}
}

Reading the Request Body · MultipartReader reference