expodoc
Version:
A tool to generate API documentation automatically for Express.js applications.
362 lines (361 loc) • 15.2 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.RouteParser = void 0;
const node_fs_1 = require("node:fs");
const node_path_1 = require("node:path");
const defaults_1 = require("../../core/config/defaults");
const stringUtils_1 = require("../utils/stringUtils");
class RouteParser {
constructor(projectPath = ".", config = defaults_1.ExpressDocGenConfig) {
this.projectPath = (0, node_path_1.resolve)(projectPath);
this.routerBasePaths = {};
this.routes = [];
this.config = config;
}
loadConfigFromAppFile() {
const appFilePath = (0, node_path_1.join)(this.projectPath, this.config.ROUTER_CONFIGURED_IN_APP_FILE);
if (!(0, node_fs_1.existsSync)(appFilePath))
return;
const content = (0, node_fs_1.readFileSync)(appFilePath, "utf-8");
const pattern = /app\.use\((["'])(.*?)\1\s*,\s*([a-zA-Z0-9_]+)\)/g;
let match;
match = pattern.exec(content);
while (match !== null) {
match = pattern.exec(content);
if (match === null)
break;
const basePath = match[2];
const routerVar = match[3];
this.routerBasePaths[routerVar] = basePath;
}
}
findRouterFiles() {
var _a, _b;
const routerFiles = [];
for (const folderPattern of this.config.ROUTER_FOLDERS) {
const folderPath = (0, node_path_1.join)(this.projectPath, folderPattern);
if (!(0, node_fs_1.existsSync)(folderPath))
continue;
for (const filePattern of this.config
.ROUTER_FILE_NAMING_PATTERNS) {
const files = this.globFiles(folderPath, filePattern);
for (const filePath of files) {
if ((_a = this.config.IGNORED_FOLDERS) === null || _a === void 0 ? void 0 : _a.some((ignored) => filePath.includes(ignored)))
continue;
if ((_b = this.config.IGNORED_FILES) === null || _b === void 0 ? void 0 : _b.includes((0, node_path_1.basename)(filePath)))
continue;
routerFiles.push(filePath);
}
}
}
return routerFiles;
}
globFiles(dir, pattern) {
const files = [];
const entries = (0, node_fs_1.readdirSync)(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = (0, node_path_1.join)(dir, entry.name);
if (entry.isDirectory()) {
files.push(...this.globFiles(fullPath, pattern));
}
else if (this.matchPattern(entry.name, pattern)) {
files.push(fullPath);
}
}
return files;
}
matchPattern(filename, pattern) {
const regex = new RegExp(`^${pattern.replace(/\*/g, ".*")}$`);
return regex.test(filename);
}
parseRouterFile(filePath) {
const content = (0, node_fs_1.readFileSync)(filePath, "utf-8");
const routerVarMatch = /(?:const|let|var)\s+([a-zA-Z0-9_]+)\s*=\s*Router\(\)|default\s+Router\(\)/.exec(content);
if (!routerVarMatch)
return;
const routerVar = routerVarMatch[1] || (0, node_path_1.basename)(filePath).replace(".route", "Router");
const basePath = this.routerBasePaths[routerVar] || "";
this.processRouteDefinitions(content, basePath, routerVar);
}
processRouteDefinitions(content, basePath, routerVar) {
var _a;
// const cleanedContent = content
// .replace(/\/\/.*?\n|\/\*.*?\*\//gs, '')
// .replace(/\s+/g, ' ');
const cleanedContent = content
.replace(/\/\/.*?\n/g, "") // Remove single-line comments
.replace(/\/\*[\s\S]*?\*\//g, "") // Remove multi-line comments
.replace(/\s+/g, " "); // Replace multiple spaces with a single space
const routePattern = /([a-zA-Z0-9_]+)\.(?:route\((["'])(.*?)\2\)|(get|post|put|delete|patch|options|head|all)\((["'])(.*?)\5)\s*\)?/g;
let match;
match = routePattern.exec(cleanedContent);
while (match !== null) {
match = routePattern.exec(cleanedContent);
if (match === null)
break;
const routerVarMatch = match[1];
const routePath = match[3] || match[6];
const httpMethod = (_a = match[4]) === null || _a === void 0 ? void 0 : _a.toLowerCase();
const chainStart = match.index;
const chainEnd = this.findEndOfChain(cleanedContent, chainStart);
const chainContent = cleanedContent.slice(chainStart, chainEnd);
if (!httpMethod) {
this.processRouteChain(chainContent, basePath, routePath, routerVarMatch);
}
else {
this.processSingleRoute(chainContent, basePath, routePath, httpMethod, routerVarMatch);
}
}
}
findEndOfChain(content, startPos) {
let balance = 0;
for (let i = startPos; i < content.length; i++) {
if (content[i] === "(")
balance++;
else if (content[i] === ")") {
balance--;
if (balance === 0) {
for (let j = i; j < content.length; j++) {
if (content[j] === ";" || content[j] === "\n")
return j + 1;
}
return i + 1;
}
}
}
return content.length;
}
tagControllerWithScope(controller, middlewares) {
for (const [scope, scopeMiddlewares] of Object.entries(this.config.ROUTER_MIDDLEWARE_SCOPE_CONFIG)) {
if (scopeMiddlewares.some((mw) => middlewares.includes(mw))) {
return `${controller} (${scope})`;
}
}
return controller;
}
processRouteChain(chainContent, basePath, routePath, routerVar) {
const methodPattern = /\.(get|post|put|delete|patch|options|head|all)\(/g;
let methodMatch;
methodMatch = methodPattern.exec(chainContent);
while (methodMatch !== null) {
methodMatch = methodPattern.exec(chainContent);
if (methodMatch === null)
break;
const httpMethod = methodMatch[1].toLowerCase();
let startPos = methodMatch.index + methodMatch[0].length;
let balance = 1;
let endPos = startPos;
while (endPos < chainContent.length && balance > 0) {
if (chainContent[endPos] === "(")
balance++;
else if (chainContent[endPos] === ")")
balance--;
endPos++;
}
const argsContent = chainContent.slice(startPos, endPos - 1);
this.processRouteArgs(argsContent, basePath, routePath, httpMethod, routerVar);
}
}
processSingleRoute(chainContent, basePath, routePath, httpMethod, routerVar) {
const argsStart = chainContent.indexOf("(") + 1;
const argsEnd = chainContent.lastIndexOf(")");
const argsContent = chainContent.slice(argsStart, argsEnd);
this.processRouteArgs(argsContent, basePath, routePath, httpMethod, routerVar);
}
processRouteArgs(argsContent, basePath, routePath, httpMethod, routerVar) {
const args = this.splitArgsWithBalance(argsContent);
if (!args.length)
return;
let controller = null;
const middlewares = [];
for (const arg of args) {
const trimmedArg = arg.trim();
if (!trimmedArg)
continue;
if (!controller &&
(middlewares.length < args.length - 1 ||
trimmedArg.includes("("))) {
middlewares.push(trimmedArg);
}
else {
controller = trimmedArg.replace(/,\s+$/, "");
}
}
if (!controller)
return;
const fullPath = `${basePath}/${routePath}`
.replace("//", "/")
.replace(/\/$/, "");
const fullUrl = `${this.config.SERVER_URL}${fullPath}`;
controller = this.normalizeCaseToSentence(controller);
controller = this.tagControllerWithScope(controller, middlewares);
const normalizedRouterVar = this.normalizeCaseToSentence(routerVar.replace(/Router/g, "").trim());
this.routes.push({
router: normalizedRouterVar,
method: httpMethod.toUpperCase(),
url: fullUrl,
controller,
middlewares,
});
}
normalizeCaseToSentence(inputStr) {
if (!inputStr || typeof inputStr !== "string")
return inputStr;
let normalized = (0, stringUtils_1.normalizeToSentenceCase)(inputStr);
if (normalized) {
const words = normalized.split(" ");
if (words[0]) {
words[0] =
words[0][0].toUpperCase() + words[0].slice(1).toLowerCase();
}
for (let i = 1; i < words.length; i++) {
if (words[i].length > 1 && words[i] === words[i].toUpperCase())
continue;
words[i] = words[i].toLowerCase();
}
normalized = words.join(" ");
}
return normalized;
}
splitArgsWithBalance(argsContent) {
const args = [];
let currentArg = "";
let parenBalance = 0;
let bracketBalance = 0;
let inString = false;
let stringChar = null;
for (const char of argsContent) {
if ((char === '"' || char === "'") && !inString) {
inString = true;
stringChar = char;
}
else if (char === stringChar && inString) {
inString = false;
stringChar = null;
}
if (!inString) {
if (char === "," &&
parenBalance === 0 &&
bracketBalance === 0) {
args.push(currentArg.trim());
currentArg = "";
continue;
}
else if (char === "(")
parenBalance++;
else if (char === ")")
parenBalance--;
else if (char === "{")
bracketBalance++;
else if (char === "}")
bracketBalance--;
}
currentArg += char;
}
if (currentArg.trim())
args.push(currentArg.trim());
return args;
}
getPostmanCollectionServerUrl() {
var _a, _b;
return ((_a = this.config.POSTMAN_CONFIG) === null || _a === void 0 ? void 0 : _a.SERVER_URL.VARIABLE)
? `{{${this.config.POSTMAN_CONFIG.SERVER_URL.NAME}}}`
: (_b = this.config.POSTMAN_CONFIG) === null || _b === void 0 ? void 0 : _b.SERVER_URL.NAME;
}
generatePostmanCollection(baseUrlVar, outputDir, postmanFilename) {
var _a, _b, _c, _d, _e, _f;
if (baseUrlVar === void 0) { baseUrlVar = this.getPostmanCollectionServerUrl(); }
if (outputDir === void 0) { outputDir = (_a = this.config.POSTMAN_CONFIG) === null || _a === void 0 ? void 0 : _a.OUTPUT_DIR; }
if (postmanFilename === void 0) { postmanFilename = (_b = this.config.POSTMAN_CONFIG) === null || _b === void 0 ? void 0 : _b.POSTMAN_FILENAME; }
if (!this.routes || !this.routes.length) {
throw new Error("Routes are not available or are empty.");
}
const folders = {};
for (const route of this.routes) {
const { router, method, url: fullUrl, controller } = route;
// const parsedUrl = url.parse(fullUrl);
const parsedUrl = new URL(fullUrl);
const pathOnly = parsedUrl.pathname || "";
const pathParts = [];
const variables = [];
for (const part of pathOnly.split("/").filter(Boolean)) {
if (part.startsWith(":")) {
const key = part.slice(1);
const defaultVal = key.toLowerCase().includes("id") ||
key.toLowerCase().includes("phone")
? "1"
: "John";
pathParts.push(`:${key}`);
variables.push({ key, value: defaultVal });
}
else {
pathParts.push(part);
}
}
const rawUrl = `${baseUrlVar}/${pathParts.join("/")}`;
const urlObj = {
raw: rawUrl,
host: [baseUrlVar],
path: pathParts,
};
if (variables.length)
urlObj.variable = variables;
const requestObj = {
method,
header: [],
url: urlObj,
};
if (["POST", "PUT", "PATCH"].includes(method)) {
requestObj.body = {
mode: "raw",
raw: '{\n "example_key": "example_value"\n}',
options: { raw: { language: "json" } },
};
}
folders[router] = folders[router] || [];
folders[router].push({
name: controller,
request: requestObj,
response: [],
});
}
const postmanJson = {
info: {
_postman_id: "generated-id-1234",
name: (_c = this.config.POSTMAN_CONFIG) === null || _c === void 0 ? void 0 : _c.COLLECTION_NAME,
description: (_d = this.config.POSTMAN_CONFIG) === null || _d === void 0 ? void 0 : _d.COLLECTION_DESCRIPTION,
schema: (_e = this.config.POSTMAN_CONFIG) === null || _e === void 0 ? void 0 : _e.SCHEMA,
},
item: Object.entries(folders).map(([name, items]) => ({
name,
item: items,
})),
variable: [
{
key: (_f = this.config.POSTMAN_CONFIG) === null || _f === void 0 ? void 0 : _f.SERVER_URL.NAME,
value: this.config.SERVER_URL,
type: "string",
},
],
};
(0, node_fs_1.mkdirSync)(outputDir, { recursive: true });
const postmanPath = (0, node_path_1.join)(outputDir, postmanFilename);
(0, node_fs_1.writeFileSync)(postmanPath, JSON.stringify(postmanJson, null, 4), "utf-8");
return postmanPath;
}
parseAllRoutes() {
this.loadConfigFromAppFile();
const routerFiles = this.findRouterFiles();
for (const routerFile of routerFiles) {
this.parseRouterFile(routerFile);
}
this.routes.sort((a, b) => a.url.localeCompare(b.url));
}
getRoutesJson() {
return JSON.stringify(this.routes, null, 4);
}
createPostmanFile() {
return this.generatePostmanCollection(undefined, "prompts");
}
}
exports.RouteParser = RouteParser;