@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.
- package/package.json +41 -41
- package/src/contract/index.ts +63 -0
- package/src/contract/schema.ts +110 -0
- package/src/contract/types.ts +134 -0
- package/src/contract/validator.ts +257 -0
- package/src/filling/filling.ts +50 -0
- package/src/generator/contract-glue.ts +285 -0
- package/src/generator/generate.ts +83 -0
- package/src/generator/index.ts +1 -0
- package/src/generator/templates.ts +79 -4
- package/src/guard/check.ts +5 -0
- package/src/guard/contract-guard.ts +221 -0
- package/src/guard/index.ts +1 -0
- package/src/guard/rules.ts +21 -0
- package/src/index.ts +2 -0
- package/src/openapi/generator.ts +480 -0
- package/src/openapi/index.ts +6 -0
- package/src/spec/schema.ts +3 -0
|
@@ -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
|
+
}
|
package/src/spec/schema.ts
CHANGED
|
@@ -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
|
|