@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 μμ²μΌλ‘ λ
ΈμΆλ©λλ€
}
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" ;
// CSV νμΌ λ€μ΄λ‘λ
@ api ({ contentType: "text/plain" })
async exportCsv () {
return "id,name,email \n 1,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/plainCSV, TXT νμΌ λ€μ΄λ‘λ stringtext/htmlHTML λ λλ§ (SSR) stringtext/xmlXML λ°μ΄ν° λ°ν stringapplication/jsonJSON λ°μ΄ν° (κΈ°λ³Έκ°) objectapplication/octet-streamλ°μ΄λ리 νμΌ (μ΄λ―Έμ§, PDF, ZIP λ±) Buffer or Uint8Array
application/octet-stream μ¬μ© μ μ£Όμμ¬ν : - λ°λμ Buffer λλ Uint8Arrayλ₯Ό λ°νν΄μΌ
ν©λλ€ - Content-Disposition ν€λλ‘ λ€μ΄λ‘λ νμΌλͺ
μ μ§μ ν μ μμ΅λλ€ - νμΌλͺ
μ νκΈμ΄
ν¬ν¨λ κ²½μ° encodeURIComponent()λ‘ μΈμ½λ©νμΈμ - λμ©λ νμΌμ μ€νΈλ¦¬λ° λ°©μμ κ³ λ €νμΈμ
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 ν΄λΌμ΄μΈνΈ μμ±
}
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
}
κ·μΉ:
ν΄λμ€ μ΄λ¦μμ βModelClassβ μ κ±°
λλ¨Έμ§λ₯Ό camelCaseλ‘ λ³ν
/{modelName}/{methodName} νμ
Frame ν΄λμ€
class AuthFrameClass extends BaseFrameClass {
@ api ()
async login () {}
// κ²½λ‘: /auth/login
}
κ·μΉ:
ν΄λμ€ μ΄λ¦μμ βFrameClassβ μ κ±°
λλ¨Έμ§λ₯Ό camelCaseλ‘ λ³ν
/{frameName}/{methodName} νμ
λ€λ₯Έ λ°μ½λ μ΄ν°μ ν¨κ» μ¬μ©
@transactional
@ api ({ httpMethod: "POST" })
@ transactional ()
async save ( data : UserSaveParams ) {
const wdb = this . getDB ( "w" );
// νΈλμμ
λ΄μμ μ€ν
return this . upsert ( wdb , data );
}
@apiλ₯Ό λ¨Όμ , @transactionalμ λμ€μ μμ±νμΈμ.
@ 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 μμ΄ λ
립μ μΌλ‘ μ¬μ©ν©λλ€.
@ upload ()
async uploadAvatar () {
const { files } = Sonamu . getContext ();
const file = files ?.[ 0 ]; // 첫 λ²μ§Έ νμΌ μ¬μ©
// νμΌ μ²λ¦¬
}
μ μ½μ¬ν
1. λ€λ₯Έ λΌμ°ν
λ°μ½λ μ΄ν°(@stream, @websocket, @upload)μ μ€λ³΅ μ¬μ© λΆκ°
κ°μ λ©μλμ @api, @stream, @websocket, @upload μ€ λ μ΄μμ ν¨κ» μ¬μ©ν μ μμ΅λλ€.
// β μλ¬ λ°μ
@ 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. κ°μ λ©μλμ μ¬λ¬ λ² μ¬μ© μ
μ¬λ¬ λ² μ¬μ©νλ©΄ λ§μ§λ§ κ²μ΄ μ°μ λλ©°, μΆ©λνλ μ΅μ
μ΄ μμΌλ©΄ μλ¬ λ°μ:
// β μλ¬ - 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] νμμ
λλ€.
μμ λͺ¨μ
κΈ°λ³Έ CRUD
μΊμ±
κΆν μ μ΄
컀μ€ν
κ²½λ‘
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 () });
}
}
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 ;
}
}
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 );
}
}
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 μΉν
μ²λ¦¬
}
}
λ€μ λ¨κ³
@stream SSE μ€νΈλ¦¬λ° API λ§λ€κΈ°
@transactional λ°μ΄ν°λ² μ΄μ€ νΈλμμ
μ¬μ©νκΈ°
@upload νμΌ μ
λ‘λ API λ§λ€κΈ°
@cache λ©μλ κ²°κ³Ό μΊμ±νκΈ°