๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
@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";
@api({ contentType: "text/plain" })
async exportCsv() {
  return "id,name,email\n1,Alice,[email protected]";
}

@api({ contentType: "text/html" })
async renderTemplate() {
  return "<html><body>Hello</body></html>";
}

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

@api({ httpMethod: "POST" })
@upload({ mode: "single" })
async uploadAvatar() {
  const { file } = Sonamu.getUploadContext();
  // ํŒŒ์ผ ์ฒ˜๋ฆฌ
}

์ œ์•ฝ์‚ฌํ•ญ

1. @stream๊ณผ ์ค‘๋ณต ์‚ฌ์šฉ ๋ถˆ๊ฐ€

// โŒ ์—๋Ÿฌ ๋ฐœ์ƒ
@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 or @stream 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() });
  }
}

๋‹ค์Œ ๋‹จ๊ณ„