@omer-x/next-openapi-json-generator
Version:
a Next.js plugin to generate OpenAPI documentation from route handlers
493 lines (468 loc) • 17 kB
JavaScript
;
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
default: () => index_default
});
module.exports = __toCommonJS(index_exports);
// src/core/generateOpenApiSpec.ts
var import_node_path4 = __toESM(require("path"), 1);
var import_package_metadata = __toESM(require("@omer-x/package-metadata"), 1);
// 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
var import_fs = require("fs");
var import_promises = __toESM(require("fs/promises"), 1);
var import_node_path = __toESM(require("path"), 1);
var import_minimatch = require("minimatch");
async function directoryExists(dirPath) {
try {
await import_promises.default.access(dirPath, import_fs.constants.F_OK);
return true;
} catch {
return false;
}
}
async function getDirectoryItems(dirPath, targetFileName) {
const collection = [];
const files = await import_promises.default.readdir(dirPath);
for (const itemName of files) {
const itemPath = import_node_path.default.resolve(dirPath, itemName);
const stats = await import_promises.default.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 import_minimatch.Minimatch(pattern));
const excludedPatterns = exclude.map((pattern) => new import_minimatch.Minimatch(pattern));
return items.filter((item) => {
const relativePath = import_node_path.default.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
var import_promises2 = __toESM(require("fs/promises"), 1);
async function isDocumentedRoute(routePath2) {
try {
const rawCode = await import_promises2.default.readFile(routePath2, "utf-8");
return rawCode.includes("@omer-x/next-openapi-route-handler");
} catch {
return false;
}
}
// src/core/next.ts
var import_promises3 = __toESM(require("fs/promises"), 1);
var import_node_path2 = __toESM(require("path"), 1);
var import_next_openapi_route_handler = require("@omer-x/next-openapi-route-handler");
var import_zod = require("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 = import_node_path2.default.resolve(process.cwd(), "src", "app");
if (await directoryExists(inSrc)) {
return inSrc;
}
const inRoot = import_node_path2.default.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 import_promises3.default.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] = import_next_openapi_route_handler.defineRoute;
global.z = import_zod.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
var import_node_path3 = __toESM(require("path"), 1);
function getRoutePathName(filePath, rootPath) {
const dirName = import_node_path3.default.dirname(filePath);
return "/" + import_node_path3.default.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
var import_zod2 = require("zod");
function convertToOpenAPI(schema, isArray) {
return import_zod2.z.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 ? import_node_path4.default.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 = (0, import_package_metadata.default)();
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;