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

File Upload Overview

@upload Decorator

Automatic file parsingSingle/multiple file support

UploadedFile Class

File info accesssaveToDisk() method

Storage Manager

local, S3, GCS integrationFlexible storage

Auto URL Generation

Public URLSigned 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
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  async uploadSingle(params: {
    file: UploadedFile;
    title?: string;
  }): Promise<{
    fileId: number;
    url: string;
  }> {
    const { file, title } = params;
    
    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(`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,
        title,
      })
      .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);
  }

  @api({ httpMethod: "POST" })
  @upload({ mode: "multiple" })
  async uploadMultiple(params: {
    files: UploadedFile[];
    category?: string;
  }): Promise<{
    uploadedFiles: Array<{
      fileId: number;
      filename: string;
      url: string;
    }>;
  }> {
    const { files, category } = params;
    
    if (!files || files.length === 0) {
      throw new Error("At least one file is required");
    }
    
    const uploadedFiles = [];
    const wdb = this.getPuri("w");
    
    for (const file of files) {
      // Save file
      const key = `uploads/${Date.now()}-${file.filename}`;
      const url = await file.saveToDisk(key);
      
      // Save to DB
      const [record] = await wdb
        .table("files")
        .insert({
          filename: file.filename,
          mime_type: file.mimetype,
          size: file.size,
          url,
          category,
        })
        .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);
  }

  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  async uploadToS3(params: {
    file: UploadedFile;
  }): Promise<{ url: string }> {
    const { file } = params;
    
    // Save to S3 disk
    const url = await file.saveToDisk(
      `uploads/${Date.now()}-${file.filename}`,
      "s3" // Disk name
    );
    
    return { url };
  }
  
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  async uploadPublic(params: {
    file: UploadedFile;
  }): Promise<{ url: string }> {
    const { file } = params;
    
    // Save to public disk
    const url = await file.saveToDisk(
      `public/${Date.now()}-${file.filename}`,
      "public"
    );
    
    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);
  }

  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  async uploadImage(params: {
    image: UploadedFile;
  }): Promise<{ imageId: number; url: string }> {
    const { image } = params;
    
    // 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(`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);
  }

  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  async uploadProfileImage(params: {
    image: UploadedFile;
  }): Promise<{
    imageId: number;
    url: string;
  }> {
    const context = Sonamu.getContext();
    
    if (!context.user) {
      throw new Error("Authentication required");
    }
    
    const { image } = params;
    
    // 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(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);
  }

  @api({ httpMethod: "POST" })
  @upload({ mode: "multiple" })
  async createWithAttachments(params: {
    title: string;
    content: string;
    attachments?: UploadedFile[];
  }): Promise<{
    postId: number;
    attachmentUrls: string[];
  }> {
    const context = Sonamu.getContext();
    
    if (!context.user) {
      throw new Error("Authentication required");
    }
    
    const { title, content, attachments } = 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 (attachments && attachments.length > 0) {
      for (const file of attachments) {
        // 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(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 decorator is used with @api
  3. Set file size limits
  4. MIME type validation
  5. Check Storage configuration

Next Steps