@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -103,8 +103,9 @@ import { Link, NavLink } from "./Link";
103
103
  /**
104
104
  * Mandu Client namespace
105
105
  * v0.8.0: Hydration은 자동으로 처리됨 (generateRuntimeSource에서 생성)
106
+ * Note: Use `ManduClient` to avoid conflict with other Mandu exports
106
107
  */
107
- export const Mandu = {
108
+ export const ManduClient = {
108
109
  /**
109
110
  * Create an island component
110
111
  * @see island
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Mandu Contract Client Tests
3
+ *
4
+ * 클라이언트 타입 추론 및 기능 테스트
5
+ */
6
+
7
+ import { describe, it, expect, mock } from "bun:test";
8
+ import { z } from "zod";
9
+ import { Mandu, createClient, contractFetch } from "./index";
10
+
11
+ // === Test Contract ===
12
+ const testContract = Mandu.contract({
13
+ description: "Test API",
14
+ tags: ["test"],
15
+ request: {
16
+ GET: {
17
+ query: z.object({
18
+ page: z.coerce.number().default(1),
19
+ search: z.string().optional(),
20
+ }),
21
+ },
22
+ POST: {
23
+ body: z.object({
24
+ name: z.string(),
25
+ email: z.string().email(),
26
+ }),
27
+ },
28
+ PUT: {
29
+ params: z.object({
30
+ id: z.string(),
31
+ }),
32
+ body: z.object({
33
+ name: z.string().optional(),
34
+ }),
35
+ },
36
+ DELETE: {
37
+ params: z.object({
38
+ id: z.string(),
39
+ }),
40
+ },
41
+ },
42
+ response: {
43
+ 200: z.object({
44
+ data: z.array(
45
+ z.object({
46
+ id: z.string(),
47
+ name: z.string(),
48
+ })
49
+ ),
50
+ total: z.number(),
51
+ }),
52
+ 201: z.object({
53
+ data: z.object({
54
+ id: z.string(),
55
+ name: z.string(),
56
+ email: z.string(),
57
+ }),
58
+ }),
59
+ 204: z.undefined(),
60
+ 404: z.object({
61
+ error: z.string(),
62
+ }),
63
+ },
64
+ });
65
+
66
+ describe("Contract Client", () => {
67
+ it("should create a client with all HTTP methods", () => {
68
+ const client = createClient(testContract, {
69
+ baseUrl: "http://localhost:3000/api/test",
70
+ });
71
+
72
+ expect(client.GET).toBeDefined();
73
+ expect(client.POST).toBeDefined();
74
+ expect(client.PUT).toBeDefined();
75
+ expect(client.DELETE).toBeDefined();
76
+ expect(typeof client.GET).toBe("function");
77
+ expect(typeof client.POST).toBe("function");
78
+ });
79
+
80
+ it("should build query string correctly", async () => {
81
+ let capturedUrl = "";
82
+
83
+ const mockFetch = mock(async (url: string, _options: RequestInit) => {
84
+ capturedUrl = url;
85
+ return new Response(JSON.stringify({ data: [], total: 0 }), {
86
+ status: 200,
87
+ headers: { "Content-Type": "application/json" },
88
+ });
89
+ });
90
+
91
+ const client = createClient(testContract, {
92
+ baseUrl: "http://localhost:3000/api/test",
93
+ fetch: mockFetch as unknown as typeof fetch,
94
+ });
95
+
96
+ await client.GET({ query: { page: 2, search: "hello" } });
97
+
98
+ expect(capturedUrl).toContain("page=2");
99
+ expect(capturedUrl).toContain("search=hello");
100
+ });
101
+
102
+ it("should send JSON body for POST requests", async () => {
103
+ let capturedBody = "";
104
+ let capturedContentType = "";
105
+
106
+ const mockFetch = mock(async (_url: string, options: RequestInit) => {
107
+ capturedBody = options.body as string;
108
+ capturedContentType =
109
+ (options.headers as Record<string, string>)["Content-Type"] || "";
110
+ return new Response(
111
+ JSON.stringify({
112
+ data: { id: "1", name: "Test", email: "test@example.com" },
113
+ }),
114
+ {
115
+ status: 201,
116
+ headers: { "Content-Type": "application/json" },
117
+ }
118
+ );
119
+ });
120
+
121
+ const client = createClient(testContract, {
122
+ baseUrl: "http://localhost:3000/api/test",
123
+ fetch: mockFetch as unknown as typeof fetch,
124
+ });
125
+
126
+ await client.POST({ body: { name: "Test", email: "test@example.com" } });
127
+
128
+ expect(capturedContentType).toBe("application/json");
129
+ expect(JSON.parse(capturedBody)).toEqual({
130
+ name: "Test",
131
+ email: "test@example.com",
132
+ });
133
+ });
134
+
135
+ it("should parse JSON response", async () => {
136
+ const mockData = {
137
+ data: [
138
+ { id: "1", name: "User 1" },
139
+ { id: "2", name: "User 2" },
140
+ ],
141
+ total: 2,
142
+ };
143
+
144
+ const mockFetch = mock(async () => {
145
+ return new Response(JSON.stringify(mockData), {
146
+ status: 200,
147
+ headers: { "Content-Type": "application/json" },
148
+ });
149
+ });
150
+
151
+ const client = createClient(testContract, {
152
+ baseUrl: "http://localhost:3000/api/test",
153
+ fetch: mockFetch as unknown as typeof fetch,
154
+ });
155
+
156
+ const result = await client.GET({ query: { page: 1 } });
157
+
158
+ expect(result.status).toBe(200);
159
+ expect(result.ok).toBe(true);
160
+ expect(result.data).toEqual(mockData);
161
+ });
162
+
163
+ it("should include default headers", async () => {
164
+ let capturedHeaders: Record<string, string> = {};
165
+
166
+ const mockFetch = mock(async (_url: string, options: RequestInit) => {
167
+ capturedHeaders = options.headers as Record<string, string>;
168
+ return new Response(JSON.stringify({ data: [], total: 0 }), {
169
+ status: 200,
170
+ headers: { "Content-Type": "application/json" },
171
+ });
172
+ });
173
+
174
+ const client = createClient(testContract, {
175
+ baseUrl: "http://localhost:3000/api/test",
176
+ headers: {
177
+ Authorization: "Bearer token123",
178
+ "X-Custom-Header": "custom-value",
179
+ },
180
+ fetch: mockFetch as unknown as typeof fetch,
181
+ });
182
+
183
+ await client.GET();
184
+
185
+ expect(capturedHeaders["Authorization"]).toBe("Bearer token123");
186
+ expect(capturedHeaders["X-Custom-Header"]).toBe("custom-value");
187
+ });
188
+
189
+ it("should allow per-request headers", async () => {
190
+ let capturedHeaders: Record<string, string> = {};
191
+
192
+ const mockFetch = mock(async (_url: string, options: RequestInit) => {
193
+ capturedHeaders = options.headers as Record<string, string>;
194
+ return new Response(JSON.stringify({ data: [], total: 0 }), {
195
+ status: 200,
196
+ headers: { "Content-Type": "application/json" },
197
+ });
198
+ });
199
+
200
+ const client = createClient(testContract, {
201
+ baseUrl: "http://localhost:3000/api/test",
202
+ headers: { "X-Default": "default" },
203
+ fetch: mockFetch as unknown as typeof fetch,
204
+ });
205
+
206
+ await client.GET({
207
+ headers: { "X-Custom": "per-request" },
208
+ });
209
+
210
+ expect(capturedHeaders["X-Default"]).toBe("default");
211
+ expect(capturedHeaders["X-Custom"]).toBe("per-request");
212
+ });
213
+ });
214
+
215
+ describe("contractFetch", () => {
216
+ it("should make type-safe fetch call", async () => {
217
+ const mockFetch = mock(async () => {
218
+ return new Response(JSON.stringify({ data: [], total: 0 }), {
219
+ status: 200,
220
+ headers: { "Content-Type": "application/json" },
221
+ });
222
+ });
223
+
224
+ const result = await contractFetch(
225
+ testContract,
226
+ "GET",
227
+ "http://localhost:3000/api/test",
228
+ { query: { page: 1 } },
229
+ { fetch: mockFetch as unknown as typeof fetch }
230
+ );
231
+
232
+ expect(result.status).toBe(200);
233
+ expect(result.ok).toBe(true);
234
+ expect(mockFetch).toHaveBeenCalled();
235
+ });
236
+
237
+ it("should handle path parameters", async () => {
238
+ let capturedUrl = "";
239
+
240
+ const mockFetch = mock(async (url: string) => {
241
+ capturedUrl = url;
242
+ return new Response(JSON.stringify({ data: [], total: 0 }), {
243
+ status: 200,
244
+ headers: { "Content-Type": "application/json" },
245
+ });
246
+ });
247
+
248
+ await contractFetch(
249
+ testContract,
250
+ "PUT",
251
+ "http://localhost:3000/api/test/:id",
252
+ { params: { id: "123" }, body: { name: "Updated" } },
253
+ { fetch: mockFetch as unknown as typeof fetch }
254
+ );
255
+
256
+ expect(capturedUrl).toBe("http://localhost:3000/api/test/123");
257
+ });
258
+ });
259
+
260
+ describe("Mandu.client", () => {
261
+ it("should be accessible via Mandu namespace", () => {
262
+ expect(Mandu.client).toBe(createClient);
263
+ });
264
+
265
+ it("should work via Mandu namespace", async () => {
266
+ const mockFetch = mock(async () => {
267
+ return new Response(JSON.stringify({ data: [], total: 0 }), {
268
+ status: 200,
269
+ headers: { "Content-Type": "application/json" },
270
+ });
271
+ });
272
+
273
+ const client = Mandu.client(testContract, {
274
+ baseUrl: "http://localhost:3000/api/test",
275
+ fetch: mockFetch as unknown as typeof fetch,
276
+ });
277
+
278
+ const result = await client.GET({ query: { page: 1 } });
279
+
280
+ expect(result.status).toBe(200);
281
+ expect(result.data).toEqual({ data: [], total: 0 });
282
+ });
283
+ });
284
+
285
+ describe("Mandu.fetch", () => {
286
+ it("should be accessible via Mandu namespace", () => {
287
+ expect(Mandu.fetch).toBe(contractFetch);
288
+ });
289
+ });
290
+
291
+ describe("Type Safety (Compile-time)", () => {
292
+ it("should enforce query types", () => {
293
+ // This test verifies that the type system is working
294
+ // If the types are wrong, this won't compile
295
+ const client = createClient(testContract, {
296
+ baseUrl: "http://localhost:3000/api/test",
297
+ });
298
+
299
+ // These are valid calls (would compile)
300
+ const _validGet = () => client.GET({ query: { page: 1 } });
301
+ const _validPost = () =>
302
+ client.POST({ body: { name: "Test", email: "test@example.com" } });
303
+
304
+ // Type-level assertions
305
+ expect(typeof client.GET).toBe("function");
306
+ expect(typeof client.POST).toBe("function");
307
+ });
308
+ });
@@ -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
+ }