UNPKG

adba

Version:
413 lines (412 loc) 16.8 kB
/** * @file express-router.ts * @description This file provides functionalities for creating an Express router with dynamic routes based on ORM models. */ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __rest = (this && this.__rest) || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; import { Model } from "objection"; import express from "express"; import moment from "moment"; import { kebabCase } from "change-case-all"; import { v4 as uuidv4 } from "uuid"; import { unflatten } from "dbl-utils"; import GenericController from "./controller"; import Controller from "./controller"; import getStatusCode from "./status-codes"; const ctrlRef = { GenericController, }; const aliasing = {}; /** * Predefined RESTful routes mapped to controller actions. * * @example * ```typescript * // Default routes for a table * { * "GET /": "list", * "POST /": "list", * "PUT /": "insert", * "PATCH /": "update", * "DELETE /": "delete", * "GET /meta": "meta", * "GET /:name([\\w\\-\\d]+)": "selectByName", * "GET /:id(\\d+)": "selectById", * "PATCH /:id(\\d+)": "update", * "DELETE /:id(\\d+)": "delete" * } * ``` */ const definedREST = { "GET /": "list", "POST /": "list", "PUT /": "insert", "PATCH /": "update", "DELETE /": "delete", "GET /meta": "meta", //wildcards "GET /:name([\\w\\-\\d]+)": "selectByName", "GET /:id(\\d+)": "selectById", "PATCH /:id(\\d+)": "update", "DELETE /:id(\\d+)": "delete", }; /** * Modifies predefined routes by either adding new ones or removing existing ones. * @param defs - The definitions to be added or removed. * @param remove - Whether to remove the definitions. */ export function modifyDefinedRoutes(defs, remove = false) { if (remove && Array.isArray(defs)) { defs.forEach((k) => delete definedREST[k]); } else { Object.assign(definedREST, defs); } } /** * Adds alias mappings for table names. * @param alias - The alias mappings. */ export function addTableAlias(alias) { Object.assign(aliasing, alias); } /** * Prepares the routes object by building route entries for each model. * @param routesObj - The routes object to populate. * @param TheModel - The model for which to create routes. * @param controllers - The controllers associated with the models. * @param includeTable - Whether to include the table in route definitions. */ function prepareRoutesObj(routesObj, TheModel, controllers, includeTable) { if (includeTable === false) return; const tableName = TheModel.tableName; const TheController = Object.values(controllers).find((C) => { const ctrl = new C(Model); return ctrl.Model.tableName === tableName; }) || ctrlRef.GenericController; if (includeTable === true) { Object.entries(definedREST).forEach(([service, action]) => { buildRoutesObj(routesObj, tableName, service, action, TheController, TheModel); }); return; } const { defaultAction = "includes" } = includeTable, rest = __rest(includeTable, ["defaultAction"]); if (defaultAction === "excludes") { Object.entries(rest).forEach(([service, action]) => { if (!action) return; if (action === true) action = definedREST[service]; buildRoutesObj(routesObj, tableName, service, action, TheController, TheModel); }); } else { const cpRest = Object.assign({}, rest); Object.entries(definedREST).forEach(([service, action]) => { if (rest[service] === false) { delete cpRest[service]; return; } if (typeof rest[service] === "string") { action = rest[service]; delete cpRest[service]; } buildRoutesObj(routesObj, tableName, service, action, TheController, TheModel); }); // Add custom routes not defined in definedREST Object.entries(cpRest).forEach(([service, action]) => { if (!action) return; if (action === true) action = definedREST[service]; buildRoutesObj(routesObj, tableName, service, action, TheController, TheModel); }); } } /** * Constructs the routes object with respective service paths, actions, and controllers. * @param routesObj - Object to store constructed routes. * @param tableName - The table name for the model. * @param service - The HTTP method and endpoint. * @param action - The action to be performed. * @param TheController - The controller class. * @param TheModel - The model class. */ function buildRoutesObj(routesObj, tableName, service, action, TheController, TheModel) { const [m, path] = service.split(" "); const slugTableName = aliasing[tableName] || kebabCase(tableName); const method = m.toUpperCase(); const servicePath = `/${slugTableName}${path}`; routesObj[`${method} ${servicePath}`] = [ method, servicePath, action, TheController, TheModel, ]; } /** * Generates an object containing routes for each model and controller based on provided configuration. * @param models - The models to generate routes for. * @param controllers - The associated controllers for each model. * @param config - The configuration for including or excluding routes. * @returns The constructed routes object. */ export function routesObject(models, controllers = {}, config = {}) { const routesObj = {}; if (config.filters) { const _a = config.filters, { defaultAction = "includes" } = _a, rest = __rest(_a, ["defaultAction"]); if (defaultAction === "excludes") { Object.entries(rest).forEach(([tableName, includeTable]) => { if (!includeTable) return; const TheModel = Object.values(models).find((M) => M.tableName === tableName); if (!TheModel) throw new Error(`Model for **${tableName}** Not Found`); prepareRoutesObj(routesObj, TheModel, controllers, includeTable); }); } else { // Include logic Object.values(models).forEach((TheModel) => { const includeTable = rest[TheModel.tableName] !== undefined ? rest[TheModel.tableName] : rest["*"] !== undefined ? rest["*"] : true; prepareRoutesObj(routesObj, TheModel, controllers, includeTable); }); } } else { Object.values(models).forEach((TheModel) => { prepareRoutesObj(routesObj, TheModel, controllers, true); }); } if (config.customEndpoints) { Object.entries(config.customEndpoints).forEach(([basePath, endpoints]) => { const cleanBase = basePath.replace(/^\/+|\/+$/g, ""); Object.entries(endpoints).forEach(([service, handler]) => { const [controllerName, action] = handler.split("."); const TheController = controllers[controllerName]; if (!TheController) return; const [m, p] = service.split(" "); const method = m.toUpperCase(); const servicePath = `/${cleanBase}${p}`; routesObj[`${method} ${servicePath}`] = [ method, servicePath, action, TheController, Model, ]; }); }); } return routesObj; } /** * Lists the routes currently defined in the Express router. * @param router - The Express router. * @returns An array of strings representing each route method and path. */ export function listRoutes(router) { const routes = []; router.stack.forEach((middleware) => { if (middleware.route) { // It's a defined route const method = Object.keys(middleware.route.methods)[0].toUpperCase(); const routePath = middleware.route.path; routes.push(`${method} ${routePath}`); } }); return routes; } /** * Generates a summary of available services (tables) with their base endpoints. * Groups routes by table name and returns one representative endpoint per table. * Excludes custom endpoints. * * @param routesObj - The routes object containing route definitions. * @param customEndpointPaths - Optional set of custom endpoint paths to exclude. * @returns An object mapping table names (in kebab-case) to their base endpoints. * * @example * ```typescript * // Returns: * // { * // "users": "GET /users", * // "join-users": "GET /join-users", * // "other-table": "GET /other-table" * // } * ``` */ export function generateServicesSummary(routesObj, customEndpointPaths) { const services = {}; Object.values(routesObj).forEach((value) => { const [method, path] = value; // Extract the base path (first segment after /) // Example: "/users" from "/users/123" or "/users/meta" const pathSegments = path.split('/').filter(Boolean); if (pathSegments.length === 0) return; const baseService = pathSegments[0]; // Skip if this is a custom endpoint if (customEndpointPaths === null || customEndpointPaths === void 0 ? void 0 : customEndpointPaths.has(baseService)) return; // Only add if not already added (to get just one per service) // Prefer GET method for the representative route if (!services[baseService] || method === 'GET') { services[baseService] = `${method} /${baseService}`; } }); return services; } /** * Replaces the default GenericController with a custom controller reference. * * @param CustomController - A class that extends from the original GenericController. * @returns {boolean} True if replacement was successful. * * @example * ```typescript * class MyController extends GenericController {} * replaceGenericController(MyController); * ``` */ export function replaceGenericController(CustomController) { ctrlRef.GenericController = CustomController; return true; } /** * Main function to configure an Express router with dynamic routes. * * @param routesObject - The routes object containing route definitions. * @param config - Configuration options for the router. * @param config.router - Express router instance. * @param config.beforeProcess - Hook called before processing a request. * @param config.afterProcess - Hook called after processing a request. * @param config.debugLog - Enable debug logging. * @returns The configured Express router. * * @example * ```typescript * import { expressRouter, routesObject } from 'adba'; * const models = await generateModels(knexInstance); * const routes = routesObject(models); * const router = expressRouter(routes, { debugLog: true }); * app.use('/api', router); * ``` */ export default function expressRouter(routesObject, { router = express.Router(), beforeProcess = (tn, a, data, i) => data, afterProcess = (tn, a, data, i) => data, debugLog = false, } = {}) { // Detect custom endpoint paths by checking if the route's Model is the generic Model class // (custom endpoints use Model directly, while table routes use specific Model subclasses) const customEndpointPaths = new Set(); Object.values(routesObject).forEach((value) => { const [, path, , , TheModel] = value; if (TheModel === Model) { // This is a custom endpoint, extract its base path const pathSegments = path.split('/').filter(Boolean); if (pathSegments.length > 0) { customEndpointPaths.add(pathSegments[0]); } } }); // Generate services summary before setting up routes (excluding custom endpoints) const servicesSummary = generateServicesSummary(routesObject, customEndpointPaths); Object.values(routesObject).forEach((value) => { const [method, path, action, TheController, TheModel] = value; const routerMethod = router[method.toLowerCase()].bind(router); routerMethod(path, (req, res, next) => __awaiter(this, void 0, void 0, function* () { const protocol = req.protocol; const host = req.get("host"); const _idx = uuidv4(); console.log(""); console.log(""); console.time(_idx); console.group(moment().format("YYYY-MM-DD HH:mm:ss")); console.log(_idx, ">", `${method} ${protocol}://${host}${req.originalUrl}`, Controller.name, action); if (debugLog) { console.debug("HEADERS", req.headers); console.debug("PARAMS", req.params); console.debug("QUERY", req.query); console.debug("BODY", req.body); console.debug("COOKIES", req.cookies); } try { const controller = new TheController(TheModel); // Normalize query keys: convert bracket notation (filters[active]) // into dot notation (filters.active) so `unflatten` handles both. const rawQuery = req.query || {}; const normalizedQuery = {}; Object.keys(rawQuery).forEach((k) => { // Replace bracket groups like `[key]` with `.key` const nk = k.replace(/\[(\w+)\]/g, '.$1'); normalizedQuery[nk] = rawQuery[k]; }); const all = Object.assign(Object.assign(Object.assign({}, (req.body || {})), unflatten(normalizedQuery)), (req.params || {})); const ctrlAction = controller[action].bind(controller); const inputData = yield beforeProcess(controller.Model.tableName, action, all, _idx); const outputData = yield ctrlAction(inputData); const payload = yield afterProcess(controller.Model.tableName, action, outputData, _idx); payload.requestId = _idx; // Check if the output data is an error or if it needs a special response if (payload instanceof Error) throw payload; if (!payload) throw getStatusCode(503); else res.status(payload.status).json(payload); if (debugLog) { console.debug("RESPONSE", payload); } } catch (error) { if (error instanceof Error) { console.error(error); const e = error; const code = e.statusCode || e.code || 0; const payload = getStatusCode(500, code); payload.data = error.message; res.status(500).json(payload); } else if (typeof error.status === "number") { res.status(error.status).json(error); } } console.timeEnd(_idx); console.groupEnd(); })); }); // Setup a default GET route that lists available services (tables) and endpoints router.get("/", (req, res) => { const availableRoutes = listRoutes(router); const success = getStatusCode(200); success.data = { endpoints: availableRoutes, tables: Object.keys(servicesSummary), }; res.status(success.status).json(success); }); return router; }