Skip to content

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.

typescript
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.body and files on req.files,
  • adapts each uploaded file to a web-standard File (so file.name, file.type, file.size, file.arrayBuffer(), and file.stream() all work),
  • assembles those into the body object and validates it against your schema with the same safeParse as any other body — file .max()/.mime() constraints included,
  • and injects the result: @File(name) gives a single File, @Files(name) gives File[], 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[]:

typescript
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:

  • sizez.file().max(bytes) / .min(bytes)
  • mime typez.file().mime(['image/png', 'image/jpeg'])
  • required — a plain z.file() is required; .optional() makes it not
  • countz.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:

typescript
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:

jsonc
"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.