The @api decorator exposes methods of Model or Frame classes as HTTP API endpoints.
Basic Usage
import { BaseModelClass , api } from "sonamu" ;
class UserModelClass extends BaseModelClass <
UserSubsetKey ,
UserSubsetMapping ,
UserSubsetQueries
> {
@ api ({ httpMethod: "GET" })
async findById ( subset : UserSubsetKey , id : number ) {
const rdb = this . getPuri ( "r" );
return rdb . table ( "users" ). where ( "id" , id ). first ();
}
@ api ({ httpMethod: "POST" })
async save ( data : UserSaveParams ) {
const wdb = this . getDB ( "w" );
return this . upsert ( wdb , data );
}
}
export const UserModel = new UserModelClass ();
Options
httpMethod
Specifies the HTTP method.
type HTTPMethods = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS" ;
Default: "GET"
@ api ({ httpMethod: "POST" })
async create ( data : CreateParams ) {
// Exposed as POST request
}
@ api ({ httpMethod: "DELETE" })
async remove ( id : number ) {
// Exposed as DELETE request
}
path
Specifies the API endpoint path.
Default: /{modelName}/{methodName} (camelCase)
@ api ({ path: "/api/v1/users/profile" })
async getProfile () {
// Accessible at /api/v1/users/profile
}
@ api ()
async findById ( id : number ) {
// Default path: /user/findById
}
Paths are automatically converted to camelCase. UserModel.findById → /user/findById
contentType
Specifies the Content-Type of the response.
Default: "application/json"
type ContentType =
| "text/plain"
| "text/html"
| "text/xml"
| "application/json"
| "application/octet-stream" ;
// CSV file download
@ api ({ contentType: "text/plain" })
async exportCsv () {
return "id,name,email \n 1,Alice,alice@example.com" ;
}
// HTML rendering
@ api ({ contentType: "text/html" })
async renderTemplate () {
return "<html><body>Hello</body></html>" ;
}
// Binary file download (images, PDFs, etc.)
@ api ({ contentType: "application/octet-stream" })
async downloadFile ( fileId : number , ctx : Context ) {
// Fetch file
const file = await this . getPuri ( "r" )
. table ( "files" )
. where ( "id" , fileId )
. first ();
if ( ! file ) {
throw new NotFoundError ( "File not found" );
}
// Read file from storage
const disk = Sonamu . storage . use ();
const buffer = await disk . get ( file . path );
// Set download filename with Content-Disposition header
ctx . reply . header (
"Content-Disposition" ,
`attachment; filename=" ${ encodeURIComponent ( file . original_name ) } "`
);
return buffer ;
}
contentType Use Cases :
Content-Type Use Case Return Type text/plainCSV, TXT file download stringtext/htmlHTML rendering (SSR) stringtext/xmlXML data response stringapplication/jsonJSON data (default) objectapplication/octet-streamBinary files (images, PDFs, ZIPs, etc.) Buffer or Uint8Array
Notes for application/octet-stream :
Must return Buffer or Uint8Array
Use Content-Disposition header to specify download filename
Use encodeURIComponent() for filenames containing non-ASCII characters
Consider streaming for large files
clients
Specifies the client types to generate.
Default: ["axios"]
type ServiceClient =
| "axios" // Axios client
| "axios-multipart" // Multipart form-data
| "tanstack-query" // TanStack Query (read)
| "tanstack-mutation" // TanStack Mutation (write)
| "tanstack-mutation-multipart" // TanStack Mutation (file upload)
| "window-fetch" ; // Native Fetch API
@ api ({
httpMethod: "GET" ,
clients: [ "axios" , "tanstack-query" ]
})
async list () {
// Generates axios and TanStack Query clients
}
@ api ({
httpMethod: "POST" ,
clients: [ "axios" , "tanstack-mutation" ]
})
async create ( data : CreateParams ) {
// Generates axios and TanStack Mutation clients
}
guards
Specifies API access permissions.
type GuardKey = "query" | "admin" | "user" ;
@ api ({ guards: [ "admin" ] })
async deleteUser ( id : number ) {
// Admin only
}
@ api ({ guards: [ "user" ] })
async getProfile () {
// Authenticated users only
}
@ api ({ guards: [ "query" , "admin" ] })
async search ( query : string ) {
// Requires query or admin permission
}
description
Adds API description. Included in generated types and documentation.
@ api ({
description: "Fetches user profile by ID."
})
async findById ( id : number ) {
// ...
}
resourceName
Specifies the resource name for generated service files.
@ api ({ resourceName: "Users" })
async list () {
// Included in UsersService.ts file
}
timeout
Specifies the API request timeout in milliseconds.
@ api ({ timeout: 30000 }) // 30 seconds
async heavyOperation () {
// Long-running task
}
cacheControl
Sets the Cache-Control header for the response.
@ api ({
cacheControl: {
maxAge: "10m" , // Cache for 10 minutes
sMaxAge: "1h" , // Cache on CDN for 1 hour
public: true
}
})
async getPublicData () {
// Cache-Control: public, max-age=600, s-maxage=3600
}
CacheControlConfig Type:
type CacheControlConfig = {
maxAge ?: string ; // Browser cache duration
sMaxAge ?: string ; // CDN/proxy cache duration
public ?: boolean ; // public/private
noCache ?: boolean ; // no-cache
noStore ?: boolean ; // no-store
mustRevalidate ?: boolean ;
};
Time notation supports: "10s", "5m", "1h", "1d" formats
compress
Specifies response compression settings.
@ api ({
compress: {
threshold: 1024 , // Compress only if >= 1KB
level: 6 // Compression level (0-9)
}
})
async getLargeData () {
// Returns large data
}
@ api ({ compress: false }) // Disable compression
async getSmallData () {
// Small data not compressed
}
Complete Options Example
@ api ({
httpMethod: "POST" ,
path: "/api/v1/users/search" ,
contentType: "application/json" ,
clients: [ "axios" , "tanstack-query" ],
guards: [ "user" ],
description: "User search API" ,
resourceName: "Users" ,
timeout: 5000 ,
cacheControl: {
maxAge: "5m" ,
public: true
},
compress: {
threshold: 1024 ,
level: 6
}
})
async search ( params : SearchParams ) {
// Implementation
}
Path Generation Rules
Model Classes
class UserModelClass extends BaseModelClass {
@ api ()
async findById ( id : number ) {}
// Path: /user/findById
}
class PostModelClass extends BaseModelClass {
@ api ()
async getComments () {}
// Path: /post/getComments
}
Rules:
Remove “ModelClass” from class name
Convert the rest to camelCase
Format: /{modelName}/{methodName}
Frame Classes
class AuthFrameClass extends BaseFrameClass {
@ api ()
async login () {}
// Path: /auth/login
}
Rules:
Remove “FrameClass” from class name
Convert the rest to camelCase
Format: /{frameName}/{methodName}
Using with Other Decorators
@transactional
@ api ({ httpMethod: "POST" })
@ transactional ()
async save ( data : UserSaveParams ) {
const wdb = this . getDB ( "w" );
// Executed within transaction
return this . upsert ( wdb , data );
}
Write @api first, then @transactional.
@cache
@ api ({ httpMethod: "GET" })
@ cache ({ ttl: "10m" })
async findById ( id : number ) {
// Result cached for 10 minutes
const rdb = this . getPuri ( "r" );
return rdb . table ( "users" ). where ( "id" , id ). first ();
}
@upload
@upload is used independently without @api.
@ upload ()
async uploadAvatar () {
const { files } = Sonamu . getContext ();
const file = files ?.[ 0 ]; // Use first file
// Process file
}
Constraints
1. Cannot be used with @stream
// ❌ Error
@ api ()
@ stream ({ type: "sse" , events: EventSchema })
async subscribe () {}
Error Message:
@api decorator can only be used once on UserModel.subscribe.
You can use only one of @api or @stream decorator on the same method.
2. Using Multiple Times on Same Method
Using multiple times gives last one priority , and conflicting options cause errors:
// ❌ Error - path conflict
@ api ({ path: "/users/list" })
@ api ({ path: "/users/all" })
async list () {}
Error Message:
@api decorator on UserModel.list has conflicting path: /users/all.
The decorator is trying to override the existing path(/users/list)
with the new path(/users/all).
3. Only Available in Model/Frame Classes
// ❌ Not available in regular classes
class UtilClass {
@ api ()
async helper () {}
// Error: modelName is required
}
// ✅ Requires BaseModelClass inheritance
class UserModelClass extends BaseModelClass {
@ api ()
async findById ( id : number ) {}
}
Generated Code
Using the @api decorator automatically generates:
1. API Route Registration
// Fastify route automatically registered
fastify . get ( "/user/findById" , async ( request , reply ) => {
// Parameter validation and processing
const result = await UserModel . findById ( subset , id );
return result ;
});
2. Type Definitions
// api/src/application/services/UserService.types.ts
export type UserFindByIdParams = {
subset : UserSubsetKey ;
id : number ;
};
export type UserFindByIdResult = UserSubsetMapping [ UserSubsetKey ];
3. Client Code
Axios:
// web/src/services/UserService.ts
export const UserService = {
async findById ( params : UserFindByIdParams ) {
return axios . get < UserFindByIdResult >( "/user/findById" , { params });
}
};
TanStack Query:
export const useUserFindById = ( params : UserFindByIdParams ) => {
return useQuery ({
queryKey: [ "user" , "findById" , params ],
queryFn : () => UserService . findById ( params )
});
};
Logging
The @api decorator automatically logs:
@ api ({ httpMethod: "GET" })
async findById ( id : number ) {
// Automatic log:
// [DEBUG] api: GET UserModel.findById
}
Logs are recorded through LogTape, with categories in [model:user] or [frame:auth] format.
Examples
Basic CRUD
Caching
Access Control
Custom Paths
class UserModelClass extends BaseModelClass {
@ api ({ httpMethod: "GET" })
async list ( params : UserListParams ) {
const rdb = this . getPuri ( "r" );
return rdb . table ( "users" )
. where ( "deleted_at" , null )
. paginate ( params );
}
@ api ({ httpMethod: "GET" })
async findById ( id : number ) {
const rdb = this . getPuri ( "r" );
return rdb . table ( "users" ). where ( "id" , id ). first ();
}
@ api ({ httpMethod: "POST" })
@ transactional ()
async create ( data : UserCreateParams ) {
const wdb = this . getDB ( "w" );
return this . insert ( wdb , data );
}
@ api ({ httpMethod: "PUT" })
@ transactional ()
async update ( id : number , data : UserUpdateParams ) {
const wdb = this . getDB ( "w" );
return this . upsert ( wdb , { id , ... data });
}
@ api ({ httpMethod: "DELETE" })
@ transactional ()
async delete ( id : number ) {
const wdb = this . getDB ( "w" );
return wdb . table ( "users" )
. where ( "id" , id )
. update ({ deleted_at: new Date () });
}
}
class ProductModelClass extends BaseModelClass {
@ api ({ httpMethod: "GET" })
@ cache ({ ttl: "1h" , tags: [ "products" ] })
async featured () {
// Cached for 1 hour
const rdb = this . getPuri ( "r" );
return rdb . table ( "products" )
. where ( "featured" , true )
. limit ( 10 );
}
@ api ({ httpMethod: "GET" })
@ cache ({ ttl: "5m" })
async findById ( id : number ) {
// Cached for 5 minutes
const rdb = this . getPuri ( "r" );
return rdb . table ( "products" ). where ( "id" , id ). first ();
}
@ api ({ httpMethod: "POST" })
@ transactional ()
async update ( id : number , data : ProductUpdateParams ) {
const wdb = this . getDB ( "w" );
const result = await this . upsert ( wdb , { id , ... data });
// Invalidate cache
await Sonamu . cache . deleteByTags ([ "products" ]);
return result ;
}
}
class AdminFrameClass extends BaseFrameClass {
@ api ({
httpMethod: "GET" ,
guards: [ "admin" ]
})
async dashboard () {
// Admin only
return {
users: await UserModel . count (),
orders: await OrderModel . count ()
};
}
@ api ({
httpMethod: "POST" ,
guards: [ "admin" ]
})
@ transactional ()
async banUser ( userId : number ) {
// Admin only
const wdb = UserModel . getDB ( "w" );
return wdb . table ( "users" )
. where ( "id" , userId )
. update ({ banned_at: new Date () });
}
}
class UserFrameClass extends BaseFrameClass {
@ api ({
httpMethod: "GET" ,
guards: [ "user" ]
})
async profile () {
// Authenticated users only
const { user } = Sonamu . getContext ();
return UserModel . findById ( user . id );
}
}
class ApiFrameClass extends BaseFrameClass {
@ api ({
path: "/api/v1/health" ,
httpMethod: "GET"
})
async health () {
return { status: "ok" };
}
@ api ({
path: "/api/v1/users/:id/profile" ,
httpMethod: "GET"
})
async userProfile ( id : number ) {
return UserModel . findById ( id );
}
@ api ({
path: "/webhooks/stripe" ,
httpMethod: "POST" ,
contentType: "application/json"
})
async stripeWebhook ( payload : StripePayload ) {
// Handle Stripe webhook
}
}
Next Steps