메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
@api λ°μ½”λ ˆμ΄ν„°λŠ” Model λ˜λŠ” Frame 클래슀의 λ©”μ„œλ“œλ₯Ό HTTP API μ—”λ“œν¬μΈνŠΈλ‘œ λ…ΈμΆœν•©λ‹ˆλ‹€.

κΈ°λ³Έ μ‚¬μš©λ²•

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

μ˜΅μ…˜

httpMethod

HTTP λ©”μ„œλ“œλ₯Ό μ§€μ •ν•©λ‹ˆλ‹€.
type HTTPMethods = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS";
κΈ°λ³Έκ°’: "GET"
@api({ httpMethod: "POST" })
async create(data: CreateParams) {
  // POST μš”μ²­μœΌλ‘œ λ…ΈμΆœλ©λ‹ˆλ‹€
}

@api({ httpMethod: "DELETE" })
async remove(id: number) {
  // DELETE μš”μ²­μœΌλ‘œ λ…ΈμΆœλ©λ‹ˆλ‹€
}

path

API μ—”λ“œν¬μΈνŠΈ 경둜λ₯Ό μ§€μ •ν•©λ‹ˆλ‹€. κΈ°λ³Έκ°’: /{modelName}/{methodName} (camelCase)
@api({ path: "/api/v1/users/profile" })
async getProfile() {
  // /api/v1/users/profile둜 μ ‘κ·Ό
}

@api()
async findById(id: number) {
  // 기본 경둜: /user/findById
}
κ²½λ‘œλŠ” μžλ™μœΌλ‘œ camelCase둜 λ³€ν™˜λ©λ‹ˆλ‹€. UserModel.findById β†’ /user/findById

contentType

μ‘λ‹΅μ˜ Content-Type을 μ§€μ •ν•©λ‹ˆλ‹€. κΈ°λ³Έκ°’: "application/json"
type ContentType =
  | "text/plain"
  | "text/html"
  | "text/xml"
  | "application/json"
  | "application/octet-stream";
// CSV 파일 λ‹€μš΄λ‘œλ“œ
@api({ contentType: "text/plain" })
async exportCsv() {
  return "id,name,email\n1,Alice,alice@example.com";
}

// HTML λ Œλ”λ§
@api({ contentType: "text/html" })
async renderTemplate() {
  return "<html><body>Hello</body></html>";
}

// λ°”μ΄λ„ˆλ¦¬ 파일 λ‹€μš΄λ‘œλ“œ (이미지, PDF λ“±)
@api({ contentType: "application/octet-stream" })
async downloadFile(fileId: number, ctx: Context) {
  // 파일 쑰회
  const file = await this.getPuri("r")
    .table("files")
    .where("id", fileId)
    .first();

  if (!file) {
    throw new NotFoundError("νŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€");
  }

  // Storageμ—μ„œ 파일 읽기
  const disk = Sonamu.storage.use();
  const buffer = await disk.get(file.path);

  // Content-Disposition ν—€λ”λ‘œ λ‹€μš΄λ‘œλ“œ 파일λͺ… μ§€μ •
  ctx.reply.header(
    "Content-Disposition",
    `attachment; filename="${encodeURIComponent(file.original_name)}"`
  );

  return buffer;
}
contentType μ‚¬μš© μΌ€μ΄μŠ€:
Content-Typeμ‚¬μš© μΌ€μ΄μŠ€λ°˜ν™˜ νƒ€μž…
text/plainCSV, TXT 파일 λ‹€μš΄λ‘œλ“œstring
text/htmlHTML λ Œλ”λ§ (SSR)string
text/xmlXML 데이터 λ°˜ν™˜string
application/jsonJSON 데이터 (κΈ°λ³Έκ°’)object
application/octet-streamλ°”μ΄λ„ˆλ¦¬ 파일 (이미지, PDF, ZIP λ“±)Buffer or Uint8Array
application/octet-stream μ‚¬μš© μ‹œ μ£Όμ˜μ‚¬ν•­: - λ°˜λ“œμ‹œ Buffer λ˜λŠ” Uint8Arrayλ₯Ό λ°˜ν™˜ν•΄μ•Ό ν•©λ‹ˆλ‹€ - Content-Disposition ν—€λ”λ‘œ λ‹€μš΄λ‘œλ“œ 파일λͺ…을 μ§€μ •ν•  수 μžˆμŠ΅λ‹ˆλ‹€ - 파일λͺ…에 ν•œκΈ€μ΄ ν¬ν•¨λœ 경우 encodeURIComponent()둜 μΈμ½”λ”©ν•˜μ„Έμš” - λŒ€μš©λŸ‰ νŒŒμΌμ€ 슀트리밍 방식을 κ³ λ €ν•˜μ„Έμš”

clients

생성할 ν΄λΌμ΄μ–ΈνŠΈ νƒ€μž…μ„ μ§€μ •ν•©λ‹ˆλ‹€. κΈ°λ³Έκ°’: ["axios"]
type ServiceClient =
  | "axios" // Axios ν΄λΌμ΄μ–ΈνŠΈ
  | "axios-multipart" // Multipart form-data
  | "tanstack-query" // TanStack Query (읽기)
  | "tanstack-mutation" // TanStack Mutation (μ“°κΈ°)
  | "tanstack-mutation-multipart" // TanStack Mutation (파일 μ—…λ‘œλ“œ)
  | "window-fetch"; // Native Fetch API
@api({
  httpMethod: "GET",
  clients: ["axios", "tanstack-query"]
})
async list() {
  // axios와 TanStack Query ν΄λΌμ΄μ–ΈνŠΈ 생성
}

@api({
  httpMethod: "POST",
  clients: ["axios", "tanstack-mutation"]
})
async create(data: CreateParams) {
  // axios와 TanStack Mutation ν΄λΌμ΄μ–ΈνŠΈ 생성
}

guards

API μ ‘κ·Ό κΆŒν•œμ„ μ§€μ •ν•©λ‹ˆλ‹€.
type GuardKey = "query" | "admin" | "user";
@api({ guards: ["admin"] })
async deleteUser(id: number) {
  // κ΄€λ¦¬μžλ§Œ μ ‘κ·Ό κ°€λŠ₯
}

@api({ guards: ["user"] })
async getProfile() {
  // λ‘œκ·ΈμΈν•œ μ‚¬μš©μžλ§Œ μ ‘κ·Ό κ°€λŠ₯
}

@api({ guards: ["query", "admin"] })
async search(query: string) {
  // query λ˜λŠ” admin κΆŒν•œ ν•„μš”
}

description

API μ„€λͺ…을 μΆ”κ°€ν•©λ‹ˆλ‹€. μƒμ„±λœ νƒ€μž…κ³Ό λ¬Έμ„œμ— ν¬ν•¨λ©λ‹ˆλ‹€.
@api({
  description: "μ‚¬μš©μž ID둜 ν”„λ‘œν•„μ„ μ‘°νšŒν•©λ‹ˆλ‹€."
})
async findById(id: number) {
  // ...
}

resourceName

μƒμ„±λ˜λŠ” μ„œλΉ„μŠ€ 파일의 λ¦¬μ†ŒμŠ€ 이름을 μ§€μ •ν•©λ‹ˆλ‹€.
@api({ resourceName: "Users" })
async list() {
  // UsersService.ts νŒŒμΌμ— ν¬ν•¨λ©λ‹ˆλ‹€
}

timeout

API μš”μ²­μ˜ νƒ€μž„μ•„μ›ƒμ„ λ°€λ¦¬μ΄ˆ λ‹¨μœ„λ‘œ μ§€μ •ν•©λ‹ˆλ‹€.
@api({ timeout: 30000 })  // 30초
async heavyOperation() {
  // 였래 κ±Έλ¦¬λŠ” μž‘μ—…
}

cacheControl

μ‘λ‹΅μ˜ Cache-Control 헀더λ₯Ό μ„€μ •ν•©λ‹ˆλ‹€.
@api({
  cacheControl: {
    maxAge: "10m",        // 10λΆ„ 캐싱
    sMaxAge: "1h",        // CDNμ—μ„œ 1μ‹œκ°„ 캐싱
    public: true
  }
})
async getPublicData() {
  // Cache-Control: public, max-age=600, s-maxage=3600
}
CacheControlConfig νƒ€μž…:
type CacheControlConfig = {
  maxAge?: string; // λΈŒλΌμš°μ € μΊμ‹œ μ‹œκ°„
  sMaxAge?: string; // CDN/ν”„λ‘μ‹œ μΊμ‹œ μ‹œκ°„
  public?: boolean; // public/private
  noCache?: boolean; // no-cache
  noStore?: boolean; // no-store
  mustRevalidate?: boolean;
};
μ‹œκ°„ ν‘œκΈ°: "10s", "5m", "1h", "1d" ν˜•μ‹ 지원

compress

응닡 μ••μΆ• 섀정을 μ§€μ •ν•©λ‹ˆλ‹€.
@api({
  compress: {
    threshold: 1024,      // 1KB μ΄μƒλ§Œ μ••μΆ•
    level: 6              // μ••μΆ• 레벨 (0-9)
  }
})
async getLargeData() {
  // 큰 데이터 λ°˜ν™˜
}

@api({ compress: false })  // μ••μΆ• λΉ„ν™œμ„±ν™”
async getSmallData() {
  // μž‘μ€ λ°μ΄ν„°λŠ” μ••μΆ•ν•˜μ§€ μ•ŠμŒ
}

전체 μ˜΅μ…˜ μ˜ˆμ‹œ

@api({
  httpMethod: "POST",
  path: "/api/v1/users/search",
  contentType: "application/json",
  clients: ["axios", "tanstack-query"],
  guards: ["user"],
  description: "μ‚¬μš©μž 검색 API",
  resourceName: "Users",
  timeout: 5000,
  cacheControl: {
    maxAge: "5m",
    public: true
  },
  compress: {
    threshold: 1024,
    level: 6
  }
})
async search(params: SearchParams) {
  // κ΅¬ν˜„
}

경둜 생성 κ·œμΉ™

Model 클래슀

class UserModelClass extends BaseModelClass {
  @api()
  async findById(id: number) {}
  // 경둜: /user/findById
}

class PostModelClass extends BaseModelClass {
  @api()
  async getComments() {}
  // 경둜: /post/getComments
}
κ·œμΉ™:
  1. 클래슀 μ΄λ¦„μ—μ„œ β€œModelClass” 제거
  2. λ‚˜λ¨Έμ§€λ₯Ό camelCase둜 λ³€ν™˜
  3. /{modelName}/{methodName} ν˜•μ‹

Frame 클래슀

class AuthFrameClass extends BaseFrameClass {
  @api()
  async login() {}
  // 경둜: /auth/login
}
κ·œμΉ™:
  1. 클래슀 μ΄λ¦„μ—μ„œ β€œFrameClass” 제거
  2. λ‚˜λ¨Έμ§€λ₯Ό camelCase둜 λ³€ν™˜
  3. /{frameName}/{methodName} ν˜•μ‹

λ‹€λ₯Έ λ°μ½”λ ˆμ΄ν„°μ™€ ν•¨κ»˜ μ‚¬μš©

@transactional

@api({ httpMethod: "POST" })
@transactional()
async save(data: UserSaveParams) {
  const wdb = this.getDB("w");
  // νŠΈλžœμž­μ…˜ λ‚΄μ—μ„œ μ‹€ν–‰
  return this.upsert(wdb, data);
}
@apiλ₯Ό λ¨Όμ €, @transactional을 λ‚˜μ€‘μ— μž‘μ„±ν•˜μ„Έμš”.

@cache

@api({ httpMethod: "GET" })
@cache({ ttl: "10m" })
async findById(id: number) {
  // 10λΆ„κ°„ κ²°κ³Ό 캐싱
  const rdb = this.getPuri("r");
  return rdb.table("users").where("id", id).first();
}

@upload

@upload은 @api 없이 λ…λ¦½μ μœΌλ‘œ μ‚¬μš©ν•©λ‹ˆλ‹€.
@upload()
async uploadAvatar() {
  const { files } = Sonamu.getContext();
  const file = files?.[0]; // 첫 번째 파일 μ‚¬μš©
  // 파일 처리
}

μ œμ•½μ‚¬ν•­

1. λ‹€λ₯Έ λΌμš°νŒ… λ°μ½”λ ˆμ΄ν„°(@stream, @websocket, @upload)와 쀑볡 μ‚¬μš© λΆˆκ°€

같은 λ©”μ„œλ“œμ— @api, @stream, @websocket, @upload 쀑 λ‘˜ 이상을 ν•¨κ»˜ μ‚¬μš©ν•  수 μ—†μŠ΅λ‹ˆλ‹€.
// ❌ μ—λŸ¬ λ°œμƒ
@api()
@stream({ type: "sse", events: EventSchema })
async subscribe() {}
μ—λŸ¬ λ©”μ‹œμ§€:
@api decorator can only be used once on UserModel.subscribe.
You can use only one of @api, @stream, @websocket, or @upload decorator on the same method.

2. 같은 λ©”μ„œλ“œμ— μ—¬λŸ¬ 번 μ‚¬μš© μ‹œ

μ—¬λŸ¬ 번 μ‚¬μš©ν•˜λ©΄ λ§ˆμ§€λ§‰ 것이 μš°μ„ λ˜λ©°, μΆ©λŒν•˜λŠ” μ˜΅μ…˜μ΄ 있으면 μ—λŸ¬ λ°œμƒ:
// ❌ μ—λŸ¬ - path 좩돌
@api({ path: "/users/list" })
@api({ path: "/users/all" })
async list() {}
μ—λŸ¬ λ©”μ‹œμ§€:
@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. Model/Frame ν΄λž˜μŠ€μ—μ„œλ§Œ μ‚¬μš© κ°€λŠ₯

// ❌ 일반 ν΄λž˜μŠ€μ—μ„œλŠ” μ‚¬μš© λΆˆκ°€
class UtilClass {
  @api()
  async helper() {}
  // μ—λŸ¬: modelName is required
}

// βœ… BaseModelClass 상속 ν•„μš”
class UserModelClass extends BaseModelClass {
  @api()
  async findById(id: number) {}
}

μƒμ„±λ˜λŠ” μ½”λ“œ

@api λ°μ½”λ ˆμ΄ν„°λ₯Ό μ‚¬μš©ν•˜λ©΄ λ‹€μŒμ΄ μžλ™ μƒμ„±λ©λ‹ˆλ‹€:

1. API 라우트 등둝

// Fastify 라우트 μžλ™ 등둝
fastify.get("/user/findById", async (request, reply) => {
  // νŒŒλΌλ―Έν„° 검증 및 처리
  const result = await UserModel.findById(subset, id);
  return result;
});

2. νƒ€μž… μ •μ˜ 생성

// api/src/application/services/UserService.types.ts
export type UserFindByIdParams = {
  subset: UserSubsetKey;
  id: number;
};

export type UserFindByIdResult = UserSubsetMapping[UserSubsetKey];

3. ν΄λΌμ΄μ–ΈνŠΈ μ½”λ“œ 생성

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

λ‘œκΉ…

@api λ°μ½”λ ˆμ΄ν„°λŠ” μžλ™μœΌλ‘œ 둜그λ₯Ό λ‚¨κΉλ‹ˆλ‹€:
@api({ httpMethod: "GET" })
async findById(id: number) {
  // μžλ™ 둜그:
  // [DEBUG] api: GET UserModel.findById
}
λ‘œκ·ΈλŠ” LogTapeλ₯Ό 톡해 기둝되며, μΉ΄ν…Œκ³ λ¦¬λŠ” [model:user] λ˜λŠ” [frame:auth] ν˜•μ‹μž…λ‹ˆλ‹€.

μ˜ˆμ‹œ λͺ¨μŒ

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

λ‹€μŒ 단계

@stream

SSE 슀트리밍 API λ§Œλ“€κΈ°

@transactional

λ°μ΄ν„°λ² μ΄μŠ€ νŠΈλžœμž­μ…˜ μ‚¬μš©ν•˜κΈ°

@upload

파일 μ—…λ‘œλ“œ API λ§Œλ“€κΈ°

@cache

λ©”μ„œλ“œ κ²°κ³Ό μΊμ‹±ν•˜κΈ°