next-openapi-gen
Version:
Automatically generate OpenAPI 3.0 documentation from Next.js projects, with support for Zod schemas and TypeScript types.
401 lines (400 loc) • 17.8 kB
JavaScript
import * as t from "@babel/types";
import fs from "fs";
import path from "path";
import traverseModule from "@babel/traverse";
// Handle both ES modules and CommonJS
const traverse = traverseModule.default || traverseModule;
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, config.schemaFiles);
}
buildResponsesFromConfig(dataTypes, method) {
const responses = {};
// 1. Add success response
const successCode = dataTypes.successCode || this.getDefaultSuccessCode(method);
if (dataTypes.responseType) {
// Handle array notation (e.g., "Type[]", "Type[][]", "Generic<T>[]")
let schema;
let baseType = dataTypes.responseType;
let arrayDepth = 0;
// Count and remove array brackets
while (baseType.endsWith('[]')) {
arrayDepth++;
baseType = baseType.slice(0, -2);
}
// Ensure the base schema is defined in components/schemas
this.schemaProcessor.getSchemaContent({
responseType: baseType,
});
// Build schema reference
if (arrayDepth === 0) {
// Not an array
schema = { $ref: `#/components/schemas/${baseType}` };
}
else {
// Build nested array schema
schema = { $ref: `#/components/schemas/${baseType}` };
for (let i = 0; i < arrayDepth; i++) {
schema = {
type: "array",
items: schema,
};
}
}
responses[successCode] = {
description: dataTypes.responseDescription || "Successful response",
content: {
"application/json": {
schema: schema,
},
},
};
}
// 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);
}
/**
* Check if a route should be ignored based on config patterns or @ignore tag
*/
shouldIgnoreRoute(routePath, dataTypes) {
// Check if route has @ignore tag
if (dataTypes.isIgnored) {
return true;
}
// Check if route matches any ignore patterns
const ignorePatterns = this.config.ignoreRoutes || [];
if (ignorePatterns.length === 0) {
return false;
}
return ignorePatterns.some((pattern) => {
// Support wildcards
const regexPattern = pattern.replace(/\*/g, ".*").replace(/\//g, "\\/");
const regex = new RegExp(`^${regexPattern}$`);
return regex.test(routePath);
});
}
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(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)) {
const routePath = this.getRoutePath(filePath);
// Skip if route should be ignored
if (this.shouldIgnoreRoute(routePath, dataTypes)) {
logger.debug(`Ignoring route: ${routePath}`);
return;
}
// 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 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);
const routePath = this.getRoutePath(filePath);
// Skip if route should be ignored
if (this.shouldIgnoreRoute(routePath, dataTypes)) {
logger.debug(`Ignoring route: ${routePath}`);
return;
}
// 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 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 = dataTypes.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) {
// Normalize path separators first
const normalizedPath = filePath.replaceAll("\\", "/");
// Normalize apiDir to ensure consistent format
const normalizedApiDir = this.config.apiDir
.replaceAll("\\", "/")
.replace(/^\.\//, "")
.replace(/\/$/, "");
// Find the apiDir position in the normalized path
const apiDirIndex = normalizedPath.indexOf(normalizedApiDir);
if (apiDirIndex === -1) {
throw new Error(`Could not find apiDir "${this.config.apiDir}" in file path "${filePath}"`);
}
// Extract the path after apiDir
let relativePath = normalizedPath.substring(apiDirIndex + normalizedApiDir.length);
// Remove the /route.ts or /route.tsx suffix
relativePath = relativePath.replace(/\/route\.tsx?$/, "");
// Ensure the path starts with /
if (!relativePath.startsWith("/")) {
relativePath = "/" + relativePath;
}
// Remove trailing slash
relativePath = relativePath.replace(/\/$/, "");
// Remove Next.js route groups (folders in parentheses like (authenticated), (marketing))
relativePath = relativePath.replace(/\/\([^)]+\)/g, "");
// Handle catch-all routes ([...param]) before converting dynamic routes
// This must come first because [...param] would also match the [param] pattern
relativePath = relativePath.replace(/\/\[\.\.\.(.*?)\]/g, "/{$1}");
// Convert Next.js dynamic route syntax to OpenAPI parameter syntax
relativePath = relativePath.replace(/\/\[([^\]]+)\]/g, "/{$1}");
return relativePath || "/";
}
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);
}
}