@mandujs/core 0.9.2 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/client/index.ts +2 -1
- package/src/contract/client.test.ts +308 -0
- package/src/contract/client.ts +345 -0
- package/src/contract/handler.ts +270 -0
- package/src/contract/index.ts +137 -1
- package/src/contract/infer.test.ts +346 -0
- package/src/contract/types.ts +83 -0
- package/src/filling/filling.ts +5 -1
- package/src/filling/index.ts +1 -1
- package/src/index.ts +75 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Contract Handler
|
|
3
|
+
* Contract 기반 타입 안전 핸들러 정의
|
|
4
|
+
*
|
|
5
|
+
* Elysia 패턴 채택: Contract → Handler 타입 자동 추론
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { z } from "zod";
|
|
9
|
+
import type {
|
|
10
|
+
ContractSchema,
|
|
11
|
+
ContractMethod,
|
|
12
|
+
MethodRequestSchema,
|
|
13
|
+
} from "./schema";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Typed request context for a handler
|
|
17
|
+
* Contract에서 추론된 타입으로 요청 컨텍스트 제공
|
|
18
|
+
*/
|
|
19
|
+
export interface TypedContext<
|
|
20
|
+
TQuery = unknown,
|
|
21
|
+
TBody = unknown,
|
|
22
|
+
TParams = unknown,
|
|
23
|
+
THeaders = unknown,
|
|
24
|
+
> {
|
|
25
|
+
/** Parsed and validated query parameters */
|
|
26
|
+
query: TQuery;
|
|
27
|
+
/** Parsed and validated request body */
|
|
28
|
+
body: TBody;
|
|
29
|
+
/** Parsed and validated path parameters */
|
|
30
|
+
params: TParams;
|
|
31
|
+
/** Parsed and validated headers */
|
|
32
|
+
headers: THeaders;
|
|
33
|
+
/** Original Request object */
|
|
34
|
+
request: Request;
|
|
35
|
+
/** Route path (e.g., "/users/:id") */
|
|
36
|
+
path: string;
|
|
37
|
+
/** HTTP method */
|
|
38
|
+
method: ContractMethod;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Infer context type from method schema
|
|
43
|
+
*/
|
|
44
|
+
type InferContextFromMethod<T extends MethodRequestSchema | undefined> =
|
|
45
|
+
T extends MethodRequestSchema
|
|
46
|
+
? TypedContext<
|
|
47
|
+
T["query"] extends z.ZodTypeAny ? z.infer<T["query"]> : undefined,
|
|
48
|
+
T["body"] extends z.ZodTypeAny ? z.infer<T["body"]> : undefined,
|
|
49
|
+
T["params"] extends z.ZodTypeAny ? z.infer<T["params"]> : undefined,
|
|
50
|
+
T["headers"] extends z.ZodTypeAny ? z.infer<T["headers"]> : undefined
|
|
51
|
+
>
|
|
52
|
+
: TypedContext<undefined, undefined, undefined, undefined>;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Handler function type for a specific method
|
|
56
|
+
*/
|
|
57
|
+
export type HandlerFn<TContext, TResponse> = (
|
|
58
|
+
ctx: TContext
|
|
59
|
+
) => TResponse | Promise<TResponse>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Infer response type union from contract response schema
|
|
63
|
+
*/
|
|
64
|
+
type InferResponseUnion<TResponse extends ContractSchema["response"]> = {
|
|
65
|
+
[K in keyof TResponse]: TResponse[K] extends z.ZodTypeAny
|
|
66
|
+
? z.infer<TResponse[K]>
|
|
67
|
+
: never;
|
|
68
|
+
}[keyof TResponse];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Handler definition for all methods in a contract
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```typescript
|
|
75
|
+
* const contract = Mandu.contract({
|
|
76
|
+
* request: {
|
|
77
|
+
* GET: { query: z.object({ page: z.number() }) },
|
|
78
|
+
* POST: { body: z.object({ name: z.string() }) },
|
|
79
|
+
* },
|
|
80
|
+
* response: {
|
|
81
|
+
* 200: z.object({ users: z.array(z.string()) }),
|
|
82
|
+
* 201: z.object({ user: z.string() }),
|
|
83
|
+
* },
|
|
84
|
+
* });
|
|
85
|
+
*
|
|
86
|
+
* // handlers is typed: { GET: (ctx) => ..., POST: (ctx) => ... }
|
|
87
|
+
* const handlers = Mandu.handler(contract, {
|
|
88
|
+
* GET: (ctx) => {
|
|
89
|
+
* // ctx.query is { page: number }
|
|
90
|
+
* return { users: [] };
|
|
91
|
+
* },
|
|
92
|
+
* POST: (ctx) => {
|
|
93
|
+
* // ctx.body is { name: string }
|
|
94
|
+
* return { user: ctx.body.name };
|
|
95
|
+
* },
|
|
96
|
+
* });
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
export type ContractHandlers<T extends ContractSchema> = {
|
|
100
|
+
[M in Extract<keyof T["request"], ContractMethod>]?: HandlerFn<
|
|
101
|
+
InferContextFromMethod<
|
|
102
|
+
T["request"][M] extends MethodRequestSchema ? T["request"][M] : undefined
|
|
103
|
+
>,
|
|
104
|
+
InferResponseUnion<T["response"]>
|
|
105
|
+
>;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Define type-safe handlers for a contract
|
|
110
|
+
*
|
|
111
|
+
* @param contract - The contract schema
|
|
112
|
+
* @param handlers - Handler implementations for each method
|
|
113
|
+
* @returns Typed handler object
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```typescript
|
|
117
|
+
* const handlers = defineHandler(userContract, {
|
|
118
|
+
* GET: async (ctx) => {
|
|
119
|
+
* const { page, limit } = ctx.query; // Typed!
|
|
120
|
+
* const users = await db.users.findMany({ skip: page * limit, take: limit });
|
|
121
|
+
* return { data: users };
|
|
122
|
+
* },
|
|
123
|
+
* POST: async (ctx) => {
|
|
124
|
+
* const user = await db.users.create({ data: ctx.body }); // Typed!
|
|
125
|
+
* return { data: user };
|
|
126
|
+
* },
|
|
127
|
+
* });
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
export function defineHandler<T extends ContractSchema>(
|
|
131
|
+
_contract: T,
|
|
132
|
+
handlers: ContractHandlers<T>
|
|
133
|
+
): ContractHandlers<T> {
|
|
134
|
+
return handlers;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Handler result with status code
|
|
139
|
+
* 응답에 상태 코드를 명시적으로 지정
|
|
140
|
+
*/
|
|
141
|
+
export interface HandlerResult<T = unknown> {
|
|
142
|
+
status: number;
|
|
143
|
+
data: T;
|
|
144
|
+
headers?: Record<string, string>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Create a typed response with status code
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* ```typescript
|
|
152
|
+
* const handler = defineHandler(contract, {
|
|
153
|
+
* POST: async (ctx) => {
|
|
154
|
+
* const user = await createUser(ctx.body);
|
|
155
|
+
* return response(201, { data: user });
|
|
156
|
+
* },
|
|
157
|
+
* });
|
|
158
|
+
* ```
|
|
159
|
+
*/
|
|
160
|
+
export function response<T>(
|
|
161
|
+
status: number,
|
|
162
|
+
data: T,
|
|
163
|
+
headers?: Record<string, string>
|
|
164
|
+
): HandlerResult<T> {
|
|
165
|
+
return { status, data, headers };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Type guard for HandlerResult
|
|
170
|
+
*/
|
|
171
|
+
export function isHandlerResult(value: unknown): value is HandlerResult {
|
|
172
|
+
return (
|
|
173
|
+
typeof value === "object" &&
|
|
174
|
+
value !== null &&
|
|
175
|
+
"status" in value &&
|
|
176
|
+
"data" in value
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Extract method-specific handler type from contract
|
|
182
|
+
*
|
|
183
|
+
* @example
|
|
184
|
+
* ```typescript
|
|
185
|
+
* type GetHandler = ExtractHandler<typeof userContract, "GET">;
|
|
186
|
+
* // (ctx: { query: { page: number }, ... }) => Promise<{ data: User[] }>
|
|
187
|
+
* ```
|
|
188
|
+
*/
|
|
189
|
+
export type ExtractHandler<
|
|
190
|
+
T extends ContractSchema,
|
|
191
|
+
M extends ContractMethod,
|
|
192
|
+
> = M extends keyof T["request"]
|
|
193
|
+
? HandlerFn<
|
|
194
|
+
InferContextFromMethod<
|
|
195
|
+
T["request"][M] extends MethodRequestSchema
|
|
196
|
+
? T["request"][M]
|
|
197
|
+
: undefined
|
|
198
|
+
>,
|
|
199
|
+
InferResponseUnion<T["response"]>
|
|
200
|
+
>
|
|
201
|
+
: never;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Utility to create a handler context from raw request
|
|
205
|
+
* 런타임에서 Request → TypedContext 변환
|
|
206
|
+
*/
|
|
207
|
+
export async function createContext<
|
|
208
|
+
TQuery = unknown,
|
|
209
|
+
TBody = unknown,
|
|
210
|
+
TParams = unknown,
|
|
211
|
+
THeaders = unknown,
|
|
212
|
+
>(
|
|
213
|
+
request: Request,
|
|
214
|
+
path: string,
|
|
215
|
+
method: ContractMethod,
|
|
216
|
+
parsedData: {
|
|
217
|
+
query?: TQuery;
|
|
218
|
+
body?: TBody;
|
|
219
|
+
params?: TParams;
|
|
220
|
+
headers?: THeaders;
|
|
221
|
+
} = {}
|
|
222
|
+
): Promise<TypedContext<TQuery, TBody, TParams, THeaders>> {
|
|
223
|
+
return {
|
|
224
|
+
query: parsedData.query as TQuery,
|
|
225
|
+
body: parsedData.body as TBody,
|
|
226
|
+
params: parsedData.params as TParams,
|
|
227
|
+
headers: parsedData.headers as THeaders,
|
|
228
|
+
request,
|
|
229
|
+
path,
|
|
230
|
+
method,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Combined contract + handler definition
|
|
236
|
+
* Contract와 Handler를 한 번에 정의
|
|
237
|
+
*
|
|
238
|
+
* @example
|
|
239
|
+
* ```typescript
|
|
240
|
+
* export default Mandu.route({
|
|
241
|
+
* contract: {
|
|
242
|
+
* request: {
|
|
243
|
+
* GET: { query: z.object({ id: z.string() }) },
|
|
244
|
+
* },
|
|
245
|
+
* response: {
|
|
246
|
+
* 200: z.object({ user: UserSchema }),
|
|
247
|
+
* },
|
|
248
|
+
* },
|
|
249
|
+
* handler: {
|
|
250
|
+
* GET: async (ctx) => {
|
|
251
|
+
* const user = await db.users.findUnique({ where: { id: ctx.query.id } });
|
|
252
|
+
* return { user };
|
|
253
|
+
* },
|
|
254
|
+
* },
|
|
255
|
+
* });
|
|
256
|
+
* ```
|
|
257
|
+
*/
|
|
258
|
+
export interface RouteDefinition<T extends ContractSchema> {
|
|
259
|
+
contract: T;
|
|
260
|
+
handler: ContractHandlers<T>;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Define a complete route with contract and handler
|
|
265
|
+
*/
|
|
266
|
+
export function defineRoute<T extends ContractSchema>(
|
|
267
|
+
definition: RouteDefinition<T>
|
|
268
|
+
): RouteDefinition<T> {
|
|
269
|
+
return definition;
|
|
270
|
+
}
|
package/src/contract/index.ts
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mandu Contract Module
|
|
3
3
|
* Contract-first API 정의 시스템
|
|
4
|
+
*
|
|
5
|
+
* Elysia DNA 패턴 채택:
|
|
6
|
+
* - Contract → Handler 타입 자동 추론
|
|
7
|
+
* - TypedContext로 요청 데이터 접근
|
|
8
|
+
* - z.object({...}) 스키마 기반 검증
|
|
4
9
|
*/
|
|
5
10
|
|
|
6
11
|
export * from "./schema";
|
|
7
12
|
export * from "./types";
|
|
8
13
|
export * from "./validator";
|
|
14
|
+
export * from "./handler";
|
|
15
|
+
export * from "./client";
|
|
9
16
|
|
|
10
|
-
import type { ContractDefinition, ContractInstance } from "./schema";
|
|
17
|
+
import type { ContractDefinition, ContractInstance, ContractSchema } from "./schema";
|
|
18
|
+
import type { ContractHandlers, RouteDefinition } from "./handler";
|
|
19
|
+
import { defineHandler, defineRoute } from "./handler";
|
|
20
|
+
import { createClient, contractFetch, type ClientOptions } from "./client";
|
|
11
21
|
|
|
12
22
|
/**
|
|
13
23
|
* Create a Mandu API Contract
|
|
@@ -61,3 +71,129 @@ export function createContract<T extends ContractDefinition>(definition: T): T &
|
|
|
61
71
|
_validated: false,
|
|
62
72
|
};
|
|
63
73
|
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Mandu Namespace
|
|
77
|
+
*
|
|
78
|
+
* Contract-first API 개발을 위한 메인 인터페이스
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```typescript
|
|
82
|
+
* import { Mandu } from "@mandujs/core";
|
|
83
|
+
* import { z } from "zod";
|
|
84
|
+
*
|
|
85
|
+
* // 1. Contract 정의
|
|
86
|
+
* const userContract = Mandu.contract({
|
|
87
|
+
* request: {
|
|
88
|
+
* GET: { query: z.object({ id: z.string() }) },
|
|
89
|
+
* POST: { body: z.object({ name: z.string(), email: z.string().email() }) },
|
|
90
|
+
* },
|
|
91
|
+
* response: {
|
|
92
|
+
* 200: z.object({ user: z.object({ id: z.string(), name: z.string() }) }),
|
|
93
|
+
* 201: z.object({ user: z.object({ id: z.string(), name: z.string() }) }),
|
|
94
|
+
* },
|
|
95
|
+
* });
|
|
96
|
+
*
|
|
97
|
+
* // 2. Handler 정의 (타입 자동 추론)
|
|
98
|
+
* const handlers = Mandu.handler(userContract, {
|
|
99
|
+
* GET: async (ctx) => {
|
|
100
|
+
* // ctx.query.id는 string 타입으로 자동 추론
|
|
101
|
+
* const user = await db.users.findUnique({ where: { id: ctx.query.id } });
|
|
102
|
+
* return { user };
|
|
103
|
+
* },
|
|
104
|
+
* POST: async (ctx) => {
|
|
105
|
+
* // ctx.body.name, ctx.body.email 자동 추론
|
|
106
|
+
* const user = await db.users.create({ data: ctx.body });
|
|
107
|
+
* return { user };
|
|
108
|
+
* },
|
|
109
|
+
* });
|
|
110
|
+
*
|
|
111
|
+
* // 3. 또는 Route로 한 번에 정의
|
|
112
|
+
* export default Mandu.route({
|
|
113
|
+
* contract: userContract,
|
|
114
|
+
* handler: handlers,
|
|
115
|
+
* });
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
/**
|
|
119
|
+
* Contract-specific Mandu functions
|
|
120
|
+
* Note: Use `ManduContract` to avoid conflict with other Mandu exports
|
|
121
|
+
*/
|
|
122
|
+
export const ManduContract = {
|
|
123
|
+
/**
|
|
124
|
+
* Create a typed Contract
|
|
125
|
+
* Contract 스키마 정의 및 타입 추론
|
|
126
|
+
*/
|
|
127
|
+
contract: createContract,
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Create typed handlers for a contract
|
|
131
|
+
* Contract 기반 타입 안전 핸들러 정의
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```typescript
|
|
135
|
+
* const handlers = Mandu.handler(contract, {
|
|
136
|
+
* GET: (ctx) => {
|
|
137
|
+
* // ctx.query, ctx.body, ctx.params 모두 타입 추론
|
|
138
|
+
* return { data: ctx.query.id };
|
|
139
|
+
* },
|
|
140
|
+
* });
|
|
141
|
+
* ```
|
|
142
|
+
*/
|
|
143
|
+
handler: defineHandler,
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Define a complete route with contract and handler
|
|
147
|
+
* Contract와 Handler를 한 번에 정의
|
|
148
|
+
*
|
|
149
|
+
* @example
|
|
150
|
+
* ```typescript
|
|
151
|
+
* export default Mandu.route({
|
|
152
|
+
* contract: {
|
|
153
|
+
* request: { GET: { query: z.object({ id: z.string() }) } },
|
|
154
|
+
* response: { 200: z.object({ data: z.string() }) },
|
|
155
|
+
* },
|
|
156
|
+
* handler: {
|
|
157
|
+
* GET: (ctx) => ({ data: ctx.query.id }),
|
|
158
|
+
* },
|
|
159
|
+
* });
|
|
160
|
+
* ```
|
|
161
|
+
*/
|
|
162
|
+
route: defineRoute,
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Create a type-safe API client from contract
|
|
166
|
+
* Contract 기반 타입 안전 클라이언트 생성
|
|
167
|
+
*
|
|
168
|
+
* @example
|
|
169
|
+
* ```typescript
|
|
170
|
+
* const client = Mandu.client(userContract, {
|
|
171
|
+
* baseUrl: "http://localhost:3000/api/users",
|
|
172
|
+
* });
|
|
173
|
+
*
|
|
174
|
+
* // Type-safe API calls
|
|
175
|
+
* const users = await client.GET({ query: { page: 1 } });
|
|
176
|
+
* const newUser = await client.POST({ body: { name: "Alice" } });
|
|
177
|
+
* ```
|
|
178
|
+
*/
|
|
179
|
+
client: createClient,
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Single type-safe fetch call
|
|
183
|
+
* 단일 타입 안전 fetch 호출
|
|
184
|
+
*
|
|
185
|
+
* @example
|
|
186
|
+
* ```typescript
|
|
187
|
+
* const result = await Mandu.fetch(contract, "GET", "/api/users", {
|
|
188
|
+
* query: { page: 1 },
|
|
189
|
+
* });
|
|
190
|
+
* ```
|
|
191
|
+
*/
|
|
192
|
+
fetch: contractFetch,
|
|
193
|
+
} as const;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Alias for backward compatibility within contract module
|
|
197
|
+
* 외부에서는 메인 index.ts의 Mandu를 사용하세요
|
|
198
|
+
*/
|
|
199
|
+
export const Mandu = ManduContract;
|