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 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
- // These are handled by the isFieldOptional check
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, extractJSDocComments, parseTypeScriptFile, extractPathParameters, getOperationId, } from "./utils.js";
4
+ import { capitalize, extractPathParameters, getOperationId, } from "./utils.js";
9
5
  import { logger } from "./logger.js";
10
- const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
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
- processFile(filePath) {
185
- // Check if the file has already been processed
186
- if (this.processFileTracker[filePath])
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
- const content = fs.readFileSync(filePath, "utf-8");
189
- const ast = parseTypeScriptFile(content);
190
- traverse(ast, {
191
- ExportNamedDeclaration: (path) => {
192
- const declaration = path.node.declaration;
193
- if (t.isFunctionDeclaration(declaration) &&
194
- t.isIdentifier(declaration.id)) {
195
- const dataTypes = extractJSDocComments(path);
196
- if (this.isRoute(declaration.id.name)) {
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 (file.endsWith(".ts") || file.endsWith(".tsx")) {
263
- if (file === "route.ts" || file === "route.tsx") {
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
- const requiredFields = Object.keys(schema.properties);
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
- // Check if the schema itself has nullable set (which processZodNode sets for optional fields)
422
- const isOptional = propSchema.nullable === true;
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
- // Don't add nullable for schema references wrapped in allOf
1243
- // as it doesn't make sense in that context
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
- // Don't add nullable for schema references wrapped in allOf
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": // Handles both null and undefined
1255
- // Don't add nullable for schema references wrapped in allOf
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-openapi-gen",
3
- "version": "0.9.4",
3
+ "version": "0.10.1",
4
4
  "description": "Automatically generate OpenAPI 3.0 documentation from Next.js projects, with support for Zod schemas and TypeScript types.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",