Skip to main content
Sonamu allows you to easily handle file uploads through the @upload decorator.

File Upload Overview

@upload Decorator

Automatic file parsing Single/multiple file support

UploadedFile Class

File info access saveToDisk() method

Storage Manager

local, S3, GCS integration Flexible storage

Auto URL Generation

Public URL Signed URL

@upload Decorator

Basic Usage

import { BaseModelClass, api, upload } from "sonamu";
import type { UploadedFile } from "sonamu";
import type { FileSubsetKey, FileSubsetMapping } from "../sonamu.generated";
import { fileLoaderQueries, fileSubsetQueries } from "../sonamu.generated.sso";

class FileModelClass extends BaseModelClass<
  FileSubsetKey,
  FileSubsetMapping,
  typeof fileSubsetQueries,
  typeof fileLoaderQueries
> {
  constructor() {
    super("File", fileSubsetQueries, fileLoaderQueries);
  }

  // Single file upload
  @upload()
  async uploadSingle(): Promise<{
    fileId: number;
    url: string;
  }> {
    const { bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];

    if (!file) {
      throw new Error("File is required");
    }

    // File info
    console.log({
      filename: file.filename,
      mimetype: file.mimetype,
      size: file.size,
    });

    // Save file
    const url = await file.saveToDisk("fs", `uploads/${Date.now()}-${file.filename}`);

    // Save metadata to DB
    const wdb = this.getPuri("w");
    const [record] = await wdb
      .table("files")
      .insert({
        filename: file.filename,
        mime_type: file.mimetype,
        size: file.size,
        url,
      })
      .returning({ id: "id" });

    return {
      fileId: record.id,
      url,
    };
  }
}

Multiple File Upload

class FileModelClass extends BaseModelClass<
  FileSubsetKey,
  FileSubsetMapping,
  typeof fileSubsetQueries,
  typeof fileLoaderQueries
> {
  constructor() {
    super("File", fileSubsetQueries, fileLoaderQueries);
  }

  @upload()
  async uploadMultiple(): Promise<{
    uploadedFiles: Array<{
      fileId: number;
      filename: string;
      url: string;
    }>;
  }> {
    const { bufferedFiles } = Sonamu.getContext();

    if (!bufferedFiles || bufferedFiles.length === 0) {
      throw new Error("At least one file is required");
    }

    const uploadedFiles = [];
    const wdb = this.getPuri("w");

    for (const file of bufferedFiles) {
      // Save file
      const key = `uploads/${Date.now()}-${file.filename}`;
      const url = await file.saveToDisk("fs", key);

      // Save to DB
      const [record] = await wdb
        .table("files")
        .insert({
          filename: file.filename,
          mime_type: file.mimetype,
          size: file.size,
          url,
        })
        .returning({ id: "id" });

      uploadedFiles.push({
        fileId: record.id,
        filename: file.filename,
        url,
      });
    }

    return { uploadedFiles };
  }
}

Storage Configuration

Storage Manager

Sonamu provides a Storage Manager that manages multiple storages in an integrated way.
// storage.config.ts
import type { StorageConfig } from "sonamu";

export const storageConfig: StorageConfig = {
  default: "local", // Default disk

  disks: {
    // Local storage
    local: {
      driver: "local",
      root: "uploads",
      url: process.env.APP_URL || "http://localhost:3000",
    },

    // AWS S3
    s3: {
      driver: "s3",
      bucket: process.env.S3_BUCKET!,
      region: process.env.S3_REGION || "us-east-1",
      credentials: {
        accessKeyId: process.env.S3_ACCESS_KEY_ID!,
        secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
      },
      endpoint: process.env.S3_ENDPOINT, // MinIO, etc.
    },

    // Google Cloud Storage
    gcs: {
      driver: "gcs",
      bucket: process.env.GCS_BUCKET!,
      projectId: process.env.GCS_PROJECT_ID!,
      keyFilename: process.env.GCS_KEY_FILENAME!,
    },

    // Separate disk for public files
    public: {
      driver: "s3",
      bucket: process.env.PUBLIC_S3_BUCKET!,
      region: "us-east-1",
      credentials: {
        accessKeyId: process.env.S3_ACCESS_KEY_ID!,
        secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
      },
    },
  },
};

Environment Variable Setup

# .env

# App URL
APP_URL=http://localhost:3000

# AWS S3
S3_BUCKET=my-app-uploads
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=your-access-key
S3_SECRET_ACCESS_KEY=your-secret-key
# S3_ENDPOINT=http://localhost:9000  # MinIO, etc.

# Public S3 (optional)
PUBLIC_S3_BUCKET=my-app-public

# Google Cloud Storage
GCS_BUCKET=my-app-uploads
GCS_PROJECT_ID=my-project
GCS_KEY_FILENAME=/path/to/service-account-key.json

Disk Selection

Save to Specific Disk

class FileModelClass extends BaseModelClass<
  FileSubsetKey,
  FileSubsetMapping,
  typeof fileSubsetQueries,
  typeof fileLoaderQueries
> {
  constructor() {
    super("File", fileSubsetQueries, fileLoaderQueries);
  }

  @upload()
  async uploadToS3(): Promise<{ url: string }> {
    const { bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];
    if (!file) throw new Error("File is required");

    // Save to S3 disk
    const url = await file.saveToDisk("s3", `uploads/${Date.now()}-${file.filename}`);

    return { url };
  }

  @upload()
  async uploadPublic(): Promise<{ url: string }> {
    const { bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];
    if (!file) throw new Error("File is required");

    // Save to public disk
    const url = await file.saveToDisk("public", `public/${Date.now()}-${file.filename}`);

    return { url };
  }
}

Fastify Multipart Setup (Required)

Since Sonamu uses Fastify internally, you need to register the multipart plugin.

Server Configuration

// server.ts
import fastify from "fastify";
import multipart from "@fastify/multipart";

const app = fastify({
  bodyLimit: 50 * 1024 * 1024, // 50MB
});

// Register multipart plugin
app.register(multipart, {
  limits: {
    fileSize: 50 * 1024 * 1024, // Max file size (50MB)
    files: 10, // Max number of files
    fields: 10, // Max number of fields
  },
});

// Register Sonamu routes
// ...
Sonamu’s @upload decorator automatically handles Fastify multipart.

File Validation

Size and Type Validation

class ImageModelClass extends BaseModelClass<
  ImageSubsetKey,
  ImageSubsetMapping,
  typeof imageSubsetQueries,
  typeof imageLoaderQueries
> {
  constructor() {
    super("Image", imageSubsetQueries, imageLoaderQueries);
  }

  @upload()
  async uploadImage(): Promise<{ imageId: number; url: string }> {
    const { bufferedFiles } = Sonamu.getContext();
    const image = bufferedFiles?.[0];

    // Check file exists
    if (!image) {
      throw new Error("Image is required");
    }

    // Check size
    const maxSize = 5 * 1024 * 1024; // 5MB
    if (image.size > maxSize) {
      throw new Error(`Image too large (max ${maxSize / 1024 / 1024}MB)`);
    }

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

    // Check extension
    const ext = image.extname;
    if (!ext || !["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) {
      throw new Error(`Invalid image extension: ${ext}`);
    }

    // Save image
    const url = await image.saveToDisk("fs", `images/${Date.now()}.${ext}`);

    // Save to DB
    const wdb = this.getPuri("w");
    const [record] = await wdb
      .table("images")
      .insert({
        filename: image.filename,
        mime_type: image.mimetype,
        size: image.size,
        url,
      })
      .returning({ id: "id" });

    return {
      imageId: record.id,
      url,
    };
  }
}

Practical Examples

Profile Image Upload

class UserModelClass extends BaseModelClass<
  UserSubsetKey,
  UserSubsetMapping,
  typeof userSubsetQueries,
  typeof userLoaderQueries
> {
  constructor() {
    super("User", userSubsetQueries, userLoaderQueries);
  }

  @upload()
  async uploadProfileImage(): Promise<{
    imageId: number;
    url: string;
  }> {
    const context = Sonamu.getContext();

    if (!context.user) {
      throw new Error("Authentication required");
    }

    const { bufferedFiles } = context;
    const image = bufferedFiles?.[0];

    // Validation
    if (!image) {
      throw new Error("Image is required");
    }

    if (image.size > 5 * 1024 * 1024) {
      throw new Error("Image too large (max 5MB)");
    }

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

    // Save file
    const ext = image.extname;
    const key = `profiles/${context.user.id}/${Date.now()}.${ext}`;
    const url = await image.saveToDisk("fs", key);

    // Delete existing profile image
    const rdb = this.getPuri("r");
    const oldImage = await rdb.table("profile_images").where("user_id", context.user.id).first();

    if (oldImage) {
      // Delete existing image (using Storage Manager)
      const disk = Sonamu.storage.use();
      await disk.delete(oldImage.key);

      // Delete DB record
      const wdb = this.getPuri("w");
      await wdb.table("profile_images").where("id", oldImage.id).delete();
    }

    // Save new image
    const wdb = this.getPuri("w");
    const [record] = await wdb
      .table("profile_images")
      .insert({
        user_id: context.user.id,
        key,
        filename: image.filename,
        mime_type: image.mimetype,
        size: image.size,
        url,
      })
      .returning({ id: "id" });

    return {
      imageId: record.id,
      url,
    };
  }
}

Post Attachments

class PostModelClass extends BaseModelClass<
  PostSubsetKey,
  PostSubsetMapping,
  typeof postSubsetQueries,
  typeof postLoaderQueries
> {
  constructor() {
    super("Post", postSubsetQueries, postLoaderQueries);
  }

  @upload()
  async createWithAttachments(params: { title: string; content: string }): Promise<{
    postId: number;
    attachmentUrls: string[];
  }> {
    const context = Sonamu.getContext();

    if (!context.user) {
      throw new Error("Authentication required");
    }

    const { bufferedFiles } = context;
    const { title, content } = params;

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

    // Create post
    const [post] = await wdb
      .table("posts")
      .insert({
        user_id: context.user.id,
        title,
        content,
      })
      .returning({ id: "id" });

    const attachmentUrls: string[] = [];

    // Process attachments
    if (bufferedFiles && bufferedFiles.length > 0) {
      for (const file of bufferedFiles) {
        // Validation
        if (file.size > 20 * 1024 * 1024) {
          throw new Error(`File ${file.filename} too large (max 20MB)`);
        }

        // Save
        const ext = file.extname;
        const key = `posts/${post.id}/${Date.now()}.${ext}`;
        const url = await file.saveToDisk("fs", key);

        // Save attachment info to DB
        await wdb.table("post_attachments").insert({
          post_id: post.id,
          key,
          filename: file.filename,
          mime_type: file.mimetype,
          size: file.size,
          url,
        });

        attachmentUrls.push(url);
      }
    }

    return {
      postId: post.id,
      attachmentUrls,
    };
  }
}

Client Examples

HTML Form

<!DOCTYPE html>
<html>
  <head>
    <title>File Upload</title>
  </head>
  <body>
    <h1>Upload File</h1>

    <form id="uploadForm">
      <input type="file" name="file" required />
      <input type="text" name="title" placeholder="Title" />
      <button type="submit">Upload</button>
    </form>

    <div id="result"></div>

    <script>
      document.getElementById("uploadForm").addEventListener("submit", async (e) => {
        e.preventDefault();

        const formData = new FormData(e.target);

        try {
          const response = await fetch("http://localhost:3000/api/file/uploadSingle", {
            method: "POST",
            body: formData,
          });

          const result = await response.json();

          document.getElementById("result").innerHTML = `
          <p>Uploaded!</p>
          <p>File ID: ${result.fileId}</p>
          <p>URL: <a href="${result.url}" target="_blank">${result.url}</a></p>
        `;
        } catch (error) {
          console.error("Upload failed:", error);
        }
      });
    </script>
  </body>
</html>

React Example

import React, { useState } from "react";

export function FileUpload() {
  const [file, setFile] = useState<File | null>(null);
  const [uploading, setUploading] = useState(false);
  const [result, setResult] = useState<any>(null);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!file) return;

    setUploading(true);

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

    try {
      const response = await fetch("http://localhost:3000/api/file/uploadSingle", {
        method: "POST",
        body: formData,
      });

      const data = await response.json();
      setResult(data);
    } catch (error) {
      console.error("Upload failed:", error);
    } finally {
      setUploading(false);
    }
  };

  return (
    <div>
      <h1>Upload File</h1>

      <form onSubmit={handleSubmit}>
        <input
          type="file"
          onChange={(e) => setFile(e.target.files?.[0] || null)}
        />

        <button type="submit" disabled={!file || uploading}>
          {uploading ? "Uploading..." : "Upload"}
        </button>
      </form>

      {result && (
        <div>
          <p>Uploaded!</p>
          <p>File ID: {result.fileId}</p>
          <p>URL: <a href={result.url}>{result.url}</a></p>
        </div>
      )}
    </div>
  );
}

Cautions

Cautions when setting up file upload: 1. Fastify multipart plugin must be registered 2. @upload is used independently (without @api) 3. Set file size limits 4. MIME type validation 5. Check Storage configuration

Next Steps

UploadedFile Class

File info and methods

Saving Files

saveToDisk details

URL Generation

Generating URLs

@api Decorator

API basic usage