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
| Option | Type | Description |
|---|---|---|
type | Type | string | OPRA type used to coerce and validate the field value. |
required | boolean | Whether the field must be present. Validated after all parts are read. |
contentType | string | string[] | Accepted MIME type(s) for this part. |
description | string | Schema description. |
.File() options
| Option | Type | Description |
|---|---|---|
required | boolean | Whether the file must be present. |
contentType | string | string[] | Accepted MIME type(s). Requests with a non-matching content type are rejected with 400 Bad Request. |
description | string | Schema description. |
.MultipartContent() size limits
| Option | Type | Description |
|---|---|---|
maxParts | number | Maximum total number of parts (fields + files). |
maxPartSize | number | Maximum size in bytes for any single part. |
maxFieldSize | number | Maximum size in bytes for text field parts only. |
maxTotalSize | number | Maximum 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.
| Property | Type | Description |
|---|---|---|
kind | 'field' | Discriminant — use to narrow the union type. |
field | string | The part name from Content-Disposition. |
value | any | The decoded value, coerced to the type declared in .Field(). Raw string if no type was declared. |
mimeType | string | undefined | Part content type, if the client sent one. |
encoding | string | undefined | Part character encoding. |
headers | Record<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.
| Property | Type | Description |
|---|---|---|
kind | 'file' | Discriminant. |
field | string | The part name from Content-Disposition. |
filename | string | undefined | The original filename the client provided. |
storedPath | string | Absolute path of the temporary file on disk. |
type | string | undefined | MIME type of the file. |
encoding | BufferEncoding | File encoding (default 'utf-8'). |
headers | Record<string, string> | All raw part headers. |
size | number | File 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. |
autoDelete | boolean | When true (default), the file is deleted automatically when the LocalFile object is garbage collected or the process exits. |
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
BadRequestErroron failure. - File content type — checked against the
contentTypedeclared in.File(). ThrowsBadRequestErrorif 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()returnsundefined). Missing required fields throw aValidationErrorcollected 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
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:
reader.purge()is called when theHttpContextemits'finish'— this deletes all temp files created during the request.LocalFile.autoDelete = trueregisters withFinalizationRegistryandprocess.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 });
}
}