> ## 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 Decorator

> HTTP API 엔드포인트를 자동 생성하는 @api 데코레이터 활용법

`@api` 데코레이터는 Model 메서드를 HTTP API 엔드포인트로 자동 변환합니다. 메서드에 데코레이터를 추가하면 라우팅, 타입 검증, 클라이언트 코드가 자동 생성됩니다.

## 기본 사용법

```typescript theme={null}
import { api } from "sonamu";

class UserModelClass extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async findById(id: number): Promise<User> {
    return this.getPuri("r").table("users").where("id", id).first();
  }
}
```

**생성되는 것들**:

* HTTP 엔드포인트: `GET /user/findById?id=1`
* TypeScript 클라이언트 함수
* TanStack Query hooks (선택 시)
* API 문서

## 데코레이터 옵션

### 전체 옵션

```typescript theme={null}
@api({
  httpMethod: "GET",                    // HTTP 메서드
  contentType: "application/json",      // Content-Type
  clients: ["axios", "tanstack-query"], // 생성할 클라이언트
  path: "/custom/path",                 // 커스텀 경로
  resourceName: "Users",                // 리소스 이름
  guards: ["admin", "user"],            // 인증/권한 가드
  description: "사용자 조회 API",       // API 설명
  timeout: 5000,                        // 타임아웃 (ms)
  cacheControl: { maxAge: 60 },         // 캐시 설정
  compress: false,                      // 응답 압축 비활성화
})
```

### httpMethod

HTTP 메서드를 지정합니다.

| 메서드      | 용도        | 예시                     |
| -------- | --------- | ---------------------- |
| `GET`    | 데이터 조회    | `findById`, `findMany` |
| `POST`   | 데이터 생성/수정 | `save`, `login`        |
| `PUT`    | 데이터 업데이트  | `update`               |
| `DELETE` | 데이터 삭제    | `del`, `remove`        |
| `PATCH`  | 부분 업데이트   | `updateProfile`        |

<CodeGroup>
  ```typescript title="GET - 조회" theme={null}
  @api({ httpMethod: "GET" })
  async findById(id: number): Promise<User> {
    // ...
  }
  // 엔드포인트: GET /user/findById?id=1
  ```

  ```typescript title="POST - 생성/수정" theme={null}
  @api({ httpMethod: "POST" })
  async save(params: UserSaveParams[]): Promise<number[]> {
    // ...
  }
  // 엔드포인트: POST /user/save
  // Body: { params: [...] }
  ```

  ```typescript title="DELETE - 삭제" theme={null}
  @api({ httpMethod: "DELETE" })
  async del(ids: number[]): Promise<number> {
    // ...
  }
  // 엔드포인트: DELETE /user/del
  // Body: { ids: [1, 2, 3] }
  ```
</CodeGroup>

<Tip>
  **기본값**: `httpMethod`를 생략하면 `GET`이 기본값입니다.

  ```typescript theme={null}
  @api()  // httpMethod: "GET"
  async findById(id: number) { }
  ```
</Tip>

### clients

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

| Client                        | 설명                    | 사용 예시     |
| ----------------------------- | --------------------- | --------- |
| `axios`                       | Axios 기반 함수           | 일반 API 호출 |
| `axios-multipart`             | 파일 업로드용 Axios         | 이미지 업로드   |
| `tanstack-query`              | Query hook            | 데이터 조회    |
| `tanstack-mutation`           | Mutation hook         | 데이터 변경    |
| `tanstack-mutation-multipart` | 파일 업로드용 Mutation hook | 파일 업로드 변경 |
| `window-fetch`                | Fetch API             | 브라우저 네이티브 |

<CodeGroup>
  ```typescript title="조회 API - Query" theme={null}
  @api({
    httpMethod: "GET",
    clients: ["axios", "tanstack-query"],
  })
  async findById(id: number): Promise<User> {
    // ...
  }
  ```

  ```typescript title="변경 API - Mutation" theme={null}
  @api({
    httpMethod: "POST",
    clients: ["axios", "tanstack-mutation"],
  })
  async save(params: UserSaveParams[]): Promise<number[]> {
    // ...
  }
  ```

  ```typescript title="파일 업로드" theme={null}
  @upload()
  async uploadAvatar(): Promise<{ url: string }> {
    // clients: ["axios-multipart", "tanstack-mutation-multipart"] 자동 설정
    // ...
  }
  ```
</CodeGroup>

**생성되는 클라이언트 코드**:

<CodeGroup>
  ```typescript title="axios" theme={null}
  // services/user.service.ts
  export async function findUserById(id: number): Promise<User> {
    const { data } = await axios.get("/user/findById", { params: { id } });
    return data;
  }
  ```

  ```typescript title="tanstack-query" theme={null}
  // hooks/useUserQuery.ts
  export function useUserById(id: number) {
    return useQuery({
      queryKey: ["user", "findById", id],
      queryFn: () => findUserById(id),
    });
  }

  // 사용
  const { data: user } = useUserById(1);
  ```

  ```typescript title="tanstack-mutation" theme={null}
  // hooks/useUserMutation.ts
  export function useSaveUser() {
    return useMutation({
      mutationFn: (params: UserSaveParams[]) => saveUser(params),
    });
  }

  // 사용
  const { mutate } = useSaveUser();
  mutate([{ email: "test@test.com" }]);
  ```
</CodeGroup>

<Tip>
  **기본값**: `clients`를 생략하면 `["axios"]`가 기본값입니다.
</Tip>

### path

커스텀 API 경로를 지정합니다.

```typescript theme={null}
// 기본 경로
@api({ httpMethod: "GET" })
async findById(id: number) { }
// 경로: /user/findById

// 커스텀 경로
@api({ httpMethod: "GET", path: "/api/v1/users/:id" })
async findById(id: number) { }
// 경로: /api/v1/users/:id
```

**경로 파라미터**:

```typescript theme={null}
@api({ httpMethod: "GET", path: "/posts/:postId/comments/:commentId" })
async findComment(postId: number, commentId: number): Promise<Comment> {
  // ...
}
// 호출: GET /posts/123/comments/456
```

<Info>
  경로를 생략하면 `/{model}/{method}` 형식으로 자동 생성됩니다.

  * Model: `UserModel` → `user`
  * Method: `findById` → `findById`
  * 결과: `/user/findById`
</Info>

### resourceName

API 리소스 이름을 지정합니다. TanStack Query의 queryKey에 사용됩니다.

```typescript theme={null}
@api({
  httpMethod: "GET",
  resourceName: "Users",  // 복수형
  clients: ["tanstack-query"],
})
async findMany(): Promise<User[]> {
  // ...
}

// 생성되는 Query Hook
export function useUsers() {
  return useQuery({
    queryKey: ["Users", "findMany"],  // resourceName 사용
    queryFn: () => findManyUsers(),
  });
}
```

**네이밍 가이드**:

| API 타입 | resourceName | 예시      |
| ------ | ------------ | ------- |
| 단일 조회  | 단수형          | `User`  |
| 목록 조회  | 복수형          | `Users` |
| 생성/수정  | 단수형          | `User`  |
| 삭제     | 복수형          | `Users` |

### guards

인증 및 권한 검사를 설정합니다.

```typescript theme={null}
// 인증만 필요
@api({ guards: ["user"] })
async getMyProfile(): Promise<User> {
  // 로그인한 사용자만 접근 가능
}

// 관리자 권한 필요
@api({ guards: ["admin"] })
async deleteUser(id: number): Promise<void> {
  // 관리자만 접근 가능
}

// 여러 가드 조합
@api({ guards: ["user", "admin"] })
async someAdminAction(): Promise<void> {
  // user AND admin 모두 만족해야 함
}
```

**Guard 종류**:

| Guard   | 설명     | 확인 내용                           |
| ------- | ------ | ------------------------------- |
| `user`  | 로그인 필요 | `context.user` 존재 여부            |
| `admin` | 관리자 권한 | `context.user.role === "admin"` |
| `query` | 커스텀 검사 | 사용자 정의 로직                       |

<Info>
  Guard 로직은 `sonamu.config.ts`의 `guardHandler`에서 정의합니다.

  ```typescript title="sonamu.config.ts" theme={null}
  export default {
    guardHandler: (guard, request, api) => {
      if (guard === "user" && !request.user) {
        throw new UnauthorizedException("로그인이 필요합니다");
      }
      if (guard === "admin" && request.user?.role !== "admin") {
        throw new UnauthorizedException("관리자 권한이 필요합니다");
      }
    },
  };
  ```
</Info>

### contentType

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

```typescript theme={null}
// JSON (기본값)
@api({ contentType: "application/json" })
async getUser(): Promise<User> {
  return { id: 1, name: "John" };
}

// HTML
@api({ contentType: "text/html" })
async renderProfile(): Promise<string> {
  return "<html><body>Profile</body></html>";
}

// Plain Text
@api({ contentType: "text/plain" })
async getLog(): Promise<string> {
  return "Log content...";
}

// Binary (파일 다운로드)
@api({ contentType: "application/octet-stream" })
async downloadFile(): Promise<Buffer> {
  return fileBuffer;
}
```

### timeout

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

```typescript theme={null}
@api({ 
  httpMethod: "GET",
  timeout: 5000,  // 5초
})
async longRunningQuery(): Promise<Result> {
  // 5초 이상 걸리면 타임아웃
}
```

<Warning>
  타임아웃은 **클라이언트 측** 설정입니다. 서버에서는 계속 실행될 수 있으므로, 서버 측 타임아웃도 별도로 설정해야 할 수 있습니다.
</Warning>

### cacheControl

HTTP Cache-Control 헤더를 설정합니다.

```typescript theme={null}
// 1분 캐싱
@api({
  cacheControl: {
    maxAge: 60,  // seconds
  },
})
async getStaticData(): Promise<Data> {
  // ...
}

// 캐싱 비활성화
@api({
  cacheControl: {
    maxAge: 0,
    noCache: true,
  },
})
async getDynamicData(): Promise<Data> {
  // ...
}
```

**CacheControl 옵션**:

| 옵션        | 타입      | 설명           | 예시     |
| --------- | ------- | ------------ | ------ |
| `maxAge`  | number  | 최대 캐시 시간 (초) | `60`   |
| `noCache` | boolean | 캐시 사용 안함     | `true` |
| `noStore` | boolean | 저장 안함        | `true` |
| `private` | boolean | 사용자별 캐시      | `true` |
| `public`  | boolean | 공용 캐시        | `true` |

### compress

응답 압축 설정을 제어합니다.

```typescript theme={null}
// 압축 활성화 (기본값)
@api({ compress: true })
async getData(): Promise<LargeData> {
  // 응답이 gzip으로 압축됨
}

// 압축 비활성화
@api({ compress: false })
async streamData(): Promise<StreamData> {
  // 압축 없이 전송 (스트리밍에 적합)
}
```

## 여러 데코레이터 조합

### @api + @transactional

트랜잭션 내에서 API를 실행합니다.

```typescript theme={null}
@api({ httpMethod: "POST" })
@transactional()
async updateUserAndProfile(
  userId: number,
  userData: UserData,
  profileData: ProfileData
): Promise<void> {
  await this.save([userData]);
  await ProfileModel.save([profileData]);
  // 자동 커밋 또는 롤백
}
```

### @upload (독립 사용)

파일 업로드 API를 만듭니다. `@upload`는 `@api` 없이 독립적으로 사용합니다.

```typescript theme={null}
@upload()
async uploadAvatar(): Promise<{ url: string }> {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0]; // 첫 번째 파일 사용

  if (!file) {
    throw new BadRequestException("파일이 없습니다");
  }

  // 파일 처리...
  const url = await file.saveToDisk("fs", `avatars/${Date.now()}-${file.filename}`);

  return { url };
}
```

**Upload 옵션**:

| 옵션                | 설명               | 사용 예시                     |
| ----------------- | ---------------- | ------------------------- |
| `limits.fileSize` | 최대 파일 크기 (bytes) | `10 * 1024 * 1024` (10MB) |
| `limits.files`    | 최대 파일 개수         | `5`                       |

단일/다중 파일 여부는 파라미터 타입(`UploadedFile` vs `UploadedFile[]`)으로 결정됩니다.

## API 경로 규칙

기본 경로는 다음 규칙으로 생성됩니다:

```
/{modelName}/{methodName}
```

**변환 규칙**:

* Model 이름: PascalCase → camelCase
  * `UserModel` → `user`
  * `BlogPostModel` → `blogPost`
* Method 이름: 그대로 사용
  * `findById` → `findById`

**예시**:

| Model           | Method     | 경로                   |
| --------------- | ---------- | -------------------- |
| `UserModel`     | `findById` | `/user/findById`     |
| `BlogPostModel` | `findMany` | `/blogPost/findMany` |
| `CommentModel`  | `save`     | `/comment/save`      |

## 실전 예제

### 기본 CRUD API

<CodeGroup>
  ```typescript title="조회 API" theme={null}
  @api({
    httpMethod: "GET",
    clients: ["axios", "tanstack-query"],
    resourceName: "User",
  })
  async findById(id: number): Promise<User> {
    const user = await this.getPuri("r")
      .where("id", id)
      .first();
      
    if (!user) {
      throw new NotFoundException(`User not found: ${id}`);
    }
    
    return user;
  }
  ```

  ```typescript title="목록 API" theme={null}
  @api({
    httpMethod: "GET",
    clients: ["axios", "tanstack-query"],
    resourceName: "Users",
    cacheControl: { maxAge: 30 },
  })
  async findMany(params: UserListParams): Promise<ListResult<User>> {
    const { qb } = this.getSubsetQueries("A");
    
    if (params.keyword) {
      qb.whereLike("email", `%${params.keyword}%`);
    }
    
    return this.executeSubsetQuery({
      subset: "A",
      qb,
      params,
    });
  }
  ```

  ```typescript title="생성/수정 API" theme={null}
  @api({
    httpMethod: "POST",
    clients: ["axios", "tanstack-mutation"],
  })
  async save(params: UserSaveParams[]): Promise<number[]> {
    // 검증
    for (const param of params) {
      const existing = await this.getPuri("r")
        .where("email", param.email)
        .whereNot("id", param.id ?? 0)
        .first();
        
      if (existing) {
        throw new BadRequestException("이미 사용중인 이메일입니다");
      }
    }
    
    // 저장
    const wdb = this.getPuri("w");
    params.forEach(p => wdb.ubRegister("users", p));
    
    return wdb.transaction(async (trx) => {
      return trx.ubUpsert("users");
    });
  }
  ```

  ```typescript title="삭제 API" theme={null}
  @api({
    httpMethod: "DELETE",
    clients: ["axios", "tanstack-mutation"],
    guards: ["admin"],
  })
  async del(ids: number[]): Promise<number> {
    const wdb = this.getPuri("w");
    
    await wdb.transaction(async (trx) => {
      await trx.table("users")
        .whereIn("id", ids)
        .delete();
    });
    
    return ids.length;
  }
  ```
</CodeGroup>

### 인증 API

<Info>
  Sonamu는 인증을 better-auth의 HTTP 엔드포인트로 처리합니다. 로그인(`/api/auth/sign-in/email`), 로그아웃(`/api/auth/sign-out`) 등은 Sonamu `@api` 데코레이터가 아닌 better-auth가 직접 제공하는 엔드포인트를 사용하세요.
</Info>

로그인한 사용자 정보를 반환하는 `me()` API는 `context.user`를 통해 구현합니다.

```typescript theme={null}
@api({
  httpMethod: "GET",
  clients: ["axios", "tanstack-query"],
  guards: ["user"],
})
async me(): Promise<User | null> {
  const context = Sonamu.getContext();
  
  if (!context.user) {
    return null;
  }
  
  return this.findById(context.user.id);
}
```

## 다음 단계

<CardGroup cols={2}>
  <Card title="Business Logic" icon="lightbulb" href="/ko/core-concepts/model/business-logic">
    비즈니스 로직 작성 패턴 배우기
  </Card>

  <Card title="Stream Decorator" icon="broadcast-tower" href="/ko/api-development/streaming/sse">
    @stream으로 실시간 이벤트 전송하기
  </Card>

  <Card title="Upload Decorator" icon="upload" href="/ko/api-development/file-upload/handling-uploads">
    @upload로 파일 업로드 처리하기
  </Card>

  <Card title="Guards" icon="shield" href="/ko/api-development/authentication/guards">
    인증과 권한 검사 구현하기
  </Card>
</CardGroup>
