diff --git a/src/Router.ts b/src/Router.ts index 3916604..715c423 100644 --- a/src/Router.ts +++ b/src/Router.ts @@ -7,6 +7,12 @@ import { logMessage, LogLevel } from '@BridgemanAccessible/ba-logging'; import { BaseController } from './controllers/BaseController.js'; import { ErrorController } from './controllers/ErrorController.js'; +import { GET_METADATA_KEY, GET_FORCE_PREFIX_METADATA_KEY } from './decorators/GET.js'; +import { POST_METADATA_KEY, POST_FORCE_PREFIX_METADATA_KEY } from './decorators/POST.js'; +import { PUT_METADATA_KEY, PUT_FORCE_PREFIX_METADATA_KEY } from './decorators/PUT.js'; +import { DELETE_METADATA_KEY, DELETE_FORCE_PREFIX_METADATA_KEY } from './decorators/DELETE.js'; +import { CHILD_CONTROLLER_METADATA_KEY } from './decorators/ChildController.js'; + /** * The Router sets up the routes/endpoints for the App. * @@ -60,7 +66,7 @@ export class Router { * @param folder The folder that the file is in * @returns An object with 2 properties: a `controllers` property which holds the controller class (or list of controller classes) in the "file" or null if the file is not a controller and `errorControllers` property which holds the error controller class (or list of error controller classes) in the "file" or null if the file is not an error controller. */ - private async checkIfControllerFile(file: string, folder: string): Promise<{ controllers: BaseController | BaseController[] | null, errorControllers: (new (...args: any[]) => ErrorController) | (new (...args: any[]) => ErrorController)[] | null }> { + private async checkIfControllerFile(file: string, folder: string): Promise<{ controllers: ( new (...args: any[]) => BaseController) | ( new (...args: any[]) => BaseController)[] | null, errorControllers: (new (...args: any[]) => ErrorController) | (new (...args: any[]) => ErrorController)[] | null }> { // Get the path to the specific file const controllerPath = path.join(folder, file); @@ -68,15 +74,26 @@ export class Router { const stats = await fse.stat(controllerPath); if(stats.isDirectory()) { // Recurse into the directory - const controllersFoundInDir = await Promise.all((await fse.readdir(controllerPath)).map(file => this.checkIfControllerFile(file, controllerPath))); + const controllersFoundInDir = await Promise.all( + ( + await fse.readdir(controllerPath) + ) + .map(file => this.checkIfControllerFile(file, controllerPath)) + ); const controllersInDir = controllersFoundInDir.filter(controller => controller !== null); if(controllersInDir.length === 0) { return { controllers: null, errorControllers: null }; } else { return { - controllers: controllersInDir.map(controllerInDir => controllerInDir.controllers).length > 1 ? controllersInDir.map(controllerInDir => controllerInDir.controllers).filter(ctlr => ctlr !== null).flat() : controllersInDir.map(controllerInDir => controllerInDir.controllers)[0], - errorControllers: controllersInDir.map(controllerInDir => controllerInDir.errorControllers).length > 1 ? controllersInDir.map(controllerInDir => controllerInDir.errorControllers).filter(ctlr => ctlr !== null).flat() : controllersInDir.map(controllerInDir => controllerInDir.errorControllers)[0] + controllers: controllersInDir.map(controllerInDir => controllerInDir.controllers).length > 1 + ? controllersInDir.map(controllerInDir => controllerInDir.controllers) + .filter(ctlr => ctlr !== null).flat() + : controllersInDir.map(controllerInDir => controllerInDir.controllers)[0], + errorControllers: controllersInDir.map(controllerInDir => controllerInDir.errorControllers).length > 1 + ? controllersInDir.map(controllerInDir => controllerInDir.errorControllers) + .filter(ctlr => ctlr !== null).flat() + : controllersInDir.map(controllerInDir => controllerInDir.errorControllers)[0] }; } } @@ -106,7 +123,7 @@ export class Router { // This is because native JavaScript doesn't have decorators (which are used to mark a class as a controller). // Consequently, we enforce that all controllers in addition to using the decorator must extend the `BaseController` class so that we can identify them. // Further, note we can't do this as part of the decorator because of the way decorators get transpiled to JavaScript. - const controllers: BaseController[] = classes + const controllers: (new (...args: any[]) => BaseController)[] = classes .filter(exportedClassName => controllerModule[exportedClassName].prototype instanceof BaseController) .map(controllerClassName => { return controllerModule[controllerClassName]; @@ -120,7 +137,7 @@ export class Router { // This is because native JavaScript doesn't have decorators (which are used to mark a class as a error controller). // Consequently, we enforce that all error controllers in addition to using the decorator must extend the `ErrorController` class so that we can identify them. // Further, note we can't do this as part of the decorator because of the way decorators get transpiled to JavaScript. - const errorControllers: ErrorController[] = classes + const errorControllers: (new (...args: any[]) => ErrorController)[] = classes .filter(exportedClassName => controllerModule[exportedClassName].prototype instanceof ErrorController) .map(controllerClassName => { return controllerModule[controllerClassName]; @@ -149,6 +166,20 @@ export class Router { return { controllers: foundControllers, errorControllers: foundErrorControllers }; } + /** Helper function to resolve the full path for a route */ + private resolvePath(controller: (new (...args: any[]) => BaseController), path: string, forcePrefix: boolean, inheritedPrefix: string = '') { + const controllerBasePath = Reflect.getMetadata('basePath', controller); + const currentFullBase = (controller as any).joinPaths(inheritedPrefix, controllerBasePath); + + // DEFAULT LOGIC: forcePrefix defaults to true if undefined + // ACCESS TRICK: Cast 'controller' to 'any' to access the protected static 'joinPaths' + if (forcePrefix !== false) { + return (controller as any).joinPaths(currentFullBase, path); + } + + return path; + }; + /** * Get the routes in a controller * @@ -157,7 +188,7 @@ export class Router { * @param controller The controller to get the routes from * @returns The routes in the controller */ - private getRoutesInController(controller: any) { + private getRoutesInController(controller: (new (...args: any[]) => BaseController), inheritedPrefix: string = '') { if (typeof controller === 'undefined') { logMessage(`Something went wrong while processing ${JSON.stringify(controller)}`, LogLevel.ERROR); throw new Error('The controller must be a class'); @@ -166,13 +197,13 @@ export class Router { // Loop over all the methods in the provided class looking for methods that use the GET decorator const pathsGET = Object.getOwnPropertyNames(controller.prototype) // Find all methods that have a Get metadata key (GET decorator) - .filter((method) => Reflect.getMetadata('Get', controller.prototype, method)) + .filter((method) => typeof Reflect.getMetadata(GET_METADATA_KEY, controller.prototype, method) !== 'undefined') .map((method) => { // Get the method const fn = controller.prototype[method]; // Get the path - const path = Reflect.getMetadata('Get', controller.prototype, method) as string; + const path = this.resolvePath(controller, Reflect.getMetadata(GET_METADATA_KEY, controller.prototype, method), Reflect.getMetadata(GET_FORCE_PREFIX_METADATA_KEY, controller.prototype, method), inheritedPrefix); return path; }); @@ -180,29 +211,29 @@ export class Router { // Loop over all the methods in the provided class looking for methods that use the POST decorator const pathsPOST = Object.getOwnPropertyNames(controller.prototype) // Find all methods that have a Post metadata key (POST decorator) - .filter((method) => Reflect.getMetadata('Post', controller.prototype, method)) + .filter((method) => typeof Reflect.getMetadata(POST_METADATA_KEY, controller.prototype, method) !== 'undefined') .map((method) => { // Get the method const fn = controller.prototype[method]; // Get the metadata object (which contains a path and middleware) - const postRoute = Reflect.getMetadata('Post', controller.prototype, method); - const path = postRoute.path; - + const postRoute = Reflect.getMetadata(POST_METADATA_KEY, controller.prototype, method); + const path = this.resolvePath(controller, postRoute.path, Reflect.getMetadata(POST_FORCE_PREFIX_METADATA_KEY, controller.prototype, method), inheritedPrefix); + return path; }); // Loop over all the methods in the provided class looking for methods that use the `@PUT` decorator const pathsPUT = Object.getOwnPropertyNames(controller.prototype) // Find all methods that have a `Put` metadata key (`@PUT` decorator) - .filter((method) => Reflect.getMetadata('Put', controller.prototype, method)) + .filter((method) => typeof Reflect.getMetadata(PUT_METADATA_KEY, controller.prototype, method) !== 'undefined') .map((method) => { // Get the method const fn = controller.prototype[method]; // Get the metadata object (which contains a path and middleware) - const putRoute = Reflect.getMetadata('Put', controller.prototype, method); - const path = putRoute.path; + const putRoute = Reflect.getMetadata(PUT_METADATA_KEY, controller.prototype, method); + const path = this.resolvePath(controller, putRoute.path, Reflect.getMetadata(PUT_FORCE_PREFIX_METADATA_KEY, controller.prototype, method), inheritedPrefix); return path; }); @@ -210,13 +241,15 @@ export class Router { // Loop over all the methods in the provided class looking for methods that use the `@DELETE` decorator const pathsDELETE = Object.getOwnPropertyNames(controller.prototype) // Find all methods that have a `Delete` metadata key (`@DELETE` decorator) - .filter((method) => Reflect.getMetadata('Delete', controller.prototype, method)) + .filter((method) => typeof Reflect.getMetadata(DELETE_METADATA_KEY, controller.prototype, method) !== 'undefined') .map((method) => { // Get the method const fn = controller.prototype[method]; + // Get the metadata object (which contains a path and middleware) - const deleteRoute = Reflect.getMetadata('Delete', controller.prototype, method); - const path = deleteRoute.path; + const deleteRoute = Reflect.getMetadata(DELETE_METADATA_KEY, controller.prototype, method); + const path = this.resolvePath(controller, deleteRoute.path, Reflect.getMetadata(DELETE_FORCE_PREFIX_METADATA_KEY, controller.prototype, method), inheritedPrefix); + return path; }); @@ -228,22 +261,30 @@ export class Router { } } - async processControllerRoutes(app: Application, decoratedController: any | any[], callSetup: boolean = true) { + async processControllerRoutes(app: Application, decoratedController: (new (...args: any[]) => BaseController) | (new (...args: any[]) => BaseController)[], callSetup: boolean = true, inheritedPrefix: string = '') { let addedRoutes: string[] = []; // If the input is an array, recurse over the array if (Array.isArray(decoratedController)) { //console.log('Is an array - recursing...') - addedRoutes = addedRoutes.concat((await Promise.all(decoratedController.map(async (decoratedControllerCls) => await this.processControllerRoutes(app, decoratedControllerCls, callSetup)))).flat()); + + addedRoutes = addedRoutes.concat( + ( + await Promise.all( + decoratedController.map( + async (decoratedControllerCls) => await this.processControllerRoutes(app, decoratedControllerCls, callSetup, inheritedPrefix) + ) + ) + ).flat()); } else { - const controller = Reflect.getMetadata('originalClass', decoratedController); + const controller = Reflect.getMetadata('originalClass', decoratedController) as (new (...args: any[]) => BaseController); //console.log(`Processing ${(typeof controller).toString()} - ${typeof controller !== 'undefined' && controller !== null && typeof controller.name !== 'undefined' ? controller.name : 'unknown'} (${(typeof decoratedController).toString()} - ${typeof decoratedController !== 'undefined' && decoratedController !== null && typeof decoratedController.name !== 'undefined' ? decoratedController.name : 'unknown'})...`); let routes: { GET: string[], POST: string[], PUT: string[], DELETE: string[] } = { GET: [], POST: [], PUT: [], DELETE: [] }; try { - routes = this.getRoutesInController(controller); + routes = this.getRoutesInController(controller, inheritedPrefix); } catch (e) { logMessage(`Something went wrong while processing ${JSON.stringify(controller)} (${JSON.stringify(decoratedController)})`, LogLevel.ERROR); @@ -264,11 +305,31 @@ export class Router { routes.DELETE.forEach((path) => { addedRoutes.push(`DELETE ${path} from ${(new controller()).constructor.name}`); }); + + const children = Reflect.getMetadata(CHILD_CONTROLLER_METADATA_KEY, controller); + if (typeof children !== 'undefined' && (children instanceof BaseController || (Array.isArray(children) && children.length > 0))) { + const childArray = Array.isArray(children) ? children : [children]; + + const controllerBasePath = Reflect.getMetadata('basePath', controller); + const currentFullBase = (controller as any).joinPaths(inheritedPrefix, controllerBasePath); + + // RECURSE: We pass 'currentFullBase' as the new prefix + // We pass 'false' for isRoot because the parent's setup() handles their registration + const childRoutes = ( + await Promise.all( + childArray.map(child => + this.processControllerRoutes(app, child, false, currentFullBase) + ) + ) + ).flat(); + + addedRoutes = addedRoutes.concat(childRoutes); + } // Because we reuse this method for adding information about the child routes but we don't want to call the setup method (because it's called on the parent controller) // We have a switch to determine if we should call the setup method or not. if (callSetup) { - decoratedController.setup(app); + (decoratedController as any).setup(app); } } @@ -292,7 +353,7 @@ export class Router { // Get the controller classes from the files const loadedControllerObjects = (await Promise.all(files.map(async (file) => (await this.checkIfControllerFile(file, this.controllersPath /*, app*/))))); - const loadedControllers: BaseController[] = loadedControllerObjects + const loadedControllers: (new (...args: any[]) => BaseController)[] = loadedControllerObjects .map(loadedControllerObjects => loadedControllerObjects.controllers) .filter(controller => controller !== null) .flat(); @@ -300,27 +361,47 @@ export class Router { let addedRoutes: string[] = []; // Get all controllers that have the child controller decorator - const controllersWithChildControllers = loadedControllers.filter(controller => Reflect.getMetadata('ChildController', controller) !== undefined); + const controllersWithChildControllers = loadedControllers.filter(controller => Reflect.getMetadata(CHILD_CONTROLLER_METADATA_KEY, controller) !== undefined); // Get all those controllers that are designated as children controllers by parent controllers - const childrenControllers = controllersWithChildControllers.map(controller => Reflect.getMetadata('ChildController', controller)); + const childrenControllers = controllersWithChildControllers.map(controller => { + const children = Reflect.getMetadata(CHILD_CONTROLLER_METADATA_KEY, controller) as ((new (...args: any[]) => BaseController) | (new (...args: any[]) => BaseController)[]); + + return Array.isArray(children) ? children : [children]; + }).flat(); // Get the exclusive set of controllers that don't fit in the group of controllers with child controllers OR a child controller themselves - const controllersWithoutChildren = loadedControllers.filter(controller => !controllersWithChildControllers.includes(controller) && !childrenControllers.includes(controller)); + //const controllersWithoutChildren = loadedControllers.filter(controller => !controllersWithChildControllers.includes(controller) && !childrenControllers.includes(controller)); // Take the list of controllers with child controllers and the list without children (that excludes children themselves) // And forma a list that has all child controllers remove - const topLevelControllers = [...controllersWithChildControllers, ...controllersWithoutChildren]; + const topLevelControllers = loadedControllers.filter(controller => !childrenControllers.includes(controller)) //[...controllersWithChildControllers, ...controllersWithoutChildren]; //console.log(`Child controllers: ${childrenControllers.map(controller => (typeof controller).toString() + ' - ' + (typeof controller !== 'undefined' && controller !== null ? controller.name : 'unknown')).join(', ')}`); // Add the routes for the child controllers to the list of added routes WITHOUT calling the setup method (as this is called in the parent controller's setup method) - addedRoutes = addedRoutes.concat((await Promise.all(childrenControllers.map(async (decoratedController) => await this.processControllerRoutes(app, decoratedController, false)))).flat()); + /*addedRoutes = addedRoutes.concat( + ( + await Promise.all( + childrenControllers.map( + async (decoratedController) => await this.processControllerRoutes(app, decoratedController, false) + ) + ) + ).flat() + );*/ //console.log(`Top level controllers: ${topLevelControllers.map(controller => (typeof controller).toString() + ' - ' + (typeof controller !== 'undefined' && controller !== null ? controller.name : 'unknown')).join(', ')}`); // Add the routes for the top level controllers to the list of added routes and call the setup method - addedRoutes = addedRoutes.concat((await Promise.all(topLevelControllers.map(async (decoratedController) => await this.processControllerRoutes(app, decoratedController)))).flat()); + addedRoutes = addedRoutes.concat( + ( + await Promise.all( + topLevelControllers.map( + async (decoratedController) => await this.processControllerRoutes(app, decoratedController) + ) + ) + ).flat() + ); logMessage('Routes added:', LogLevel.DEBUG); addedRoutes.forEach(route => logMessage('\t' + route, LogLevel.DEBUG)); @@ -356,7 +437,7 @@ export class Router { }); logMessage('Error controllers added:', LogLevel.DEBUG); - handledErrors.forEach(errorControllers => logMessage('\t' + errorControllers, LogLevel.DEBUG)); + handledErrors.forEach(errorControllers => logMessage('\t' + errorControllers.constructor.name, LogLevel.DEBUG)); } /** diff --git a/src/controllers/BaseController.ts b/src/controllers/BaseController.ts index a716408..dceba80 100644 --- a/src/controllers/BaseController.ts +++ b/src/controllers/BaseController.ts @@ -1,5 +1,26 @@ import type { Application } from 'express'; export abstract class BaseController { + /** Helper function to join paths cleanly */ + protected static joinPaths(base: string, path: string): string { + // Combine the base path and the specific route path + let fullPath = base + path; + + // Remove double slashes (e.g., //user -> /user) + fullPath = fullPath.replace(/\/\//g, '/'); + + // Ensure it starts with / (in case base was empty and path was empty) + if (!fullPath.startsWith('/')) { + fullPath = '/' + fullPath; + } + + // Remove trailing slash (optional, unless it's just root '/') + if (fullPath.length > 1 && fullPath.endsWith('/')) { + fullPath = fullPath.slice(0, -1); + } + + return fullPath; + } + static setup(app: Application) {} } \ No newline at end of file diff --git a/src/decorators/Controller.ts b/src/decorators/Controller.ts index 9d9057f..732ff17 100644 --- a/src/decorators/Controller.ts +++ b/src/decorators/Controller.ts @@ -70,27 +70,6 @@ export function Controller(b // We extend the class that is decorated and override the setup method to automatically setup the routes return class extends target { - // Helper function to join paths cleanly - static joinPaths(base: string, path: string): string { - // Combine the base path and the specific route path - let fullPath = base + path; - - // Remove double slashes (e.g., //user -> /user) - fullPath = fullPath.replace(/\/\//g, '/'); - - // Ensure it starts with / (in case base was empty and path was empty) - if (!fullPath.startsWith('/')) { - fullPath = '/' + fullPath; - } - - // Remove trailing slash (optional, unless it's just root '/') - if (fullPath.length > 1 && fullPath.endsWith('/')) { - fullPath = fullPath.slice(0, -1); - } - - return fullPath; - } - /** * Setup the routes for the controller. * @@ -101,7 +80,7 @@ export function Controller(b // Order matters: Incoming Prefix + Current Controller Base Path // Ex: "" + "/api" = "/api" // Ex: "/api" + "/v1" = "/api/v1" - const currentPrefix = this.joinPaths(pathPrefix, Reflect.getMetadata('basePath', target) as string); + const currentPrefix = BaseController.joinPaths(pathPrefix, Reflect.getMetadata('basePath', target) as string); // If the decorated class is also decorated with the `@ChildController` decorator, // then we call the child controller's setup method as well. @@ -133,7 +112,7 @@ export function Controller(b // Get the path const path = Reflect.getMetadata(GET_METADATA_KEY, target.prototype, method); - const fullPath = Reflect.getMetadata(GET_FORCE_PREFIX_METADATA_KEY, target.prototype, method) ? this.joinPaths(currentPrefix, path) : path; + const fullPath = Reflect.getMetadata(GET_FORCE_PREFIX_METADATA_KEY, target.prototype, method) ? BaseController.joinPaths(currentPrefix, path) : path; // Bind the method to the class instance app.get(fullPath, ...[ @@ -196,7 +175,7 @@ export function Controller(b const path = postRoute.path; const middleware: NextFunction[] = postRoute.middleware; - const fullPath = Reflect.getMetadata(POST_FORCE_PREFIX_METADATA_KEY, target.prototype, method) ? this.joinPaths(currentPrefix, path) : path; + const fullPath = Reflect.getMetadata(POST_FORCE_PREFIX_METADATA_KEY, target.prototype, method) ? BaseController.joinPaths(currentPrefix, path) : path; // Bind the method to the class instance app.post(fullPath, ...[ @@ -248,7 +227,7 @@ export function Controller(b const path = putRoute.path; const middleware = putRoute.middleware; - const fullPath = Reflect.getMetadata(PUT_FORCE_PREFIX_METADATA_KEY, target.prototype, method) ? this.joinPaths(currentPrefix, path) : path; + const fullPath = Reflect.getMetadata(PUT_FORCE_PREFIX_METADATA_KEY, target.prototype, method) ? BaseController.joinPaths(currentPrefix, path) : path; // Bind the method to the class instance app.put(fullPath, ...[ @@ -295,7 +274,7 @@ export function Controller(b const path = deleteRoute.path; const middleware = deleteRoute.middleware; - const fullPath = Reflect.getMetadata(DELETE_FORCE_PREFIX_METADATA_KEY, target.prototype, method) ? this.joinPaths(currentPrefix, path) : path; + const fullPath = Reflect.getMetadata(DELETE_FORCE_PREFIX_METADATA_KEY, target.prototype, method) ? BaseController.joinPaths(currentPrefix, path) : path; // Bind the method to the class instance app.delete(fullPath, ...[