@mandujs/core 0.4.2 → 0.5.0

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,480 @@
1
+ /**
2
+ * Mandu OpenAPI Generator
3
+ * Contract에서 OpenAPI 3.0 스펙 자동 생성
4
+ */
5
+
6
+ import type { z } from "zod";
7
+ import type { RoutesManifest, RouteSpec } from "../spec/schema";
8
+ import type { ContractSchema, MethodRequestSchema } from "../contract/schema";
9
+ import path from "path";
10
+
11
+ // ============================================
12
+ // OpenAPI Types
13
+ // ============================================
14
+
15
+ export interface OpenAPIInfo {
16
+ title: string;
17
+ version: string;
18
+ description?: string;
19
+ }
20
+
21
+ export interface OpenAPIServer {
22
+ url: string;
23
+ description?: string;
24
+ }
25
+
26
+ export interface OpenAPIParameter {
27
+ name: string;
28
+ in: "query" | "path" | "header" | "cookie";
29
+ required?: boolean;
30
+ description?: string;
31
+ schema: OpenAPISchema;
32
+ }
33
+
34
+ export interface OpenAPIRequestBody {
35
+ required?: boolean;
36
+ description?: string;
37
+ content: {
38
+ [mediaType: string]: {
39
+ schema: OpenAPISchema;
40
+ };
41
+ };
42
+ }
43
+
44
+ export interface OpenAPIResponse {
45
+ description: string;
46
+ content?: {
47
+ [mediaType: string]: {
48
+ schema: OpenAPISchema;
49
+ };
50
+ };
51
+ }
52
+
53
+ export interface OpenAPIOperation {
54
+ summary?: string;
55
+ description?: string;
56
+ operationId?: string;
57
+ tags?: string[];
58
+ parameters?: OpenAPIParameter[];
59
+ requestBody?: OpenAPIRequestBody;
60
+ responses: {
61
+ [statusCode: string]: OpenAPIResponse;
62
+ };
63
+ }
64
+
65
+ export interface OpenAPIPathItem {
66
+ get?: OpenAPIOperation;
67
+ post?: OpenAPIOperation;
68
+ put?: OpenAPIOperation;
69
+ patch?: OpenAPIOperation;
70
+ delete?: OpenAPIOperation;
71
+ }
72
+
73
+ export interface OpenAPISchema {
74
+ type?: string;
75
+ format?: string;
76
+ description?: string;
77
+ properties?: Record<string, OpenAPISchema>;
78
+ items?: OpenAPISchema;
79
+ required?: string[];
80
+ enum?: unknown[];
81
+ default?: unknown;
82
+ minimum?: number;
83
+ maximum?: number;
84
+ minLength?: number;
85
+ maxLength?: number;
86
+ pattern?: string;
87
+ nullable?: boolean;
88
+ oneOf?: OpenAPISchema[];
89
+ anyOf?: OpenAPISchema[];
90
+ allOf?: OpenAPISchema[];
91
+ $ref?: string;
92
+ }
93
+
94
+ export interface OpenAPIDocument {
95
+ openapi: "3.0.3";
96
+ info: OpenAPIInfo;
97
+ servers?: OpenAPIServer[];
98
+ paths: Record<string, OpenAPIPathItem>;
99
+ components?: {
100
+ schemas?: Record<string, OpenAPISchema>;
101
+ };
102
+ tags?: Array<{ name: string; description?: string }>;
103
+ }
104
+
105
+ // ============================================
106
+ // Zod to OpenAPI Conversion
107
+ // ============================================
108
+
109
+ /**
110
+ * Convert Zod schema to OpenAPI schema
111
+ * Note: This is a simplified conversion. For production,
112
+ * consider using zod-to-openapi or similar library.
113
+ */
114
+ export function zodToOpenAPISchema(zodSchema: z.ZodTypeAny): OpenAPISchema {
115
+ const def = zodSchema._def;
116
+
117
+ // Handle ZodOptional
118
+ if (def.typeName === "ZodOptional") {
119
+ const innerSchema = zodToOpenAPISchema(def.innerType);
120
+ return { ...innerSchema, nullable: true };
121
+ }
122
+
123
+ // Handle ZodDefault
124
+ if (def.typeName === "ZodDefault") {
125
+ const innerSchema = zodToOpenAPISchema(def.innerType);
126
+ return { ...innerSchema, default: def.defaultValue() };
127
+ }
128
+
129
+ // Handle ZodNullable
130
+ if (def.typeName === "ZodNullable") {
131
+ const innerSchema = zodToOpenAPISchema(def.innerType);
132
+ return { ...innerSchema, nullable: true };
133
+ }
134
+
135
+ // Handle ZodEffects (coerce, transform, etc.)
136
+ if (def.typeName === "ZodEffects") {
137
+ return zodToOpenAPISchema(def.schema);
138
+ }
139
+
140
+ // Handle ZodString
141
+ if (def.typeName === "ZodString") {
142
+ const schema: OpenAPISchema = { type: "string" };
143
+ for (const check of def.checks || []) {
144
+ if (check.kind === "email") schema.format = "email";
145
+ if (check.kind === "uuid") schema.format = "uuid";
146
+ if (check.kind === "url") schema.format = "uri";
147
+ if (check.kind === "datetime") schema.format = "date-time";
148
+ if (check.kind === "min") schema.minLength = check.value;
149
+ if (check.kind === "max") schema.maxLength = check.value;
150
+ if (check.kind === "regex") schema.pattern = check.regex.source;
151
+ }
152
+ return schema;
153
+ }
154
+
155
+ // Handle ZodNumber
156
+ if (def.typeName === "ZodNumber") {
157
+ const schema: OpenAPISchema = { type: "number" };
158
+ for (const check of def.checks || []) {
159
+ if (check.kind === "int") schema.type = "integer";
160
+ if (check.kind === "min") schema.minimum = check.value;
161
+ if (check.kind === "max") schema.maximum = check.value;
162
+ }
163
+ return schema;
164
+ }
165
+
166
+ // Handle ZodBoolean
167
+ if (def.typeName === "ZodBoolean") {
168
+ return { type: "boolean" };
169
+ }
170
+
171
+ // Handle ZodArray
172
+ if (def.typeName === "ZodArray") {
173
+ return {
174
+ type: "array",
175
+ items: zodToOpenAPISchema(def.type),
176
+ };
177
+ }
178
+
179
+ // Handle ZodObject
180
+ if (def.typeName === "ZodObject") {
181
+ const properties: Record<string, OpenAPISchema> = {};
182
+ const required: string[] = [];
183
+
184
+ const shape = def.shape();
185
+ for (const [key, value] of Object.entries(shape)) {
186
+ properties[key] = zodToOpenAPISchema(value as z.ZodTypeAny);
187
+
188
+ // Check if field is required
189
+ const fieldDef = (value as z.ZodTypeAny)._def;
190
+ if (fieldDef.typeName !== "ZodOptional" && fieldDef.typeName !== "ZodDefault") {
191
+ required.push(key);
192
+ }
193
+ }
194
+
195
+ return {
196
+ type: "object",
197
+ properties,
198
+ ...(required.length > 0 ? { required } : {}),
199
+ };
200
+ }
201
+
202
+ // Handle ZodEnum
203
+ if (def.typeName === "ZodEnum") {
204
+ return {
205
+ type: "string",
206
+ enum: def.values,
207
+ };
208
+ }
209
+
210
+ // Handle ZodUnion
211
+ if (def.typeName === "ZodUnion") {
212
+ return {
213
+ oneOf: def.options.map((opt: z.ZodTypeAny) => zodToOpenAPISchema(opt)),
214
+ };
215
+ }
216
+
217
+ // Handle ZodLiteral
218
+ if (def.typeName === "ZodLiteral") {
219
+ const value = def.value;
220
+ return {
221
+ type: typeof value as string,
222
+ enum: [value],
223
+ };
224
+ }
225
+
226
+ // Handle ZodVoid/ZodUndefined (no content)
227
+ if (def.typeName === "ZodVoid" || def.typeName === "ZodUndefined") {
228
+ return {};
229
+ }
230
+
231
+ // Handle ZodAny/ZodUnknown
232
+ if (def.typeName === "ZodAny" || def.typeName === "ZodUnknown") {
233
+ return {};
234
+ }
235
+
236
+ // Fallback
237
+ return {};
238
+ }
239
+
240
+ // ============================================
241
+ // OpenAPI Generation
242
+ // ============================================
243
+
244
+ /**
245
+ * Get HTTP status description
246
+ */
247
+ function getStatusDescription(status: number): string {
248
+ const descriptions: Record<number, string> = {
249
+ 200: "OK",
250
+ 201: "Created",
251
+ 204: "No Content",
252
+ 400: "Bad Request",
253
+ 401: "Unauthorized",
254
+ 403: "Forbidden",
255
+ 404: "Not Found",
256
+ 500: "Internal Server Error",
257
+ };
258
+ return descriptions[status] || "Response";
259
+ }
260
+
261
+ /**
262
+ * Generate OpenAPI parameters from method request schema
263
+ */
264
+ function generateParameters(
265
+ methodSchema: MethodRequestSchema,
266
+ routePattern: string
267
+ ): OpenAPIParameter[] {
268
+ const parameters: OpenAPIParameter[] = [];
269
+
270
+ // Extract path parameters from pattern
271
+ const pathParamMatches = routePattern.matchAll(/:(\w+)/g);
272
+ for (const match of pathParamMatches) {
273
+ parameters.push({
274
+ name: match[1],
275
+ in: "path",
276
+ required: true,
277
+ schema: { type: "string" },
278
+ });
279
+ }
280
+
281
+ // Query parameters
282
+ if (methodSchema.query) {
283
+ const querySchema = zodToOpenAPISchema(methodSchema.query);
284
+ if (querySchema.properties) {
285
+ for (const [name, schema] of Object.entries(querySchema.properties)) {
286
+ parameters.push({
287
+ name,
288
+ in: "query",
289
+ required: querySchema.required?.includes(name) ?? false,
290
+ schema: schema as OpenAPISchema,
291
+ });
292
+ }
293
+ }
294
+ }
295
+
296
+ // Header parameters
297
+ if (methodSchema.headers) {
298
+ const headerSchema = zodToOpenAPISchema(methodSchema.headers);
299
+ if (headerSchema.properties) {
300
+ for (const [name, schema] of Object.entries(headerSchema.properties)) {
301
+ parameters.push({
302
+ name,
303
+ in: "header",
304
+ required: headerSchema.required?.includes(name) ?? false,
305
+ schema: schema as OpenAPISchema,
306
+ });
307
+ }
308
+ }
309
+ }
310
+
311
+ return parameters;
312
+ }
313
+
314
+ /**
315
+ * Generate OpenAPI operation for a method
316
+ */
317
+ function generateOperation(
318
+ route: RouteSpec,
319
+ method: string,
320
+ contract: ContractSchema
321
+ ): OpenAPIOperation {
322
+ const methodSchema = contract.request[method] as MethodRequestSchema | undefined;
323
+ const operation: OpenAPIOperation = {
324
+ summary: contract.description,
325
+ operationId: `${route.id}_${method.toLowerCase()}`,
326
+ tags: contract.tags || [route.id],
327
+ responses: {},
328
+ };
329
+
330
+ // Parameters
331
+ if (methodSchema) {
332
+ const params = generateParameters(methodSchema, route.pattern);
333
+ if (params.length > 0) {
334
+ operation.parameters = params;
335
+ }
336
+
337
+ // Request body
338
+ if (methodSchema.body) {
339
+ operation.requestBody = {
340
+ required: true,
341
+ content: {
342
+ "application/json": {
343
+ schema: zodToOpenAPISchema(methodSchema.body),
344
+ },
345
+ },
346
+ };
347
+ }
348
+ }
349
+
350
+ // Responses
351
+ for (const [statusCode, responseSchema] of Object.entries(contract.response)) {
352
+ const status = parseInt(statusCode, 10);
353
+ if (isNaN(status)) continue;
354
+
355
+ const schema = zodToOpenAPISchema(responseSchema as z.ZodTypeAny);
356
+ const hasContent = Object.keys(schema).length > 0;
357
+
358
+ operation.responses[statusCode] = {
359
+ description: getStatusDescription(status),
360
+ ...(hasContent && {
361
+ content: {
362
+ "application/json": { schema },
363
+ },
364
+ }),
365
+ };
366
+ }
367
+
368
+ // Default 500 response if not defined
369
+ if (!operation.responses["500"]) {
370
+ operation.responses["500"] = {
371
+ description: "Internal Server Error",
372
+ content: {
373
+ "application/json": {
374
+ schema: {
375
+ type: "object",
376
+ properties: {
377
+ error: { type: "string" },
378
+ message: { type: "string" },
379
+ },
380
+ },
381
+ },
382
+ },
383
+ };
384
+ }
385
+
386
+ return operation;
387
+ }
388
+
389
+ /**
390
+ * Load contract from file
391
+ */
392
+ async function loadContract(contractPath: string, rootDir: string): Promise<ContractSchema | null> {
393
+ try {
394
+ const fullPath = path.join(rootDir, contractPath);
395
+ const module = await import(fullPath);
396
+ return module.default;
397
+ } catch (error) {
398
+ console.warn(`Failed to load contract: ${contractPath}`, error);
399
+ return null;
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Generate OpenAPI document from manifest and contracts
405
+ */
406
+ export async function generateOpenAPIDocument(
407
+ manifest: RoutesManifest,
408
+ rootDir: string,
409
+ options: {
410
+ title?: string;
411
+ version?: string;
412
+ description?: string;
413
+ servers?: OpenAPIServer[];
414
+ } = {}
415
+ ): Promise<OpenAPIDocument> {
416
+ const paths: Record<string, OpenAPIPathItem> = {};
417
+ const tags = new Set<string>();
418
+
419
+ for (const route of manifest.routes) {
420
+ // Skip routes without contracts
421
+ if (!route.contractModule || route.kind !== "api") continue;
422
+
423
+ const contract = await loadContract(route.contractModule, rootDir);
424
+ if (!contract) continue;
425
+
426
+ const pathItem: OpenAPIPathItem = {};
427
+
428
+ // Generate operations for each method
429
+ const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"];
430
+ for (const method of methods) {
431
+ if (contract.request[method]) {
432
+ const operation = generateOperation(route, method, contract);
433
+ pathItem[method.toLowerCase() as keyof OpenAPIPathItem] = operation;
434
+
435
+ // Collect tags
436
+ for (const tag of operation.tags || []) {
437
+ tags.add(tag);
438
+ }
439
+ }
440
+ }
441
+
442
+ // Convert Mandu pattern to OpenAPI pattern
443
+ // /api/users/:id -> /api/users/{id}
444
+ const openApiPattern = route.pattern.replace(/:(\w+)/g, "{$1}");
445
+ paths[openApiPattern] = pathItem;
446
+ }
447
+
448
+ return {
449
+ openapi: "3.0.3",
450
+ info: {
451
+ title: options.title || "Mandu API",
452
+ version: options.version || `${manifest.version}.0.0`,
453
+ description: options.description || "Generated by Mandu Framework",
454
+ },
455
+ servers: options.servers || [
456
+ { url: "http://localhost:3000", description: "Development server" },
457
+ ],
458
+ paths,
459
+ tags: Array.from(tags).map((name) => ({ name })),
460
+ };
461
+ }
462
+
463
+ /**
464
+ * Convert OpenAPI document to YAML string
465
+ */
466
+ export function openAPIToYAML(doc: OpenAPIDocument): string {
467
+ // Simple YAML conversion (for production, use a proper YAML library)
468
+ return JSON.stringify(doc, null, 2)
469
+ .replace(/"/g, "")
470
+ .replace(/,$/gm, "")
471
+ .replace(/\[/g, "\n - ")
472
+ .replace(/\]/g, "");
473
+ }
474
+
475
+ /**
476
+ * Convert OpenAPI document to JSON string
477
+ */
478
+ export function openAPIToJSON(doc: OpenAPIDocument): string {
479
+ return JSON.stringify(doc, null, 2);
480
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Mandu OpenAPI Module
3
+ * Contract에서 OpenAPI 3.0 스펙 생성
4
+ */
5
+
6
+ export * from "./generator";
@@ -80,6 +80,9 @@ export const RouteSpec = z
80
80
  // 클라이언트 슬롯 (interactive 로직) [NEW]
81
81
  clientModule: z.string().optional(),
82
82
 
83
+ // Contract 모듈 (API 스키마 정의) [NEW]
84
+ contractModule: z.string().optional(),
85
+
83
86
  // Hydration 설정 [NEW]
84
87
  hydration: HydrationConfig.optional(),
85
88