adba
Version:
Any DataBase to API
413 lines (412 loc) • 16.8 kB
JavaScript
/**
* @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;
}