next-openapi-gen 0.9.4 → 0.10.1
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 +16 -4
- package/dist/lib/app-router-strategy.js +66 -0
- package/dist/lib/drizzle-zod-processor.js +5 -3
- package/dist/lib/openapi-generator.js +2 -1
- package/dist/lib/pages-router-strategy.js +196 -0
- package/dist/lib/route-processor.js +30 -104
- package/dist/lib/router-strategy.js +1 -0
- package/dist/lib/utils.js +10 -0
- package/dist/lib/zod-converter.js +11 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,9 +4,9 @@ Automatically generate OpenAPI 3.0 documentation from Next.js projects, with sup
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- ✅ Automatic OpenAPI 3.0 documentation generation from Next.js App Router
|
|
8
|
-
- ✅ Multiple schema types: `TypeScript`, `Zod`, `Drizzle-Zod`, or `custom YAML/JSON` files
|
|
9
|
-
- ✅ Mix schema sources simultaneously - perfect for gradual migrations
|
|
7
|
+
- ✅ Automatic OpenAPI 3.0 documentation generation from Next.js App Router and Pages Router 🆕
|
|
8
|
+
- ✅ Multiple schema types: `TypeScript`, `Zod`, `Drizzle-Zod`, or `custom YAML/JSON` files
|
|
9
|
+
- ✅ Mix schema sources simultaneously - perfect for gradual migrations
|
|
10
10
|
- ✅ JSDoc comments with intelligent parameter examples
|
|
11
11
|
- ✅ Multiple UI interfaces: `Scalar`, `Swagger`, `Redoc`, `Stoplight`, and `RapiDoc` available at `/api-docs` url
|
|
12
12
|
- ✅ Auto-detection of path parameters (e.g., `/users/[id]/route.ts`)
|
|
@@ -70,7 +70,8 @@ During initialization (`npx next-openapi-gen init`), a configuration file `next.
|
|
|
70
70
|
"description": "Local server"
|
|
71
71
|
}
|
|
72
72
|
],
|
|
73
|
-
"apiDir": "src/app/api",
|
|
73
|
+
"apiDir": "src/app/api", // or "pages/api" for Pages Router
|
|
74
|
+
"routerType": "app", // "app" (default) or "pages" for legacy Pages Router
|
|
74
75
|
"schemaDir": "src/types", // or "src/schemas" for Zod schemas
|
|
75
76
|
"schemaType": "zod", // or "typescript", or ["zod", "typescript"] for multiple
|
|
76
77
|
"schemaFiles": [], // Optional: ["./schemas/models.yaml", "./schemas/api.json"]
|
|
@@ -88,6 +89,7 @@ During initialization (`npx next-openapi-gen init`), a configuration file `next.
|
|
|
88
89
|
| Option | Description |
|
|
89
90
|
| ---------------------- | ----------------------------------------------------------------------------- |
|
|
90
91
|
| `apiDir` | Path to the API directory |
|
|
92
|
+
| `routerType` | Router type: `"app"` (default) or `"pages"` for legacy Pages Router |
|
|
91
93
|
| `schemaDir` | Path to the types/schemas directory |
|
|
92
94
|
| `schemaType` | Schema type: `"zod"`, `"typescript"`, or `["zod", "typescript"]` for multiple |
|
|
93
95
|
| `schemaFiles` | Optional: Array of custom OpenAPI schema files (YAML/JSON) to include |
|
|
@@ -226,6 +228,16 @@ export async function POST(request: NextRequest) {
|
|
|
226
228
|
| `@deprecated` | Marks the route as deprecated |
|
|
227
229
|
| `@openapi` | Marks the route for inclusion in documentation (if includeOpenApiRoutes is enabled) |
|
|
228
230
|
| `@ignore` | Excludes the route from OpenAPI documentation |
|
|
231
|
+
| `@method` | HTTP method for Pages Router (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`) - required for Pages Router only |
|
|
232
|
+
|
|
233
|
+
## Pages Router Support 🆕
|
|
234
|
+
|
|
235
|
+
The library now supports the legacy Next.js Pages Router. To use it:
|
|
236
|
+
|
|
237
|
+
1. Set `routerType` to `"pages"` in your configuration
|
|
238
|
+
2. Use the `@method` JSDoc tag to specify HTTP methods
|
|
239
|
+
|
|
240
|
+
See **[next15-pages-router](./examples/next15-pages-router)** for a complete working example.
|
|
229
241
|
|
|
230
242
|
## CLI Usage
|
|
231
243
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as t from "@babel/types";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import traverseModule from "@babel/traverse";
|
|
4
|
+
const traverse = traverseModule.default || traverseModule;
|
|
5
|
+
import { HTTP_METHODS } from "./router-strategy.js";
|
|
6
|
+
import { extractJSDocComments, parseTypeScriptFile } from "./utils.js";
|
|
7
|
+
export class AppRouterStrategy {
|
|
8
|
+
config;
|
|
9
|
+
constructor(config) {
|
|
10
|
+
this.config = config;
|
|
11
|
+
}
|
|
12
|
+
shouldProcessFile(fileName) {
|
|
13
|
+
return fileName === "route.ts" || fileName === "route.tsx";
|
|
14
|
+
}
|
|
15
|
+
processFile(filePath, addRoute) {
|
|
16
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
17
|
+
const ast = parseTypeScriptFile(content);
|
|
18
|
+
traverse(ast, {
|
|
19
|
+
ExportNamedDeclaration: (path) => {
|
|
20
|
+
const declaration = path.node.declaration;
|
|
21
|
+
if (t.isFunctionDeclaration(declaration) &&
|
|
22
|
+
t.isIdentifier(declaration.id)) {
|
|
23
|
+
if (HTTP_METHODS.includes(declaration.id.name)) {
|
|
24
|
+
const dataTypes = extractJSDocComments(path);
|
|
25
|
+
addRoute(declaration.id.name, filePath, dataTypes);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (t.isVariableDeclaration(declaration)) {
|
|
29
|
+
declaration.declarations.forEach((decl) => {
|
|
30
|
+
if (t.isVariableDeclarator(decl) && t.isIdentifier(decl.id)) {
|
|
31
|
+
if (HTTP_METHODS.includes(decl.id.name)) {
|
|
32
|
+
const dataTypes = extractJSDocComments(path);
|
|
33
|
+
addRoute(decl.id.name, filePath, dataTypes);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
getRoutePath(filePath) {
|
|
42
|
+
const normalizedPath = filePath.replaceAll("\\", "/");
|
|
43
|
+
const normalizedApiDir = this.config.apiDir
|
|
44
|
+
.replaceAll("\\", "/")
|
|
45
|
+
.replace(/^\.\//, "")
|
|
46
|
+
.replace(/\/$/, "");
|
|
47
|
+
const apiDirIndex = normalizedPath.indexOf(normalizedApiDir);
|
|
48
|
+
if (apiDirIndex === -1) {
|
|
49
|
+
throw new Error(`Could not find apiDir "${this.config.apiDir}" in file path "${filePath}"`);
|
|
50
|
+
}
|
|
51
|
+
let relativePath = normalizedPath.substring(apiDirIndex + normalizedApiDir.length);
|
|
52
|
+
// Remove the /route.ts or /route.tsx suffix
|
|
53
|
+
relativePath = relativePath.replace(/\/route\.tsx?$/, "");
|
|
54
|
+
if (!relativePath.startsWith("/")) {
|
|
55
|
+
relativePath = "/" + relativePath;
|
|
56
|
+
}
|
|
57
|
+
relativePath = relativePath.replace(/\/$/, "");
|
|
58
|
+
// Remove Next.js route groups (folders in parentheses like (authenticated))
|
|
59
|
+
relativePath = relativePath.replace(/\/\([^)]+\)/g, "");
|
|
60
|
+
// Handle catch-all routes before dynamic routes
|
|
61
|
+
relativePath = relativePath.replace(/\/\[\.\.\.(.*?)\]/g, "/{$1}");
|
|
62
|
+
// Convert Next.js dynamic route syntax to OpenAPI parameter syntax
|
|
63
|
+
relativePath = relativePath.replace(/\/\[([^\]]+)\]/g, "/{$1}");
|
|
64
|
+
return relativePath || "/";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -119,7 +119,6 @@ export class DrizzleZodProcessor {
|
|
|
119
119
|
? node.callee.property.name
|
|
120
120
|
: null;
|
|
121
121
|
if (methodName === "optional" ||
|
|
122
|
-
methodName === "nullable" ||
|
|
123
122
|
methodName === "nullish") {
|
|
124
123
|
return true;
|
|
125
124
|
}
|
|
@@ -291,10 +290,13 @@ export class DrizzleZodProcessor {
|
|
|
291
290
|
result.type = "integer";
|
|
292
291
|
break;
|
|
293
292
|
case "optional":
|
|
293
|
+
// Handled by isFieldOptional check, no schema modification needed
|
|
294
|
+
break;
|
|
294
295
|
case "nullable":
|
|
296
|
+
result.nullable = true;
|
|
297
|
+
break;
|
|
295
298
|
case "nullish":
|
|
296
|
-
|
|
297
|
-
// Don't modify the schema here
|
|
299
|
+
result.nullable = true;
|
|
298
300
|
break;
|
|
299
301
|
case "describe":
|
|
300
302
|
if (args.length > 0 && t.isStringLiteral(args[0])) {
|
|
@@ -17,9 +17,10 @@ export class OpenApiGenerator {
|
|
|
17
17
|
}
|
|
18
18
|
getConfig() {
|
|
19
19
|
// @ts-ignore
|
|
20
|
-
const { apiDir, schemaDir, docsUrl, ui, outputFile, outputDir, includeOpenApiRoutes, ignoreRoutes, schemaType = "typescript", schemaFiles, defaultResponseSet, responseSets, errorConfig, debug } = this.template;
|
|
20
|
+
const { apiDir, routerType, schemaDir, docsUrl, ui, outputFile, outputDir, includeOpenApiRoutes, ignoreRoutes, schemaType = "typescript", schemaFiles, defaultResponseSet, responseSets, errorConfig, debug } = this.template;
|
|
21
21
|
return {
|
|
22
22
|
apiDir: apiDir || "./src/app/api",
|
|
23
|
+
routerType: routerType || "app",
|
|
23
24
|
schemaDir: schemaDir || "./src",
|
|
24
25
|
docsUrl: docsUrl || "api-docs",
|
|
25
26
|
ui: ui || "scalar",
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import traverseModule from "@babel/traverse";
|
|
3
|
+
const traverse = traverseModule.default || traverseModule;
|
|
4
|
+
import { HTTP_METHODS } from "./router-strategy.js";
|
|
5
|
+
import { parseTypeScriptFile } from "./utils.js";
|
|
6
|
+
export class PagesRouterStrategy {
|
|
7
|
+
config;
|
|
8
|
+
constructor(config) {
|
|
9
|
+
this.config = config;
|
|
10
|
+
}
|
|
11
|
+
shouldProcessFile(fileName) {
|
|
12
|
+
return (!fileName.startsWith("_") &&
|
|
13
|
+
(fileName.endsWith(".ts") || fileName.endsWith(".tsx")));
|
|
14
|
+
}
|
|
15
|
+
processFile(filePath, addRoute) {
|
|
16
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
17
|
+
const ast = parseTypeScriptFile(content);
|
|
18
|
+
const methodComments = [];
|
|
19
|
+
traverse(ast, {
|
|
20
|
+
ExportDefaultDeclaration: (nodePath) => {
|
|
21
|
+
const allComments = ast.comments || [];
|
|
22
|
+
const exportStart = nodePath.node.start || 0;
|
|
23
|
+
allComments.forEach((comment) => {
|
|
24
|
+
if (comment.type === "CommentBlock" &&
|
|
25
|
+
(comment.end || 0) < exportStart) {
|
|
26
|
+
const commentValue = comment.value;
|
|
27
|
+
if (commentValue.includes("@method")) {
|
|
28
|
+
const dataTypes = this.extractJSDocFromComment(commentValue);
|
|
29
|
+
if (dataTypes.method && HTTP_METHODS.includes(dataTypes.method)) {
|
|
30
|
+
methodComments.push({
|
|
31
|
+
method: dataTypes.method,
|
|
32
|
+
dataTypes,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
methodComments.forEach(({ method, dataTypes }) => {
|
|
39
|
+
addRoute(method, filePath, dataTypes);
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
getRoutePath(filePath) {
|
|
45
|
+
const normalizedPath = filePath.replaceAll("\\", "/");
|
|
46
|
+
const normalizedApiDir = this.config.apiDir
|
|
47
|
+
.replaceAll("\\", "/")
|
|
48
|
+
.replace(/^\.\//, "")
|
|
49
|
+
.replace(/\/$/, "");
|
|
50
|
+
const apiDirIndex = normalizedPath.indexOf(normalizedApiDir);
|
|
51
|
+
if (apiDirIndex === -1) {
|
|
52
|
+
throw new Error(`Could not find apiDir "${this.config.apiDir}" in file path "${filePath}"`);
|
|
53
|
+
}
|
|
54
|
+
let relativePath = normalizedPath.substring(apiDirIndex + normalizedApiDir.length);
|
|
55
|
+
// Remove the file extension (.ts or .tsx)
|
|
56
|
+
relativePath = relativePath.replace(/\.tsx?$/, "");
|
|
57
|
+
// Remove /index suffix (pages/api/users/index.ts -> /users)
|
|
58
|
+
relativePath = relativePath.replace(/\/index$/, "");
|
|
59
|
+
if (!relativePath.startsWith("/")) {
|
|
60
|
+
relativePath = "/" + relativePath;
|
|
61
|
+
}
|
|
62
|
+
relativePath = relativePath.replace(/\/$/, "");
|
|
63
|
+
// Handle catch-all routes before dynamic routes
|
|
64
|
+
relativePath = relativePath.replace(/\/\[\.\.\.(.*?)\]/g, "/{$1}");
|
|
65
|
+
// Convert Next.js dynamic route syntax to OpenAPI parameter syntax
|
|
66
|
+
relativePath = relativePath.replace(/\/\[([^\]]+)\]/g, "/{$1}");
|
|
67
|
+
return relativePath || "/";
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Extract JSDoc data from a raw comment string (Pages Router specific)
|
|
71
|
+
*/
|
|
72
|
+
extractJSDocFromComment(commentValue) {
|
|
73
|
+
const cleanedComment = commentValue.replace(/\*\s*/g, "").trim();
|
|
74
|
+
let tag = "";
|
|
75
|
+
let summary = "";
|
|
76
|
+
let description = "";
|
|
77
|
+
let paramsType = "";
|
|
78
|
+
let pathParamsType = "";
|
|
79
|
+
let bodyType = "";
|
|
80
|
+
let auth = "";
|
|
81
|
+
let isOpenApi = cleanedComment.includes("@openapi");
|
|
82
|
+
let isIgnored = cleanedComment.includes("@ignore");
|
|
83
|
+
let deprecated = cleanedComment.includes("@deprecated");
|
|
84
|
+
let bodyDescription = "";
|
|
85
|
+
let contentType = "";
|
|
86
|
+
let responseType = "";
|
|
87
|
+
let responseDescription = "";
|
|
88
|
+
let responseSet = "";
|
|
89
|
+
let addResponses = "";
|
|
90
|
+
let successCode = "";
|
|
91
|
+
let operationId = "";
|
|
92
|
+
let method = "";
|
|
93
|
+
const methodMatch = cleanedComment.match(/@method\s+(\S+)/);
|
|
94
|
+
if (methodMatch) {
|
|
95
|
+
method = methodMatch[1].trim().toUpperCase();
|
|
96
|
+
}
|
|
97
|
+
const firstLine = cleanedComment.split("\n")[0];
|
|
98
|
+
if (!firstLine.trim().startsWith("@")) {
|
|
99
|
+
summary = firstLine.trim();
|
|
100
|
+
}
|
|
101
|
+
const descMatch = cleanedComment.match(/@description\s+(.*)/);
|
|
102
|
+
if (descMatch) {
|
|
103
|
+
description = descMatch[1].trim();
|
|
104
|
+
}
|
|
105
|
+
const tagMatch = cleanedComment.match(/@tag\s+(.*)/);
|
|
106
|
+
if (tagMatch) {
|
|
107
|
+
tag = tagMatch[1].trim();
|
|
108
|
+
}
|
|
109
|
+
const paramsMatch = cleanedComment.match(/@queryParams\s+([\w<>,\s\[\]]+)/) ||
|
|
110
|
+
cleanedComment.match(/@params\s+([\w<>,\s\[\]]+)/);
|
|
111
|
+
if (paramsMatch) {
|
|
112
|
+
paramsType = paramsMatch[1].trim();
|
|
113
|
+
}
|
|
114
|
+
const pathParamsMatch = cleanedComment.match(/@pathParams\s+([\w<>,\s\[\]]+)/);
|
|
115
|
+
if (pathParamsMatch) {
|
|
116
|
+
pathParamsType = pathParamsMatch[1].trim();
|
|
117
|
+
}
|
|
118
|
+
const bodyMatch = cleanedComment.match(/@body\s+([\w<>,\s\[\]]+)/);
|
|
119
|
+
if (bodyMatch) {
|
|
120
|
+
bodyType = bodyMatch[1].trim();
|
|
121
|
+
}
|
|
122
|
+
const bodyDescMatch = cleanedComment.match(/@bodyDescription\s+(.*)/);
|
|
123
|
+
if (bodyDescMatch) {
|
|
124
|
+
bodyDescription = bodyDescMatch[1].trim();
|
|
125
|
+
}
|
|
126
|
+
const contentTypeMatch = cleanedComment.match(/@contentType\s+(.*)/);
|
|
127
|
+
if (contentTypeMatch) {
|
|
128
|
+
contentType = contentTypeMatch[1].trim();
|
|
129
|
+
}
|
|
130
|
+
const responseMatch = cleanedComment.match(/@response\s+(?:(\d+):)?([^@\n\r]+)/);
|
|
131
|
+
if (responseMatch) {
|
|
132
|
+
const [, code, type] = responseMatch;
|
|
133
|
+
const trimmedType = type?.trim();
|
|
134
|
+
if (!code && trimmedType && /^\d{3}$/.test(trimmedType)) {
|
|
135
|
+
successCode = trimmedType;
|
|
136
|
+
responseType = undefined;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
successCode = code || "";
|
|
140
|
+
responseType = trimmedType;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const respDescMatch = cleanedComment.match(/@responseDescription\s+(.*)/);
|
|
144
|
+
if (respDescMatch) {
|
|
145
|
+
responseDescription = respDescMatch[1].trim();
|
|
146
|
+
}
|
|
147
|
+
const respSetMatch = cleanedComment.match(/@responseSet\s+(.*)/);
|
|
148
|
+
if (respSetMatch) {
|
|
149
|
+
responseSet = respSetMatch[1].trim();
|
|
150
|
+
}
|
|
151
|
+
const addMatch = cleanedComment.match(/@add\s+(.*)/);
|
|
152
|
+
if (addMatch) {
|
|
153
|
+
addResponses = addMatch[1].trim();
|
|
154
|
+
}
|
|
155
|
+
const opIdMatch = cleanedComment.match(/@operationId\s+(\S+)/);
|
|
156
|
+
if (opIdMatch) {
|
|
157
|
+
operationId = opIdMatch[1].trim();
|
|
158
|
+
}
|
|
159
|
+
const authMatch = cleanedComment.match(/@auth\s+(.*)/);
|
|
160
|
+
if (authMatch) {
|
|
161
|
+
const authValue = authMatch[1].trim();
|
|
162
|
+
switch (authValue) {
|
|
163
|
+
case "bearer":
|
|
164
|
+
auth = "BearerAuth";
|
|
165
|
+
break;
|
|
166
|
+
case "basic":
|
|
167
|
+
auth = "BasicAuth";
|
|
168
|
+
break;
|
|
169
|
+
case "apikey":
|
|
170
|
+
auth = "ApiKeyAuth";
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
tag,
|
|
176
|
+
auth,
|
|
177
|
+
summary,
|
|
178
|
+
description,
|
|
179
|
+
paramsType,
|
|
180
|
+
pathParamsType,
|
|
181
|
+
bodyType,
|
|
182
|
+
isOpenApi,
|
|
183
|
+
isIgnored,
|
|
184
|
+
deprecated,
|
|
185
|
+
bodyDescription,
|
|
186
|
+
contentType,
|
|
187
|
+
responseType,
|
|
188
|
+
responseDescription,
|
|
189
|
+
responseSet,
|
|
190
|
+
addResponses,
|
|
191
|
+
successCode,
|
|
192
|
+
operationId,
|
|
193
|
+
method,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -1,24 +1,25 @@
|
|
|
1
|
-
import * as t from "@babel/types";
|
|
2
1
|
import fs from "fs";
|
|
3
2
|
import path from "path";
|
|
4
|
-
import traverseModule from "@babel/traverse";
|
|
5
|
-
// Handle both ES modules and CommonJS
|
|
6
|
-
const traverse = traverseModule.default || traverseModule;
|
|
7
3
|
import { SchemaProcessor } from "./schema-processor.js";
|
|
8
|
-
import { capitalize,
|
|
4
|
+
import { capitalize, extractPathParameters, getOperationId, } from "./utils.js";
|
|
9
5
|
import { logger } from "./logger.js";
|
|
10
|
-
|
|
6
|
+
import { AppRouterStrategy } from "./app-router-strategy.js";
|
|
7
|
+
import { PagesRouterStrategy } from "./pages-router-strategy.js";
|
|
11
8
|
const MUTATION_HTTP_METHODS = ["PATCH", "POST", "PUT"];
|
|
12
9
|
export class RouteProcessor {
|
|
13
10
|
swaggerPaths = {};
|
|
14
11
|
schemaProcessor;
|
|
15
12
|
config;
|
|
13
|
+
strategy;
|
|
16
14
|
directoryCache = {};
|
|
17
15
|
statCache = {};
|
|
18
16
|
processFileTracker = {};
|
|
19
17
|
constructor(config) {
|
|
20
18
|
this.config = config;
|
|
21
19
|
this.schemaProcessor = new SchemaProcessor(config.schemaDir, config.schemaType, config.schemaFiles);
|
|
20
|
+
this.strategy = config.routerType === "pages"
|
|
21
|
+
? new PagesRouterStrategy(config)
|
|
22
|
+
: new AppRouterStrategy(config);
|
|
22
23
|
}
|
|
23
24
|
buildResponsesFromConfig(dataTypes, method) {
|
|
24
25
|
const responses = {};
|
|
@@ -158,9 +159,6 @@ export class RouteProcessor {
|
|
|
158
159
|
getSchemaProcessor() {
|
|
159
160
|
return this.schemaProcessor;
|
|
160
161
|
}
|
|
161
|
-
isRoute(varName) {
|
|
162
|
-
return HTTP_METHODS.includes(varName);
|
|
163
|
-
}
|
|
164
162
|
/**
|
|
165
163
|
* Check if a route should be ignored based on config patterns or @ignore tag
|
|
166
164
|
*/
|
|
@@ -181,65 +179,23 @@ export class RouteProcessor {
|
|
|
181
179
|
return regex.test(routePath);
|
|
182
180
|
});
|
|
183
181
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
182
|
+
/**
|
|
183
|
+
* Register a discovered route after filtering
|
|
184
|
+
*/
|
|
185
|
+
registerRoute(method, filePath, dataTypes) {
|
|
186
|
+
const routePath = this.strategy.getRoutePath(filePath);
|
|
187
|
+
if (this.shouldIgnoreRoute(routePath, dataTypes)) {
|
|
188
|
+
logger.debug(`Ignoring route: ${routePath}`);
|
|
187
189
|
return;
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const routePath = this.getRoutePath(filePath);
|
|
198
|
-
// Skip if route should be ignored
|
|
199
|
-
if (this.shouldIgnoreRoute(routePath, dataTypes)) {
|
|
200
|
-
logger.debug(`Ignoring route: ${routePath}`);
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
// Don't bother adding routes for processing if only including OpenAPI routes and the route is not OpenAPI
|
|
204
|
-
if (!this.config.includeOpenApiRoutes ||
|
|
205
|
-
(this.config.includeOpenApiRoutes && dataTypes.isOpenApi)) {
|
|
206
|
-
// Check for URL parameters in the route path
|
|
207
|
-
const pathParams = extractPathParameters(routePath);
|
|
208
|
-
// If we have path parameters but no pathParamsType defined, we should log a warning
|
|
209
|
-
if (pathParams.length > 0 && !dataTypes.pathParamsType) {
|
|
210
|
-
logger.debug(`Route ${routePath} contains path parameters ${pathParams.join(", ")} but no @pathParams type is defined.`);
|
|
211
|
-
}
|
|
212
|
-
this.addRouteToPaths(declaration.id.name, filePath, dataTypes);
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
if (t.isVariableDeclaration(declaration)) {
|
|
217
|
-
declaration.declarations.forEach((decl) => {
|
|
218
|
-
if (t.isVariableDeclarator(decl) && t.isIdentifier(decl.id)) {
|
|
219
|
-
if (this.isRoute(decl.id.name)) {
|
|
220
|
-
const dataTypes = extractJSDocComments(path);
|
|
221
|
-
const routePath = this.getRoutePath(filePath);
|
|
222
|
-
// Skip if route should be ignored
|
|
223
|
-
if (this.shouldIgnoreRoute(routePath, dataTypes)) {
|
|
224
|
-
logger.debug(`Ignoring route: ${routePath}`);
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
// Don't bother adding routes for processing if only including OpenAPI routes and the route is not OpenAPI
|
|
228
|
-
if (!this.config.includeOpenApiRoutes ||
|
|
229
|
-
(this.config.includeOpenApiRoutes && dataTypes.isOpenApi)) {
|
|
230
|
-
const pathParams = extractPathParameters(routePath);
|
|
231
|
-
if (pathParams.length > 0 && !dataTypes.pathParamsType) {
|
|
232
|
-
logger.debug(`Route ${routePath} contains path parameters ${pathParams.join(", ")} but no @pathParams type is defined.`);
|
|
233
|
-
}
|
|
234
|
-
this.addRouteToPaths(decl.id.name, filePath, dataTypes);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
},
|
|
241
|
-
});
|
|
242
|
-
this.processFileTracker[filePath] = true;
|
|
190
|
+
}
|
|
191
|
+
if (this.config.includeOpenApiRoutes && !dataTypes.isOpenApi) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const pathParams = extractPathParameters(routePath);
|
|
195
|
+
if (pathParams.length > 0 && !dataTypes.pathParamsType) {
|
|
196
|
+
logger.debug(`Route ${routePath} contains path parameters ${pathParams.join(", ")} but no @pathParams type is defined.`);
|
|
197
|
+
}
|
|
198
|
+
this.addRouteToPaths(method, filePath, dataTypes);
|
|
243
199
|
}
|
|
244
200
|
scanApiRoutes(dir) {
|
|
245
201
|
logger.debug(`Scanning API routes in: ${dir}`);
|
|
@@ -257,18 +213,20 @@ export class RouteProcessor {
|
|
|
257
213
|
}
|
|
258
214
|
if (stat.isDirectory()) {
|
|
259
215
|
this.scanApiRoutes(filePath);
|
|
260
|
-
// @ts-ignore
|
|
261
216
|
}
|
|
262
|
-
else if (
|
|
263
|
-
if (
|
|
264
|
-
this.processFile(filePath)
|
|
217
|
+
else if (this.strategy.shouldProcessFile(file)) {
|
|
218
|
+
if (!this.processFileTracker[filePath]) {
|
|
219
|
+
this.strategy.processFile(filePath, (method, fp, dataTypes) => {
|
|
220
|
+
this.registerRoute(method, fp, dataTypes);
|
|
221
|
+
});
|
|
222
|
+
this.processFileTracker[filePath] = true;
|
|
265
223
|
}
|
|
266
224
|
}
|
|
267
225
|
});
|
|
268
226
|
}
|
|
269
227
|
addRouteToPaths(varName, filePath, dataTypes) {
|
|
270
228
|
const method = varName.toLowerCase();
|
|
271
|
-
const routePath = this.getRoutePath(filePath);
|
|
229
|
+
const routePath = this.strategy.getRoutePath(filePath);
|
|
272
230
|
const rootPath = capitalize(routePath.split("/")[1]);
|
|
273
231
|
const operationId = dataTypes.operationId || getOperationId(routePath, method);
|
|
274
232
|
const { tag, summary, description, auth, isOpenApi, deprecated, bodyDescription, responseDescription, } = dataTypes;
|
|
@@ -355,38 +313,6 @@ export class RouteProcessor {
|
|
|
355
313
|
}
|
|
356
314
|
this.swaggerPaths[routePath][method] = definition;
|
|
357
315
|
}
|
|
358
|
-
getRoutePath(filePath) {
|
|
359
|
-
// Normalize path separators first
|
|
360
|
-
const normalizedPath = filePath.replaceAll("\\", "/");
|
|
361
|
-
// Normalize apiDir to ensure consistent format
|
|
362
|
-
const normalizedApiDir = this.config.apiDir
|
|
363
|
-
.replaceAll("\\", "/")
|
|
364
|
-
.replace(/^\.\//, "")
|
|
365
|
-
.replace(/\/$/, "");
|
|
366
|
-
// Find the apiDir position in the normalized path
|
|
367
|
-
const apiDirIndex = normalizedPath.indexOf(normalizedApiDir);
|
|
368
|
-
if (apiDirIndex === -1) {
|
|
369
|
-
throw new Error(`Could not find apiDir "${this.config.apiDir}" in file path "${filePath}"`);
|
|
370
|
-
}
|
|
371
|
-
// Extract the path after apiDir
|
|
372
|
-
let relativePath = normalizedPath.substring(apiDirIndex + normalizedApiDir.length);
|
|
373
|
-
// Remove the /route.ts or /route.tsx suffix
|
|
374
|
-
relativePath = relativePath.replace(/\/route\.tsx?$/, "");
|
|
375
|
-
// Ensure the path starts with /
|
|
376
|
-
if (!relativePath.startsWith("/")) {
|
|
377
|
-
relativePath = "/" + relativePath;
|
|
378
|
-
}
|
|
379
|
-
// Remove trailing slash
|
|
380
|
-
relativePath = relativePath.replace(/\/$/, "");
|
|
381
|
-
// Remove Next.js route groups (folders in parentheses like (authenticated), (marketing))
|
|
382
|
-
relativePath = relativePath.replace(/\/\([^)]+\)/g, "");
|
|
383
|
-
// Handle catch-all routes ([...param]) before converting dynamic routes
|
|
384
|
-
// This must come first because [...param] would also match the [param] pattern
|
|
385
|
-
relativePath = relativePath.replace(/\/\[\.\.\.(.*?)\]/g, "/{$1}");
|
|
386
|
-
// Convert Next.js dynamic route syntax to OpenAPI parameter syntax
|
|
387
|
-
relativePath = relativePath.replace(/\/\[([^\]]+)\]/g, "/{$1}");
|
|
388
|
-
return relativePath || "/";
|
|
389
|
-
}
|
|
390
316
|
getSortedPaths(paths) {
|
|
391
317
|
function comparePaths(a, b) {
|
|
392
318
|
const aMethods = this.swaggerPaths[a] || {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
package/dist/lib/utils.js
CHANGED
|
@@ -35,6 +35,7 @@ export function extractJSDocComments(path) {
|
|
|
35
35
|
let addResponses = "";
|
|
36
36
|
let successCode = "";
|
|
37
37
|
let operationId = "";
|
|
38
|
+
let method = "";
|
|
38
39
|
if (comments) {
|
|
39
40
|
comments.forEach((comment) => {
|
|
40
41
|
const commentValue = cleanComment(comment.value);
|
|
@@ -133,6 +134,13 @@ export function extractJSDocComments(path) {
|
|
|
133
134
|
operationId = match[1].trim();
|
|
134
135
|
}
|
|
135
136
|
}
|
|
137
|
+
if (commentValue.includes("@method")) {
|
|
138
|
+
const regex = /@method\s+(\S+)/;
|
|
139
|
+
const match = commentValue.match(regex);
|
|
140
|
+
if (match && match[1]) {
|
|
141
|
+
method = match[1].trim().toUpperCase();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
136
144
|
if (commentValue.includes("@response")) {
|
|
137
145
|
// Updated regex to support generic types
|
|
138
146
|
const responseMatch = commentValue.match(/@response\s+(?:(\d+):)?([^@\n\r]+)(?:\s+(.*))?/);
|
|
@@ -175,6 +183,7 @@ export function extractJSDocComments(path) {
|
|
|
175
183
|
addResponses,
|
|
176
184
|
successCode,
|
|
177
185
|
operationId,
|
|
186
|
+
method,
|
|
178
187
|
};
|
|
179
188
|
}
|
|
180
189
|
export function extractTypeFromComment(commentValue, tag) {
|
|
@@ -190,6 +199,7 @@ export function cleanComment(commentValue) {
|
|
|
190
199
|
export function cleanSpec(spec) {
|
|
191
200
|
const propsToRemove = [
|
|
192
201
|
"apiDir",
|
|
202
|
+
"routerType",
|
|
193
203
|
"schemaDir",
|
|
194
204
|
"docsUrl",
|
|
195
205
|
"ui",
|
|
@@ -347,12 +347,8 @@ export class ZodSchemaConverter {
|
|
|
347
347
|
}
|
|
348
348
|
break;
|
|
349
349
|
case "partial":
|
|
350
|
-
// All fields become optional
|
|
350
|
+
// All fields become optional (T | undefined), not nullable
|
|
351
351
|
if (schema.properties) {
|
|
352
|
-
Object.keys(schema.properties).forEach((key) => {
|
|
353
|
-
schema.properties[key].nullable = true;
|
|
354
|
-
});
|
|
355
|
-
// Remove all required
|
|
356
352
|
delete schema.required;
|
|
357
353
|
}
|
|
358
354
|
break;
|
|
@@ -390,14 +386,9 @@ export class ZodSchemaConverter {
|
|
|
390
386
|
}
|
|
391
387
|
break;
|
|
392
388
|
case "required":
|
|
393
|
-
// All fields become required
|
|
389
|
+
// All fields become required — preserve genuine nullable flags
|
|
394
390
|
if (schema.properties) {
|
|
395
|
-
|
|
396
|
-
schema.required = requiredFields;
|
|
397
|
-
// Remove nullable from fields
|
|
398
|
-
Object.keys(schema.properties).forEach((key) => {
|
|
399
|
-
delete schema.properties[key].nullable;
|
|
400
|
-
});
|
|
391
|
+
schema.required = Object.keys(schema.properties);
|
|
401
392
|
}
|
|
402
393
|
break;
|
|
403
394
|
case "extend":
|
|
@@ -418,8 +409,9 @@ export class ZodSchemaConverter {
|
|
|
418
409
|
const propSchema = this.processZodNode(prop.value);
|
|
419
410
|
if (propSchema) {
|
|
420
411
|
extensionProperties[key] = propSchema;
|
|
421
|
-
|
|
422
|
-
|
|
412
|
+
const isOptional =
|
|
413
|
+
// @ts-ignore
|
|
414
|
+
this.isOptional(prop.value) || this.hasOptionalMethod(prop.value);
|
|
423
415
|
if (!isOptional) {
|
|
424
416
|
extensionRequired.push(key);
|
|
425
417
|
}
|
|
@@ -1239,20 +1231,17 @@ export class ZodSchemaConverter {
|
|
|
1239
1231
|
// Apply the current method
|
|
1240
1232
|
switch (methodName) {
|
|
1241
1233
|
case "optional":
|
|
1242
|
-
//
|
|
1243
|
-
//
|
|
1244
|
-
if (!schema.allOf) {
|
|
1245
|
-
schema.nullable = true;
|
|
1246
|
-
}
|
|
1234
|
+
// optional means T | undefined — not in required array, no nullable flag
|
|
1235
|
+
// Required array exclusion is handled by hasOptionalMethod() in processZodObject()
|
|
1247
1236
|
break;
|
|
1248
1237
|
case "nullable":
|
|
1249
|
-
//
|
|
1238
|
+
// nullable means T | null — field stays required but can be null
|
|
1250
1239
|
if (!schema.allOf) {
|
|
1251
1240
|
schema.nullable = true;
|
|
1252
1241
|
}
|
|
1253
1242
|
break;
|
|
1254
|
-
case "nullish": //
|
|
1255
|
-
//
|
|
1243
|
+
case "nullish": // T | null | undefined
|
|
1244
|
+
// Not in required array (handled by hasOptionalMethod) AND can be null
|
|
1256
1245
|
if (!schema.allOf) {
|
|
1257
1246
|
schema.nullable = true;
|
|
1258
1247
|
}
|
|
@@ -1540,7 +1529,6 @@ export class ZodSchemaConverter {
|
|
|
1540
1529
|
if (t.isMemberExpression(node.callee) &&
|
|
1541
1530
|
t.isIdentifier(node.callee.property) &&
|
|
1542
1531
|
(node.callee.property.name === "optional" ||
|
|
1543
|
-
node.callee.property.name === "nullable" ||
|
|
1544
1532
|
node.callee.property.name === "nullish")) {
|
|
1545
1533
|
return true;
|
|
1546
1534
|
}
|
package/package.json
CHANGED