> ## Documentation Index
> Fetch the complete documentation index at: https://sonamu.cartanova.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# @api

> API 엔드포인트 생성 데코레이터

`@api` 데코레이터는 Model 또는 Frame 클래스의 메서드를 HTTP API 엔드포인트로 노출합니다.

## 기본 사용법

```typescript theme={null}
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 메서드를 지정합니다.

```typescript theme={null}
type HTTPMethods = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS";
```

**기본값:** `"GET"`

```typescript theme={null}
@api({ httpMethod: "POST" })
async create(data: CreateParams) {
  // POST 요청으로 노출됩니다
}

@api({ httpMethod: "DELETE" })
async remove(id: number) {
  // DELETE 요청으로 노출됩니다
}
```

### path

API 엔드포인트 경로를 지정합니다.

**기본값:** `/{modelName}/{methodName}` (camelCase)

```typescript theme={null}
@api({ path: "/api/v1/users/profile" })
async getProfile() {
  // /api/v1/users/profile로 접근
}

@api()
async findById(id: number) {
  // 기본 경로: /user/findById
}
```

<Info>경로는 자동으로 camelCase로 변환됩니다. `UserModel.findById` → `/user/findById`</Info>

### contentType

응답의 Content-Type을 지정합니다.

**기본값:** `"application/json"`

```typescript theme={null}
type ContentType =
  | "text/plain"
  | "text/html"
  | "text/xml"
  | "application/json"
  | "application/octet-stream";
```

```typescript theme={null}
// 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/plain`               | CSV, TXT 파일 다운로드          | `string`                 |
| `text/html`                | HTML 렌더링 (SSR)            | `string`                 |
| `text/xml`                 | XML 데이터 반환                | `string`                 |
| `application/json`         | JSON 데이터 (기본값)            | `object`                 |
| `application/octet-stream` | 바이너리 파일 (이미지, PDF, ZIP 등) | `Buffer` or `Uint8Array` |

<Info>
  **application/octet-stream 사용 시 주의사항**: - 반드시 `Buffer` 또는 `Uint8Array`를 반환해야
  합니다 - `Content-Disposition` 헤더로 다운로드 파일명을 지정할 수 있습니다 - 파일명에 한글이
  포함된 경우 `encodeURIComponent()`로 인코딩하세요 - 대용량 파일은 스트리밍 방식을 고려하세요
</Info>

### clients

생성할 클라이언트 타입을 지정합니다.

**기본값:** `["axios"]`

```typescript theme={null}
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
```

```typescript theme={null}
@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 접근 권한을 지정합니다.

```typescript theme={null}
type GuardKey = "query" | "admin" | "user";
```

```typescript theme={null}
@api({ guards: ["admin"] })
async deleteUser(id: number) {
  // 관리자만 접근 가능
}

@api({ guards: ["user"] })
async getProfile() {
  // 로그인한 사용자만 접근 가능
}

@api({ guards: ["query", "admin"] })
async search(query: string) {
  // query 또는 admin 권한 필요
}
```

### description

API 설명을 추가합니다. 생성된 타입과 문서에 포함됩니다.

```typescript theme={null}
@api({
  description: "사용자 ID로 프로필을 조회합니다."
})
async findById(id: number) {
  // ...
}
```

### resourceName

생성되는 서비스 파일의 리소스 이름을 지정합니다.

```typescript theme={null}
@api({ resourceName: "Users" })
async list() {
  // UsersService.ts 파일에 포함됩니다
}
```

### timeout

API 요청의 타임아웃을 밀리초 단위로 지정합니다.

```typescript theme={null}
@api({ timeout: 30000 })  // 30초
async heavyOperation() {
  // 오래 걸리는 작업
}
```

### cacheControl

응답의 Cache-Control 헤더를 설정합니다.

```typescript theme={null}
@api({
  cacheControl: {
    maxAge: "10m",        // 10분 캐싱
    sMaxAge: "1h",        // CDN에서 1시간 캐싱
    public: true
  }
})
async getPublicData() {
  // Cache-Control: public, max-age=600, s-maxage=3600
}
```

**CacheControlConfig 타입:**

```typescript theme={null}
type CacheControlConfig = {
  maxAge?: string; // 브라우저 캐시 시간
  sMaxAge?: string; // CDN/프록시 캐시 시간
  public?: boolean; // public/private
  noCache?: boolean; // no-cache
  noStore?: boolean; // no-store
  mustRevalidate?: boolean;
};
```

<Tip>시간 표기: `"10s"`, `"5m"`, `"1h"`, `"1d"` 형식 지원</Tip>

### compress

응답 압축 설정을 지정합니다.

```typescript theme={null}
@api({
  compress: {
    threshold: 1024,      // 1KB 이상만 압축
    level: 6              // 압축 레벨 (0-9)
  }
})
async getLargeData() {
  // 큰 데이터 반환
}

@api({ compress: false })  // 압축 비활성화
async getSmallData() {
  // 작은 데이터는 압축하지 않음
}
```

## 전체 옵션 예시

```typescript theme={null}
@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 클래스

```typescript theme={null}
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 클래스

```typescript theme={null}
class AuthFrameClass extends BaseFrameClass {
  @api()
  async login() {}
  // 경로: /auth/login
}
```

**규칙:**

1. 클래스 이름에서 "FrameClass" 제거
2. 나머지를 camelCase로 변환
3. `/{frameName}/{methodName}` 형식

## 다른 데코레이터와 함께 사용

### @transactional

```typescript theme={null}
@api({ httpMethod: "POST" })
@transactional()
async save(data: UserSaveParams) {
  const wdb = this.getDB("w");
  // 트랜잭션 내에서 실행
  return this.upsert(wdb, data);
}
```

<Warning>`@api`를 먼저, `@transactional`을 나중에 작성하세요.</Warning>

### @cache

```typescript theme={null}
@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` 없이 독립적으로 사용합니다.

```typescript theme={null}
@upload()
async uploadAvatar() {
  const { files } = Sonamu.getContext();
  const file = files?.[0]; // 첫 번째 파일 사용
  // 파일 처리
}
```

## 제약사항

### 1. 다른 라우팅 데코레이터(@stream, @websocket, @upload)와 중복 사용 불가

같은 메서드에 `@api`, `@stream`, `@websocket`, `@upload` 중 둘 이상을 함께 사용할 수 없습니다.

```typescript theme={null}
// ❌ 에러 발생
@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. 같은 메서드에 여러 번 사용 시

여러 번 사용하면 **마지막 것이 우선**되며, 충돌하는 옵션이 있으면 에러 발생:

```typescript theme={null}
// ❌ 에러 - 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 클래스에서만 사용 가능

```typescript theme={null}
// ❌ 일반 클래스에서는 사용 불가
class UtilClass {
  @api()
  async helper() {}
  // 에러: modelName is required
}

// ✅ BaseModelClass 상속 필요
class UserModelClass extends BaseModelClass {
  @api()
  async findById(id: number) {}
}
```

## 생성되는 코드

`@api` 데코레이터를 사용하면 다음이 자동 생성됩니다:

### 1. API 라우트 등록

```typescript theme={null}
// Fastify 라우트 자동 등록
fastify.get("/user/findById", async (request, reply) => {
  // 파라미터 검증 및 처리
  const result = await UserModel.findById(subset, id);
  return result;
});
```

### 2. 타입 정의 생성

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

export type UserFindByIdResult = UserSubsetMapping[UserSubsetKey];
```

### 3. 클라이언트 코드 생성

**Axios:**

```typescript theme={null}
// web/src/services/UserService.ts
export const UserService = {
  async findById(params: UserFindByIdParams) {
    return axios.get<UserFindByIdResult>("/user/findById", { params });
  },
};
```

**TanStack Query:**

```typescript theme={null}
export const useUserFindById = (params: UserFindByIdParams) => {
  return useQuery({
    queryKey: ["user", "findById", params],
    queryFn: () => UserService.findById(params),
  });
};
```

## 로깅

`@api` 데코레이터는 자동으로 로그를 남깁니다:

```typescript theme={null}
@api({ httpMethod: "GET" })
async findById(id: number) {
  // 자동 로그:
  // [DEBUG] api: GET UserModel.findById
}
```

로그는 LogTape를 통해 기록되며, 카테고리는 `[model:user]` 또는 `[frame:auth]` 형식입니다.

## 예시 모음

<Tabs>
  <Tab title="기본 CRUD" icon="database">
    ```typescript theme={null}
    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() });
      }
    }
    ```
  </Tab>

  <Tab title="캐싱" icon="bolt">
    ```typescript theme={null}
    class ProductModelClass extends BaseModelClass {
      @api({ httpMethod: "GET" })
      @cache({ ttl: "1h", tags: ["products"] })
      async featured() {
        // 1시간 동안 캐싱
        const rdb = this.getPuri("r");
        return rdb.table("products")
          .where("featured", true)
          .limit(10);
      }

      @api({ httpMethod: "GET" })
      @cache({ ttl: "5m" })
      async findById(id: number) {
        // 5분 동안 캐싱
        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 });

        // 캐시 무효화
        await Sonamu.cache.deleteByTags(["products"]);

        return result;
      }
    }
    ```
  </Tab>

  <Tab title="권한 제어" icon="shield-check">
    ```typescript theme={null}
    class AdminFrameClass extends BaseFrameClass {
      @api({
        httpMethod: "GET",
        guards: ["admin"]
      })
      async dashboard() {
        // 관리자만 접근 가능 — findMany의 queryMode: "count"로 집계만 수행
        const [users, orders] = await Promise.all([
          UserModel.findMany("A", { queryMode: "count" }),
          OrderModel.findMany("A", { queryMode: "count" })
        ]);
        return {
          users: users.total ?? 0,
          orders: orders.total ?? 0
        };
      }

      @api({
        httpMethod: "POST",
        guards: ["admin"]
      })
      @transactional()
      async banUser(userId: number) {
        // 관리자만 실행 가능
        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() {
        // 로그인한 사용자만 접근 가능
        const { user } = Sonamu.getContext();
        return UserModel.findById(user.id);
      }
    }
    ```
  </Tab>

  <Tab title="커스텀 경로" icon="route">
    ```typescript theme={null}
    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) {
        // Stripe 웹훅 처리
      }
    }
    ```
  </Tab>
</Tabs>

## 다음 단계

<CardGroup cols={2}>
  <Card title="@stream" icon="tower-broadcast" href="/ko/api-reference/decorators/stream">
    SSE 스트리밍 API 만들기
  </Card>

  <Card title="@transactional" icon="arrows-rotate" href="/ko/api-reference/decorators/transactional">
    데이터베이스 트랜잭션 사용하기
  </Card>

  <Card title="@upload" icon="upload" href="/ko/api-reference/decorators/upload">
    파일 업로드 API 만들기
  </Card>

  <Card title="@cache" icon="database" href="/ko/api-reference/decorators/cache">
    메서드 결과 캐싱하기
  </Card>
</CardGroup>
