Skip to main content
The @upload decorator creates an API that handles file uploads. It automatically parses multipart form-data requests and provides file objects. Can be used independently without @api decorator, automatically setting POST method and multipart clients (axios-multipart, tanstack-mutation-multipart).

Upload Modes

Sonamu provides two file upload modes:
ModeOptionContext PropertyFile TypeFeatures
Buffer (default)consume: "buffer" or omitbufferedFilesBufferedFile[]Loads into memory, flexible for MD5 calculation/image processing
Streamconsume: "stream"uploadedFilesUploadedFile[]Streams directly to storage, suitable for large files

Buffer Mode (Default)

Buffer mode loads files into memory before processing. Use this when you need to work directly with file contents like MD5 hash calculation or image resizing.
import { BaseModelClass, upload, Sonamu } from "sonamu";

class FileModelClass extends BaseModelClass {
  @upload()  // default: consume: "buffer"
  async uploadAvatar() {
    const { bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];

    if (!file) {
      throw new Error("No file uploaded");
    }

    // Use MD5 hash to prevent duplicates
    const md5 = await file.md5();
    const key = `avatars/${md5}.${file.extname}`;
    const url = await file.saveToDisk("fs", key);

    return { url, filename: file.filename, size: file.size };
  }
}

BufferedFile Object

class BufferedFile {
  // Original file name
  get filename(): string;

  // MIME type
  get mimetype(): string;

  // File size (bytes)
  get size(): number;

  // Extension (without dot, e.g., "jpg", "png")
  get extname(): string | false;

  // File Buffer (data loaded in memory)
  get buffer(): Buffer;

  // URL after saveToDisk (Unsigned)
  get url(): string;

  // Signed URL after saveToDisk
  get signedUrl(): string;

  // Access to raw Fastify MultipartFile
  get raw(): MultipartFile;

  // Calculate MD5 hash
  async md5(): Promise<string>;

  // Save file to disk (returns URL)
  async saveToDisk(diskName: DriverKey, key: string): Promise<string>;
}
The parameter order for saveToDisk is (diskName, key).

Stream Mode

Stream mode streams files directly to storage without loading them into memory. Suitable for large file uploads.
@upload({
  consume: "stream",
  destination: "s3",  // disk name to save to
  keyGenerator: (file) => `uploads/${Date.now()}-${file.filename}`,  // key generator function
  limits: { files: 5 }
})
async uploadLargeFiles() {
  const { uploadedFiles } = Sonamu.getContext();

  if (!uploadedFiles || uploadedFiles.length === 0) {
    throw new Error("No files uploaded");
  }

  // Files are already uploaded to storage
  return {
    files: uploadedFiles.map((file) => ({
      filename: file.filename,
      url: file.url,
      key: file.key,
      size: file.size,
    })),
  };
}

UploadedFile Object

In Stream mode, files are already uploaded to storage, so only metadata is accessible.
class UploadedFile {
  // Original file name
  get filename(): string;

  // MIME type
  get mimetype(): string;

  // File size (bytes)
  get size(): number;

  // Extension (without dot, e.g., "jpg", "png")
  get extname(): string | false;

  // Stored URL (Unsigned)
  get url(): string;

  // Stored Signed URL
  get signedUrl(): string;

  // Key in storage
  get key(): string;

  // Disk name where file is stored
  get diskName(): DriverKey;

  // Download file from storage (for later processing)
  async download(): Promise<Buffer>;
}

Options

guards

Specifies guards to apply to the upload API. Use this for upload APIs that require authentication.
@upload({ guards: ["user"] })
async uploadAvatar() {
  const { user, bufferedFiles } = Sonamu.getContext();
  // With user guard applied, only authenticated users can access
  // ...
}

description

Specifies the API description. Displayed in auto-generated API documentation.
@upload({ description: "Upload user profile image" })
async uploadAvatar() {
  // ...
}

limits

Specifies file upload limits. Accepts Fastify multipart’s limits options directly.
type UploadDecoratorOptions = {
  // Common options
  guards?: GuardKey[];    // Guards to apply (e.g., ["user", "admin"])
  description?: string;   // API description
  limits?: {
    fileSize?: number;    // Max file size (bytes)
    files?: number;       // Max number of files
    fields?: number;      // Max number of fields
    fieldSize?: number;   // Max field size
    parts?: number;       // Max number of parts
  };
} & (
  // Buffer mode
  | { consume?: "buffer" }
  // Stream mode
  | {
      consume: "stream";
      destination: DriverKey;
      keyGenerator?: (file: { filename: string; mimetype: string }) => string;
    }
);
@upload({
  limits: {
    fileSize: 10 * 1024 * 1024, // 10MB
    files: 5,                    // Max 5 files
  }
})
async uploadWithLimits() {
  const { bufferedFiles } = Sonamu.getContext();
  // ...
}
@upload()
async uploadAvatar() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  const md5 = await file.md5();
  return {
    filename: file.filename,
    size: file.size,
    md5
  };
}

Getting File Information

@upload()
async uploadFile() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  console.log("Filename:", file.filename);
  console.log("MIME type:", file.mimetype);
  console.log("Size:", file.size);
  console.log("Extension:", file.extname);

  return { uploaded: true };
}

File Processing (Buffer Mode)

Accessing Buffer

@upload()
async processImage() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  // Direct buffer access
  const buffer = file.buffer;

  // Image processing
  const processed = await sharp(buffer)
    .resize(300, 300)
    .toBuffer();

  return { size: processed.length };
}

Saving Files

@upload()
async uploadDocument() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  // Save to disk (diskName, key order)
  const md5 = await file.md5();
  const key = `uploads/${md5}.${file.extname}`;
  const url = await file.saveToDisk("fs", key);

  // Access saved URLs
  console.log("URL:", file.url);
  console.log("Signed URL:", file.signedUrl);

  return { url, filename: file.filename, size: file.size };
}

MD5 Hash Calculation

@upload()
async uploadWithHash() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  // Calculate MD5 hash (useful for preventing duplicate files)
  const hash = await file.md5();

  // Use hash as filename
  const key = `uploads/${hash}${file.extname ? `.${file.extname}` : ''}`;
  const url = await file.saveToDisk("fs", key);

  return { url, hash };
}

Using Storage Drivers

Sonamu provides multiple storage drivers.
@upload()
async uploadToS3() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  // Save to S3 disk (configured in sonamu.config.ts)
  const md5 = await file.md5();
  const key = `avatars/${md5}.${file.extname}`;
  const url = await file.saveToDisk("s3", key);

  return { url };
}
Storage drivers are configured in sonamu.config.ts. Pass the disk name as the first parameter.

Using with Other Decorators

With @transactional

@upload()
@transactional()
async uploadAndSave() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  const wdb = this.getDB("w");

  // File save + DB update in transaction
  const md5 = await file.md5();
  const key = `documents/${md5}.${file.extname}`;
  const url = await file.saveToDisk("fs", key);

  await wdb.table("documents").insert({
    filename: file.filename,
    url,
    size: file.size,
    created_at: new Date()
  });

  return { url };
}

Client Usage (Web)

Sonamu automatically generates file upload client code.

Axios (Single File)

import { FileService } from "@/services/FileService";

const formData = new FormData();
formData.append("file", file);

const result = await FileService.uploadAvatar(formData);

Axios (Multiple Files)

const formData = new FormData();
files.forEach(file => {
  formData.append("files", file);
});

const result = await FileService.uploadDocuments(formData);

React Example

import { useState } from "react";
import { FileService } from "@/services/FileService";

function FileUploader() {
  const [uploading, setUploading] = useState(false);

  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    setUploading(true);
    try {
      const formData = new FormData();
      formData.append("file", file);

      const result = await FileService.uploadAvatar(formData);
      console.log("Uploaded:", result.url);
    } catch (error) {
      console.error("Upload failed:", error);
    } finally {
      setUploading(false);
    }
  };

  return (
    <div>
      <input
        type="file"
        onChange={handleFileChange}
        disabled={uploading}
      />
      {uploading && <p>Uploading...</p>}
    </div>
  );
}

TanStack Query Example

import { useMutation } from "@tanstack/react-query";
import { FileService } from "@/services/FileService";

function useUploadFile() {
  return useMutation({
    mutationFn: (file: File) => {
      const formData = new FormData();
      formData.append("file", file);
      return FileService.uploadAvatar(formData);
    },
    onSuccess: (data) => {
      console.log("Upload success:", data);
    },
    onError: (error) => {
      console.error("Upload failed:", error);
    }
  });
}

function FileUploader() {
  const upload = useUploadFile();

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) {
      upload.mutate(file);
    }
  };

  return (
    <div>
      <input
        type="file"
        onChange={handleFileChange}
        disabled={upload.isPending}
      />
      {upload.isPending && <p>Uploading...</p>}
      {upload.isSuccess && <p>Success: {upload.data.url}</p>}
      {upload.isError && <p>Error: {upload.error.message}</p>}
    </div>
  );
}

File Validation

MIME Type Validation

@upload()
async uploadImage() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  const allowedTypes = ["image/jpeg", "image/png", "image/gif"];
  if (!allowedTypes.includes(file.mimetype)) {
    throw new Error(`Invalid file type: ${file.mimetype}`);
  }

  return await this.processImage(file);
}

File Size Validation

@upload()
async uploadDocument() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  const maxSize = 10 * 1024 * 1024; // 10MB
  if (file.size > maxSize) {
    throw new Error("File too large");
  }

  return await this.saveDocument(file);
}

File Extension Validation

@upload()
async uploadFile() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  const allowedExtensions = ["jpg", "png", "pdf"];
  if (!file.extname || !allowedExtensions.includes(file.extname)) {
    throw new Error(`Invalid file extension: ${file.extname}`);
  }

  return await this.saveFile(file);
}

Image Processing

Using Sharp

import sharp from "sharp";

@upload()
async uploadAndResizeImage() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  const buffer = file.buffer;

  // Resize image
  const resized = await sharp(buffer)
    .resize(800, 600, { fit: "inside" })
    .jpeg({ quality: 80 })
    .toBuffer();

  // Create thumbnail
  const thumbnail = await sharp(buffer)
    .resize(200, 200, { fit: "cover" })
    .jpeg({ quality: 70 })
    .toBuffer();

  // Upload to S3 (directly save Buffer)
  const imageKey = `images/${Date.now()}.jpg`;
  await Sonamu.storage.use("s3").put(imageKey, resized);
  const imageUrl = await Sonamu.storage.use("s3").getUrl(imageKey);

  const thumbKey = `thumbnails/${Date.now()}.jpg`;
  await Sonamu.storage.use("s3").put(thumbKey, thumbnail);
  const thumbUrl = await Sonamu.storage.use("s3").getUrl(thumbKey);

  return { imageUrl, thumbUrl };
}

Constraints

1. Independent Usage without @api

@upload is used independently without @api decorator:
// Correct usage
@upload()
async uploadFile() {}

// Unnecessary - @upload automatically sets up API
@api({ httpMethod: "POST" })
@upload()
async uploadFile() {}

2. httpMethod is POST

When using @upload, httpMethod: "POST" is automatically set.

3. Automatic clients Setting

When using @upload, the clients option is automatically set to ["axios-multipart", "tanstack-mutation-multipart"]:
@upload()
async uploadFile() {
  // clients: ["axios-multipart", "tanstack-mutation-multipart"]
}

Examples

class UserModelClass extends BaseModelClass {
  @upload()
  @transactional()
  async uploadAvatar() {
    const { user, bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];

    if (!file) {
      throw new Error("No file uploaded");
    }

    // Validate image type
    const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
    if (!allowedTypes.includes(file.mimetype)) {
      throw new Error("Invalid image type");
    }

    // Process image
    const buffer = file.buffer;
    const processed = await sharp(buffer)
      .resize(300, 300, { fit: "cover" })
      .jpeg({ quality: 85 })
      .toBuffer();

    // Upload to S3
    const key = `avatars/${user.id}/${Date.now()}.jpg`;
    await Sonamu.storage.use("s3").put(key, processed);
    const url = await Sonamu.storage.use("s3").getUrl(key);

    // Update DB
    const wdb = this.getDB("w");
    await wdb.table("users")
      .where("id", user.id)
      .update({ avatar_url: url });

    return { url };
  }
}

Notes

Relationship with @api Decorator

The @upload decorator automatically creates an API endpoint internally. Therefore, you don’t need to use the @api decorator separately. Automatically configured values:
  • httpMethod: "POST" (fixed)
  • clients: ["axios-multipart", "tanstack-mutation-multipart"] (multipart-specific clients)
  • guards: guards value from @upload options is passed to the API
  • description: description value from @upload options is passed to the API
// Use @upload only (recommended)
@upload()
async uploadFile() {
  // ...
}

// Unnecessary - no need to use with @api
@api({ httpMethod: "POST" })  // Unnecessary
@upload()
async uploadFile() {
  // ...
}
@upload automatically applies optimized settings for file uploads, so there’s no need to add the @api decorator.

Next Steps