next-openapi-gen 0.6.2 → 0.6.5
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/README.md +91 -1
- package/dist/lib/logger.js +39 -0
- package/dist/lib/openapi-generator.js +92 -2
- package/dist/lib/route-processor.js +96 -5
- package/dist/lib/schema-processor.js +27 -6
- package/dist/lib/utils.js +47 -11
- package/dist/lib/zod-converter.js +22 -21
- package/dist/openapi-template.js +44 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -61,7 +61,8 @@ During initialization (`npx next-openapi-gen init`), a configuration file `next.
|
|
|
61
61
|
"schemaType": "typescript", // or "zod" for Zod schemas
|
|
62
62
|
"outputFile": "openapi.json",
|
|
63
63
|
"docsUrl": "/api-docs",
|
|
64
|
-
"includeOpenApiRoutes": false
|
|
64
|
+
"includeOpenApiRoutes": false,
|
|
65
|
+
"debug": false
|
|
65
66
|
}
|
|
66
67
|
```
|
|
67
68
|
|
|
@@ -75,6 +76,10 @@ During initialization (`npx next-openapi-gen init`), a configuration file `next.
|
|
|
75
76
|
| `outputFile` | Path to the OpenAPI output file |
|
|
76
77
|
| `docsUrl` | API documentation URL (for Swagger UI) |
|
|
77
78
|
| `includeOpenApiRoutes` | Whether to include only routes with @openapi tag |
|
|
79
|
+
| `defaultResponseSet` | Default error response set for all endpoints |
|
|
80
|
+
| `responseSets` | Named sets of error response codes |
|
|
81
|
+
| `errorConfig` | Error schema configuration |
|
|
82
|
+
| `debug` | Enable detailed logging during generation |
|
|
78
83
|
|
|
79
84
|
## Documenting Your API
|
|
80
85
|
|
|
@@ -154,6 +159,8 @@ export async function GET(
|
|
|
154
159
|
| `@bodyDescription` | Request body description |
|
|
155
160
|
| `@response` | Response type/schema |
|
|
156
161
|
| `@responseDescription` | Response description |
|
|
162
|
+
| `@responseSet` | Override default response set (`public`, `auth`, `none`) |
|
|
163
|
+
| `@add` | Add custom response codes (`409:ConflictResponse`, `429`) |
|
|
157
164
|
| `@contentType` | Request body content type (`application/json`, `multipart/form-data`) |
|
|
158
165
|
| `@auth` | Authorization type (`bearer`, `basic`, `apikey`) |
|
|
159
166
|
| `@tag` | Custom tag |
|
|
@@ -373,6 +380,89 @@ export async function POST() {
|
|
|
373
380
|
}
|
|
374
381
|
```
|
|
375
382
|
|
|
383
|
+
## Response Management
|
|
384
|
+
|
|
385
|
+
### Zero Config + Response Sets
|
|
386
|
+
|
|
387
|
+
Configure reusable error sets in `next.openapi.json`:
|
|
388
|
+
|
|
389
|
+
```json
|
|
390
|
+
{
|
|
391
|
+
"defaultResponseSet": "common",
|
|
392
|
+
"responseSets": {
|
|
393
|
+
"common": ["400", "401", "500"],
|
|
394
|
+
"public": ["400", "500"],
|
|
395
|
+
"auth": ["400", "401", "403", "500"]
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Usage Examples
|
|
401
|
+
|
|
402
|
+
```typescript
|
|
403
|
+
/**
|
|
404
|
+
* Auto-default responses
|
|
405
|
+
* @response UserResponse
|
|
406
|
+
* @openapi
|
|
407
|
+
*/
|
|
408
|
+
export async function GET() {}
|
|
409
|
+
// Generates: 200:UserResponse + common errors (400, 401, 500)
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Override response set
|
|
413
|
+
* @response ProductResponse
|
|
414
|
+
* @responseSet public
|
|
415
|
+
* @openapi
|
|
416
|
+
*/
|
|
417
|
+
export async function GET() {}
|
|
418
|
+
// Generates: 200:ProductResponse + public errors (400, 500)
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Add custom responses
|
|
422
|
+
* @response 201:UserResponse
|
|
423
|
+
* @add 409:ConflictResponse
|
|
424
|
+
* @openapi
|
|
425
|
+
*/
|
|
426
|
+
export async function POST() {}
|
|
427
|
+
// Generates: 201:UserResponse + common errors + 409:ConflictResponse
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Combine multiple sets
|
|
431
|
+
* @response UserResponse
|
|
432
|
+
* @responseSet auth,crud
|
|
433
|
+
* @add 429:RateLimitResponse
|
|
434
|
+
* @openapi
|
|
435
|
+
*/
|
|
436
|
+
export async function PUT() {}
|
|
437
|
+
// Combines: auth + crud errors + custom 429
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### Error Schema Configuration
|
|
441
|
+
|
|
442
|
+
#### Define consistent error schemas using templates:
|
|
443
|
+
|
|
444
|
+
```json
|
|
445
|
+
{
|
|
446
|
+
"errorConfig": {
|
|
447
|
+
"template": {
|
|
448
|
+
"type": "object",
|
|
449
|
+
"properties": {
|
|
450
|
+
"error": {
|
|
451
|
+
"type": "string",
|
|
452
|
+
"example": "{{ERROR_MESSAGE}}"
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
"codes": {
|
|
457
|
+
"invalid_request": {
|
|
458
|
+
"description": "Invalid request",
|
|
459
|
+
"variables": { "ERROR_MESSAGE": "Validation failed" }
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
```
|
|
465
|
+
|
|
376
466
|
## Advanced Usage
|
|
377
467
|
|
|
378
468
|
### Automatic Path Parameter Detection
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
class Logger {
|
|
2
|
+
config = null;
|
|
3
|
+
init(config) {
|
|
4
|
+
this.config = config;
|
|
5
|
+
}
|
|
6
|
+
getCallerInfo() {
|
|
7
|
+
const stack = new Error().stack;
|
|
8
|
+
if (!stack)
|
|
9
|
+
return 'Unknown';
|
|
10
|
+
const lines = stack.split('\n');
|
|
11
|
+
// Skip: Error, getCallerInfo, log/warn/error
|
|
12
|
+
const callerLine = lines[3] || lines[2];
|
|
13
|
+
// Extract class/function name
|
|
14
|
+
const match = callerLine.match(/at (\w+)\.(\w+)|at (\w+)/);
|
|
15
|
+
if (match) {
|
|
16
|
+
return match[1] || match[3] || 'Unknown';
|
|
17
|
+
}
|
|
18
|
+
return 'Unknown';
|
|
19
|
+
}
|
|
20
|
+
log(message, ...args) {
|
|
21
|
+
if (this.config?.debug) {
|
|
22
|
+
const source = this.getCallerInfo();
|
|
23
|
+
console.log(`[${source}] ${message}`, ...args);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
warn(message, ...args) {
|
|
27
|
+
if (this.config?.debug) {
|
|
28
|
+
const source = this.getCallerInfo();
|
|
29
|
+
console.warn(`[${source}] ${message}`, ...args);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
error(message, ...args) {
|
|
33
|
+
if (this.config?.debug) {
|
|
34
|
+
const source = this.getCallerInfo();
|
|
35
|
+
console.error(`[${source}] ${message}`, ...args);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export const logger = new Logger();
|
|
@@ -2,6 +2,7 @@ import path from "path";
|
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import { RouteProcessor } from "./route-processor.js";
|
|
4
4
|
import { cleanSpec } from "./utils.js";
|
|
5
|
+
import { logger } from "./logger.js";
|
|
5
6
|
export class OpenApiGenerator {
|
|
6
7
|
config;
|
|
7
8
|
template;
|
|
@@ -11,10 +12,12 @@ export class OpenApiGenerator {
|
|
|
11
12
|
this.template = JSON.parse(fs.readFileSync(templatePath, "utf-8"));
|
|
12
13
|
this.config = this.getConfig();
|
|
13
14
|
this.routeProcessor = new RouteProcessor(this.config);
|
|
15
|
+
// Initialize logger
|
|
16
|
+
logger.init(this.config);
|
|
14
17
|
}
|
|
15
18
|
getConfig() {
|
|
16
19
|
// @ts-ignore
|
|
17
|
-
const { apiDir, schemaDir, docsUrl, ui, outputFile, includeOpenApiRoutes, schemaType = "typescript" } = this.template;
|
|
20
|
+
const { apiDir, schemaDir, docsUrl, ui, outputFile, includeOpenApiRoutes, schemaType = "typescript", defaultResponseSet, responseSets, errorConfig, debug } = this.template;
|
|
18
21
|
return {
|
|
19
22
|
apiDir,
|
|
20
23
|
schemaDir,
|
|
@@ -23,15 +26,20 @@ export class OpenApiGenerator {
|
|
|
23
26
|
outputFile,
|
|
24
27
|
includeOpenApiRoutes,
|
|
25
28
|
schemaType,
|
|
29
|
+
defaultResponseSet,
|
|
30
|
+
responseSets,
|
|
31
|
+
errorConfig,
|
|
32
|
+
debug,
|
|
26
33
|
};
|
|
27
34
|
}
|
|
28
35
|
generate() {
|
|
36
|
+
logger.log("Starting OpenAPI generation...");
|
|
29
37
|
const { apiDir } = this.config;
|
|
30
38
|
// Check if app router structure exists
|
|
31
39
|
let appRouterApiDir = "";
|
|
32
40
|
if (fs.existsSync(path.join(path.dirname(apiDir), "app", "api"))) {
|
|
33
41
|
appRouterApiDir = path.join(path.dirname(apiDir), "app", "api");
|
|
34
|
-
|
|
42
|
+
logger.log(`Found app router API directory at ${appRouterApiDir}`);
|
|
35
43
|
}
|
|
36
44
|
// Scan pages router routes
|
|
37
45
|
this.routeProcessor.scanApiRoutes(apiDir);
|
|
@@ -57,6 +65,21 @@ export class OpenApiGenerator {
|
|
|
57
65
|
if (!this.template.components.schemas) {
|
|
58
66
|
this.template.components.schemas = {};
|
|
59
67
|
}
|
|
68
|
+
// Generate error responses using errorConfig or manual definitions
|
|
69
|
+
if (!this.template.components.responses) {
|
|
70
|
+
this.template.components.responses = {};
|
|
71
|
+
}
|
|
72
|
+
const errorConfig = this.config.errorConfig;
|
|
73
|
+
if (errorConfig) {
|
|
74
|
+
this.generateErrorResponsesFromConfig(errorConfig);
|
|
75
|
+
}
|
|
76
|
+
else if (this.config.errorDefinitions) {
|
|
77
|
+
// Use manual definitions (existing logic - if exists)
|
|
78
|
+
Object.entries(this.config.errorDefinitions).forEach(([code, errorDef]) => {
|
|
79
|
+
this.template.components.responses[code] =
|
|
80
|
+
this.createErrorResponseComponent(code, errorDef);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
60
83
|
// Get defined schemas from the processor
|
|
61
84
|
const definedSchemas = this.routeProcessor
|
|
62
85
|
.getSchemaProcessor()
|
|
@@ -68,6 +91,73 @@ export class OpenApiGenerator {
|
|
|
68
91
|
};
|
|
69
92
|
}
|
|
70
93
|
const openapiSpec = cleanSpec(this.template);
|
|
94
|
+
logger.log("OpenAPI generation completed");
|
|
71
95
|
return openapiSpec;
|
|
72
96
|
}
|
|
97
|
+
generateErrorResponsesFromConfig(errorConfig) {
|
|
98
|
+
const { template, codes, variables: globalVars = {} } = errorConfig;
|
|
99
|
+
Object.entries(codes).forEach(([errorCode, config]) => {
|
|
100
|
+
const httpStatus = (config.httpStatus || this.guessHttpStatus(errorCode)).toString();
|
|
101
|
+
// Merge variables: global + per-code + built-in
|
|
102
|
+
const allVariables = {
|
|
103
|
+
...globalVars,
|
|
104
|
+
...config.variables,
|
|
105
|
+
ERROR_CODE: errorCode,
|
|
106
|
+
DESCRIPTION: config.description,
|
|
107
|
+
HTTP_STATUS: httpStatus,
|
|
108
|
+
};
|
|
109
|
+
const processedSchema = this.processTemplate(template, allVariables);
|
|
110
|
+
this.template.components.responses[httpStatus] = {
|
|
111
|
+
description: config.description,
|
|
112
|
+
content: {
|
|
113
|
+
"application/json": {
|
|
114
|
+
schema: processedSchema,
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
processTemplate(template, variables) {
|
|
121
|
+
const jsonStr = JSON.stringify(template);
|
|
122
|
+
let result = jsonStr;
|
|
123
|
+
Object.entries(variables).forEach(([key, value]) => {
|
|
124
|
+
result = result.replace(new RegExp(`{{${key}}}`, "g"), value);
|
|
125
|
+
});
|
|
126
|
+
return JSON.parse(result);
|
|
127
|
+
}
|
|
128
|
+
guessHttpStatus(errorCode) {
|
|
129
|
+
const statusMap = {
|
|
130
|
+
bad: 400,
|
|
131
|
+
invalid: 400,
|
|
132
|
+
validation: 422,
|
|
133
|
+
unauthorized: 401,
|
|
134
|
+
auth: 401,
|
|
135
|
+
forbidden: 403,
|
|
136
|
+
permission: 403,
|
|
137
|
+
not_found: 404,
|
|
138
|
+
missing: 404,
|
|
139
|
+
conflict: 409,
|
|
140
|
+
duplicate: 409,
|
|
141
|
+
rate_limit: 429,
|
|
142
|
+
too_many: 429,
|
|
143
|
+
server: 500,
|
|
144
|
+
internal: 500,
|
|
145
|
+
};
|
|
146
|
+
for (const [key, status] of Object.entries(statusMap)) {
|
|
147
|
+
if (errorCode.toLowerCase().includes(key)) {
|
|
148
|
+
return status;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return 500;
|
|
152
|
+
}
|
|
153
|
+
createErrorResponseComponent(code, errorDef) {
|
|
154
|
+
return {
|
|
155
|
+
description: errorDef.description,
|
|
156
|
+
content: {
|
|
157
|
+
"application/json": {
|
|
158
|
+
schema: errorDef.schema,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
73
163
|
}
|
|
@@ -5,6 +5,7 @@ import traverse from "@babel/traverse";
|
|
|
5
5
|
import { parse } from "@babel/parser";
|
|
6
6
|
import { SchemaProcessor } from "./schema-processor.js";
|
|
7
7
|
import { capitalize, extractJSDocComments, extractPathParameters, getOperationId, } from "./utils.js";
|
|
8
|
+
import { logger } from "./logger.js";
|
|
8
9
|
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
9
10
|
const MUTATION_HTTP_METHODS = ["PATCH", "POST", "PUT"];
|
|
10
11
|
export class RouteProcessor {
|
|
@@ -18,6 +19,91 @@ export class RouteProcessor {
|
|
|
18
19
|
this.config = config;
|
|
19
20
|
this.schemaProcessor = new SchemaProcessor(config.schemaDir, config.schemaType);
|
|
20
21
|
}
|
|
22
|
+
buildResponsesFromConfig(dataTypes, method) {
|
|
23
|
+
const responses = {};
|
|
24
|
+
// 1. Add success response
|
|
25
|
+
const successCode = dataTypes.successCode || this.getDefaultSuccessCode(method);
|
|
26
|
+
if (dataTypes.responseType) {
|
|
27
|
+
const responseSchema = this.schemaProcessor.getSchemaContent({
|
|
28
|
+
responseType: dataTypes.responseType,
|
|
29
|
+
}).responses;
|
|
30
|
+
responses[successCode] = {
|
|
31
|
+
description: dataTypes.responseDescription || "Successful response",
|
|
32
|
+
content: {
|
|
33
|
+
"application/json": {
|
|
34
|
+
schema: responseSchema,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// 2. Add responses from ResponseSet
|
|
40
|
+
const responseSetName = dataTypes.responseSet || this.config.defaultResponseSet;
|
|
41
|
+
if (responseSetName && responseSetName !== "none") {
|
|
42
|
+
const responseSets = this.config.responseSets || {};
|
|
43
|
+
const setNames = responseSetName.split(",").map((s) => s.trim());
|
|
44
|
+
setNames.forEach((setName) => {
|
|
45
|
+
const responseSet = responseSets[setName];
|
|
46
|
+
if (responseSet) {
|
|
47
|
+
responseSet.forEach((errorCode) => {
|
|
48
|
+
// Use $ref for components/responses
|
|
49
|
+
responses[errorCode] = {
|
|
50
|
+
$ref: `#/components/responses/${errorCode}`,
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
// 3. Add custom responses (@add)
|
|
57
|
+
if (dataTypes.addResponses) {
|
|
58
|
+
const customResponses = dataTypes.addResponses
|
|
59
|
+
.split(",")
|
|
60
|
+
.map((s) => s.trim());
|
|
61
|
+
customResponses.forEach((responseRef) => {
|
|
62
|
+
const [code, ref] = responseRef.split(":");
|
|
63
|
+
if (ref) {
|
|
64
|
+
// Custom schema: "409:ConflictResponse"
|
|
65
|
+
responses[code] = {
|
|
66
|
+
description: this.getDefaultErrorDescription(code) || `HTTP ${code} response`,
|
|
67
|
+
content: {
|
|
68
|
+
"application/json": {
|
|
69
|
+
schema: { $ref: `#/components/schemas/${ref}` },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
// Only code: "409" - use $ref fro components/responses
|
|
76
|
+
responses[code] = {
|
|
77
|
+
$ref: `#/components/responses/${code}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return responses;
|
|
83
|
+
}
|
|
84
|
+
getDefaultSuccessCode(method) {
|
|
85
|
+
switch (method.toUpperCase()) {
|
|
86
|
+
case "POST":
|
|
87
|
+
return "201";
|
|
88
|
+
case "DELETE":
|
|
89
|
+
return "204";
|
|
90
|
+
default:
|
|
91
|
+
return "200";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
getDefaultErrorDescription(code) {
|
|
95
|
+
const defaults = {
|
|
96
|
+
400: "Bad Request",
|
|
97
|
+
401: "Unauthorized",
|
|
98
|
+
403: "Forbidden",
|
|
99
|
+
404: "Not Found",
|
|
100
|
+
409: "Conflict",
|
|
101
|
+
422: "Unprocessable Entity",
|
|
102
|
+
429: "Too Many Requests",
|
|
103
|
+
500: "Internal Server Error",
|
|
104
|
+
};
|
|
105
|
+
return defaults[code] || `HTTP ${code}`;
|
|
106
|
+
}
|
|
21
107
|
/**
|
|
22
108
|
* Get the SchemaProcessor instance
|
|
23
109
|
*/
|
|
@@ -51,7 +137,7 @@ export class RouteProcessor {
|
|
|
51
137
|
const pathParams = extractPathParameters(routePath);
|
|
52
138
|
// If we have path parameters but no pathParamsType defined, we should log a warning
|
|
53
139
|
if (pathParams.length > 0 && !dataTypes.pathParamsType) {
|
|
54
|
-
|
|
140
|
+
logger.warn(`Route ${routePath} contains path parameters ${pathParams.join(", ")} but no @pathParams type is defined.`);
|
|
55
141
|
}
|
|
56
142
|
this.addRouteToPaths(declaration.id.name, filePath, dataTypes);
|
|
57
143
|
}
|
|
@@ -68,7 +154,7 @@ export class RouteProcessor {
|
|
|
68
154
|
const routePath = this.getRoutePath(filePath);
|
|
69
155
|
const pathParams = extractPathParameters(routePath);
|
|
70
156
|
if (pathParams.length > 0 && !dataTypes.pathParamsType) {
|
|
71
|
-
|
|
157
|
+
logger.warn(`Route ${routePath} contains path parameters ${pathParams.join(", ")} but no @pathParams type is defined.`);
|
|
72
158
|
}
|
|
73
159
|
this.addRouteToPaths(decl.id.name, filePath, dataTypes);
|
|
74
160
|
}
|
|
@@ -81,6 +167,7 @@ export class RouteProcessor {
|
|
|
81
167
|
this.processFileTracker[filePath] = true;
|
|
82
168
|
}
|
|
83
169
|
scanApiRoutes(dir) {
|
|
170
|
+
logger.log(`Scanning API routes in: ${dir}`);
|
|
84
171
|
let files = this.directoryCache[dir];
|
|
85
172
|
if (!files) {
|
|
86
173
|
files = fs.readdirSync(dir);
|
|
@@ -163,9 +250,13 @@ export class RouteProcessor {
|
|
|
163
250
|
definition.requestBody = this.schemaProcessor.createRequestBodySchema(body, bodyDescription, dataTypes.contentType);
|
|
164
251
|
}
|
|
165
252
|
// Add responses
|
|
166
|
-
definition.responses =
|
|
167
|
-
|
|
168
|
-
|
|
253
|
+
definition.responses = this.buildResponsesFromConfig(dataTypes, method);
|
|
254
|
+
// If there are no responses from config, use the old logic
|
|
255
|
+
if (Object.keys(definition.responses).length === 0) {
|
|
256
|
+
definition.responses = responses
|
|
257
|
+
? this.schemaProcessor.createResponseSchema(responses, responseDescription)
|
|
258
|
+
: {};
|
|
259
|
+
}
|
|
169
260
|
this.swaggerPaths[routePath][method] = definition;
|
|
170
261
|
}
|
|
171
262
|
getRoutePath(filePath) {
|
|
@@ -4,6 +4,7 @@ import { parse } from "@babel/parser";
|
|
|
4
4
|
import traverse from "@babel/traverse";
|
|
5
5
|
import * as t from "@babel/types";
|
|
6
6
|
import { ZodSchemaConverter } from "./zod-converter.js";
|
|
7
|
+
import { logger } from "./logger.js";
|
|
7
8
|
export class SchemaProcessor {
|
|
8
9
|
schemaDir;
|
|
9
10
|
typeDefinitions = {};
|
|
@@ -42,20 +43,20 @@ export class SchemaProcessor {
|
|
|
42
43
|
this.contentType = contentType;
|
|
43
44
|
// Check if we should use Zod schemas
|
|
44
45
|
if (this.schemaType === "zod") {
|
|
45
|
-
|
|
46
|
+
logger.log(`Looking for Zod schema: ${schemaName}`);
|
|
46
47
|
// Check type mapping first
|
|
47
48
|
const mappedSchemaName = this.zodSchemaConverter.typeToSchemaMapping[schemaName];
|
|
48
49
|
if (mappedSchemaName) {
|
|
49
|
-
|
|
50
|
+
logger.log(`Type '${schemaName}' is mapped to Zod schema '${mappedSchemaName}'`);
|
|
50
51
|
}
|
|
51
52
|
// Try to convert Zod schema
|
|
52
53
|
const zodSchema = this.zodSchemaConverter.convertZodSchemaToOpenApi(schemaName);
|
|
53
54
|
if (zodSchema) {
|
|
54
|
-
|
|
55
|
+
logger.log(`Found and processed Zod schema: ${schemaName}`);
|
|
55
56
|
this.openapiDefinitions[schemaName] = zodSchema;
|
|
56
57
|
return zodSchema;
|
|
57
58
|
}
|
|
58
|
-
|
|
59
|
+
logger.log(`No Zod schema found for ${schemaName}, trying TypeScript fallback`);
|
|
59
60
|
}
|
|
60
61
|
// Fall back to TypeScript types
|
|
61
62
|
this.scanSchemaDir(this.schemaDir, schemaName);
|
|
@@ -394,7 +395,7 @@ export class SchemaProcessor {
|
|
|
394
395
|
if (t.isTSTypeReference(node) && t.isIdentifier(node.typeName)) {
|
|
395
396
|
return { $ref: `#/components/schemas/${node.typeName.name}` };
|
|
396
397
|
}
|
|
397
|
-
|
|
398
|
+
logger.log("Unrecognized TypeScript type node:", node);
|
|
398
399
|
return { type: "object" }; // By default we return an object
|
|
399
400
|
}
|
|
400
401
|
processSchemaFile(filePath, schemaName) {
|
|
@@ -417,7 +418,7 @@ export class SchemaProcessor {
|
|
|
417
418
|
return definition;
|
|
418
419
|
}
|
|
419
420
|
catch (error) {
|
|
420
|
-
|
|
421
|
+
logger.error(`Error processing schema file ${filePath} for schema ${schemaName}: ${error}`);
|
|
421
422
|
return { type: "object" }; // By default we return an empty object on error
|
|
422
423
|
}
|
|
423
424
|
}
|
|
@@ -514,6 +515,26 @@ export class SchemaProcessor {
|
|
|
514
515
|
}
|
|
515
516
|
return "application/json";
|
|
516
517
|
}
|
|
518
|
+
createMultipleResponsesSchema(responses, defaultDescription) {
|
|
519
|
+
const result = {};
|
|
520
|
+
Object.entries(responses).forEach(([code, response]) => {
|
|
521
|
+
if (typeof response === "string") {
|
|
522
|
+
// Reference do components/responses
|
|
523
|
+
result[code] = { $ref: `#/components/responses/${response}` };
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
result[code] = {
|
|
527
|
+
description: response.description || defaultDescription || "Response",
|
|
528
|
+
content: {
|
|
529
|
+
"application/json": {
|
|
530
|
+
schema: response.schema || response,
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
return result;
|
|
537
|
+
}
|
|
517
538
|
createFormDataSchema(body) {
|
|
518
539
|
if (!body.properties) {
|
|
519
540
|
return body;
|
package/dist/lib/utils.js
CHANGED
|
@@ -22,13 +22,16 @@ export function extractJSDocComments(path) {
|
|
|
22
22
|
let paramsType = "";
|
|
23
23
|
let pathParamsType = "";
|
|
24
24
|
let bodyType = "";
|
|
25
|
-
let responseType = "";
|
|
26
25
|
let auth = "";
|
|
27
26
|
let isOpenApi = false;
|
|
28
27
|
let deprecated = false;
|
|
29
28
|
let bodyDescription = "";
|
|
30
|
-
let responseDescription = "";
|
|
31
29
|
let contentType = "";
|
|
30
|
+
let responseType = "";
|
|
31
|
+
let responseDescription = "";
|
|
32
|
+
let responseSet = "";
|
|
33
|
+
let addResponses = "";
|
|
34
|
+
let successCode = "";
|
|
32
35
|
if (comments) {
|
|
33
36
|
comments.forEach((comment) => {
|
|
34
37
|
const commentValue = cleanComment(comment.value);
|
|
@@ -43,13 +46,6 @@ export function extractJSDocComments(path) {
|
|
|
43
46
|
bodyDescription = match[1].trim();
|
|
44
47
|
}
|
|
45
48
|
}
|
|
46
|
-
if (commentValue.includes("@responseDescription")) {
|
|
47
|
-
const regex = /@responseDescription\s*(.*)/;
|
|
48
|
-
const match = commentValue.match(regex);
|
|
49
|
-
if (match && match[1]) {
|
|
50
|
-
responseDescription = match[1].trim();
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
49
|
if (!summary) {
|
|
54
50
|
summary = commentValue.split("\n")[0];
|
|
55
51
|
}
|
|
@@ -98,6 +94,38 @@ export function extractJSDocComments(path) {
|
|
|
98
94
|
contentType = match[1].trim();
|
|
99
95
|
}
|
|
100
96
|
}
|
|
97
|
+
if (commentValue.includes("@responseDescription")) {
|
|
98
|
+
const regex = /@responseDescription\s*(.*)/;
|
|
99
|
+
const match = commentValue.match(regex);
|
|
100
|
+
if (match && match[1]) {
|
|
101
|
+
responseDescription = match[1].trim();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (commentValue.includes("@responseSet")) {
|
|
105
|
+
const regex = /@responseSet\s*(.*)/;
|
|
106
|
+
const match = commentValue.match(regex);
|
|
107
|
+
if (match && match[1]) {
|
|
108
|
+
responseSet = match[1].trim();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (commentValue.includes("@add")) {
|
|
112
|
+
const regex = /@add\s*(.*)/;
|
|
113
|
+
const match = commentValue.match(regex);
|
|
114
|
+
if (match && match[1]) {
|
|
115
|
+
addResponses = match[1].trim();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (commentValue.includes("@response")) {
|
|
119
|
+
const responseMatch = commentValue.match(/@response\s+(?:(\d+):)?(\w+)(?:\s+(.*))?/);
|
|
120
|
+
if (responseMatch) {
|
|
121
|
+
const [, code, type] = responseMatch;
|
|
122
|
+
successCode = code || "";
|
|
123
|
+
responseType = type;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
responseType = extractTypeFromComment(commentValue, "@response");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
101
129
|
});
|
|
102
130
|
}
|
|
103
131
|
return {
|
|
@@ -108,12 +136,15 @@ export function extractJSDocComments(path) {
|
|
|
108
136
|
paramsType,
|
|
109
137
|
pathParamsType,
|
|
110
138
|
bodyType,
|
|
111
|
-
responseType,
|
|
112
139
|
isOpenApi,
|
|
113
140
|
deprecated,
|
|
114
141
|
bodyDescription,
|
|
115
|
-
responseDescription,
|
|
116
142
|
contentType,
|
|
143
|
+
responseType,
|
|
144
|
+
responseDescription,
|
|
145
|
+
responseSet,
|
|
146
|
+
addResponses,
|
|
147
|
+
successCode,
|
|
117
148
|
};
|
|
118
149
|
}
|
|
119
150
|
export function extractTypeFromComment(commentValue, tag) {
|
|
@@ -130,6 +161,11 @@ export function cleanSpec(spec) {
|
|
|
130
161
|
"ui",
|
|
131
162
|
"outputFile",
|
|
132
163
|
"includeOpenApiRoutes",
|
|
164
|
+
"schemaType",
|
|
165
|
+
"defaultResponseSet",
|
|
166
|
+
"responseSets",
|
|
167
|
+
"errorConfig",
|
|
168
|
+
"debug",
|
|
133
169
|
];
|
|
134
170
|
const newSpec = { ...spec };
|
|
135
171
|
propsToRemove.forEach((key) => delete newSpec[key]);
|
|
@@ -3,6 +3,7 @@ import path from "path";
|
|
|
3
3
|
import { parse } from "@babel/parser";
|
|
4
4
|
import traverse from "@babel/traverse";
|
|
5
5
|
import * as t from "@babel/types";
|
|
6
|
+
import { logger } from "./logger.js";
|
|
6
7
|
/**
|
|
7
8
|
* Class for converting Zod schemas to OpenAPI specifications
|
|
8
9
|
*/
|
|
@@ -23,11 +24,11 @@ export class ZodSchemaConverter {
|
|
|
23
24
|
if (Object.keys(this.typeToSchemaMapping).length === 0) {
|
|
24
25
|
this.preScanForTypeMappings();
|
|
25
26
|
}
|
|
26
|
-
|
|
27
|
+
logger.log(`Looking for Zod schema: ${schemaName}`);
|
|
27
28
|
// Check mapped types
|
|
28
29
|
const mappedSchemaName = this.typeToSchemaMapping[schemaName];
|
|
29
30
|
if (mappedSchemaName) {
|
|
30
|
-
|
|
31
|
+
logger.log(`Type '${schemaName}' is mapped to schema '${mappedSchemaName}'`);
|
|
31
32
|
schemaName = mappedSchemaName;
|
|
32
33
|
}
|
|
33
34
|
// Check for circular references
|
|
@@ -46,7 +47,7 @@ export class ZodSchemaConverter {
|
|
|
46
47
|
for (const routeFile of routeFiles) {
|
|
47
48
|
this.processFileForZodSchema(routeFile, schemaName);
|
|
48
49
|
if (this.zodSchemas[schemaName]) {
|
|
49
|
-
|
|
50
|
+
logger.log(`Found Zod schema '${schemaName}' in route file: ${routeFile}`);
|
|
50
51
|
return this.zodSchemas[schemaName];
|
|
51
52
|
}
|
|
52
53
|
}
|
|
@@ -54,10 +55,10 @@ export class ZodSchemaConverter {
|
|
|
54
55
|
this.scanDirectoryForZodSchema(this.schemaDir, schemaName);
|
|
55
56
|
// Return the schema if found, or null if not
|
|
56
57
|
if (this.zodSchemas[schemaName]) {
|
|
57
|
-
|
|
58
|
+
logger.log(`Found and processed Zod schema: ${schemaName}`);
|
|
58
59
|
return this.zodSchemas[schemaName];
|
|
59
60
|
}
|
|
60
|
-
|
|
61
|
+
logger.log(`Could not find Zod schema: ${schemaName}`);
|
|
61
62
|
return null;
|
|
62
63
|
}
|
|
63
64
|
finally {
|
|
@@ -104,7 +105,7 @@ export class ZodSchemaConverter {
|
|
|
104
105
|
}
|
|
105
106
|
}
|
|
106
107
|
catch (error) {
|
|
107
|
-
|
|
108
|
+
logger.log(`Error scanning directory ${dir} for route files: ${error}`);
|
|
108
109
|
}
|
|
109
110
|
}
|
|
110
111
|
/**
|
|
@@ -125,7 +126,7 @@ export class ZodSchemaConverter {
|
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
128
|
catch (error) {
|
|
128
|
-
|
|
129
|
+
logger.log(`Error scanning directory ${dir}: ${error}`);
|
|
129
130
|
}
|
|
130
131
|
}
|
|
131
132
|
/**
|
|
@@ -272,7 +273,7 @@ export class ZodSchemaConverter {
|
|
|
272
273
|
? prop.key.value
|
|
273
274
|
: null;
|
|
274
275
|
if (key && schema.properties) {
|
|
275
|
-
|
|
276
|
+
logger.log(`Removing property: ${key}`);
|
|
276
277
|
delete schema.properties[key];
|
|
277
278
|
if (schema.required) {
|
|
278
279
|
schema.required = schema.required.filter((r) => r !== key);
|
|
@@ -357,20 +358,20 @@ export class ZodSchemaConverter {
|
|
|
357
358
|
if (t.isCallExpression(path.node.init)) {
|
|
358
359
|
const baseSchemaName = findBaseSchema(path.node.init);
|
|
359
360
|
if (baseSchemaName && baseSchemaName !== "z") {
|
|
360
|
-
|
|
361
|
+
logger.log(`Found chained call starting from: ${baseSchemaName}`);
|
|
361
362
|
// First make sure the underlying schema is processed
|
|
362
363
|
if (!this.zodSchemas[baseSchemaName]) {
|
|
363
|
-
|
|
364
|
+
logger.log(`Base schema ${baseSchemaName} not found, processing it first`);
|
|
364
365
|
this.processFileForZodSchema(filePath, baseSchemaName);
|
|
365
366
|
}
|
|
366
367
|
if (this.zodSchemas[baseSchemaName]) {
|
|
367
|
-
|
|
368
|
+
logger.log("Base schema found, applying transformations");
|
|
368
369
|
// Copy base schema
|
|
369
370
|
const baseSchema = JSON.parse(JSON.stringify(this.zodSchemas[baseSchemaName]));
|
|
370
371
|
// Process the entire call chain
|
|
371
372
|
const finalSchema = processChainedCall(path.node.init, baseSchema);
|
|
372
373
|
this.zodSchemas[schemaName] = finalSchema;
|
|
373
|
-
|
|
374
|
+
logger.log(`Created ${schemaName} with properties: ${Object.keys(finalSchema.properties || {})}`);
|
|
374
375
|
return;
|
|
375
376
|
}
|
|
376
377
|
}
|
|
@@ -412,7 +413,7 @@ export class ZodSchemaConverter {
|
|
|
412
413
|
const referencedSchemaName = param.exprName.name;
|
|
413
414
|
// Save mapping: TypeName -> SchemaName
|
|
414
415
|
this.typeToSchemaMapping[typeName] = referencedSchemaName;
|
|
415
|
-
|
|
416
|
+
logger.log(`Mapped type '${typeName}' to schema '${referencedSchemaName}'`);
|
|
416
417
|
// Process the referenced schema if not already processed
|
|
417
418
|
if (!this.zodSchemas[referencedSchemaName]) {
|
|
418
419
|
this.processFileForZodSchema(filePath, referencedSchemaName);
|
|
@@ -1222,7 +1223,7 @@ export class ZodSchemaConverter {
|
|
|
1222
1223
|
schema.description = baseSchema.description;
|
|
1223
1224
|
}
|
|
1224
1225
|
else {
|
|
1225
|
-
|
|
1226
|
+
logger.warn("Could not resolve base schema for extend");
|
|
1226
1227
|
schema = extendedProps || { type: "object" };
|
|
1227
1228
|
}
|
|
1228
1229
|
}
|
|
@@ -1313,7 +1314,7 @@ export class ZodSchemaConverter {
|
|
|
1313
1314
|
* Pre-scan all files to build type mappings
|
|
1314
1315
|
*/
|
|
1315
1316
|
preScanForTypeMappings() {
|
|
1316
|
-
|
|
1317
|
+
logger.log("Pre-scanning for type mappings...");
|
|
1317
1318
|
// Scan route files
|
|
1318
1319
|
const routeFiles = this.findRouteFiles();
|
|
1319
1320
|
for (const routeFile of routeFiles) {
|
|
@@ -1359,7 +1360,7 @@ export class ZodSchemaConverter {
|
|
|
1359
1360
|
if (t.isTSTypeQuery(param) && t.isIdentifier(param.exprName)) {
|
|
1360
1361
|
const referencedSchemaName = param.exprName.name;
|
|
1361
1362
|
this.typeToSchemaMapping[typeName] = referencedSchemaName;
|
|
1362
|
-
|
|
1363
|
+
logger.log(`Pre-scan: Mapped type '${typeName}' to schema '${referencedSchemaName}'`);
|
|
1363
1364
|
}
|
|
1364
1365
|
}
|
|
1365
1366
|
}
|
|
@@ -1368,7 +1369,7 @@ export class ZodSchemaConverter {
|
|
|
1368
1369
|
});
|
|
1369
1370
|
}
|
|
1370
1371
|
catch (error) {
|
|
1371
|
-
|
|
1372
|
+
logger.log(`Error scanning file ${filePath} for type mappings: ${error}`);
|
|
1372
1373
|
}
|
|
1373
1374
|
}
|
|
1374
1375
|
/**
|
|
@@ -1389,7 +1390,7 @@ export class ZodSchemaConverter {
|
|
|
1389
1390
|
}
|
|
1390
1391
|
}
|
|
1391
1392
|
catch (error) {
|
|
1392
|
-
|
|
1393
|
+
logger.error(`Error scanning directory ${dir} for type mappings: ${error}`);
|
|
1393
1394
|
}
|
|
1394
1395
|
}
|
|
1395
1396
|
/**
|
|
@@ -1412,7 +1413,7 @@ export class ZodSchemaConverter {
|
|
|
1412
1413
|
// Check if is Zos schema
|
|
1413
1414
|
if (this.isZodSchema(declaration.init) &&
|
|
1414
1415
|
!this.zodSchemas[schemaName]) {
|
|
1415
|
-
|
|
1416
|
+
logger.log(`Pre-processing Zod schema: ${schemaName}`);
|
|
1416
1417
|
this.processingSchemas.add(schemaName);
|
|
1417
1418
|
const schema = this.processZodNode(declaration.init);
|
|
1418
1419
|
if (schema) {
|
|
@@ -1432,7 +1433,7 @@ export class ZodSchemaConverter {
|
|
|
1432
1433
|
if (this.isZodSchema(declaration.init) &&
|
|
1433
1434
|
!this.zodSchemas[schemaName] &&
|
|
1434
1435
|
!this.processingSchemas.has(schemaName)) {
|
|
1435
|
-
|
|
1436
|
+
logger.log(`Pre-processing Zod schema: ${schemaName}`);
|
|
1436
1437
|
this.processingSchemas.add(schemaName);
|
|
1437
1438
|
const schema = this.processZodNode(declaration.init);
|
|
1438
1439
|
if (schema) {
|
|
@@ -1446,7 +1447,7 @@ export class ZodSchemaConverter {
|
|
|
1446
1447
|
});
|
|
1447
1448
|
}
|
|
1448
1449
|
catch (error) {
|
|
1449
|
-
|
|
1450
|
+
logger.error(`Error pre-processing file ${filePath}: ${error}`);
|
|
1450
1451
|
}
|
|
1451
1452
|
}
|
|
1452
1453
|
/**
|
package/dist/openapi-template.js
CHANGED
|
@@ -20,6 +20,49 @@ export default {
|
|
|
20
20
|
},
|
|
21
21
|
},
|
|
22
22
|
},
|
|
23
|
+
defaultResponseSet: "common",
|
|
24
|
+
responseSets: {
|
|
25
|
+
common: ["400", "500"],
|
|
26
|
+
auth: ["401"],
|
|
27
|
+
},
|
|
28
|
+
errorConfig: {
|
|
29
|
+
template: {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: {
|
|
32
|
+
success: {
|
|
33
|
+
type: "boolean",
|
|
34
|
+
example: false,
|
|
35
|
+
},
|
|
36
|
+
error: {
|
|
37
|
+
type: "string",
|
|
38
|
+
example: "{{ERROR_MESSAGE}}",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
codes: {
|
|
43
|
+
invalid: {
|
|
44
|
+
description: "Bad request",
|
|
45
|
+
httpStatus: 400,
|
|
46
|
+
variables: {
|
|
47
|
+
ERROR_MESSAGE: "Validation error",
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
auth: {
|
|
51
|
+
description: "Unauthorized",
|
|
52
|
+
httpStatus: 401,
|
|
53
|
+
variables: {
|
|
54
|
+
ERROR_MESSAGE: "Unathorized",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
server_error: {
|
|
58
|
+
description: "Internal server error",
|
|
59
|
+
httpStatus: 500,
|
|
60
|
+
variables: {
|
|
61
|
+
ERROR_MESSAGE: "Something went wrong",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
23
66
|
apiDir: "./src/app/api",
|
|
24
67
|
schemaDir: "./src",
|
|
25
68
|
schemaType: "typescript",
|
|
@@ -27,4 +70,5 @@ export default {
|
|
|
27
70
|
ui: "scalar",
|
|
28
71
|
outputFile: "openapi.json",
|
|
29
72
|
includeOpenApiRoutes: false,
|
|
73
|
+
debug: false,
|
|
30
74
|
};
|
package/package.json
CHANGED