next-openapi-gen
Version:
Automatically generate OpenAPI 3.0 documentation from Next.js projects, with support for Zod schemas and TypeScript types.
341 lines (340 loc) • 15.3 kB
JavaScript
import * as t from "@babel/types";
import fs from "fs";
import path from "path";
import traverse from "@babel/traverse";
import { SchemaProcessor } from "./schema-processor.js";
import { capitalize, extractJSDocComments, parseTypeScriptFile, extractPathParameters, getOperationId, } from "./utils.js";
import { logger } from "./logger.js";
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
const MUTATION_HTTP_METHODS = ["PATCH", "POST", "PUT"];
export class RouteProcessor {
swaggerPaths = {};
schemaProcessor;
config;
directoryCache = {};
statCache = {};
processFileTracker = {};
constructor(config) {
this.config = config;
this.schemaProcessor = new SchemaProcessor(config.schemaDir, config.schemaType);
}
buildResponsesFromConfig(dataTypes, method) {
const responses = {};
// 1. Add success response
const successCode = dataTypes.successCode || this.getDefaultSuccessCode(method);
if (dataTypes.responseType) {
// Ensure the schema is defined in components/schemas
this.schemaProcessor.getSchemaContent({
responseType: dataTypes.responseType,
});
responses[successCode] = {
description: dataTypes.responseDescription || "Successful response",
content: {
"application/json": {
schema: { $ref: `#/components/schemas/${dataTypes.responseType}` },
},
},
};
}
// 2. Add responses from ResponseSet
const responseSetName = dataTypes.responseSet || this.config.defaultResponseSet;
if (responseSetName && responseSetName !== "none") {
const responseSets = this.config.responseSets || {};
const setNames = responseSetName.split(",").map((s) => s.trim());
setNames.forEach((setName) => {
const responseSet = responseSets[setName];
if (responseSet) {
responseSet.forEach((errorCode) => {
// Use $ref for components/responses
responses[errorCode] = {
$ref: `#/components/responses/${errorCode}`,
};
});
}
});
}
// 3. Add custom responses (@add)
if (dataTypes.addResponses) {
const customResponses = dataTypes.addResponses
.split(",")
.map((s) => s.trim());
customResponses.forEach((responseRef) => {
const [code, ref] = responseRef.split(":");
if (ref) {
// Custom schema: "409:ConflictResponse"
responses[code] = {
description: this.getDefaultErrorDescription(code) || `HTTP ${code} response`,
content: {
"application/json": {
schema: { $ref: `#/components/schemas/${ref}` },
},
},
};
}
else {
// Only code: "409" - use $ref fro components/responses
responses[code] = {
$ref: `#/components/responses/${code}`,
};
}
});
}
return responses;
}
getDefaultSuccessCode(method) {
switch (method.toUpperCase()) {
case "POST":
return "201";
case "DELETE":
return "204";
default:
return "200";
}
}
getDefaultErrorDescription(code) {
const defaults = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
409: "Conflict",
422: "Unprocessable Entity",
429: "Too Many Requests",
500: "Internal Server Error",
};
return defaults[code] || `HTTP ${code}`;
}
/**
* Get the SchemaProcessor instance
*/
getSchemaProcessor() {
return this.schemaProcessor;
}
isRoute(varName) {
return HTTP_METHODS.includes(varName);
}
processFile(filePath) {
// Check if the file has already been processed
if (this.processFileTracker[filePath])
return;
const content = fs.readFileSync(filePath, "utf-8");
const ast = parseTypeScriptFile(content);
traverse.default(ast, {
ExportNamedDeclaration: (path) => {
const declaration = path.node.declaration;
if (t.isFunctionDeclaration(declaration) &&
t.isIdentifier(declaration.id)) {
const dataTypes = extractJSDocComments(path);
if (this.isRoute(declaration.id.name)) {
// Don't bother adding routes for processing if only including OpenAPI routes and the route is not OpenAPI
if (!this.config.includeOpenApiRoutes ||
(this.config.includeOpenApiRoutes && dataTypes.isOpenApi)) {
// Check for URL parameters in the route path
const routePath = this.getRoutePath(filePath);
const pathParams = extractPathParameters(routePath);
// If we have path parameters but no pathParamsType defined, we should log a warning
if (pathParams.length > 0 && !dataTypes.pathParamsType) {
logger.debug(`Route ${routePath} contains path parameters ${pathParams.join(", ")} but no type is defined.`);
}
this.addRouteToPaths(declaration.id.name, filePath, dataTypes);
}
}
}
if (t.isVariableDeclaration(declaration)) {
declaration.declarations.forEach((decl) => {
if (t.isVariableDeclarator(decl) && t.isIdentifier(decl.id)) {
if (this.isRoute(decl.id.name)) {
const dataTypes = extractJSDocComments(path);
// Don't bother adding routes for processing if only including OpenAPI routes and the route is not OpenAPI
if (!this.config.includeOpenApiRoutes ||
(this.config.includeOpenApiRoutes && dataTypes.isOpenApi)) {
const routePath = this.getRoutePath(filePath);
const pathParams = extractPathParameters(routePath);
if (pathParams.length > 0 && !dataTypes.pathParamsType) {
logger.debug(`Route ${routePath} contains path parameters ${pathParams.join(", ")} but no type is defined.`);
}
this.addRouteToPaths(decl.id.name, filePath, dataTypes);
}
}
}
});
}
},
});
this.processFileTracker[filePath] = true;
}
scanApiRoutes(dir) {
logger.debug(`Scanning API routes in: ${dir}`);
let files = this.directoryCache[dir];
if (!files) {
files = fs.readdirSync(dir);
this.directoryCache[dir] = files;
}
files.forEach((file) => {
const filePath = path.join(dir, file);
let stat = this.statCache[filePath];
if (!stat) {
stat = fs.statSync(filePath);
this.statCache[filePath] = stat;
}
if (stat.isDirectory()) {
this.scanApiRoutes(filePath);
// @ts-ignore
}
else if (file.endsWith(".ts") || file.endsWith(".tsx")) {
if (file === "route.ts" || file === "route.tsx") {
this.processFile(filePath);
}
}
});
}
addRouteToPaths(varName, filePath, dataTypes) {
const method = varName.toLowerCase();
const routePath = this.getRoutePath(filePath);
const rootPath = capitalize(routePath.split("/")[1]);
const operationId = getOperationId(routePath, method);
const { tag, summary, description, auth, isOpenApi, deprecated, bodyDescription, responseDescription, } = dataTypes;
if (this.config.includeOpenApiRoutes && !isOpenApi) {
// If flag is enabled and there is no @openapi tag, then skip path
return;
}
if (!this.swaggerPaths[routePath]) {
this.swaggerPaths[routePath] = {};
}
const { params, pathParams, body, responses } = this.schemaProcessor.getSchemaContent(dataTypes);
const definition = {
operationId: operationId,
summary: summary,
description: description,
tags: [tag || rootPath],
parameters: [],
};
if (deprecated) {
definition.deprecated = true;
}
// Add auth
if (auth) {
definition.security = [
{
[auth]: [],
},
];
}
if (params) {
definition.parameters =
this.schemaProcessor.createRequestParamsSchema(params);
}
// Add path parameters
const pathParamNames = extractPathParameters(routePath);
if (pathParamNames.length > 0) {
// If we have path parameters but no schema, create a default schema
if (!pathParams) {
const defaultPathParams = this.schemaProcessor.createDefaultPathParamsSchema(pathParamNames);
definition.parameters.push(...defaultPathParams);
}
else {
const moreParams = this.schemaProcessor.createRequestParamsSchema(pathParams, true);
definition.parameters.push(...moreParams);
}
}
else if (pathParams) {
// If no path parameters in route but we have a schema, use it
const moreParams = this.schemaProcessor.createRequestParamsSchema(pathParams, true);
definition.parameters.push(...moreParams);
}
// Add request body
if (MUTATION_HTTP_METHODS.includes(method.toUpperCase())) {
if (dataTypes.bodyType) {
// Ensure the schema is defined in components/schemas
this.schemaProcessor.getSchemaContent({
bodyType: dataTypes.bodyType,
});
// Use reference to the schema
const contentType = this.schemaProcessor.detectContentType(dataTypes.bodyType || "", dataTypes.contentType);
definition.requestBody = {
content: {
[contentType]: {
schema: { $ref: `#/components/schemas/${dataTypes.bodyType}` },
},
},
};
if (bodyDescription) {
definition.requestBody.description = bodyDescription;
}
}
else if (body && Object.keys(body).length > 0) {
// Fallback to inline schema for backward compatibility
definition.requestBody = this.schemaProcessor.createRequestBodySchema(body, bodyDescription, dataTypes.contentType);
}
}
// Add responses
definition.responses = this.buildResponsesFromConfig(dataTypes, method);
// If there are no responses from config, use the old logic
if (Object.keys(definition.responses).length === 0) {
definition.responses = responses
? this.schemaProcessor.createResponseSchema(responses, responseDescription)
: {};
}
this.swaggerPaths[routePath][method] = definition;
}
getRoutePath(filePath) {
// First, check if it's an app router path
if (filePath.includes("/app/api/")) {
// Get the relative path from the api directory
const apiDirPos = filePath.indexOf("/app/api/");
let relativePath = filePath.substring(apiDirPos + "/app/api".length);
// Remove the /route.ts or /route.tsx suffix
relativePath = relativePath.replace(/\/route\.tsx?$/, "");
// Convert directory separators to URL path format
relativePath = relativePath.replaceAll("\\", "/");
// Remove Next.js route groups (folders in parentheses like (authenticated), (marketing))
relativePath = relativePath.replace(/\/\([^)]+\)/g, "");
// Convert Next.js dynamic route syntax to OpenAPI parameter syntax
relativePath = relativePath.replace(/\/\[([^\]]+)\]/g, "/{$1}");
// Handle catch-all routes ([...param])
relativePath = relativePath.replace(/\/\[\.\.\.(.*)\]/g, "/{$1}");
return relativePath;
}
// For pages router or other formats
const suffixPath = filePath.split("api")[1];
return suffixPath
.replace(/route\.tsx?$/, "")
.replaceAll("\\", "/")
.replace(/\/$/, "")
.replace(/\/\([^)]+\)/g, "") // Remove route groups for pages router too
.replace(/\/\[([^\]]+)\]/g, "/{$1}") // Replace [param] with {param}
.replace(/\/\[\.\.\.(.*)\]/g, "/{$1}"); // Replace [...param] with {param}
}
getSortedPaths(paths) {
function comparePaths(a, b) {
const aMethods = this.swaggerPaths[a] || {};
const bMethods = this.swaggerPaths[b] || {};
// Extract tags for all methods in path a
const aTags = Object.values(aMethods).flatMap((method) => method.tags || []);
// Extract tags for all methods in path b
const bTags = Object.values(bMethods).flatMap((method) => method.tags || []);
// Let's user only the first tags
const aPrimaryTag = aTags[0] || "";
const bPrimaryTag = bTags[0] || "";
// Sort alphabetically based on the first tag
const tagComparison = aPrimaryTag.localeCompare(bPrimaryTag);
if (tagComparison !== 0) {
return tagComparison; // Return the result of tag comparison
}
// Compare lengths of the paths
const aLength = a.split("/").length;
const bLength = b.split("/").length;
return aLength - bLength; // Shorter paths come before longer ones
}
return Object.keys(paths)
.sort(comparePaths.bind(this))
.reduce((sorted, key) => {
sorted[key] = paths[key];
return sorted;
}, {});
}
getSwaggerPaths() {
const paths = this.getSortedPaths(this.swaggerPaths);
return this.getSortedPaths(paths);
}
}