File Uploads
To receive files, declare them in your @Body schema as z.file() fields. covenix auto-detects the route as multipart/form-data (there is no @Multipart marker), parses it with multer, and injects each uploaded file as a web-standard File via @File / @Files.
import { z } from 'zod';
import { Route, Post, Params, Body, Returns, Param, File, BodyParam } from 'covenix';
const AvatarUpload = z.object({
// The z.file() field is what makes this route multipart. Size/mime
// constraints live in the schema — the single source of truth.
avatar: z.file().max(2_000_000).mime(['image/png', 'image/jpeg']),
caption: z.string().max(140).optional(),
});
@Route('users')
class UsersController {
@Post('{id}/avatar')
@Params(z.object({ id: z.string().uuid() }))
@Body(AvatarUpload)
@Returns(200, UploadResultSchema)
async uploadAvatar(
@Param('id') id: string,
@File('avatar') avatar: File,
@BodyParam('caption') caption?: string,
): Promise<UploadResult> {
const bytes = new Uint8Array(await avatar.arrayBuffer());
await db.users.setAvatar(id, { bytes, contentType: avatar.type });
return { filename: avatar.name, contentType: avatar.type, size: avatar.size, caption };
}
}How it works
A @Body schema is normally validated as JSON. The moment it contains a file field, covenix switches the route to multipart/form-data and:
- runs multer before the handler, so text fields land on
req.bodyand files onreq.files, - adapts each uploaded file to a web-standard
File(sofile.name,file.type,file.size,file.arrayBuffer(), andfile.stream()all work), - assembles those into the body object and validates it against your schema with the same
safeParseas any other body — file.max()/.mime()constraints included, - and injects the result:
@File(name)gives a singleFile,@Files(name)givesFile[], and@BodyParam(name)gives a text field.
A validation failure (oversized file, wrong mime type, missing required file, too many files) responds 422, just like any other body failure — including multer's own limit errors.
Single vs. multiple files
The schema decides the shape. A single z.file() injects one File; wrap it in z.array(z.file()) to accept several and inject a File[]:
const GalleryUpload = z.object({
photos: z.array(z.file().max(5_000_000).mime(['image/png', 'image/jpeg'])).max(8),
});
@Post('{id}/photos')
@Body(GalleryUpload)
@Returns(200, z.object({ uploaded: z.number().int() }))
async uploadPhotos(@Files('photos') photos: File[]): Promise<{ uploaded: number }> {
return { uploaded: photos.length };
}Constraints live in the schema
Because file fields go through the same validation path as everything else, their constraints belong in the Zod schema — not in the decorator:
- size —
z.file().max(bytes)/.min(bytes) - mime type —
z.file().mime(['image/png', 'image/jpeg']) - required — a plain
z.file()is required;.optional()makes it not - count —
z.array(z.file()).max(n)
These same constraints are reflected in the generated OpenAPI document, so the spec and the runtime check never drift.
Storage and limits
By default uploads are buffered in memory, so each handler receives a File backed by the bytes. Configure multer through the Covenix constructor — the multipart option is passed straight to multer:
import multer from 'multer';
const api = new Covenix({
info: { title: 'My API', version: '1.0.0' },
multipart: {
// Disk storage for large uploads — covenix still hands the handler a `File`,
// backed lazily by the file on disk (it isn't read into memory until you
// read the File).
storage: multer.diskStorage({ destination: '/tmp/uploads' }),
// Reject oversized uploads *before* they're fully buffered. A per-field
// `z.file().max()` only runs once the bytes are in hand, so a global
// `fileSize` limit is the cheaper first line of defense.
limits: { fileSize: 10 * 1024 * 1024 },
},
});TIP
With in-memory storage, z.file().max() runs only after the whole file has been buffered. For untrusted clients, set a multer limits.fileSize so the upload is rejected before it can fill memory.
OpenAPI
A multipart @Body is documented as a multipart/form-data request body, with file fields rendered as binary:
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"avatar": { "type": "string", "format": "binary" },
"caption": { "type": "string" }
},
"required": ["avatar"]
}
}
}
}This is the upload counterpart to File downloads, which covers sending binary responses with @ReturnsFile, FileResponse, and RangeFileResponse.
