UNPKG

next-openapi-gen

Version:

Automatically generate OpenAPI 3.0 documentation from Next.js projects, with support for Zod schemas and TypeScript types.

254 lines (253 loc) 9.29 kB
import { parse } from "@babel/parser"; export function capitalize(string) { return string.charAt(0).toUpperCase() + string.slice(1); } /** * Extract path parameters from a route path * e.g. /users/{id}/posts/{postId} -> ['id', 'postId'] */ export function extractPathParameters(routePath) { const paramRegex = /{([^}]+)}/g; const params = []; let match; while ((match = paramRegex.exec(routePath)) !== null) { params.push(match[1]); } return params; } export function extractJSDocComments(path) { const comments = path.node.leadingComments; let tag = ""; let summary = ""; let description = ""; let paramsType = ""; let pathParamsType = ""; let bodyType = ""; let auth = ""; let isOpenApi = false; let isIgnored = false; let deprecated = false; let bodyDescription = ""; let contentType = ""; let responseType = ""; let responseDescription = ""; let responseSet = ""; let addResponses = ""; let successCode = ""; let operationId = ""; if (comments) { comments.forEach((comment) => { const commentValue = cleanComment(comment.value); isOpenApi = commentValue.includes("@openapi"); if (commentValue.includes("@ignore")) { isIgnored = true; } if (commentValue.includes("@deprecated")) { deprecated = true; } if (commentValue.includes("@bodyDescription")) { const regex = /@bodyDescription\s*(.*)/; const match = commentValue.match(regex); if (match && match[1]) { bodyDescription = match[1].trim(); } } if (!summary) { const firstLine = commentValue.split("\n")[0]; // Don't use tags as summary - only use actual descriptions if (!firstLine.trim().startsWith("@")) { summary = firstLine; } } if (commentValue.includes("@auth")) { const regex = /@auth\s*(.*)/; const value = commentValue.match(regex)[1].trim(); switch (value) { case "bearer": auth = "BearerAuth"; break; case "basic": auth = "BasicAuth"; break; case "apikey": auth = "ApiKeyAuth"; break; } } if (commentValue.includes("@description")) { const regex = /@description\s*(.*)/; description = commentValue.match(regex)[1].trim(); } if (commentValue.includes("@tag")) { const regex = /@tag\s*(.*)/; const match = commentValue.match(regex); if (match && match[1]) { tag = match[1].trim(); } } if (commentValue.includes("@params") || commentValue.includes("@queryParams")) { paramsType = extractTypeFromComment(commentValue, "@queryParams") || extractTypeFromComment(commentValue, "@params"); } if (commentValue.includes("@pathParams")) { pathParamsType = extractTypeFromComment(commentValue, "@pathParams"); } if (commentValue.includes("@body")) { bodyType = extractTypeFromComment(commentValue, "@body"); } if (commentValue.includes("@response")) { responseType = extractTypeFromComment(commentValue, "@response"); } if (commentValue.includes("@contentType")) { const regex = /@contentType\s*(.*)/; const match = commentValue.match(regex); if (match && match[1]) { contentType = match[1].trim(); } } if (commentValue.includes("@responseDescription")) { const regex = /@responseDescription\s*(.*)/; const match = commentValue.match(regex); if (match && match[1]) { responseDescription = match[1].trim(); } } if (commentValue.includes("@responseSet")) { const regex = /@responseSet\s*(.*)/; const match = commentValue.match(regex); if (match && match[1]) { responseSet = match[1].trim(); } } if (commentValue.includes("@add")) { const regex = /@add\s*(.*)/; const match = commentValue.match(regex); if (match && match[1]) { addResponses = match[1].trim(); } } if (commentValue.includes("@operationId")) { const regex = /@operationId\s+(\S+)/; const match = commentValue.match(regex); if (match && match[1]) { operationId = match[1].trim(); } } if (commentValue.includes("@response")) { // Updated regex to support generic types const responseMatch = commentValue.match(/@response\s+(?:(\d+):)?([^@\n\r]+)(?:\s+(.*))?/); if (responseMatch) { const [, code, type] = responseMatch; successCode = code || ""; responseType = type?.trim(); } else { responseType = extractTypeFromComment(commentValue, "@response"); } } }); } return { tag, auth, summary, description, paramsType, pathParamsType, bodyType, isOpenApi, isIgnored, deprecated, bodyDescription, contentType, responseType, responseDescription, responseSet, addResponses, successCode, operationId, }; } export function extractTypeFromComment(commentValue, tag) { // Updated regex to support generic types with angle brackets and array brackets // Use multiline mode (m flag) to match tag at start of line (after optional * from JSDoc) return (commentValue .match(new RegExp(`^\\s*\\*?\\s*${tag}\\s+([\\w<>,\\s\\[\\]]+)`, 'm'))?.[1] ?.trim() || ""); } export function cleanComment(commentValue) { return commentValue.replace(/\*\s*/g, "").trim(); } export function cleanSpec(spec) { const propsToRemove = [ "apiDir", "schemaDir", "docsUrl", "ui", "outputFile", "includeOpenApiRoutes", "ignoreRoutes", "schemaType", "defaultResponseSet", "responseSets", "errorConfig", "debug", "schemaFiles", "outputDir", ]; const newSpec = { ...spec }; propsToRemove.forEach((key) => delete newSpec[key]); // Process paths to ensure good examples for path parameters if (newSpec.paths) { Object.keys(newSpec.paths).forEach((path) => { // Check if path contains parameters if (path.includes("{") && path.includes("}")) { // For each HTTP method in this path Object.keys(newSpec.paths[path]).forEach((method) => { const operation = newSpec.paths[path][method]; // Set example properties for each path parameter if (operation.parameters) { operation.parameters.forEach((param) => { if (param.in === "path" && !param.example) { // Generate an example based on parameter name if (param.name === "id" || param.name.endsWith("Id")) { param.example = 123; } else if (param.name === "slug") { param.example = "example-slug"; } else { param.example = "example"; } } }); } }); } }); } return newSpec; } export function getOperationId(routePath, method) { const operation = routePath.replaceAll(/\//g, "-").replace(/^-/, ""); return `${method}-${operation}`; } /** * Common Babel parser configuration for TypeScript files with JSX support */ const DEFAULT_PARSER_OPTIONS = { sourceType: "module", plugins: ["typescript", "jsx", "decorators-legacy"], }; /** * Parse TypeScript/TSX file content with the standard configuration * @param content - File content to parse * @param options - Optional parser options to override defaults * @returns Parsed AST */ export function parseTypeScriptFile(content, options) { return parse(content, { ...DEFAULT_PARSER_OPTIONS, ...options, }); }