@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").table("users").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 | ๋ฐ์ดํฐ ๋ณ๊ฒฝ |
tanstack-mutation-multipart | ํ์ผ ์
๋ก๋์ฉ 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 UnauthorizedException("๊ด๋ฆฌ์ ๊ถํ์ด ํ์ํฉ๋๋ค");
}
},
};
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
์ธ์ฆ๊ณผ ๊ถํ ๊ฒ์ฌ ๊ตฌํํ๊ธฐ