UNPKG

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
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 @pathParams 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 @pathParams 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); } }