@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.
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Mandu Contract Type Inference Tests
3
+ *
4
+ * 이 테스트는 타입 추론이 올바르게 동작하는지 검증합니다.
5
+ * - Contract → Handler 타입 추론
6
+ * - TypedContext 타입 추론
7
+ * - Response 타입 추론
8
+ */
9
+
10
+ import { describe, it, expect } from "bun:test";
11
+ import { z } from "zod";
12
+ import { Mandu, type InferContract, type InferQuery, type InferBody, type InferResponse } from "./index";
13
+
14
+ // === Test Schemas ===
15
+ const UserSchema = z.object({
16
+ id: z.string().uuid(),
17
+ email: z.string().email(),
18
+ name: z.string().min(2),
19
+ createdAt: z.string().datetime(),
20
+ });
21
+
22
+ const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
23
+
24
+ const PaginationSchema = z.object({
25
+ page: z.coerce.number().default(1),
26
+ limit: z.coerce.number().default(10),
27
+ });
28
+
29
+ // === Test Contract ===
30
+ const userContract = Mandu.contract({
31
+ description: "User Management API",
32
+ tags: ["users"],
33
+ request: {
34
+ GET: {
35
+ query: PaginationSchema,
36
+ },
37
+ POST: {
38
+ body: CreateUserSchema,
39
+ },
40
+ PUT: {
41
+ params: z.object({ id: z.string().uuid() }),
42
+ body: CreateUserSchema.partial(),
43
+ },
44
+ DELETE: {
45
+ params: z.object({ id: z.string().uuid() }),
46
+ },
47
+ },
48
+ response: {
49
+ 200: z.object({
50
+ data: z.array(UserSchema),
51
+ total: z.number(),
52
+ }),
53
+ 201: z.object({
54
+ data: UserSchema,
55
+ }),
56
+ 204: z.undefined(),
57
+ 400: z.object({
58
+ error: z.string(),
59
+ details: z.array(z.string()).optional(),
60
+ }),
61
+ 404: z.object({
62
+ error: z.string(),
63
+ }),
64
+ },
65
+ });
66
+
67
+ // === Type Inference Tests ===
68
+ describe("Contract Type Inference", () => {
69
+ it("should infer contract types correctly", () => {
70
+ // Type-level test: InferContract
71
+ type ContractTypes = InferContract<typeof userContract>;
72
+
73
+ // These are compile-time checks - if they compile, types are correct
74
+ type _GetQuery = ContractTypes["request"]["GET"]["query"];
75
+ type _PostBody = ContractTypes["request"]["POST"]["body"];
76
+
77
+ // Runtime check
78
+ expect(userContract.description).toBe("User Management API");
79
+ expect(userContract.tags).toEqual(["users"]);
80
+ });
81
+
82
+ it("should infer query types for specific methods", () => {
83
+ // Type-level test: InferQuery
84
+ type GetQuery = InferQuery<typeof userContract, "GET">;
85
+
86
+ // Verify at runtime that schema exists
87
+ expect(userContract.request.GET?.query).toBeDefined();
88
+
89
+ // Test schema validation
90
+ const querySchema = userContract.request.GET?.query;
91
+ if (querySchema) {
92
+ const result = querySchema.safeParse({ page: "2", limit: "20" });
93
+ expect(result.success).toBe(true);
94
+ if (result.success) {
95
+ expect(result.data.page).toBe(2);
96
+ expect(result.data.limit).toBe(20);
97
+ }
98
+ }
99
+ });
100
+
101
+ it("should infer body types for specific methods", () => {
102
+ // Type-level test: InferBody
103
+ type PostBody = InferBody<typeof userContract, "POST">;
104
+
105
+ // Verify at runtime that schema exists
106
+ expect(userContract.request.POST?.body).toBeDefined();
107
+
108
+ // Test schema validation
109
+ const bodySchema = userContract.request.POST?.body;
110
+ if (bodySchema) {
111
+ const validData = {
112
+ email: "test@example.com",
113
+ name: "Test User",
114
+ };
115
+ const result = bodySchema.safeParse(validData);
116
+ expect(result.success).toBe(true);
117
+ }
118
+ });
119
+
120
+ it("should infer response types for specific status codes", () => {
121
+ // Type-level test: InferResponse
122
+ type Success200 = InferResponse<typeof userContract, 200>;
123
+ type Created201 = InferResponse<typeof userContract, 201>;
124
+ type Error400 = InferResponse<typeof userContract, 400>;
125
+
126
+ // Verify at runtime that schemas exist
127
+ expect(userContract.response[200]).toBeDefined();
128
+ expect(userContract.response[201]).toBeDefined();
129
+ expect(userContract.response[400]).toBeDefined();
130
+ });
131
+ });
132
+
133
+ // === Handler Type Inference Tests ===
134
+ describe("Handler Type Inference", () => {
135
+ it("should create typed handlers with correct context types", () => {
136
+ const handlers = Mandu.handler(userContract, {
137
+ GET: async (ctx) => {
138
+ // ctx.query should be typed as { page: number, limit: number }
139
+ const { page, limit } = ctx.query;
140
+ expect(typeof page).toBe("undefined"); // Not parsed yet at this level
141
+
142
+ // Return type should match response schema
143
+ return {
144
+ data: [],
145
+ total: 0,
146
+ };
147
+ },
148
+ POST: async (ctx) => {
149
+ // ctx.body should be typed as { email: string, name: string }
150
+ // Return type should match 201 response schema
151
+ return {
152
+ data: {
153
+ id: "123e4567-e89b-12d3-a456-426614174000",
154
+ email: "test@example.com",
155
+ name: "Test",
156
+ createdAt: new Date().toISOString(),
157
+ },
158
+ };
159
+ },
160
+ });
161
+
162
+ expect(handlers.GET).toBeDefined();
163
+ expect(handlers.POST).toBeDefined();
164
+ expect(typeof handlers.GET).toBe("function");
165
+ expect(typeof handlers.POST).toBe("function");
166
+ });
167
+
168
+ it("should allow partial handler implementation", () => {
169
+ // Only implement some methods
170
+ const partialHandlers = Mandu.handler(userContract, {
171
+ GET: (ctx) => ({
172
+ data: [],
173
+ total: 0,
174
+ }),
175
+ // POST, PUT, DELETE not implemented - should be allowed
176
+ });
177
+
178
+ expect(partialHandlers.GET).toBeDefined();
179
+ expect(partialHandlers.POST).toBeUndefined();
180
+ });
181
+ });
182
+
183
+ // === Route Definition Tests ===
184
+ describe("Route Definition", () => {
185
+ it("should define route with contract and handler", () => {
186
+ const route = Mandu.route({
187
+ contract: userContract,
188
+ handler: {
189
+ GET: (ctx) => ({
190
+ data: [],
191
+ total: 0,
192
+ }),
193
+ POST: (ctx) => ({
194
+ data: {
195
+ id: "123e4567-e89b-12d3-a456-426614174000",
196
+ email: ctx.body.email,
197
+ name: ctx.body.name,
198
+ createdAt: new Date().toISOString(),
199
+ },
200
+ }),
201
+ },
202
+ });
203
+
204
+ expect(route.contract).toBe(userContract);
205
+ expect(route.handler.GET).toBeDefined();
206
+ expect(route.handler.POST).toBeDefined();
207
+ });
208
+
209
+ it("should preserve contract metadata in route", () => {
210
+ const route = Mandu.route({
211
+ contract: {
212
+ description: "Test Route",
213
+ tags: ["test"],
214
+ request: {
215
+ GET: { query: z.object({ id: z.string() }) },
216
+ },
217
+ response: {
218
+ 200: z.object({ result: z.string() }),
219
+ },
220
+ },
221
+ handler: {
222
+ GET: (ctx) => ({ result: ctx.query.id }),
223
+ },
224
+ });
225
+
226
+ expect(route.contract.description).toBe("Test Route");
227
+ expect(route.contract.tags).toEqual(["test"]);
228
+ });
229
+ });
230
+
231
+ // === Integration with Validator Tests ===
232
+ describe("Contract + Validator Integration", () => {
233
+ it("should validate request against contract schema", async () => {
234
+ const { ContractValidator } = await import("./validator");
235
+
236
+ const validator = new ContractValidator(userContract);
237
+
238
+ // Create a mock request
239
+ const request = new Request("http://localhost/users?page=1&limit=10", {
240
+ method: "GET",
241
+ });
242
+
243
+ const result = await validator.validateRequest(request, "GET");
244
+ expect(result.success).toBe(true);
245
+ });
246
+
247
+ it("should reject invalid request", async () => {
248
+ const { ContractValidator } = await import("./validator");
249
+
250
+ const validator = new ContractValidator(userContract);
251
+
252
+ // Create request with invalid body
253
+ const request = new Request("http://localhost/users", {
254
+ method: "POST",
255
+ headers: { "Content-Type": "application/json" },
256
+ body: JSON.stringify({ email: "invalid-email" }), // Missing name, invalid email
257
+ });
258
+
259
+ const result = await validator.validateRequest(request, "POST");
260
+ expect(result.success).toBe(false);
261
+ expect(result.errors).toBeDefined();
262
+ });
263
+ });
264
+
265
+ // === Complex Type Scenarios ===
266
+ describe("Complex Type Scenarios", () => {
267
+ it("should handle nested schemas", () => {
268
+ const nestedContract = Mandu.contract({
269
+ request: {
270
+ POST: {
271
+ body: z.object({
272
+ user: z.object({
273
+ profile: z.object({
274
+ bio: z.string(),
275
+ avatar: z.string().url().optional(),
276
+ }),
277
+ }),
278
+ }),
279
+ },
280
+ },
281
+ response: {
282
+ 200: z.object({ success: z.boolean() }),
283
+ },
284
+ });
285
+
286
+ const handlers = Mandu.handler(nestedContract, {
287
+ POST: (ctx) => {
288
+ // Deep nested access should be typed
289
+ const bio = ctx.body.user.profile.bio;
290
+ return { success: true };
291
+ },
292
+ });
293
+
294
+ expect(handlers.POST).toBeDefined();
295
+ });
296
+
297
+ it("should handle union types in response", () => {
298
+ const unionContract = Mandu.contract({
299
+ request: {
300
+ GET: { query: z.object({ id: z.string() }) },
301
+ },
302
+ response: {
303
+ 200: z.object({ found: z.literal(true), data: z.string() }),
304
+ 404: z.object({ found: z.literal(false), message: z.string() }),
305
+ },
306
+ });
307
+
308
+ const handlers = Mandu.handler(unionContract, {
309
+ GET: (ctx) => {
310
+ // Can return either 200 or 404 response shape
311
+ if (ctx.query.id === "not-found") {
312
+ return { found: false as const, message: "Not found" };
313
+ }
314
+ return { found: true as const, data: "Found!" };
315
+ },
316
+ });
317
+
318
+ expect(handlers.GET).toBeDefined();
319
+ });
320
+
321
+ it("should handle optional fields correctly", () => {
322
+ const optionalContract = Mandu.contract({
323
+ request: {
324
+ POST: {
325
+ body: z.object({
326
+ required: z.string(),
327
+ optional: z.string().optional(),
328
+ withDefault: z.string().default("default"),
329
+ }),
330
+ },
331
+ },
332
+ response: {
333
+ 200: z.object({ result: z.string() }),
334
+ },
335
+ });
336
+
337
+ const handlers = Mandu.handler(optionalContract, {
338
+ POST: (ctx) => {
339
+ const { required, optional, withDefault } = ctx.body;
340
+ return { result: required + (optional ?? "") + withDefault };
341
+ },
342
+ });
343
+
344
+ expect(handlers.POST).toBeDefined();
345
+ });
346
+ });
@@ -132,3 +132,86 @@ export type ContractMethods<T extends ContractSchema> = Extract<
132
132
  * Helper type to get all defined status codes in a contract
133
133
  */
134
134
  export type ContractStatusCodes<T extends ContractSchema> = keyof T["response"];
135
+
136
+ /**
137
+ * Helper type to check if a method exists in contract
138
+ */
139
+ export type HasMethod<
140
+ T extends ContractSchema,
141
+ M extends string
142
+ > = M extends keyof T["request"] ? true : false;
143
+
144
+ /**
145
+ * Extract all required fields from a method schema
146
+ */
147
+ export type RequiredFields<
148
+ T extends ContractSchema,
149
+ M extends keyof T["request"]
150
+ > = T["request"][M] extends MethodRequestSchema
151
+ ? {
152
+ query: T["request"][M]["query"] extends z.ZodTypeAny ? true : false;
153
+ body: T["request"][M]["body"] extends z.ZodTypeAny ? true : false;
154
+ params: T["request"][M]["params"] extends z.ZodTypeAny ? true : false;
155
+ headers: T["request"][M]["headers"] extends z.ZodTypeAny ? true : false;
156
+ }
157
+ : never;
158
+
159
+ /**
160
+ * Get the success response type (200 or 201)
161
+ */
162
+ export type SuccessResponse<T extends ContractSchema> = T["response"][200] extends z.ZodTypeAny
163
+ ? z.infer<T["response"][200]>
164
+ : T["response"][201] extends z.ZodTypeAny
165
+ ? z.infer<T["response"][201]>
166
+ : never;
167
+
168
+ /**
169
+ * Get the error response type (400, 404, 500, etc.)
170
+ */
171
+ export type ErrorResponse<T extends ContractSchema> =
172
+ | (T["response"][400] extends z.ZodTypeAny ? z.infer<T["response"][400]> : never)
173
+ | (T["response"][401] extends z.ZodTypeAny ? z.infer<T["response"][401]> : never)
174
+ | (T["response"][403] extends z.ZodTypeAny ? z.infer<T["response"][403]> : never)
175
+ | (T["response"][404] extends z.ZodTypeAny ? z.infer<T["response"][404]> : never)
176
+ | (T["response"][500] extends z.ZodTypeAny ? z.infer<T["response"][500]> : never);
177
+
178
+ /**
179
+ * Utility type for strict contract enforcement
180
+ * Contract에 정의된 메서드만 허용
181
+ */
182
+ export type StrictContractMethods<T extends ContractSchema> = {
183
+ [M in ContractMethods<T>]: true;
184
+ };
185
+
186
+ /**
187
+ * Type-safe fetch options derived from contract
188
+ * 클라이언트에서 Contract 기반 fetch 호출에 사용
189
+ */
190
+ export type ContractFetchOptions<
191
+ T extends ContractSchema,
192
+ M extends keyof T["request"]
193
+ > = T["request"][M] extends MethodRequestSchema
194
+ ? {
195
+ query?: T["request"][M]["query"] extends z.ZodTypeAny
196
+ ? z.input<T["request"][M]["query"]>
197
+ : never;
198
+ body?: T["request"][M]["body"] extends z.ZodTypeAny
199
+ ? z.input<T["request"][M]["body"]>
200
+ : never;
201
+ params?: T["request"][M]["params"] extends z.ZodTypeAny
202
+ ? z.input<T["request"][M]["params"]>
203
+ : never;
204
+ headers?: T["request"][M]["headers"] extends z.ZodTypeAny
205
+ ? z.input<T["request"][M]["headers"]>
206
+ : never;
207
+ }
208
+ : never;
209
+
210
+ /**
211
+ * Response type union for a contract
212
+ */
213
+ export type ContractResponseUnion<T extends ContractSchema> = {
214
+ [K in keyof T["response"]]: T["response"][K] extends z.ZodTypeAny
215
+ ? { status: K; data: z.infer<T["response"][K]> }
216
+ : never;
217
+ }[keyof T["response"]];
@@ -293,7 +293,11 @@ export class ManduFilling<TLoaderData = unknown> {
293
293
  }
294
294
  }
295
295
 
296
- export const Mandu = {
296
+ /**
297
+ * Mandu Filling factory functions
298
+ * Note: These are also available via the main `Mandu` namespace
299
+ */
300
+ export const ManduFillingFactory = {
297
301
  filling<TLoaderData = unknown>(): ManduFilling<TLoaderData> {
298
302
  return new ManduFilling<TLoaderData>();
299
303
  },
@@ -4,7 +4,7 @@
4
4
 
5
5
  export { ManduContext, ValidationError, CookieManager } from "./context";
6
6
  export type { CookieOptions } from "./context";
7
- export { ManduFilling, Mandu, LoaderTimeoutError } from "./filling";
7
+ export { ManduFilling, ManduFillingFactory, LoaderTimeoutError } from "./filling";
8
8
  export type { Handler, Guard, HttpMethod, Loader, LoaderOptions } from "./filling";
9
9
 
10
10
  // Auth Guards
package/src/index.ts CHANGED
@@ -12,3 +12,78 @@ export * from "./contract";
12
12
  export * from "./openapi";
13
13
  export * from "./brain";
14
14
  export * from "./watcher";
15
+
16
+ // Consolidated Mandu namespace
17
+ import { ManduFilling, ManduContext, ManduFillingFactory } from "./filling";
18
+ import { createContract, defineHandler, defineRoute, createClient, contractFetch } from "./contract";
19
+ import type { ContractDefinition, ContractInstance, ContractSchema } from "./contract";
20
+ import type { ContractHandlers, ClientOptions } from "./contract";
21
+
22
+ /**
23
+ * Mandu - Unified Namespace
24
+ *
25
+ * 통합된 Mandu API 인터페이스
26
+ *
27
+ * @example
28
+ * ```typescript
29
+ * import { Mandu } from "@mandujs/core";
30
+ * import { z } from "zod";
31
+ *
32
+ * // Filling (Handler) API
33
+ * export default Mandu.filling()
34
+ * .get(async (ctx) => ctx.json({ message: "Hello" }));
35
+ *
36
+ * // Contract API
37
+ * const contract = Mandu.contract({
38
+ * request: { GET: { query: z.object({ id: z.string() }) } },
39
+ * response: { 200: z.object({ data: z.string() }) },
40
+ * });
41
+ *
42
+ * // Handler API (with type inference)
43
+ * const handlers = Mandu.handler(contract, {
44
+ * GET: (ctx) => ({ data: ctx.query.id }),
45
+ * });
46
+ *
47
+ * // Client API (type-safe fetch)
48
+ * const client = Mandu.client(contract, { baseUrl: "/api" });
49
+ * const result = await client.GET({ query: { id: "123" } });
50
+ * ```
51
+ */
52
+ export const Mandu = {
53
+ // === Filling (Handler) API ===
54
+ /**
55
+ * Create a new filling (handler chain)
56
+ */
57
+ filling: ManduFillingFactory.filling,
58
+
59
+ /**
60
+ * Create a ManduContext from a Request
61
+ */
62
+ context: ManduFillingFactory.context,
63
+
64
+ // === Contract API ===
65
+ /**
66
+ * Define a typed API contract
67
+ */
68
+ contract: createContract,
69
+
70
+ /**
71
+ * Create typed handlers for a contract
72
+ */
73
+ handler: defineHandler,
74
+
75
+ /**
76
+ * Define a complete route (contract + handler)
77
+ */
78
+ route: defineRoute,
79
+
80
+ /**
81
+ * Create a type-safe API client
82
+ */
83
+ client: createClient,
84
+
85
+ /**
86
+ * Make a type-safe fetch call
87
+ */
88
+ fetch: contractFetch,
89
+ } as const;