@api decorator automatically transforms Model class methods into HTTP API endpoints.
Decorator Overview
Automatic Routing
Transform methods to APIs Auto-generate URLs
Type Safety
Parameter type validation Compile-time checks
HTTP Methods
GET, POST, PUT, DELETE RESTful API support
Error Handling
Automatic error conversion Consistent response format
Basic Usage
Simplest Form
import { BaseModelClass, api } from "sonamu";
import type { UserSubsetKey, UserSubsetMapping } from "../sonamu.generated";
import { userLoaderQueries, userSubsetQueries } from "../sonamu.generated.sso";
class UserModelClass extends BaseModelClass<
UserSubsetKey,
UserSubsetMapping,
typeof userSubsetQueries,
typeof userLoaderQueries
> {
constructor() {
super("User", userSubsetQueries, userLoaderQueries);
}
@api({ httpMethod: "GET" })
async getUser(id: number): Promise<User> {
const rdb = this.getPuri("r");
const user = await rdb.table("users").where("id", id).first();
if (!user) {
throw new Error("User not found");
}
return user;
}
}
export const UserModel = new UserModelClass();
- URL:
GET /api/user/getUser - Parameters:
{ id: number } - Response:
Userobject
Specifying HTTP Methods
class UserModelClass extends BaseModelClass<
UserSubsetKey,
UserSubsetMapping,
typeof userSubsetQueries,
typeof userLoaderQueries
> {
constructor() {
super("User", userSubsetQueries, userLoaderQueries);
}
// GET request
@api({ httpMethod: "GET" })
async list(): Promise<User[]> {
const rdb = this.getPuri("r");
return rdb.table("users").select("*");
}
// POST request
@api({ httpMethod: "POST" })
async create(params: UserSaveParams): Promise<{ userId: number }> {
const wdb = this.getPuri("w");
const [userId] = await wdb.table("users").insert(params).returning("id");
return { userId: userId.id };
}
// PUT request
@api({ httpMethod: "PUT" })
async update(id: number, params: Partial<UserSaveParams>): Promise<void> {
const wdb = this.getPuri("w");
await wdb.table("users").where("id", id).update(params);
}
// DELETE request
@api({ httpMethod: "DELETE" })
async remove(id: number): Promise<void> {
const wdb = this.getPuri("w");
await wdb.table("users").where("id", id).delete();
}
}
export const UserModel = new UserModelClass();
API Routing Rules
URL Generation Pattern
class UserModelClass extends BaseModelClass<
UserSubsetKey,
UserSubsetMapping,
typeof userSubsetQueries,
typeof userLoaderQueries
> {
constructor() {
super("User", userSubsetQueries, userLoaderQueries); // ← Model name used in URL
}
@api({ httpMethod: "GET" })
async getProfile(userId: number) {
// URL: GET /api/user/getProfile
// ...
}
@api({ httpMethod: "POST" })
async register(params: RegisterParams) {
// URL: POST /api/user/register
// ...
}
}
export const UserModel = new UserModelClass();
URL Rules: - Base path:
/api/{modelName}/{methodName} - modelName is converted to lowercase- Example:
UserModel.getProfile→/api/user/getProfile
Parameter Handling
Single Parameter
class UserModelClass extends BaseModelClass<
UserSubsetKey,
UserSubsetMapping,
typeof userSubsetQueries,
typeof userLoaderQueries
> {
constructor() {
super("User", userSubsetQueries, userLoaderQueries);
}
// Number parameter
@api({ httpMethod: "GET" })
async getUser(id: number): Promise<User> {
// GET /api/user/getUser?id=1
// ...
}
// String parameter
@api({ httpMethod: "GET" })
async findByEmail(email: string): Promise<User | null> {
// GET /api/user/findByEmail?email=test@example.com
// ...
}
// Boolean parameter
@api({ httpMethod: "GET" })
async listActive(active: boolean): Promise<User[]> {
// GET /api/user/listActive?active=true
// ...
}
}
export const UserModel = new UserModelClass();
Complex Parameters (Objects)
interface UserListParams {
page?: number;
pageSize?: number;
search?: string;
role?: UserRole;
}
class UserModelClass extends BaseModelClass<
UserSubsetKey,
UserSubsetMapping,
typeof userSubsetQueries,
typeof userLoaderQueries
> {
constructor() {
super("User", userSubsetQueries, userLoaderQueries);
}
@api({ httpMethod: "GET" })
async list(params: UserListParams): Promise<{
users: User[];
total: number;
}> {
const rdb = this.getPuri("r");
let query = rdb.table("users");
if (params.search) {
query = query.where("username", "like", `%${params.search}%`);
}
if (params.role) {
query = query.where("role", params.role);
}
const page = params.page || 1;
const pageSize = params.pageSize || 20;
const users = await query
.limit(pageSize)
.offset((page - 1) * pageSize)
.select("*");
const [{ count }] = await rdb.table("users").count({ count: "*" });
return { users, total: count };
}
}
// Call: GET /api/user/list?page=1&pageSize=20&search=john&role=admin
Multiple Parameters
class OrderModelClass extends BaseModelClass<
OrderSubsetKey,
OrderSubsetMapping,
typeof orderSubsetQueries,
typeof orderLoaderQueries
> {
constructor() {
super("Order", orderSubsetQueries, orderLoaderQueries);
}
@api({ httpMethod: "POST" })
async createOrder(
userId: number,
items: OrderItem[],
shippingAddress: string,
): Promise<{ orderId: number }> {
// POST /api/order/createOrder
// Body: { userId: 1, items: [...], shippingAddress: "..." }
const wdb = this.getPuri("w");
const [order] = await wdb
.table("orders")
.insert({
user_id: userId,
shipping_address: shippingAddress,
status: "pending",
})
.returning("id");
// Save order items
for (const item of items) {
await wdb.table("order_items").insert({
order_id: order.id,
product_id: item.productId,
quantity: item.quantity,
});
}
return { orderId: order.id };
}
}
Return Types
Basic Types
class UserModelClass extends BaseModelClass<
UserSubsetKey,
UserSubsetMapping,
typeof userSubsetQueries,
typeof userLoaderQueries
> {
constructor() {
super("User", userSubsetQueries, userLoaderQueries);
}
// Return object
@api({ httpMethod: "GET" })
async getUser(id: number): Promise<User> {
// ...
return user;
}
// Return array
@api({ httpMethod: "GET" })
async list(): Promise<User[]> {
// ...
return users;
}
// void (no response)
@api({ httpMethod: "DELETE" })
async remove(id: number): Promise<void> {
// ...
// Empty response on success
}
// Return number
@api({ httpMethod: "GET" })
async count(): Promise<number> {
// ...
return count;
}
}
Structured Response
class UserModelClass extends BaseModelClass<
UserSubsetKey,
UserSubsetMapping,
typeof userSubsetQueries,
typeof userLoaderQueries
> {
constructor() {
super("User", userSubsetQueries, userLoaderQueries);
}
@api({ httpMethod: "GET" })
async list(params: UserListParams): Promise<{
data: User[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
}> {
const rdb = this.getPuri("r");
const page = params.page || 1;
const pageSize = params.pageSize || 20;
const users = await rdb
.table("users")
.limit(pageSize)
.offset((page - 1) * pageSize)
.select("*");
const [{ count }] = await rdb.table("users").count({ count: "*" });
return {
data: users,
pagination: {
page,
pageSize,
total: count,
totalPages: Math.ceil(count / pageSize),
},
};
}
}
Decorator Combinations
@api + @transactional
class UserModelClass extends BaseModelClass<
UserSubsetKey,
UserSubsetMapping,
typeof userSubsetQueries,
typeof userLoaderQueries
> {
constructor() {
super("User", userSubsetQueries, userLoaderQueries);
}
// Order doesn't matter
@api({ httpMethod: "POST" })
@transactional()
async register(params: RegisterParams): Promise<{ userId: number }> {
const wdb = this.getPuri("w");
// Check duplicates
const existing = await wdb.table("users").where("email", params.email).first();
if (existing) {
throw new Error("Email already exists");
}
// Create User
wdb.ubRegister("users", {
email: params.email,
username: params.username,
password: params.password,
role: "normal",
});
const [userId] = await wdb.ubUpsert("users");
return { userId };
}
// Reverse order also works
@transactional()
@api({ httpMethod: "PUT" })
async updateProfile(userId: number, params: ProfileParams): Promise<void> {
const wdb = this.getPuri("w");
await wdb.table("users").where("id", userId).update({
bio: params.bio,
avatar_url: params.avatarUrl,
});
}
}
@api + cacheControl/compress Options
You can control caching and compression of API responses directly from the@api decorator.
Cache-Control Configuration
class PostModelClass extends BaseModelClass<
PostSubsetKey,
PostSubsetMapping,
typeof postSubsetQueries,
typeof postLoaderQueries
> {
constructor() {
super("Post", postSubsetQueries, postLoaderQueries);
}
// Public post list - 1 hour cache
@api({
httpMethod: "GET",
cacheControl: {
maxAge: 3600, // Browser cache: 1 hour
sMaxAge: 7200, // CDN cache: 2 hours
staleWhileRevalidate: 86400, // Allow stale state for 24 hours
},
})
async getPublicPosts(): Promise<Post[]> {
const rdb = this.getPuri("r");
return rdb.table("posts").where("public", true).select("*");
}
// Using cache presets
@api({
httpMethod: "GET",
cacheControl: "5m", // 5 minute cache
})
async getTrendingPosts(): Promise<Post[]> {
// ...
}
}
maxAge: Browser cache duration (seconds)sMaxAge: CDN/proxy cache duration (seconds)staleWhileRevalidate: Stale state allowance duration (seconds)public: Public cache flag (default: true)private: Private cache (per-user)
- Time unit strings like
"1m","5m","1h","1d"can be used
Compression Configuration
class DataModelClass extends BaseModelClass<
DataSubsetKey,
DataSubsetMapping,
typeof dataSubsetQueries,
typeof dataLoaderQueries
> {
constructor() {
super("Data", dataSubsetQueries, dataLoaderQueries);
}
// Large dataset - enable compression
@api({
httpMethod: "GET",
compress: true, // Enable gzip/deflate compression
})
async getLargeDataset(): Promise<DataPoint[]> {
const rdb = this.getPuri("r");
return rdb.table("data_points").limit(10000).select("*");
}
// Already compressed data - disable compression
@api({
httpMethod: "GET",
compress: false, // Disable compression
})
async getCompressedFile(): Promise<Buffer> {
// Return already compressed file
// ...
}
}
true: Compress response with gzip/deflatefalse: Disable compression (default)
Combined Usage
class ApiModelClass extends BaseModelClass<
ApiSubsetKey,
ApiSubsetMapping,
typeof apiSubsetQueries,
typeof apiLoaderQueries
> {
constructor() {
super("Api", apiSubsetQueries, apiLoaderQueries);
}
// Caching + compression
@api({
httpMethod: "GET",
cacheControl: { maxAge: 3600, sMaxAge: 7200 },
compress: true,
})
async getReport(reportId: number): Promise<Report> {
const rdb = this.getPuri("r");
// Large report data
const report = await rdb.table("reports").where("id", reportId).first();
if (!report) {
throw new Error("Report not found");
}
return report;
}
}
class OptimizedApiModelClass extends BaseModelClass<
OptimizedApiSubsetKey,
OptimizedApiSubsetMapping,
typeof optimizedApiSubsetQueries,
typeof optimizedApiLoaderQueries
> {
constructor() {
super("OptimizedApi", optimizedApiSubsetQueries, optimizedApiLoaderQueries);
}
// Static content: long cache + compression
@api({
httpMethod: "GET",
cacheControl: { maxAge: 86400, sMaxAge: 604800 }, // 1 day / 1 week
compress: true,
})
async getStaticContent(): Promise<Content> {
// ...
}
// Dynamic content: short cache + compression
@api({
httpMethod: "GET",
cacheControl: "5m",
compress: true,
})
async getDynamicData(): Promise<Data[]> {
// ...
}
// Private data: no cache (private)
@api({
httpMethod: "GET",
cacheControl: { private: true, maxAge: 0 },
})
async getUserPrivateData(userId: number): Promise<PrivateData> {
// ...
}
// Large download: compression only
@api({
httpMethod: "GET",
compress: true,
})
async downloadLargeFile(fileId: string): Promise<Buffer> {
// ...
}
}
Performance Tips:
- Use
cacheControlfor static or infrequently changing data - Use
compress: truefor responses larger than 10KB - Use
{ private: true, maxAge: 0 }to prevent caching of private data
Caution: -
compress: true increases CPU usage (inefficient for small responses) - Excessively
long cache durations can delay update propagation - private: true disables CDN cachingError Handling
Automatic Error Conversion
class UserModelClass extends BaseModelClass<
UserSubsetKey,
UserSubsetMapping,
typeof userSubsetQueries,
typeof userLoaderQueries
> {
constructor() {
super("User", userSubsetQueries, userLoaderQueries);
}
@api({ httpMethod: "GET" })
async getUser(id: number): Promise<User> {
const rdb = this.getPuri("r");
const user = await rdb.table("users").where("id", id).first();
if (!user) {
// Error object → HTTP 500
throw new Error("User not found");
}
return user;
}
@api({ httpMethod: "POST" })
async create(params: UserSaveParams): Promise<{ userId: number }> {
const wdb = this.getPuri("w");
try {
const [userId] = await wdb.table("users").insert(params).returning("id");
return { userId: userId.id };
} catch (error) {
// DB error → HTTP 500
throw new Error("Failed to create user");
}
}
}
By default, all errors are converted to HTTP 500. For custom error handling, you need to implement
a separate error handler.
Practical Examples
CRUD API
interface UserSaveParams {
email: string;
username: string;
password: string;
role: UserRole;
}
interface UserListParams {
page?: number;
pageSize?: number;
search?: string;
}
class UserModelClass extends BaseModelClass<
UserSubsetKey,
UserSubsetMapping,
typeof userSubsetQueries,
typeof userLoaderQueries
> {
constructor() {
super("User", userSubsetQueries, userLoaderQueries);
}
// Create
@api({ httpMethod: "POST" })
@transactional()
async create(params: UserSaveParams): Promise<{ userId: number }> {
const wdb = this.getPuri("w");
wdb.ubRegister("users", params);
const [userId] = await wdb.ubUpsert("users");
return { userId };
}
// Read (single)
@api({ httpMethod: "GET" })
async get(id: number): Promise<User> {
const rdb = this.getPuri("r");
const user = await rdb.table("users").where("id", id).first();
if (!user) {
throw new Error("User not found");
}
return user;
}
// Read (list)
@api({ httpMethod: "GET" })
async list(params: UserListParams): Promise<{
users: User[];
total: number;
}> {
const rdb = this.getPuri("r");
let query = rdb.table("users");
if (params.search) {
query = query.where("username", "like", `%${params.search}%`);
}
const page = params.page || 1;
const pageSize = params.pageSize || 20;
const users = await query
.limit(pageSize)
.offset((page - 1) * pageSize)
.select("*");
const [{ count }] = await rdb.table("users").count({ count: "*" });
return { users, total: count };
}
// Update
@api({ httpMethod: "PUT" })
@transactional()
async update(id: number, params: Partial<UserSaveParams>): Promise<void> {
const wdb = this.getPuri("w");
await wdb.table("users").where("id", id).update(params);
}
// Delete
@api({ httpMethod: "DELETE" })
@transactional()
async remove(id: number): Promise<void> {
const wdb = this.getPuri("w");
await wdb.table("users").where("id", id).delete();
}
}
Complex Business Logic
class OrderModelClass extends BaseModelClass<
OrderSubsetKey,
OrderSubsetMapping,
typeof orderSubsetQueries,
typeof orderLoaderQueries
> {
constructor() {
super("Order", orderSubsetQueries, orderLoaderQueries);
}
@api({ httpMethod: "POST" })
@transactional()
async placeOrder(params: {
userId: number;
items: Array<{ productId: number; quantity: number }>;
shippingAddress: string;
paymentMethod: string;
}): Promise<{
orderId: number;
totalAmount: number;
}> {
const wdb = this.getPuri("w");
// 1. Check inventory
for (const item of params.items) {
const product = await wdb.table("products").where("id", item.productId).first();
if (!product || product.stock < item.quantity) {
throw new Error(`Insufficient stock for product ${item.productId}`);
}
}
// 2. Calculate total
let totalAmount = 0;
for (const item of params.items) {
const product = await wdb.table("products").where("id", item.productId).first();
totalAmount += product.price * item.quantity;
}
// 3. Create order
const [order] = await wdb
.table("orders")
.insert({
user_id: params.userId,
total_amount: totalAmount,
shipping_address: params.shippingAddress,
payment_method: params.paymentMethod,
status: "pending",
})
.returning("id");
// 4. Create order items
for (const item of params.items) {
await wdb.table("order_items").insert({
order_id: order.id,
product_id: item.productId,
quantity: item.quantity,
});
// Deduct inventory
await wdb.table("products").where("id", item.productId).decrement("stock", item.quantity);
}
return {
orderId: order.id,
totalAmount,
};
}
}
Type Safety
Parameter Type Validation
interface CreateUserParams {
email: string;
username: string;
password: string;
role: "admin" | "normal";
}
class UserModelClass extends BaseModelClass<
UserSubsetKey,
UserSubsetMapping,
typeof userSubsetQueries,
typeof userLoaderQueries
> {
constructor() {
super("User", userSubsetQueries, userLoaderQueries);
}
@api({ httpMethod: "POST" })
async create(params: CreateUserParams): Promise<{ userId: number }> {
// TypeScript validates params type
// params.email, params.username, etc. have autocomplete
const wdb = this.getPuri("w");
// ✅ OK if types match
const [userId] = await wdb
.table("users")
.insert({
email: params.email,
username: params.username,
password: params.password,
role: params.role,
})
.returning("id");
return { userId: userId.id };
}
}
// When calling:
// POST /api/user/create
// Body: {
// "email": "test@example.com",
// "username": "testuser",
// "password": "hashedpass",
// "role": "normal" // Only "admin" or "normal" allowed
// }
Explicit Return Types
class UserModelClass extends BaseModelClass<
UserSubsetKey,
UserSubsetMapping,
typeof userSubsetQueries,
typeof userLoaderQueries
> {
constructor() {
super("User", userSubsetQueries, userLoaderQueries);
}
// Explicit return type ensures type safety
@api({ httpMethod: "GET" })
async getUser(id: number): Promise<{
user: User;
profile: Profile | null;
}> {
const rdb = this.getPuri("r");
const user = await rdb.table("users").where("id", id).first();
if (!user) {
throw new Error("User not found");
}
const profile = await rdb.table("profiles").where("user_id", id).first();
// ✅ Return must match the declared structure
return {
user,
profile: profile || null,
};
}
}
Cautions
Cautions when using @api: 1. Only usable in Model classes 2. Method must be
async function
3. modelName property is required 4. Specifying parameter/return types is recommended 5.
Propagate errors with throwCommon Mistakes
// ❌ Wrong: No constructor and super() call
class UserModelClass extends BaseModelClass {
@api({ httpMethod: "GET" })
async getUser(id: number): Promise<User> {
// ...
}
}
// ❌ Wrong: Not async
class UserModelClass extends BaseModelClass<
UserSubsetKey,
UserSubsetMapping,
typeof userSubsetQueries,
typeof userLoaderQueries
> {
constructor() {
super("User", userSubsetQueries, userLoaderQueries);
}
@api({ httpMethod: "GET" })
getUser(id: number): User {
// ...
}
}
// ❌ Wrong: No type specified
class UserModelClass extends BaseModelClass<
UserSubsetKey,
UserSubsetMapping,
typeof userSubsetQueries,
typeof userLoaderQueries
> {
constructor() {
super("User", userSubsetQueries, userLoaderQueries);
}
@api({ httpMethod: "POST" })
async create(params): Promise<any> {
// params and return type are any
}
}
// ✅ Correct
class UserModelClass extends BaseModelClass<
UserSubsetKey,
UserSubsetMapping,
typeof userSubsetQueries,
typeof userLoaderQueries
> {
constructor() {
super("User", userSubsetQueries, userLoaderQueries);
}
@api({ httpMethod: "GET" })
async getUser(id: number): Promise<User> {
// ...
}
@api({ httpMethod: "POST" })
async create(params: UserSaveParams): Promise<{ userId: number }> {
// ...
}
}
export const UserModel = new UserModelClass();
Next Steps
HTTP Methods
GET, POST, PUT, DELETE details
Parameters
Type definitions and validation
Return Types
Defining response types
Error Handling
API error handling