UNPKG

adba

Version:
510 lines (474 loc) 15.9 kB
/** * @file express-router.ts * @description This file provides functionalities for creating an Express router with dynamic routes based on ORM models. */ import { Model } from "objection"; import express from "express"; import type { Request, Response, NextFunction } from "express"; import moment from "moment"; import { kebabCase } from "change-case-all"; import { v4 as uuidv4 } from "uuid"; import { unflatten } from "dbl-utils"; import type { IControllerMethods, IExpressRouterConf, IRouteConf, IRoutesObject, IStatusCode, IRouterMethods, ICustomEndpoints, } from "./types"; import GenericController from "./controller"; import Controller from "./controller"; import getStatusCode from "./status-codes"; const ctrlRef = { GenericController, }; const aliasing: Record<string, string> = {}; /** * 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: Record<string, string> = { "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: Record<string, string> | string[], remove: boolean = false ) { if (remove && Array.isArray(defs)) { defs.forEach((k: string) => delete definedREST[k]); } else { Object.assign(definedREST, defs); } } /** * Adds alias mappings for table names. * @param alias - The alias mappings. */ export function addTableAlias(alias: Record<string, string>) { 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: IRoutesObject, TheModel: typeof Model, controllers: Record<string, typeof Controller>, includeTable: boolean | IRouteConf ) { 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", ...rest } = includeTable; if (defaultAction === "excludes") { Object.entries(rest).forEach(([service, action]) => { if (!action) return; if (action === true) action = definedREST[service]; buildRoutesObj( routesObj, tableName, service, action as string, TheController, TheModel ); }); } else { const cpRest = { ...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 as string, 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: IRoutesObject, tableName: string, service: string, action: string, TheController: typeof Controller, TheModel: typeof Model ) { const [m, path] = service.split(" "); const slugTableName = aliasing[tableName] || kebabCase(tableName); const method = m.toUpperCase() as IRoutesObject[1][0]; 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: Record<string, typeof Model>, controllers: Record<string, typeof ctrlRef.GenericController> = {}, config: IExpressRouterConf = {} ) { const routesObj: IRoutesObject = {}; if (config.filters) { const { defaultAction = "includes", ...rest } = config.filters; 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 as any); }); } 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 as any); }); } } 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 as Record<string, string>).forEach( ([service, handler]) => { const [controllerName, action] = handler.split("."); const TheController = controllers[controllerName]; if (!TheController) return; const [m, p] = service.split(" "); const method = m.toUpperCase() as IRoutesObject[1][0]; 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: express.Router) { const routes: string[] = []; router.stack.forEach((middleware: any) => { 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: IRoutesObject, customEndpointPaths?: Set<string> ): Record<string, string> { const services: Record<string, string> = {}; Object.values(routesObj).forEach((value: IRoutesObject[1]) => { 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?.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: typeof GenericController ): boolean { 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: IRoutesObject, { router = express.Router(), beforeProcess = (tn: string, a: string, data: any, i: string) => data, afterProcess = (tn: string, a: string, data: any, i: string) => data, debugLog = false, } = {} ): express.Router { // 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<string>(); Object.values(routesObject).forEach((value: IRoutesObject[1]) => { 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: IRoutesObject[1]) => { const [method, path, action, TheController, TheModel] = value; const routerMethod = router[method.toLowerCase() as IRouterMethods].bind(router); routerMethod( path, async (req: Request, res: Response, next: NextFunction) => { 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: Record<string, any> = {}; Object.keys(rawQuery).forEach((k) => { // Replace bracket groups like `[key]` with `.key` const nk = k.replace(/\[(\w+)\]/g, '.$1'); normalizedQuery[nk] = (rawQuery as any)[k]; }); const all = { ...(req.body || {}), ...unflatten(normalizedQuery)!, ...(req.params || {}), }; const ctrlAction: Function = controller[action as IControllerMethods].bind(controller); const inputData = await beforeProcess( controller.Model.tableName, action, all, _idx ); const outputData = await ctrlAction(inputData); const payload = await 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: any = 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 as IStatusCode).status === "number") { res.status((error as IStatusCode).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; }