UNPKG

@omer-x/next-openapi-json-generator

Version:

a Next.js plugin to generate OpenAPI documentation from route handlers

467 lines (443 loc) 15.3 kB
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) { if (typeof require !== "undefined") return require.apply(this, arguments); throw Error('Dynamic require of "' + x + '" is not supported'); }); // src/core/generateOpenApiSpec.ts import path4 from "path"; import getPackageMetadata from "@omer-x/package-metadata"; // src/utils/object.ts function omit(object, ...keys) { return Object.fromEntries(Object.entries(object).filter(([key]) => !keys.includes(key))); } // src/core/clearUnusedSchemas.ts function countReferences(schemaName, source) { return (source.match(new RegExp(`"#/components/schemas/${schemaName}"`, "g")) ?? []).length; } function clearUnusedSchemas({ paths, components }) { if (!components.schemas) return { paths, components }; const stringifiedPaths = JSON.stringify(paths); const stringifiedSchemas = Object.fromEntries(Object.entries(components.schemas).map(([schemaName, schema]) => { return [schemaName, JSON.stringify(schema)]; })); return { paths, components: { ...components, schemas: Object.fromEntries(Object.entries(components.schemas).filter(([schemaName]) => { const otherSchemas = omit(stringifiedSchemas, schemaName); return countReferences(schemaName, stringifiedPaths) > 0 || countReferences(schemaName, Object.values(otherSchemas).join("")) > 0; })) } }; } // src/core/dir.ts import { constants } from "fs"; import fs from "fs/promises"; import path from "path"; import { Minimatch } from "minimatch"; async function directoryExists(dirPath) { try { await fs.access(dirPath, constants.F_OK); return true; } catch { return false; } } async function getDirectoryItems(dirPath, targetFileName) { const collection = []; const files = await fs.readdir(dirPath); for (const itemName of files) { const itemPath = path.resolve(dirPath, itemName); const stats = await fs.stat(itemPath); if (stats.isDirectory()) { const children = await getDirectoryItems(itemPath, targetFileName); collection.push(...children); } else if (itemName === targetFileName) { collection.push(itemPath); } } return collection; } function filterDirectoryItems(rootPath, items, include, exclude) { const includedPatterns = include.map((pattern) => new Minimatch(pattern)); const excludedPatterns = exclude.map((pattern) => new Minimatch(pattern)); return items.filter((item) => { const relativePath = path.relative(rootPath, item); const isIncluded = includedPatterns.some((pattern) => pattern.match(relativePath)); const isExcluded = excludedPatterns.some((pattern) => pattern.match(relativePath)); return (isIncluded || !include.length) && !isExcluded; }); } // src/core/isDocumentedRoute.ts import fs2 from "fs/promises"; async function isDocumentedRoute(routePath2) { try { const rawCode = await fs2.readFile(routePath2, "utf-8"); return rawCode.includes("@omer-x/next-openapi-route-handler"); } catch { return false; } } // src/core/next.ts import fs3 from "fs/promises"; import path2 from "path"; import { defineRoute } from "@omer-x/next-openapi-route-handler"; import { z } from "zod"; // src/utils/generateRandomString.ts function generateRandomString(length) { return [...Array(length)].map(() => Math.random().toString(36)[2]).join(""); } // src/utils/string-preservation.ts function preserveStrings(code2) { let replacements = {}; const output = code2.replace(/(['"`])((?:\\.|(?!\1).)*)\1/g, (match, quote, content) => { const replacementId = generateRandomString(32); replacements = { ...replacements, [replacementId]: `${quote}${content}${quote}` }; return `<@~${replacementId}~@>`; }); return { output, replacements }; } function restoreStrings(code2, replacements) { return code2.replace(/<@~(.*?)~@>/g, (_, replacementId) => { return replacements[replacementId]; }); } // src/core/injectSchemas.ts function injectSchemas(code2, refName) { const { output: preservedCode, replacements } = preserveStrings(code2); const preservedCodeWithSchemasInjected = preservedCode.replace(new RegExp(`\\b${refName}\\.`, "g"), `global.schemas[${refName}].`).replace(new RegExp(`\\b${refName}\\b`, "g"), `"${refName}"`).replace(new RegExp(`queryParams:\\s*['"\`]${refName}['"\`]`, "g"), `queryParams: global.schemas["${refName}"]`).replace(new RegExp(`pathParams:\\s*['"\`]${refName}['"\`]`, "g"), `pathParams: global.schemas["${refName}"]`); return restoreStrings(preservedCodeWithSchemasInjected, replacements); } // src/core/middleware.ts function detectMiddlewareName(code2) { const match = code2.match(/middleware:\s*(\w+)/); return match ? match[1] : null; } // src/utils/removeImports.ts function removeImports(code2) { return code2.replace(/(^import\s+[^;]+;?$|^import\s+[^;]*\sfrom\s.+;?$)/gm, "").replace(/(^import\s+{[\s\S]+?}\s+from\s+["'][^"']+["'];?)/gm, "").trim(); } // src/core/transpile.ts function fixExportsInCommonJS(code2) { const validMethods = ["GET", "POST", "PUT", "PATCH", "DELETE"]; const exportFixer1 = validMethods.map((method) => `exports.${method} = void 0; `).join("\n"); const exportFixer2 = `module.exports = { ${validMethods.map((m) => `${m}: exports.${m}`).join(", ")} };`; return `${exportFixer1} ${code2} ${exportFixer2}`; } function injectMiddlewareFixer(middlewareName) { return `const ${middlewareName} = (handler) => handler;`; } function transpile(isCommonJS, rawCode, middlewareName, transpileModule) { const parts = [ middlewareName ? injectMiddlewareFixer(middlewareName) : "", removeImports(rawCode) ]; const output = transpileModule(parts.join("\n"), { compilerOptions: { module: isCommonJS ? 3 : 99, target: 99, sourceMap: false, inlineSourceMap: false, inlineSources: false } }); if (isCommonJS) { return fixExportsInCommonJS(output.outputText); } return output.outputText; } // src/core/next.ts async function findAppFolderPath() { const inSrc = path2.resolve(process.cwd(), "src", "app"); if (await directoryExists(inSrc)) { return inSrc; } const inRoot = path2.resolve(process.cwd(), "app"); if (await directoryExists(inRoot)) { return inRoot; } return null; } async function safeEval(code, routePath) { try { if (typeof module !== "undefined" && typeof module.exports !== "undefined") { return eval(code); } return await import( /* webpackIgnore: true */ `data:text/javascript,${encodeURIComponent(code)}` ); } catch (error) { console.log(`An error occured while evaluating the route exports from "${routePath}"`); throw error; } } async function getModuleTranspiler() { if (typeof __require !== "undefined" && typeof exports !== "undefined") { return __require( /* webpackIgnore: true */ "typescript" ).transpileModule; } const { transpileModule } = await import( /* webpackIgnore: true */ "typescript" ); return transpileModule; } async function getRouteExports(routePath2, routeDefinerName, schemas) { const rawCode = await fs3.readFile(routePath2, "utf-8"); const middlewareName = detectMiddlewareName(rawCode); const isCommonJS = typeof module !== "undefined" && typeof module.exports !== "undefined"; const code2 = transpile(isCommonJS, rawCode, middlewareName, await getModuleTranspiler()); const fixedCode = Object.keys(schemas).reduce(injectSchemas, code2); global[routeDefinerName] = defineRoute; global.z = z; global.schemas = schemas; const result = await safeEval(fixedCode, routePath2); delete global.schemas; delete global[routeDefinerName]; delete global.z; return result; } // src/core/options.ts function verifyOptions(include, exclude) { if (process.env.NODE_ENV === "development") { for (const item of include) { if (!item.endsWith("/route.ts")) { console.log(`${item} is not a valid route handler path`); } } for (const item of exclude) { if (!item.endsWith("/route.ts")) { console.log(`${item} is not a valid route handler path`); } } } return { include: include.filter((item) => item.endsWith("/route.ts")), exclude: exclude.filter((item) => item.endsWith("/route.ts")) }; } // src/core/getRoutePathName.ts import path3 from "path"; function getRoutePathName(filePath, rootPath) { const dirName = path3.dirname(filePath); return "/" + path3.relative(rootPath, dirName).replaceAll("[", "{").replaceAll("]", "}").replaceAll("\\", "/"); } // src/utils/deepEqual.ts function deepEqual(a, b) { if (typeof a !== typeof b) return false; switch (typeof a) { case "object": { if (!a || !b) return a === b; if (Array.isArray(a) && Array.isArray(b)) { return a.every((item, index) => deepEqual(item, b[index])); } if (Object.keys(a).length !== Object.keys(b).length) return false; return Object.entries(a).every(([key, value]) => { return deepEqual(value, b[key]); }); } case "function": case "symbol": return false; default: return a === b; } } // src/core/zod-to-openapi.ts import { z as z2 } from "zod"; function convertToOpenAPI(schema, isArray) { return z2.toJSONSchema(isArray ? schema.array() : schema); } // src/core/mask.ts function maskWithReference(schema, storedSchemas, self) { if (self) { for (const [schemaName, zodSchema] of Object.entries(storedSchemas)) { if (deepEqual(schema, convertToOpenAPI(zodSchema, false))) { return { $ref: `#/components/schemas/${schemaName}` }; } } } if ("$ref" in schema) return schema; if (schema.oneOf) { return { ...schema, oneOf: schema.oneOf.map((i) => maskWithReference(i, storedSchemas, true)) }; } if (schema.anyOf) { return { ...schema, anyOf: schema.anyOf.map((i) => maskWithReference(i, storedSchemas, true)) }; } switch (schema.type) { case "object": return { ...schema, properties: Object.entries(schema.properties ?? {}).reduce((props, [propName, prop]) => ({ ...props, [propName]: maskWithReference(prop, storedSchemas, true) }), {}) }; case "array": if (Array.isArray(schema.items)) { return { ...schema, items: schema.items.map((i) => maskWithReference(i, storedSchemas, true)) }; } return { ...schema, items: maskWithReference(schema.items, storedSchemas, true) }; } return schema; } // src/core/operation-mask.ts function maskSchema(storedSchemas, schema) { if (!schema) return schema; return maskWithReference(schema, storedSchemas, true); } function maskParameterSchema(param, storedSchemas) { if ("$ref" in param) return param; return { ...param, schema: maskSchema(storedSchemas, param.schema) }; } function maskContentSchema(storedSchemas, bodyContent) { if (!bodyContent) return bodyContent; return Object.entries(bodyContent).reduce((collection, [contentType, content]) => ({ ...collection, [contentType]: { ...content, schema: maskSchema(storedSchemas, content.schema) } }), {}); } function maskRequestBodySchema(storedSchemas, body) { if (!body || "$ref" in body) return body; return { ...body, content: maskContentSchema(storedSchemas, body.content) }; } function maskResponseSchema(storedSchemas, response) { if ("$ref" in response) return response; return { ...response, content: maskContentSchema(storedSchemas, response.content) }; } function maskSchemasInResponses(storedSchemas, responses) { if (!responses) return responses; return Object.entries(responses).reduce((collection, [key, response]) => ({ ...collection, [key]: maskResponseSchema(storedSchemas, response) }), {}); } function maskOperationSchemas(operation, storedSchemas) { return { ...operation, parameters: operation.parameters?.map((p) => maskParameterSchema(p, storedSchemas)), requestBody: maskRequestBodySchema(storedSchemas, operation.requestBody), responses: maskSchemasInResponses(storedSchemas, operation.responses) }; } // src/core/route.ts function createRouteRecord(method, filePath, rootPath, apiData) { return { method: method.toLocaleLowerCase(), path: getRoutePathName(filePath, rootPath), apiData }; } function bundlePaths(source, storedSchemas) { source.sort((a, b) => a.path.localeCompare(b.path)); return source.reduce((collection, route) => ({ ...collection, [route.path]: { ...collection[route.path], [route.method]: maskOperationSchemas(route.apiData, storedSchemas) } }), {}); } // src/core/schema.ts function bundleSchemas(schemas) { const bundledSchemas = Object.keys(schemas).reduce((collection, schemaName) => { return { ...collection, [schemaName]: convertToOpenAPI(schemas[schemaName], false) }; }, {}); return Object.entries(bundledSchemas).reduce((bundle, [schemaName, schema]) => ({ ...bundle, [schemaName]: maskWithReference(schema, schemas, false) }), {}); } // src/core/generateOpenApiSpec.ts async function generateOpenApiSpec(schemas, { include: includeOption = [], exclude: excludeOption = [], routeDefinerName = "defineRoute", rootPath: additionalRootPath, info, servers, security, securitySchemes, clearUnusedSchemas: clearUnusedSchemasOption = true } = {}) { const verifiedOptions = verifyOptions(includeOption, excludeOption); const appFolderPath = await findAppFolderPath(); if (!appFolderPath) throw new Error("This is not a Next.js application!"); const rootPath = additionalRootPath ? path4.resolve(appFolderPath, "./" + additionalRootPath) : appFolderPath; const routes = await getDirectoryItems(rootPath, "route.ts"); const verifiedRoutes = filterDirectoryItems(rootPath, routes, verifiedOptions.include, verifiedOptions.exclude); const validRoutes = []; for (const route of verifiedRoutes) { const isDocumented = await isDocumentedRoute(route); if (!isDocumented) continue; const exportedRouteHandlers = await getRouteExports(route, routeDefinerName, schemas); for (const [method, routeHandler] of Object.entries(exportedRouteHandlers)) { if (!routeHandler || !routeHandler.apiData) continue; validRoutes.push(createRouteRecord( method.toLocaleLowerCase(), route, rootPath, routeHandler.apiData )); } } const metadata = getPackageMetadata(); const pathsAndComponents = { paths: bundlePaths(validRoutes, schemas), components: { schemas: bundleSchemas(schemas), securitySchemes } }; return JSON.parse(JSON.stringify({ openapi: "3.1.0", info: { title: metadata.serviceName, version: metadata.version, ...info ?? {} }, servers, ...clearUnusedSchemasOption ? clearUnusedSchemas(pathsAndComponents) : pathsAndComponents, security, tags: [] })); } // src/index.ts var index_default = generateOpenApiSpec; export { index_default as default };