next-openapi-gen 0.6.1 → 0.6.4

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
@@ -75,6 +75,9 @@ During initialization (`npx next-openapi-gen init`), a configuration file `next.
75
75
  | `outputFile` | Path to the OpenAPI output file |
76
76
  | `docsUrl` | API documentation URL (for Swagger UI) |
77
77
  | `includeOpenApiRoutes` | Whether to include only routes with @openapi tag |
78
+ | `defaultResponseSet` | Default error response set for all endpoints |
79
+ | `responseSets` | Named sets of error response codes |
80
+ | `errorConfig` | Error schema configuration |
78
81
 
79
82
  ## Documenting Your API
80
83
 
@@ -154,6 +157,9 @@ export async function GET(
154
157
  | `@bodyDescription` | Request body description |
155
158
  | `@response` | Response type/schema |
156
159
  | `@responseDescription` | Response description |
160
+ | `@responseSet` | Override default response set (`public`, `auth`, `none`) |
161
+ | `@add` | Add custom response codes (`409:ConflictResponse`, `429`) |
162
+ | `@contentType` | Request body content type (`application/json`, `multipart/form-data`) |
157
163
  | `@auth` | Authorization type (`bearer`, `basic`, `apikey`) |
158
164
  | `@tag` | Custom tag |
159
165
  | `@deprecated` | Marks the route as deprecated |
@@ -344,6 +350,117 @@ export async function GET() {
344
350
  }
345
351
  ```
346
352
 
353
+ ### File Uploads / Multipart Form Data
354
+
355
+ ```typescript
356
+ // src/app/api/upload/route.ts
357
+
358
+ // TypeScript
359
+ type FileUploadFormData = {
360
+ file: File;
361
+ description?: string;
362
+ category: string;
363
+ };
364
+
365
+ // Or Zod
366
+ const FileUploadSchema = z.object({
367
+ file: z.custom<File>().describe("Image file (PNG/JPG)"),
368
+ description: z.string().optional().describe("File description"),
369
+ category: z.string().describe("File category"),
370
+ });
371
+
372
+ /**
373
+ * @body FileUploadSchema
374
+ * @contentType multipart/form-data
375
+ */
376
+ export async function POST() {
377
+ // ...
378
+ }
379
+ ```
380
+
381
+ ## Response Management
382
+
383
+ ### Zero Config + Response Sets
384
+
385
+ Configure reusable error sets in `next.openapi.json`:
386
+
387
+ ```json
388
+ {
389
+ "defaultResponseSet": "common",
390
+ "responseSets": {
391
+ "common": ["400", "401", "500"],
392
+ "public": ["400", "500"],
393
+ "auth": ["400", "401", "403", "500"]
394
+ }
395
+ }
396
+ ```
397
+
398
+ ### Usage Examples
399
+
400
+ ```typescript
401
+ /**
402
+ * Auto-default responses
403
+ * @response UserResponse
404
+ * @openapi
405
+ */
406
+ export async function GET() {}
407
+ // Generates: 200:UserResponse + common errors (400, 401, 500)
408
+
409
+ /**
410
+ * Override response set
411
+ * @response ProductResponse
412
+ * @responseSet public
413
+ * @openapi
414
+ */
415
+ export async function GET() {}
416
+ // Generates: 200:ProductResponse + public errors (400, 500)
417
+
418
+ /**
419
+ * Add custom responses
420
+ * @response 201:UserResponse
421
+ * @add 409:ConflictResponse
422
+ * @openapi
423
+ */
424
+ export async function POST() {}
425
+ // Generates: 201:UserResponse + common errors + 409:ConflictResponse
426
+
427
+ /**
428
+ * Combine multiple sets
429
+ * @response UserResponse
430
+ * @responseSet auth,crud
431
+ * @add 429:RateLimitResponse
432
+ * @openapi
433
+ */
434
+ export async function PUT() {}
435
+ // Combines: auth + crud errors + custom 429
436
+ ```
437
+
438
+ ### Error Schema Configuration
439
+
440
+ #### Define consistent error schemas using templates:
441
+
442
+ ```json
443
+ {
444
+ "errorConfig": {
445
+ "template": {
446
+ "type": "object",
447
+ "properties": {
448
+ "error": {
449
+ "type": "string",
450
+ "example": "{{ERROR_MESSAGE}}"
451
+ }
452
+ }
453
+ },
454
+ "codes": {
455
+ "invalid_request": {
456
+ "description": "Invalid request",
457
+ "variables": { "ERROR_MESSAGE": "Validation failed" }
458
+ }
459
+ }
460
+ }
461
+ }
462
+ ```
463
+
347
464
  ## Advanced Usage
348
465
 
349
466
  ### Automatic Path Parameter Detection
@@ -14,7 +14,7 @@ export class OpenApiGenerator {
14
14
  }
15
15
  getConfig() {
16
16
  // @ts-ignore
17
- const { apiDir, schemaDir, docsUrl, ui, outputFile, includeOpenApiRoutes, schemaType = "typescript" } = this.template;
17
+ const { apiDir, schemaDir, docsUrl, ui, outputFile, includeOpenApiRoutes, schemaType = "typescript", defaultResponseSet, responseSets, errorConfig } = this.template;
18
18
  return {
19
19
  apiDir,
20
20
  schemaDir,
@@ -23,6 +23,9 @@ export class OpenApiGenerator {
23
23
  outputFile,
24
24
  includeOpenApiRoutes,
25
25
  schemaType,
26
+ defaultResponseSet,
27
+ responseSets,
28
+ errorConfig,
26
29
  };
27
30
  }
28
31
  generate() {
@@ -57,6 +60,21 @@ export class OpenApiGenerator {
57
60
  if (!this.template.components.schemas) {
58
61
  this.template.components.schemas = {};
59
62
  }
63
+ // Generate error responses using errorConfig or manual definitions
64
+ if (!this.template.components.responses) {
65
+ this.template.components.responses = {};
66
+ }
67
+ const errorConfig = this.config.errorConfig;
68
+ if (errorConfig) {
69
+ this.generateErrorResponsesFromConfig(errorConfig);
70
+ }
71
+ else if (this.config.errorDefinitions) {
72
+ // Use manual definitions (existing logic - if exists)
73
+ Object.entries(this.config.errorDefinitions).forEach(([code, errorDef]) => {
74
+ this.template.components.responses[code] =
75
+ this.createErrorResponseComponent(code, errorDef);
76
+ });
77
+ }
60
78
  // Get defined schemas from the processor
61
79
  const definedSchemas = this.routeProcessor
62
80
  .getSchemaProcessor()
@@ -70,4 +88,70 @@ export class OpenApiGenerator {
70
88
  const openapiSpec = cleanSpec(this.template);
71
89
  return openapiSpec;
72
90
  }
91
+ generateErrorResponsesFromConfig(errorConfig) {
92
+ const { template, codes, variables: globalVars = {} } = errorConfig;
93
+ Object.entries(codes).forEach(([errorCode, config]) => {
94
+ const httpStatus = (config.httpStatus || this.guessHttpStatus(errorCode)).toString();
95
+ // Merge variables: global + per-code + built-in
96
+ const allVariables = {
97
+ ...globalVars,
98
+ ...config.variables,
99
+ ERROR_CODE: errorCode,
100
+ DESCRIPTION: config.description,
101
+ HTTP_STATUS: httpStatus,
102
+ };
103
+ const processedSchema = this.processTemplate(template, allVariables);
104
+ this.template.components.responses[httpStatus] = {
105
+ description: config.description,
106
+ content: {
107
+ "application/json": {
108
+ schema: processedSchema,
109
+ },
110
+ },
111
+ };
112
+ });
113
+ }
114
+ processTemplate(template, variables) {
115
+ const jsonStr = JSON.stringify(template);
116
+ let result = jsonStr;
117
+ Object.entries(variables).forEach(([key, value]) => {
118
+ result = result.replace(new RegExp(`{{${key}}}`, "g"), value);
119
+ });
120
+ return JSON.parse(result);
121
+ }
122
+ guessHttpStatus(errorCode) {
123
+ const statusMap = {
124
+ bad: 400,
125
+ invalid: 400,
126
+ validation: 422,
127
+ unauthorized: 401,
128
+ auth: 401,
129
+ forbidden: 403,
130
+ permission: 403,
131
+ not_found: 404,
132
+ missing: 404,
133
+ conflict: 409,
134
+ duplicate: 409,
135
+ rate_limit: 429,
136
+ too_many: 429,
137
+ server: 500,
138
+ internal: 500,
139
+ };
140
+ for (const [key, status] of Object.entries(statusMap)) {
141
+ if (errorCode.toLowerCase().includes(key)) {
142
+ return status;
143
+ }
144
+ }
145
+ return 500;
146
+ }
147
+ createErrorResponseComponent(code, errorDef) {
148
+ return {
149
+ description: errorDef.description,
150
+ content: {
151
+ "application/json": {
152
+ schema: errorDef.schema,
153
+ },
154
+ },
155
+ };
156
+ }
73
157
  }
@@ -18,6 +18,91 @@ export class RouteProcessor {
18
18
  this.config = config;
19
19
  this.schemaProcessor = new SchemaProcessor(config.schemaDir, config.schemaType);
20
20
  }
21
+ buildResponsesFromConfig(dataTypes, method) {
22
+ const responses = {};
23
+ // 1. Add success response
24
+ const successCode = dataTypes.successCode || this.getDefaultSuccessCode(method);
25
+ if (dataTypes.responseType) {
26
+ const responseSchema = this.schemaProcessor.getSchemaContent({
27
+ responseType: dataTypes.responseType,
28
+ }).responses;
29
+ responses[successCode] = {
30
+ description: dataTypes.responseDescription || "Successful response",
31
+ content: {
32
+ "application/json": {
33
+ schema: responseSchema,
34
+ },
35
+ },
36
+ };
37
+ }
38
+ // 2. Add responses from ResponseSet
39
+ const responseSetName = dataTypes.responseSet || this.config.defaultResponseSet;
40
+ if (responseSetName && responseSetName !== "none") {
41
+ const responseSets = this.config.responseSets || {};
42
+ const setNames = responseSetName.split(",").map((s) => s.trim());
43
+ setNames.forEach((setName) => {
44
+ const responseSet = responseSets[setName];
45
+ if (responseSet) {
46
+ responseSet.forEach((errorCode) => {
47
+ // Use $ref for components/responses
48
+ responses[errorCode] = {
49
+ $ref: `#/components/responses/${errorCode}`,
50
+ };
51
+ });
52
+ }
53
+ });
54
+ }
55
+ // 3. Add custom responses (@add)
56
+ if (dataTypes.addResponses) {
57
+ const customResponses = dataTypes.addResponses
58
+ .split(",")
59
+ .map((s) => s.trim());
60
+ customResponses.forEach((responseRef) => {
61
+ const [code, ref] = responseRef.split(":");
62
+ if (ref) {
63
+ // Custom schema: "409:ConflictResponse"
64
+ responses[code] = {
65
+ description: this.getDefaultErrorDescription(code) || `HTTP ${code} response`,
66
+ content: {
67
+ "application/json": {
68
+ schema: { $ref: `#/components/schemas/${ref}` },
69
+ },
70
+ },
71
+ };
72
+ }
73
+ else {
74
+ // Only code: "409" - use $ref fro components/responses
75
+ responses[code] = {
76
+ $ref: `#/components/responses/${code}`,
77
+ };
78
+ }
79
+ });
80
+ }
81
+ return responses;
82
+ }
83
+ getDefaultSuccessCode(method) {
84
+ switch (method.toUpperCase()) {
85
+ case "POST":
86
+ return "201";
87
+ case "DELETE":
88
+ return "204";
89
+ default:
90
+ return "200";
91
+ }
92
+ }
93
+ getDefaultErrorDescription(code) {
94
+ const defaults = {
95
+ 400: "Bad Request",
96
+ 401: "Unauthorized",
97
+ 403: "Forbidden",
98
+ 404: "Not Found",
99
+ 409: "Conflict",
100
+ 422: "Unprocessable Entity",
101
+ 429: "Too Many Requests",
102
+ 500: "Internal Server Error",
103
+ };
104
+ return defaults[code] || `HTTP ${code}`;
105
+ }
21
106
  /**
22
107
  * Get the SchemaProcessor instance
23
108
  */
@@ -160,12 +245,16 @@ export class RouteProcessor {
160
245
  }
161
246
  // Add request body
162
247
  if (MUTATION_HTTP_METHODS.includes(method.toUpperCase())) {
163
- definition.requestBody = this.schemaProcessor.createRequestBodySchema(body, bodyDescription);
248
+ definition.requestBody = this.schemaProcessor.createRequestBodySchema(body, bodyDescription, dataTypes.contentType);
164
249
  }
165
250
  // Add responses
166
- definition.responses = responses
167
- ? this.schemaProcessor.createResponseSchema(responses, responseDescription)
168
- : {};
251
+ definition.responses = this.buildResponsesFromConfig(dataTypes, method);
252
+ // If there are no responses from config, use the old logic
253
+ if (Object.keys(definition.responses).length === 0) {
254
+ definition.responses = responses
255
+ ? this.schemaProcessor.createResponseSchema(responses, responseDescription)
256
+ : {};
257
+ }
169
258
  this.swaggerPaths[routePath][method] = definition;
170
259
  }
171
260
  getRoutePath(filePath) {
@@ -501,6 +501,64 @@ export class SchemaProcessor {
501
501
  return "example";
502
502
  }
503
503
  }
504
+ detectContentType(bodyType, explicitContentType) {
505
+ if (explicitContentType) {
506
+ return explicitContentType;
507
+ }
508
+ // Automatic detection based on type name
509
+ if (bodyType &&
510
+ (bodyType.toLowerCase().includes("formdata") ||
511
+ bodyType.toLowerCase().includes("fileupload") ||
512
+ bodyType.toLowerCase().includes("multipart"))) {
513
+ return "multipart/form-data";
514
+ }
515
+ return "application/json";
516
+ }
517
+ createMultipleResponsesSchema(responses, defaultDescription) {
518
+ const result = {};
519
+ Object.entries(responses).forEach(([code, response]) => {
520
+ if (typeof response === "string") {
521
+ // Reference do components/responses
522
+ result[code] = { $ref: `#/components/responses/${response}` };
523
+ }
524
+ else {
525
+ result[code] = {
526
+ description: response.description || defaultDescription || "Response",
527
+ content: {
528
+ "application/json": {
529
+ schema: response.schema || response,
530
+ },
531
+ },
532
+ };
533
+ }
534
+ });
535
+ return result;
536
+ }
537
+ createFormDataSchema(body) {
538
+ if (!body.properties) {
539
+ return body;
540
+ }
541
+ const formDataProperties = {};
542
+ Object.entries(body.properties).forEach(([key, value]) => {
543
+ // Convert File types to binary format
544
+ if (value.type === "object" &&
545
+ (key.toLowerCase().includes("file") ||
546
+ value.description?.toLowerCase().includes("file"))) {
547
+ formDataProperties[key] = {
548
+ type: "string",
549
+ format: "binary",
550
+ description: value.description,
551
+ };
552
+ }
553
+ else {
554
+ formDataProperties[key] = value;
555
+ }
556
+ });
557
+ return {
558
+ ...body,
559
+ properties: formDataProperties,
560
+ };
561
+ }
504
562
  /**
505
563
  * Create a default schema for path parameters when no schema is defined
506
564
  */
@@ -558,18 +616,24 @@ export class SchemaProcessor {
558
616
  }
559
617
  return queryParams;
560
618
  }
561
- createRequestBodySchema(body, description) {
562
- const schema = {
619
+ createRequestBodySchema(body, description, contentType) {
620
+ const detectedContentType = this.detectContentType(body?.type || "", contentType);
621
+ let schema = body;
622
+ // If it is multipart/form-data, convert schema
623
+ if (detectedContentType === "multipart/form-data") {
624
+ schema = this.createFormDataSchema(body);
625
+ }
626
+ const requestBody = {
563
627
  content: {
564
- "application/json": {
565
- schema: body,
628
+ [detectedContentType]: {
629
+ schema: schema,
566
630
  },
567
631
  },
568
632
  };
569
633
  if (description) {
570
- schema.description = description;
634
+ requestBody.description = description;
571
635
  }
572
- return schema;
636
+ return requestBody;
573
637
  }
574
638
  createResponseSchema(responses, description) {
575
639
  return {
package/dist/lib/utils.js CHANGED
@@ -22,12 +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 = "";
29
+ let contentType = "";
30
+ let responseType = "";
30
31
  let responseDescription = "";
32
+ let responseSet = "";
33
+ let addResponses = "";
34
+ let successCode = "";
31
35
  if (comments) {
32
36
  comments.forEach((comment) => {
33
37
  const commentValue = cleanComment(comment.value);
@@ -42,13 +46,6 @@ export function extractJSDocComments(path) {
42
46
  bodyDescription = match[1].trim();
43
47
  }
44
48
  }
45
- if (commentValue.includes("@responseDescription")) {
46
- const regex = /@responseDescription\s*(.*)/;
47
- const match = commentValue.match(regex);
48
- if (match && match[1]) {
49
- responseDescription = match[1].trim();
50
- }
51
- }
52
49
  if (!summary) {
53
50
  summary = commentValue.split("\n")[0];
54
51
  }
@@ -90,6 +87,45 @@ export function extractJSDocComments(path) {
90
87
  if (commentValue.includes("@response")) {
91
88
  responseType = extractTypeFromComment(commentValue, "@response");
92
89
  }
90
+ if (commentValue.includes("@contentType")) {
91
+ const regex = /@contentType\s*(.*)/;
92
+ const match = commentValue.match(regex);
93
+ if (match && match[1]) {
94
+ contentType = match[1].trim();
95
+ }
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
+ }
93
129
  });
94
130
  }
95
131
  return {
@@ -100,11 +136,15 @@ export function extractJSDocComments(path) {
100
136
  paramsType,
101
137
  pathParamsType,
102
138
  bodyType,
103
- responseType,
104
139
  isOpenApi,
105
140
  deprecated,
106
141
  bodyDescription,
142
+ contentType,
143
+ responseType,
107
144
  responseDescription,
145
+ responseSet,
146
+ addResponses,
147
+ successCode,
108
148
  };
109
149
  }
110
150
  export function extractTypeFromComment(commentValue, tag) {
@@ -121,6 +161,10 @@ export function cleanSpec(spec) {
121
161
  "ui",
122
162
  "outputFile",
123
163
  "includeOpenApiRoutes",
164
+ "schemaType",
165
+ "defaultResponseSet",
166
+ "responseSets",
167
+ "errorConfig",
124
168
  ];
125
169
  const newSpec = { ...spec };
126
170
  propsToRemove.forEach((key) => delete newSpec[key]);
@@ -846,6 +846,22 @@ export class ZodSchemaConverter {
846
846
  !t.isIdentifier(node.callee.property)) {
847
847
  return { type: "string" };
848
848
  }
849
+ if (t.isMemberExpression(node.callee) &&
850
+ t.isIdentifier(node.callee.property)) {
851
+ const zodType = node.callee.property.name;
852
+ // Custom() support for FormData
853
+ if (zodType === "custom" && node.arguments.length > 0) {
854
+ // Check if it is FormData
855
+ if (t.isArrowFunctionExpression(node.arguments[0])) {
856
+ // Assume custom FormData validation
857
+ return {
858
+ type: "object",
859
+ additionalProperties: true,
860
+ description: "Form data object",
861
+ };
862
+ }
863
+ }
864
+ }
849
865
  const zodType = node.callee.property.name;
850
866
  let schema = {};
851
867
  // Basic type mapping
@@ -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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-openapi-gen",
3
- "version": "0.6.1",
3
+ "version": "0.6.4",
4
4
  "description": "Automatically generate OpenAPI 3.0 documentation from Next.js projects, with support for TypeScript types and Zod schemas.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",