@api λ°μ½λ μ΄ν°λ Model λ©μλλ₯Ό HTTP API μλν¬μΈνΈλ‘ μλ λ³νν©λλ€. λ©μλμ λ°μ½λ μ΄ν°λ₯Ό μΆκ°νλ©΄ λΌμ°ν
, νμ
κ²μ¦, ν΄λΌμ΄μΈνΈ μ½λκ° μλ μμ±λ©λλ€.
κΈ°λ³Έ μ¬μ©λ²
import { api } from "sonamu";
class UserModelClass extends BaseModelClass {
@api({ httpMethod: "GET" })
async findById(id: number): Promise<User> {
return this.getPuri("r").where("id", id).first();
}
}
μμ±λλ κ²λ€:
- HTTP μλν¬μΈνΈ:
GET /user/findById?id=1
- TypeScript ν΄λΌμ΄μΈνΈ ν¨μ
- TanStack Query hooks (μ ν μ)
- API λ¬Έμ
λ°μ½λ μ΄ν° μ΅μ
μ 체 μ΅μ
@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 |
@api({ httpMethod: "GET" })
async findById(id: number): Promise<User> {
// ...
}
// μλν¬μΈνΈ: GET /user/findById?id=1
κΈ°λ³Έκ°: httpMethodλ₯Ό μλ΅νλ©΄ GETμ΄ κΈ°λ³Έκ°μ
λλ€.@api() // httpMethod: "GET"
async findById(id: number) { }
clients
μμ±ν ν΄λΌμ΄μΈνΈ μ½λ νμ
μ μ§μ ν©λλ€.
| Client | μ€λͺ
| μ¬μ© μμ |
|---|
axios | Axios κΈ°λ° ν¨μ | μΌλ° API νΈμΆ |
axios-multipart | νμΌ μ
λ‘λμ© Axios | μ΄λ―Έμ§ μ
λ‘λ |
tanstack-query | Query hook | λ°μ΄ν° μ‘°ν |
tanstack-mutation | Mutation hook | λ°μ΄ν° λ³κ²½ |
window-fetch | Fetch API | λΈλΌμ°μ λ€μ΄ν°λΈ |
@api({
httpMethod: "GET",
clients: ["axios", "tanstack-query"],
})
async findById(id: number): Promise<User> {
// ...
}
μμ±λλ ν΄λΌμ΄μΈνΈ μ½λ:
// services/user.service.ts
export async function findUserById(id: number): Promise<User> {
const { data } = await axios.get("/user/findById", { params: { id } });
return data;
}
κΈ°λ³Έκ°: clientsλ₯Ό μλ΅νλ©΄ ["axios"]κ° κΈ°λ³Έκ°μ
λλ€.
컀μ€ν
API κ²½λ‘λ₯Ό μ§μ ν©λλ€.
// κΈ°λ³Έ κ²½λ‘
@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
κ²½λ‘ νλΌλ―Έν°:
@api({ httpMethod: "GET", path: "/posts/:postId/comments/:commentId" })
async findComment(postId: number, commentId: number): Promise<Comment> {
// ...
}
// νΈμΆ: GET /posts/123/comments/456
κ²½λ‘λ₯Ό μλ΅νλ©΄ /{model}/{method} νμμΌλ‘ μλ μμ±λ©λλ€.
- Model:
UserModel β user
- Method:
findById β findById
- κ²°κ³Ό:
/user/findById
resourceName
API 리μμ€ μ΄λ¦μ μ§μ ν©λλ€. TanStack Queryμ queryKeyμ μ¬μ©λ©λλ€.
@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 |
μΈμ¦ λ° κΆν κ²μ¬λ₯Ό μ€μ ν©λλ€.
// μΈμ¦λ§ νμ
@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 | 컀μ€ν
κ²μ¬ | μ¬μ©μ μ μ λ‘μ§ |
Guard λ‘μ§μ sonamu.config.tsμ guardHandlerμμ μ μν©λλ€.export default {
guardHandler: (guard, request, api) => {
if (guard === "user" && !request.user) {
throw new UnauthorizedException("λ‘κ·ΈμΈμ΄ νμν©λλ€");
}
if (guard === "admin" && request.user?.role !== "admin") {
throw new ForbiddenException("κ΄λ¦¬μ κΆνμ΄ νμν©λλ€");
}
},
};
contentType
μλ΅μ Content-Typeμ μ§μ ν©λλ€.
// 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 νμμμμ λ°λ¦¬μ΄ λ¨μλ‘ μ§μ ν©λλ€.
@api({
httpMethod: "GET",
timeout: 5000, // 5μ΄
})
async longRunningQuery(): Promise<Result> {
// 5μ΄ μ΄μ 걸리면 νμμμ
}
νμμμμ ν΄λΌμ΄μΈνΈ μΈ‘ μ€μ μ
λλ€. μλ²μμλ κ³μ μ€νλ μ μμΌλ―λ‘, μλ² μΈ‘ νμμμλ λ³λλ‘ μ€μ ν΄μΌ ν μ μμ΅λλ€.
cacheControl
HTTP Cache-Control ν€λλ₯Ό μ€μ ν©λλ€.
// 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
μλ΅ μμΆ μ€μ μ μ μ΄ν©λλ€.
// μμΆ νμ±ν (κΈ°λ³Έκ°)
@api({ compress: true })
async getData(): Promise<LargeData> {
// μλ΅μ΄ gzipμΌλ‘ μμΆλ¨
}
// μμΆ λΉνμ±ν
@api({ compress: false })
async streamData(): Promise<StreamData> {
// μμΆ μμ΄ μ μ‘ (μ€νΈλ¦¬λ°μ μ ν©)
}
μ¬λ¬ λ°μ½λ μ΄ν° μ‘°ν©
@api + @transactional
νΈλμμ
λ΄μμ APIλ₯Ό μ€νν©λλ€.
@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 μμ΄ λ
립μ μΌλ‘ μ¬μ©ν©λλ€.
@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 μ΄λ¦: κ·Έλλ‘ μ¬μ©
μμ:
| Model | Method | κ²½λ‘ |
|---|
UserModel | findById | /user/findById |
BlogPostModel | findMany | /blogPost/findMany |
CommentModel | save | /comment/save |
μ€μ μμ
κΈ°λ³Έ CRUD API
@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;
}
μΈμ¦ API
Sonamuλ μΈμ¦μ better-authμ HTTP μλν¬μΈνΈλ‘ μ²λ¦¬ν©λλ€. λ‘κ·ΈμΈ(/api/auth/sign-in/email), λ‘κ·Έμμ(/api/auth/sign-out) λ±μ Sonamu @api λ°μ½λ μ΄ν°κ° μλ better-authκ° μ§μ μ 곡νλ μλν¬μΈνΈλ₯Ό μ¬μ©νμΈμ.
λ‘κ·ΈμΈν μ¬μ©μ μ 보λ₯Ό λ°ννλ me() APIλ context.userλ₯Ό ν΅ν΄ ꡬνν©λλ€.
@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);
}
λ€μ λ¨κ³
Business Logic
λΉμ¦λμ€ λ‘μ§ μμ± ν¨ν΄ λ°°μ°κΈ°
Stream Decorator
@streamμΌλ‘ μ€μκ° μ΄λ²€νΈ μ μ‘νκΈ°
Upload Decorator
@uploadλ‘ νμΌ μ
λ‘λ μ²λ¦¬νκΈ°
Guards
μΈμ¦κ³Ό κΆν κ²μ¬ ꡬννκΈ°