Skip to main content
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\n1,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-TypeUse CaseReturn Type
text/plainCSV, TXT file downloadstring
text/htmlHTML rendering (SSR)string
text/xmlXML data responsestring
application/jsonJSON data (default)object
application/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:
  1. Remove “ModelClass” from class name
  2. Convert the rest to camelCase
  3. Format: /{modelName}/{methodName}

Frame Classes

class AuthFrameClass extends BaseFrameClass {
  @api()
  async login() {}
  // Path: /auth/login
}
Rules:
  1. Remove “FrameClass” from class name
  2. Convert the rest to camelCase
  3. 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

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() });
  }
}

Next Steps