@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
Copy
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
Copy
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.Copy
// 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
Copy
# .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
Copy
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
Copy
// 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
Copy
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
Copy
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
Copy
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
Copy
<!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
Copy
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:
- Fastify multipart plugin must be registered
@uploaddecorator is used with@api- Set file size limits
- MIME type validation
- Check Storage configuration