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:
Mode Option Context Property File Type Features Buffer (default)consume: "buffer" or omitbufferedFilesBufferedFile[]Loads into memory, flexible for MD5 calculation/image processing Stream consume: "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
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 () {
// ...
}
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 ();
// ...
}
Single File (Buffer)
Multiple Files (Buffer)
Stream Mode
@ 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
};
}
@ upload ({ limits: { files: 10 } })
async uploadDocuments () {
const { bufferedFiles } = Sonamu . getContext ();
if ( ! bufferedFiles || bufferedFiles . length === 0 ) {
throw new Error ( "No files" );
}
const results = [];
for ( const file of bufferedFiles ) {
const md5 = await file . md5 ();
const key = `documents/ ${ md5 } . ${ file . extname } ` ;
const url = await file . saveToDisk ( "fs" , key );
results . push ({
filename: file . filename ,
url
});
}
return results ;
}
@ upload ({
consume: "stream" ,
destination: "s3" ,
keyGenerator : ( file ) => `uploads/ ${ Date . now () } - ${ file . filename } ` ,
limits: { files: 5 }
})
async uploadToCloud () {
const { uploadedFiles } = Sonamu . getContext ();
if ( ! uploadedFiles || uploadedFiles . length === 0 ) {
throw new Error ( "No files" );
}
// Files are already uploaded to S3
return uploadedFiles . map (( file ) => ({
filename: file . filename ,
url: file . url ,
key: file . key
}));
}
@ 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
Profile Image
Multiple File Upload
CSV File Processing
MD5 Hash-based Storage
Stream Mode Large Files
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 };
}
}
class DocumentModelClass extends BaseModelClass {
@ upload ({ limits: { files: 10 } })
@ transactional ()
async uploadDocuments ( params : { projectId : number }) {
const { bufferedFiles } = Sonamu . getContext ();
const { projectId } = params ;
if ( ! bufferedFiles || bufferedFiles . length === 0 ) {
throw new Error ( "No files uploaded" );
}
const wdb = this . getDB ( "w" );
const results = [];
for ( const file of bufferedFiles ) {
// Validate file type
const allowedTypes = [
"application/pdf" ,
"application/msword" ,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
];
if ( ! allowedTypes . includes ( file . mimetype )) {
throw new Error ( `Invalid file type: ${ file . filename } ` );
}
// Save file
const md5 = await file . md5 ();
const key = `documents/ ${ projectId } / ${ md5 } . ${ file . extname } ` ;
const url = await file . saveToDisk ( "fs" , key );
// Record in DB
const doc = await wdb . table ( "documents" ). insert ({
project_id: projectId ,
filename: file . filename ,
url ,
size: file . size ,
mimetype: file . mimetype ,
created_at: new Date ()
}). returning ( "*" );
results . push ( doc [ 0 ]);
}
return results ;
}
}
import Papa from "papaparse" ;
class ImportModelClass extends BaseModelClass {
@ upload ()
@ transactional ()
async importUsers () {
const { bufferedFiles } = Sonamu . getContext ();
const file = bufferedFiles ?.[ 0 ];
if ( ! file ) {
throw new Error ( "No file" );
}
if ( file . mimetype !== "text/csv" ) {
throw new Error ( "CSV file required" );
}
// Parse CSV (use Buffer directly)
const text = file . buffer . toString ( "utf-8" );
const parsed = Papa . parse < UserImportRow >( text , {
header: true ,
skipEmptyLines: true
});
if ( parsed . errors . length > 0 ) {
throw new Error ( "CSV parsing failed" );
}
// Validate and save data
const wdb = this . getDB ( "w" );
const results = [];
for ( const row of parsed . data ) {
// Validate
if ( ! row . email || ! row . name ) {
throw new Error ( `Invalid row: ${ JSON . stringify ( row ) } ` );
}
// Save
const user = await wdb . table ( "users" )
. insert ({
email: row . email ,
name: row . name ,
phone: row . phone || null ,
created_at: new Date ()
})
. returning ( "*" );
results . push ( user [ 0 ]);
}
return {
imported: results . length ,
users: results
};
}
}
class FileModelClass extends BaseModelClass {
@ upload ({ limits: { files: 10 } })
async uploadWithDeduplication () {
const { bufferedFiles } = Sonamu . getContext ();
if ( ! bufferedFiles || bufferedFiles . length === 0 ) {
throw new Error ( "No files" );
}
const results = [];
for ( const file of bufferedFiles ) {
// Calculate MD5 hash
const hash = await file . md5 ();
// Save file with hash (prevent duplicates)
const ext = file . extname || "bin" ;
const key = `uploads/ ${ hash } . ${ ext } ` ;
const url = await file . saveToDisk ( "fs" , key );
results . push ({
filename: file . filename ,
hash ,
url ,
size: file . size
});
}
return results ;
}
}
class FileModelClass extends BaseModelClass {
@ upload ({
consume: "stream" ,
destination: "s3" ,
keyGenerator : ( file ) => `large-files/ ${ Date . now () } - ${ file . filename } ` ,
limits: { files: 3 , fileSize: 100 * 1024 * 1024 } // 100MB
})
async uploadLargeFiles () {
const { uploadedFiles } = Sonamu . getContext ();
if ( ! uploadedFiles || uploadedFiles . length === 0 ) {
throw new Error ( "No files" );
}
// Files are already uploaded to S3
return {
files: uploadedFiles . map (( file ) => ({
filename: file . filename ,
url: file . url ,
signedUrl: file . signedUrl ,
key: file . key ,
size: file . size ,
})),
};
}
}
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