@mandujs/core 0.9.1 → 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.
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Mandu Contract Client
3
+ * Contract 기반 타입 안전 클라이언트
4
+ *
5
+ * tRPC/Elysia Eden 패턴 채택:
6
+ * - Contract에서 클라이언트 타입 자동 추론
7
+ * - 타입 안전 fetch 호출
8
+ */
9
+
10
+ import type { z } from "zod";
11
+ import type {
12
+ ContractSchema,
13
+ ContractMethod,
14
+ MethodRequestSchema,
15
+ } from "./schema";
16
+
17
+ /**
18
+ * Client options for making requests
19
+ */
20
+ export interface ClientOptions {
21
+ /** Base URL for API requests */
22
+ baseUrl: string;
23
+ /** Default headers for all requests */
24
+ headers?: Record<string, string>;
25
+ /** Custom fetch function (for SSR or testing) */
26
+ fetch?: typeof fetch;
27
+ /** Request timeout in milliseconds */
28
+ timeout?: number;
29
+ }
30
+
31
+ /**
32
+ * Request options for a specific call
33
+ */
34
+ export interface RequestOptions<
35
+ TQuery = unknown,
36
+ TBody = unknown,
37
+ TParams = unknown,
38
+ THeaders = Record<string, string>,
39
+ > {
40
+ query?: TQuery;
41
+ body?: TBody;
42
+ params?: TParams;
43
+ headers?: THeaders;
44
+ signal?: AbortSignal;
45
+ }
46
+
47
+ /**
48
+ * Client response wrapper
49
+ */
50
+ export interface ClientResponse<T> {
51
+ data: T;
52
+ status: number;
53
+ headers: Headers;
54
+ ok: boolean;
55
+ }
56
+
57
+ /**
58
+ * Infer request options from method schema
59
+ */
60
+ type InferRequestOptions<T extends MethodRequestSchema | undefined> =
61
+ T extends MethodRequestSchema
62
+ ? RequestOptions<
63
+ T["query"] extends z.ZodTypeAny ? z.input<T["query"]> : undefined,
64
+ T["body"] extends z.ZodTypeAny ? z.input<T["body"]> : undefined,
65
+ T["params"] extends z.ZodTypeAny ? z.input<T["params"]> : undefined,
66
+ T["headers"] extends z.ZodTypeAny
67
+ ? z.input<T["headers"]>
68
+ : Record<string, string>
69
+ >
70
+ : RequestOptions<undefined, undefined, undefined, Record<string, string>>;
71
+
72
+ /**
73
+ * Infer success response from contract
74
+ */
75
+ type InferSuccessResponse<TResponse extends ContractSchema["response"]> =
76
+ TResponse[200] extends z.ZodTypeAny
77
+ ? z.infer<TResponse[200]>
78
+ : TResponse[201] extends z.ZodTypeAny
79
+ ? z.infer<TResponse[201]>
80
+ : unknown;
81
+
82
+ /**
83
+ * Contract client method
84
+ */
85
+ export type ContractClientMethod<
86
+ T extends MethodRequestSchema | undefined,
87
+ TResponse extends ContractSchema["response"],
88
+ > = (
89
+ options?: InferRequestOptions<T>
90
+ ) => Promise<ClientResponse<InferSuccessResponse<TResponse>>>;
91
+
92
+ /**
93
+ * Contract client interface
94
+ */
95
+ export type ContractClient<T extends ContractSchema> = {
96
+ [M in Extract<keyof T["request"], ContractMethod>]: ContractClientMethod<
97
+ T["request"][M] extends MethodRequestSchema ? T["request"][M] : undefined,
98
+ T["response"]
99
+ >;
100
+ };
101
+
102
+ /**
103
+ * Build query string from object
104
+ */
105
+ function buildQueryString(query: Record<string, unknown> | undefined): string {
106
+ if (!query) return "";
107
+
108
+ const params = new URLSearchParams();
109
+ for (const [key, value] of Object.entries(query)) {
110
+ if (value !== undefined && value !== null) {
111
+ if (Array.isArray(value)) {
112
+ for (const v of value) {
113
+ params.append(key, String(v));
114
+ }
115
+ } else {
116
+ params.append(key, String(value));
117
+ }
118
+ }
119
+ }
120
+
121
+ const str = params.toString();
122
+ return str ? `?${str}` : "";
123
+ }
124
+
125
+ /**
126
+ * Replace path parameters in URL
127
+ */
128
+ function replacePathParams(
129
+ path: string,
130
+ params: Record<string, unknown> | undefined
131
+ ): string {
132
+ if (!params) return path;
133
+
134
+ let result = path;
135
+ for (const [key, value] of Object.entries(params)) {
136
+ result = result.replace(`:${key}`, encodeURIComponent(String(value)));
137
+ result = result.replace(`[${key}]`, encodeURIComponent(String(value)));
138
+ }
139
+ return result;
140
+ }
141
+
142
+ /**
143
+ * Create a type-safe client from a contract
144
+ *
145
+ * @example
146
+ * ```typescript
147
+ * const userContract = Mandu.contract({
148
+ * request: {
149
+ * GET: { query: z.object({ page: z.number() }) },
150
+ * POST: { body: z.object({ name: z.string() }) },
151
+ * },
152
+ * response: {
153
+ * 200: z.object({ users: z.array(UserSchema) }),
154
+ * 201: z.object({ user: UserSchema }),
155
+ * },
156
+ * });
157
+ *
158
+ * const client = createClient(userContract, {
159
+ * baseUrl: "http://localhost:3000/api/users",
160
+ * });
161
+ *
162
+ * // Type-safe calls
163
+ * const users = await client.GET({ query: { page: 1 } });
164
+ * // users.data is typed as { users: User[] }
165
+ *
166
+ * const newUser = await client.POST({ body: { name: "Alice" } });
167
+ * // newUser.data is typed as { user: User }
168
+ * ```
169
+ */
170
+ export function createClient<T extends ContractSchema>(
171
+ _contract: T,
172
+ options: ClientOptions
173
+ ): ContractClient<T> {
174
+ const {
175
+ baseUrl,
176
+ headers: defaultHeaders = {},
177
+ fetch: customFetch = fetch,
178
+ timeout = 30000,
179
+ } = options;
180
+
181
+ const methods: ContractMethod[] = ["GET", "POST", "PUT", "PATCH", "DELETE"];
182
+
183
+ const client = {} as ContractClient<T>;
184
+
185
+ for (const method of methods) {
186
+ // @ts-expect-error - Dynamic method assignment
187
+ client[method] = async (
188
+ requestOptions: RequestOptions = {}
189
+ ): Promise<ClientResponse<unknown>> => {
190
+ const { query, body, params, headers = {}, signal } = requestOptions;
191
+
192
+ // Build URL
193
+ let url = replacePathParams(baseUrl, params as Record<string, unknown>);
194
+ url += buildQueryString(query as Record<string, unknown>);
195
+
196
+ // Build request options
197
+ const fetchOptions: RequestInit = {
198
+ method,
199
+ headers: {
200
+ ...defaultHeaders,
201
+ ...headers,
202
+ },
203
+ signal,
204
+ };
205
+
206
+ // Add body for non-GET methods
207
+ if (body && method !== "GET") {
208
+ fetchOptions.headers = {
209
+ ...fetchOptions.headers,
210
+ "Content-Type": "application/json",
211
+ };
212
+ fetchOptions.body = JSON.stringify(body);
213
+ }
214
+
215
+ // Add timeout
216
+ const controller = new AbortController();
217
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
218
+
219
+ if (!signal) {
220
+ fetchOptions.signal = controller.signal;
221
+ }
222
+
223
+ try {
224
+ const response = await customFetch(url, fetchOptions);
225
+ clearTimeout(timeoutId);
226
+
227
+ let data: unknown;
228
+ const contentType = response.headers.get("content-type") || "";
229
+
230
+ if (contentType.includes("application/json")) {
231
+ data = await response.json();
232
+ } else {
233
+ data = await response.text();
234
+ }
235
+
236
+ return {
237
+ data,
238
+ status: response.status,
239
+ headers: response.headers,
240
+ ok: response.ok,
241
+ };
242
+ } catch (error) {
243
+ clearTimeout(timeoutId);
244
+ throw error;
245
+ }
246
+ };
247
+ }
248
+
249
+ return client;
250
+ }
251
+
252
+ /**
253
+ * Type-safe fetch wrapper for a single endpoint
254
+ *
255
+ * @example
256
+ * ```typescript
257
+ * const result = await contractFetch(userContract, "GET", "/api/users", {
258
+ * query: { page: 1, limit: 10 },
259
+ * });
260
+ * // result.data is typed based on contract response
261
+ * ```
262
+ */
263
+ export async function contractFetch<
264
+ T extends ContractSchema,
265
+ M extends Extract<keyof T["request"], ContractMethod>,
266
+ >(
267
+ _contract: T,
268
+ method: M,
269
+ url: string,
270
+ options: InferRequestOptions<
271
+ T["request"][M] extends MethodRequestSchema ? T["request"][M] : undefined
272
+ > = {} as InferRequestOptions<
273
+ T["request"][M] extends MethodRequestSchema ? T["request"][M] : undefined
274
+ >,
275
+ clientOptions: Partial<ClientOptions> = {}
276
+ ): Promise<ClientResponse<InferSuccessResponse<T["response"]>>> {
277
+ const {
278
+ query,
279
+ body,
280
+ params,
281
+ headers = {},
282
+ signal,
283
+ } = options as RequestOptions;
284
+
285
+ const {
286
+ headers: defaultHeaders = {},
287
+ fetch: customFetch = fetch,
288
+ timeout = 30000,
289
+ } = clientOptions;
290
+
291
+ // Build URL
292
+ let finalUrl = replacePathParams(url, params as Record<string, unknown>);
293
+ finalUrl += buildQueryString(query as Record<string, unknown>);
294
+
295
+ // Build request options
296
+ const fetchOptions: RequestInit = {
297
+ method,
298
+ headers: {
299
+ ...defaultHeaders,
300
+ ...(headers as Record<string, string>),
301
+ },
302
+ signal,
303
+ };
304
+
305
+ // Add body for non-GET methods
306
+ if (body && method !== "GET") {
307
+ fetchOptions.headers = {
308
+ ...fetchOptions.headers,
309
+ "Content-Type": "application/json",
310
+ };
311
+ fetchOptions.body = JSON.stringify(body);
312
+ }
313
+
314
+ // Add timeout
315
+ const controller = new AbortController();
316
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
317
+
318
+ if (!signal) {
319
+ fetchOptions.signal = controller.signal;
320
+ }
321
+
322
+ try {
323
+ const response = await customFetch(finalUrl, fetchOptions);
324
+ clearTimeout(timeoutId);
325
+
326
+ let data: unknown;
327
+ const contentType = response.headers.get("content-type") || "";
328
+
329
+ if (contentType.includes("application/json")) {
330
+ data = await response.json();
331
+ } else {
332
+ data = await response.text();
333
+ }
334
+
335
+ return {
336
+ data: data as InferSuccessResponse<T["response"]>,
337
+ status: response.status,
338
+ headers: response.headers,
339
+ ok: response.ok,
340
+ };
341
+ } catch (error) {
342
+ clearTimeout(timeoutId);
343
+ throw error;
344
+ }
345
+ }
@@ -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
+ }