Bringing local and remote repository in line with one another again
This commit is contained in:
parent
bc581f9eac
commit
332e177f99
15 changed files with 1344 additions and 222 deletions
53
.github/workflows/publish.yml
vendored
53
.github/workflows/publish.yml
vendored
|
|
@ -18,6 +18,10 @@ jobs:
|
|||
- name: Set up NPM Auth Token
|
||||
run: echo "NODE_AUTH_TOKEN=${{ secrets.NPM_TOKEN }}" >> $GITHUB_ENV
|
||||
|
||||
# Set up NPM Auth Token
|
||||
- name: Set up NPM Auth Token
|
||||
run: echo "NODE_AUTH_TOKEN=${{ secrets.NPM_TOKEN }}" >> $GITHUB_ENV
|
||||
|
||||
# Set up Node.js
|
||||
- name: Set up Node.js version
|
||||
uses: actions/setup-node@v3
|
||||
|
|
@ -42,12 +46,53 @@ jobs:
|
|||
# > Will fall back to the repository owner when using the GitHub Packages registry (https://npm.pkg.github.com/).
|
||||
scope: '@BridgemanAccessible'
|
||||
|
||||
# Install dependencies
|
||||
- name: Install dependencies
|
||||
# Transpile/Build the package (TypeScript -> JavaScript)
|
||||
- name: Transpile/Build the package (TypeScript -> JavaScript)
|
||||
run: |
|
||||
# Install needed dependencies
|
||||
yarn install
|
||||
|
||||
# Build the package
|
||||
yarn build
|
||||
|
||||
- name: Determine Version and Increment (if needed)
|
||||
id: version_check
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "Version: $VERSION"
|
||||
|
||||
NAME=$(node -p "require('./package.json').name")
|
||||
LATEST_VERSION=$(npm show $NAME version --registry https://npm.pkg.bridgemanaccessible.ca)
|
||||
echo "Latest version: $LATEST_VERSION"
|
||||
|
||||
if [ "$LATEST_VERSION" != "$VERSION" ]; then
|
||||
echo "Manually updated version detected: $VERSION"
|
||||
else
|
||||
NEW_VERSION=$(npm version patch --no-git-tag-version)
|
||||
echo "New version: $NEW_VERSION"
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_ENV
|
||||
echo "version_changed=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Commit Version Change (if needed)
|
||||
if: steps.version_check.outputs.version_changed == 'true'
|
||||
run: |
|
||||
git config user.name "GitHub Actions"
|
||||
git config user.email "actions@github.com"
|
||||
git add package.json
|
||||
git commit -m "[Github Actions] Update version to ${{ env.new_version }}"
|
||||
git push origin HEAD:main
|
||||
|
||||
# Publish to private NPM registry
|
||||
- name: Publish package
|
||||
run: npm publish
|
||||
- name: Publish the package
|
||||
run: |
|
||||
# Copy over the files to the build output (`dist`) folder
|
||||
cp package.json dist/package.json
|
||||
cp README.md dist/README.md
|
||||
cp LICENSE dist/LICENSE
|
||||
|
||||
# Change directory to the build output (`dist`) folder
|
||||
cd dist
|
||||
|
||||
# Publish the package to the private NPM registry
|
||||
npm publish --registry http://npm.pkg.bridgemanaccessible.ca/
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -51,6 +51,8 @@ web_modules/
|
|||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
.npmrc
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"republish": "yarn build && cp package.json dist/package.json && cd dist && npm unpublish --registry https://npm.pkg.bridgemanaccessible.ca --force @BridgemanAccessible/ba-web-framework@1.0.0 && npm publish --registry https://npm.pkg.bridgemanaccessible.ca && cd ../"
|
||||
},
|
||||
"dependencies": {
|
||||
"@BridgemanAccessible/ba-auth": "^1.0.0",
|
||||
"express": "^4.19.2",
|
||||
"fs-extra": "^11.2.0",
|
||||
"jsdom": "^24.1.0",
|
||||
|
|
|
|||
36
src/App.ts
36
src/App.ts
|
|
@ -10,29 +10,51 @@ export class App {
|
|||
/** The default port to run the server on (if the environment variable isn't set) */
|
||||
private readonly DEFAULT_PORT = 3000;
|
||||
|
||||
private initializer?: Initializer;
|
||||
|
||||
getInitializer() {
|
||||
if(typeof this.initializer === 'undefined') {
|
||||
throw new Error('Initializer is not set. Please call run() first.');
|
||||
}
|
||||
|
||||
return this.initializer;
|
||||
}
|
||||
|
||||
getExpressApp() {
|
||||
return this.getInitializer().getExpressApp();
|
||||
}
|
||||
|
||||
/**
|
||||
* The main entry point for the web app
|
||||
* This is mostly required because of async/await
|
||||
*/
|
||||
async run<T extends Initializer>(initializer?: T, callback?: (app: Application) => void | Promise<void>) {
|
||||
async run<T extends Initializer>(initializer?: T, callback?: (app: App) => void | Promise<void>, onErrorCallback?: (error: any) => void | Promise<void>) {
|
||||
// Do the initial setup of the app
|
||||
let app: Application;
|
||||
if(typeof initializer !== 'undefined') {
|
||||
app = await initializer.init();
|
||||
this.initializer = initializer;
|
||||
}
|
||||
else {
|
||||
app = await (new Initializer()).init();
|
||||
this.initializer = new Initializer();
|
||||
}
|
||||
|
||||
await this.initializer.init();
|
||||
|
||||
// Start the server
|
||||
const port = process.env.PORT || this.DEFAULT_PORT;
|
||||
app.listen(port, async () => {
|
||||
this.getExpressApp().listen(port, async () => {
|
||||
console.log(`Server is running on port ${port}`);
|
||||
|
||||
// Run the callback if one is provided
|
||||
if(typeof callback !== 'undefined') {
|
||||
await callback(app);
|
||||
await callback.bind(this)(this);
|
||||
}
|
||||
});
|
||||
})
|
||||
.on('error', async (error) => {
|
||||
console.error('Error starting server:', error);
|
||||
|
||||
if(typeof onErrorCallback !== 'undefined') {
|
||||
await onErrorCallback(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,9 @@ export class Initializer {
|
|||
/** The middlewares to use */
|
||||
private middlewares: ((...args: any[]) => RequestHandler)[];
|
||||
|
||||
private app?: Application;
|
||||
private router?: Router;
|
||||
|
||||
/**
|
||||
* Create a new Initializer
|
||||
*
|
||||
|
|
@ -46,6 +49,22 @@ export class Initializer {
|
|||
this.middlewares = middlewares;
|
||||
}
|
||||
|
||||
getExpressApp() {
|
||||
if(typeof this.app === 'undefined') {
|
||||
throw new Error('App is not set. Please call init() first.');
|
||||
}
|
||||
|
||||
return this.app;
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
if(typeof this.router === 'undefined') {
|
||||
throw new Error('Router is not set. Please call init() first.');
|
||||
}
|
||||
|
||||
return this.router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Express app
|
||||
*
|
||||
|
|
@ -53,9 +72,9 @@ export class Initializer {
|
|||
*/
|
||||
private async createExpressApp(): Promise<Application> {
|
||||
// Create the Express app
|
||||
const app = express();
|
||||
this.app = express();
|
||||
|
||||
return app;
|
||||
return this.app;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -72,7 +91,7 @@ export class Initializer {
|
|||
*
|
||||
* @returns The Express app that gets created and setup
|
||||
*/
|
||||
async init(): Promise<Application> {
|
||||
async init() {
|
||||
// Create the Express app
|
||||
const app = await this.createExpressApp();
|
||||
|
||||
|
|
@ -81,11 +100,12 @@ export class Initializer {
|
|||
|
||||
// Setup the router (how the app handles requests)
|
||||
if(typeof this.controllersPath !== 'undefined') {
|
||||
await (new Router(this.controllersPath)).setup(app);
|
||||
this.router = new Router(this.controllersPath);
|
||||
}
|
||||
else {
|
||||
await (new Router()).setup(app);
|
||||
this.router = new Router();
|
||||
}
|
||||
await this.router.setup(app);
|
||||
|
||||
// Setup the static file resolver (how the app serves static files)
|
||||
if(typeof this.staticFilesPath !== 'undefined') {
|
||||
|
|
@ -107,7 +127,5 @@ export class Initializer {
|
|||
else {
|
||||
await (new Renderer()).setup(app);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
|
@ -127,7 +127,7 @@ export class OAuthApp extends App {
|
|||
*
|
||||
* @param app The app to setup the OAuth client for
|
||||
*/
|
||||
private async setupOAuthClient(app: Application) {
|
||||
private async setupOAuthClient(app: App) {
|
||||
// If the base URL of the app isn't provided, get it from the environment variable
|
||||
let baseAppUrl = this.baseAppUrl;
|
||||
if(typeof baseAppUrl === 'undefined') {
|
||||
|
|
@ -142,7 +142,7 @@ export class OAuthApp extends App {
|
|||
|
||||
console.log(`Attempting to create/register the app:\n\tApp Base URL: ${baseAppUrl}\n\tApp Abbreviation: ${appAbbrv}\n\tApp Name: ${typeof this.appName === 'undefined' ? 'uses APP_NAME environment variable (' + process.env.APP_NAME + ')' : this.appName}\n\tScopes: ${typeof this.scopes === 'undefined' ? 'uses SCOPES environment variable (' + process.env.SCOPES + ')' : this.scopes.join(', ')}`);
|
||||
|
||||
await Client.setup(app, baseAppUrl, this.onAuth, this.saveSecret, appAbbrv, this.appName, this.scopes, {
|
||||
const client = await Client.setup(app.getExpressApp(), baseAppUrl, this.onAuth, this.saveSecret, appAbbrv, this.appName, this.scopes, {
|
||||
contacts: this.contacts,
|
||||
logo_url: this.logo_url,
|
||||
tos_url: this.tos_url,
|
||||
|
|
@ -153,6 +153,10 @@ export class OAuthApp extends App {
|
|||
auth_default_response_mode: this.auth_default_response_mode,
|
||||
client_secret: this.client_secret
|
||||
});
|
||||
|
||||
client.getSetupRoutes().forEach((route) => {
|
||||
app.getInitializer().getRouter().addOutsideFrameworkRoute(route);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -164,7 +168,7 @@ export class OAuthApp extends App {
|
|||
*
|
||||
* @param app The Express app object (that is now listening)
|
||||
*/
|
||||
async onStart(app: Application, callback?: (app: Application) => void | Promise<void>) {
|
||||
async onStart(app: App, callback?: (app: App) => void | Promise<void>) {
|
||||
try {
|
||||
// Setup the OAuth client.
|
||||
// This is done here because we need the client to be serving/listening for requests for the auth library stuff to work
|
||||
|
|
@ -192,7 +196,7 @@ export class OAuthApp extends App {
|
|||
* And doesn't have to worry about the OAuth details to make this work.
|
||||
* Though it does provide tweaking the OAuth details via options provided to the constructor.
|
||||
*/
|
||||
async run<T extends Initializer>(initializer?: T, callback?: (app: Application) => void | Promise<void>) {
|
||||
await super.run(initializer, async (app: Application) => this.onStart(app, callback));
|
||||
async run<T extends Initializer>(initializer?: T, callback?: (app: App) => void | Promise<void>) {
|
||||
await super.run(initializer, async (app: App) => this.onStart(app, callback));
|
||||
}
|
||||
}
|
||||
265
src/Router.ts
265
src/Router.ts
|
|
@ -3,6 +3,7 @@ import { Application } from 'express';
|
|||
import fse from 'fs-extra';
|
||||
|
||||
import { BaseController } from './controllers/BaseController';
|
||||
import { ErrorController } from './controllers/ErrorController';
|
||||
|
||||
/**
|
||||
* The Router sets up the routes/endpoints for the App.
|
||||
|
|
@ -19,6 +20,8 @@ export class Router {
|
|||
/** The path to the controllers folder */
|
||||
private controllersPath: string;
|
||||
|
||||
private outsideFrameworkRoutes: string[];
|
||||
|
||||
/**
|
||||
* Create a new Router
|
||||
*
|
||||
|
|
@ -31,6 +34,14 @@ export class Router {
|
|||
}
|
||||
|
||||
this.controllersPath = path.resolve(controllersPath);
|
||||
|
||||
this.outsideFrameworkRoutes = [];
|
||||
}
|
||||
|
||||
addOutsideFrameworkRoute(route: string) {
|
||||
if(!this.outsideFrameworkRoutes.includes(route)) {
|
||||
this.outsideFrameworkRoutes.push(route);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -38,26 +49,39 @@ export class Router {
|
|||
*
|
||||
* Note, if the specified "file" is a directory, this method will recurse into the directory and return all controllers found in the directory.
|
||||
*
|
||||
* It's worth noting that typing in this method is deliberate but confusing.
|
||||
* This is because decorators introduce a lot of complexity because they introduce this anonymous wrapper function/class around the original function/class.
|
||||
* However, because of how we typed the decorator, we can use `instanceof` and hiarchtical typing.
|
||||
* Though we do typing of variables that would otherwise be any and DON'T explicitly cast.
|
||||
*
|
||||
* @param file The file to check if it is a controller
|
||||
* @param folder The folder that the file is in
|
||||
* @returns The controller class (or list of controller classes) in the "file" or null if the file is not a controller
|
||||
* @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<any /*BaseController*/ | any[]/*BaseController[]*/ | null> {
|
||||
private async checkIfControllerFile(file: string, folder: string): Promise<{ controllers: BaseController | 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);
|
||||
|
||||
// Check if the path is a directory/folder
|
||||
// Check if the path is a directory/folder and recurse if it is
|
||||
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 controllersInDir = controllersFoundInDir.filter(controller => controller !== null);
|
||||
return controllersInDir.length === 0 ? null : controllersInDir.flat();
|
||||
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]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// We only want to load JavaScript files (Note, Typescript files get compiled to JavaScript files)
|
||||
if(!controllerPath.endsWith('.js')) {
|
||||
return null;
|
||||
return { controllers: null, errorControllers: null };
|
||||
}
|
||||
|
||||
// Load the file as a module
|
||||
|
|
@ -65,27 +89,57 @@ export class Router {
|
|||
|
||||
console.log(`${controllerPath} loaded successfully: ${JSON.stringify(Object.keys(controllerModule))} exports found.`)
|
||||
|
||||
// Get all the classes in the module
|
||||
const classes = Object.keys(controllerModule)
|
||||
.filter(key => typeof controllerModule[key] === 'function' && /^\s*class\s+/.test(controllerModule[key].toString()));
|
||||
|
||||
// Get all the classes in the module that are controllers
|
||||
//
|
||||
// Note: We filter by if the class extends the `BaseController` class.
|
||||
// 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 = Object.keys(controllerModule)
|
||||
.filter(key => typeof controllerModule[key] === 'function' && /^\s*class\s+/.test(controllerModule[key].toString()))
|
||||
const controllers: BaseController[] = classes
|
||||
.filter(exportedClassName => controllerModule[exportedClassName].prototype instanceof BaseController)
|
||||
.map(controllerClassName => {
|
||||
return controllerModule[controllerClassName];
|
||||
});
|
||||
|
||||
switch(controllers.length) {
|
||||
// Get all the classes in the module that are error controllers (these are subtly different from normal controllers)
|
||||
//
|
||||
// We do this here, instead of braking it out into it's own separate method for efficiency reasons.
|
||||
//
|
||||
// Note: We filter by if the class extends the `ErrorController` class.
|
||||
// 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
|
||||
.filter(exportedClassName => controllerModule[exportedClassName].prototype instanceof ErrorController)
|
||||
.map(controllerClassName => {
|
||||
return controllerModule[controllerClassName];
|
||||
});
|
||||
|
||||
let foundControllers;
|
||||
switch (controllers.length) {
|
||||
case 0:
|
||||
return null;
|
||||
foundControllers = null;
|
||||
case 1:
|
||||
return controllers[0];
|
||||
foundControllers = controllers[0];
|
||||
default:
|
||||
return controllers.flat();
|
||||
foundControllers = controllers.flat();
|
||||
}
|
||||
|
||||
let foundErrorControllers;
|
||||
switch (errorControllers.length) {
|
||||
case 0:
|
||||
foundErrorControllers = null;
|
||||
case 1:
|
||||
foundErrorControllers = errorControllers[0];
|
||||
default:
|
||||
foundErrorControllers = errorControllers.flat();
|
||||
}
|
||||
|
||||
return { controllers: foundControllers, errorControllers: foundErrorControllers };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -97,6 +151,11 @@ export class Router {
|
|||
* @returns The routes in the controller
|
||||
*/
|
||||
private getRoutesInController(controller: any) {
|
||||
if (typeof controller === 'undefined') {
|
||||
console.error(`Something went wrong while processing ${JSON.stringify(controller)}`);
|
||||
throw new Error('The controller must be a class');
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
|
@ -126,12 +185,89 @@ export class Router {
|
|||
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))
|
||||
.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;
|
||||
|
||||
return path;
|
||||
});
|
||||
|
||||
// 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))
|
||||
.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;
|
||||
return path;
|
||||
});
|
||||
|
||||
return {
|
||||
GET: pathsGET,
|
||||
POST: pathsPOST
|
||||
POST: pathsPOST,
|
||||
PUT: pathsPUT,
|
||||
DELETE: pathsDELETE
|
||||
}
|
||||
}
|
||||
|
||||
async processControllerRoutes(app: Application, decoratedController: any | any[], callSetup: boolean = true) {
|
||||
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());
|
||||
}
|
||||
else {
|
||||
const controller = Reflect.getMetadata('originalClass', decoratedController);
|
||||
|
||||
//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);
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`Something went wrong while processing ${JSON.stringify(controller)} (${JSON.stringify(decoratedController)})`);
|
||||
}
|
||||
|
||||
routes.GET.forEach((path) => {
|
||||
addedRoutes.push(`GET ${path} from ${(new controller()).constructor.name}`);
|
||||
});
|
||||
|
||||
routes.POST.forEach((path) => {
|
||||
addedRoutes.push(`POST ${path} from ${(new controller()).constructor.name}`);
|
||||
});
|
||||
|
||||
routes.PUT.forEach((path) => {
|
||||
addedRoutes.push(`PUT ${path} from ${(new controller()).constructor.name}`);
|
||||
});
|
||||
|
||||
routes.DELETE.forEach((path) => {
|
||||
addedRoutes.push(`DELETE ${path} from ${(new controller()).constructor.name}`);
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
return addedRoutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the controllers for the application
|
||||
*
|
||||
|
|
@ -147,11 +283,14 @@ export class Router {
|
|||
const files = await fse.readdir(this.controllersPath);
|
||||
|
||||
// Get the controller classes from the files
|
||||
const loadedControllers = (await Promise.all(
|
||||
files.map(file => this.checkIfControllerFile(file, this.controllersPath/*, app*/))
|
||||
)).filter(controller => controller !== null);
|
||||
const loadedControllerObjects = (await Promise.all(files.map(async (file) => (await this.checkIfControllerFile(file, this.controllersPath /*, app*/)))));
|
||||
|
||||
const addedRoutes: string[] = [];
|
||||
const loadedControllers: BaseController[] = loadedControllerObjects
|
||||
.map(loadedControllerObjects => loadedControllerObjects.controllers)
|
||||
.filter(controller => controller !== null)
|
||||
.flat();
|
||||
|
||||
let addedRoutes: string[] = [];
|
||||
|
||||
// Get all controllers that have the child controller decorator
|
||||
const controllersWithChildControllers = loadedControllers.filter(controller => Reflect.getMetadata('ChildController', controller) !== undefined);
|
||||
|
|
@ -166,69 +305,51 @@ export class Router {
|
|||
// And forma a list that has all child controllers remove
|
||||
const topLevelControllers = [...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)
|
||||
childrenControllers
|
||||
.forEach(decoratedController => {
|
||||
if(Array.isArray(decoratedController)) {
|
||||
decoratedController.forEach(decoratedControllerCls => {
|
||||
const controller = Reflect.getMetadata('originalClass', decoratedControllerCls);
|
||||
addedRoutes = addedRoutes.concat((await Promise.all(childrenControllers.map(async (decoratedController) => await this.processControllerRoutes(app, decoratedController, false)))).flat());
|
||||
|
||||
const routes = this.getRoutesInController(controller);
|
||||
routes.GET.forEach((path: string) => {
|
||||
addedRoutes.push(`GET ${path} from ${(new controller()).constructor.name}`);
|
||||
});
|
||||
routes.POST.forEach((path: string) => {
|
||||
addedRoutes.push(`POST ${path} from ${(new controller()).constructor.name}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
else {
|
||||
const controller = Reflect.getMetadata('originalClass', decoratedController);
|
||||
|
||||
const routes = this.getRoutesInController(controller);
|
||||
routes.GET.forEach((path: string) => {
|
||||
addedRoutes.push(`GET ${path} from ${(new controller()).constructor.name}`);
|
||||
});
|
||||
routes.POST.forEach((path: string) => {
|
||||
addedRoutes.push(`POST ${path} from ${(new controller()).constructor.name}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
//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
|
||||
topLevelControllers
|
||||
.forEach(decoratedController => {
|
||||
if(Array.isArray(decoratedController)) {
|
||||
decoratedController.forEach(decoratedControllerCls => {
|
||||
const controller = Reflect.getMetadata('originalClass', decoratedControllerCls);
|
||||
|
||||
const routes = this.getRoutesInController(controller);
|
||||
routes.GET.forEach((path: string) => {
|
||||
addedRoutes.push(`GET ${path} from ${(new controller()).constructor.name}`);
|
||||
});
|
||||
routes.POST.forEach((path: string) => {
|
||||
addedRoutes.push(`POST ${path} from ${(new controller()).constructor.name}`);
|
||||
});
|
||||
|
||||
decoratedControllerCls.setup(app);
|
||||
});
|
||||
}
|
||||
else {
|
||||
const controller = Reflect.getMetadata('originalClass', decoratedController);
|
||||
|
||||
const routes = this.getRoutesInController(controller);
|
||||
routes.GET.forEach((path: string) => {
|
||||
addedRoutes.push(`GET ${path} from ${(new controller()).constructor.name}`);
|
||||
});
|
||||
routes.POST.forEach((path: string) => {
|
||||
addedRoutes.push(`POST ${path} from ${(new controller()).constructor.name}`);
|
||||
});
|
||||
(decoratedController as any).setup(app);
|
||||
}
|
||||
});
|
||||
addedRoutes = addedRoutes.concat((await Promise.all(topLevelControllers.map(async (decoratedController) => await this.processControllerRoutes(app, decoratedController)))).flat());
|
||||
|
||||
console.log('Routes added:');
|
||||
addedRoutes.forEach(route => console.log('\t' + route));
|
||||
|
||||
// Adding a GET handler/middleware to handle 404 errors
|
||||
// This is done by adding this as the last path (after all other routes)
|
||||
app.get('*', (req, res, next) => {
|
||||
// Check if the request path is in the list of outside framework routes
|
||||
// This is used to allow routes outside the framework to work without getting a 404 error
|
||||
if(this.outsideFrameworkRoutes.includes(req.path)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
res.status(404);
|
||||
next('Page Not Found');
|
||||
});
|
||||
|
||||
const loadedErrorControllers = loadedControllerObjects
|
||||
.map(loadedControllerObject => loadedControllerObject.errorControllers)
|
||||
.filter(controller => controller !== null)
|
||||
.flat()
|
||||
.map(ErrorControllerClassConstructor => new ErrorControllerClassConstructor());
|
||||
|
||||
let handledErrors: string[] = [];
|
||||
loadedErrorControllers.forEach(errorController => {
|
||||
if (typeof errorController.handle === 'undefined') {
|
||||
console.error(`Something went wrong while processing ${JSON.stringify(Object.entries(errorController))}`);
|
||||
return;
|
||||
}
|
||||
|
||||
app.use(errorController.handle.bind(errorController));
|
||||
handledErrors.push(errorController.handlesError ?? '');
|
||||
});
|
||||
|
||||
console.log('Error controllers added:');
|
||||
handledErrors.forEach(error => console.log('\t' + error));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { BaseController } from '../controllers/BaseController';
|
||||
|
||||
export const CHILD_CONTROLLER_METADATA_KEY = 'ChildController';
|
||||
|
||||
export function ChildController<T extends { new(...args: any[]): {} }>(childController: BaseController | BaseController[]) {
|
||||
return function (target: T) {
|
||||
// Define a metadata key for the child controller
|
||||
Reflect.defineMetadata('ChildController', childController, target);
|
||||
Reflect.defineMetadata(CHILD_CONTROLLER_METADATA_KEY, childController, target);
|
||||
};
|
||||
}
|
||||
|
|
@ -2,21 +2,23 @@ import { Application, NextFunction } from 'express';
|
|||
|
||||
import { BaseController } from '../controllers/BaseController';
|
||||
|
||||
import { CHILD_CONTROLLER_METADATA_KEY } from './ChildController';
|
||||
import { GET_METADATA_KEY } from './GET';
|
||||
import { POST_METADATA_KEY } from './POST';
|
||||
import { PUT_METADATA_KEY } from './PUT';
|
||||
import { DELETE_METADATA_KEY } from './DELETE';
|
||||
|
||||
/**
|
||||
* Class decorator to "fill in" the setup method which is called on server startup and connects the methods/functions and their desired paths in the express app.
|
||||
*
|
||||
* That is, this is largely for the convenience of controller writers/developers so that they don't have to manually create a `setup` method in their controller classes.
|
||||
*
|
||||
* @example
|
||||
* The following example show how to use the Controller decorator to setup a path `/path` with a GET and POST method.
|
||||
* The following example show how to use the Controller decorator to setup a path `/path` with a GET, POST, PUT and DELETE method.
|
||||
* ```ts
|
||||
* import { Request, Response } from 'express';
|
||||
*
|
||||
* import { Controller } from '../decorators/Controller';
|
||||
* import { GET } from '../decorators/GET';
|
||||
* import { POST } from '../decorators/POST';
|
||||
*
|
||||
* import { BaseController } from './BaseController';
|
||||
* import { Controller, GET, POST, BaseController } from '@BridgemanAccessible/ba-web-framework';
|
||||
*
|
||||
* @Controller()
|
||||
* export class MyController extends BaseController {
|
||||
|
|
@ -25,6 +27,12 @@ import { BaseController } from '../controllers/BaseController';
|
|||
*
|
||||
* @POST('/path', express.json())
|
||||
* private myPostMethod(req: Request, res: Response) {}
|
||||
*
|
||||
* @PUT('/path', express.json())
|
||||
* private myPutMethod(req: Request, res: Response) {}
|
||||
*
|
||||
* @DELETE('/path', express.json())
|
||||
* private myDeleteMethod(req: Request, res: Response) {}
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
|
@ -45,7 +53,7 @@ export function Controller<T extends { new (...args: any[]): BaseController }>()
|
|||
//
|
||||
// Currently, there is very little linkage between the decorated controller class and the child controller class(es).
|
||||
// Though in the future this may look more like forcing these to be on sub-paths of the parent controller etc...
|
||||
const childControllers = Reflect.getMetadata('ChildController', target);
|
||||
const childControllers = Reflect.getMetadata(CHILD_CONTROLLER_METADATA_KEY, target);
|
||||
if(typeof childControllers !== 'undefined') {
|
||||
if(Array.isArray(childControllers)) {
|
||||
childControllers.forEach((childController) => {
|
||||
|
|
@ -61,13 +69,13 @@ export function Controller<T extends { new (...args: any[]): BaseController }>()
|
|||
// Loop over all the methods in the decorated class looking for methods that use the GET decorator
|
||||
Object.getOwnPropertyNames(target.prototype)
|
||||
// Find all methods that have a Get metadata key (GET decorator)
|
||||
.filter((method) => Reflect.getMetadata('Get', target.prototype, method))
|
||||
.filter((method) => Reflect.getMetadata(GET_METADATA_KEY, target.prototype, method))
|
||||
.map((method) => {
|
||||
// Get the method
|
||||
const fn = target.prototype[method];
|
||||
|
||||
// Get the path
|
||||
const path = Reflect.getMetadata('Get', target.prototype, method);
|
||||
const path = Reflect.getMetadata(GET_METADATA_KEY, target.prototype, method);
|
||||
|
||||
// Bind the method to the class instance
|
||||
app.get(path, fn.bind(controller));
|
||||
|
|
@ -76,19 +84,53 @@ export function Controller<T extends { new (...args: any[]): BaseController }>()
|
|||
// Loop over all the methods in the decorated class looking for methods that use the POST decorator
|
||||
Object.getOwnPropertyNames(target.prototype)
|
||||
// Find all methods that have a Post metadata key (POST decorator)
|
||||
.filter((method) => Reflect.getMetadata('Post', target.prototype, method))
|
||||
.filter((method) => Reflect.getMetadata(POST_METADATA_KEY, target.prototype, method))
|
||||
.map((method) => {
|
||||
// Get the method
|
||||
const fn = target.prototype[method];
|
||||
|
||||
// Get the metadata object (which contains a path and middleware)
|
||||
const postRoute = Reflect.getMetadata('Post', target.prototype, method);
|
||||
const postRoute = Reflect.getMetadata(POST_METADATA_KEY, target.prototype, method);
|
||||
const path = postRoute.path;
|
||||
const middleware: NextFunction[] = postRoute.middleware;
|
||||
|
||||
// Bind the method to the class instance
|
||||
app.post(path, ...middleware, fn.bind(controller));
|
||||
});
|
||||
|
||||
// Loop over all the methods in the decorated class looking for methods that use the `@PUT` decorator
|
||||
Object.getOwnPropertyNames(target.prototype)
|
||||
// Find all methods that have the associated metadata key (`@PUT` decorator)
|
||||
.filter((method) => Reflect.getMetadata(PUT_METADATA_KEY, target.prototype, method))
|
||||
.map((method) => {
|
||||
// Get the method
|
||||
const fn = target.prototype[method];
|
||||
|
||||
// Get the metadata object (which contains a path and middleware)
|
||||
const putRoute = Reflect.getMetadata(PUT_METADATA_KEY, target.prototype, method);
|
||||
const path = putRoute.path;
|
||||
const middleware = putRoute.middleware;
|
||||
|
||||
// Bind the method to the class instance
|
||||
app.put(path, ...middleware, fn.bind(controller));
|
||||
});
|
||||
|
||||
// Loop over all the methods in the decorated class looking for methods that use the `@DELETE` decorator
|
||||
Object.getOwnPropertyNames(target.prototype)
|
||||
// Find all methods that have the associated metadata key (`@DELETE` decorator)
|
||||
.filter((method) => Reflect.getMetadata(DELETE_METADATA_KEY, target.prototype, method))
|
||||
.map((method) => {
|
||||
// Get the method
|
||||
const fn = target.prototype[method];
|
||||
|
||||
// Get the metadata object (which contains a path and middleware)
|
||||
const deleteRoute = Reflect.getMetadata(DELETE_METADATA_KEY, target.prototype, method);
|
||||
const path = deleteRoute.path;
|
||||
const middleware = deleteRoute.middleware;
|
||||
|
||||
// Bind the method to the class instance
|
||||
app.delete(path, ...middleware, fn.bind(controller));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
export const GET_METADATA_KEY = 'Get';
|
||||
|
||||
/**
|
||||
* Method decorator intended to be used on methods of a class decorated with the `@Controller` decorator.
|
||||
*
|
||||
|
|
@ -9,6 +11,6 @@
|
|||
export function GET(path: string) {
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
// Define a `Get` metadata key with the path as the value on the target's propertyKey
|
||||
Reflect.defineMetadata('Get', path, target, propertyKey);
|
||||
Reflect.defineMetadata(GET_METADATA_KEY, path, target, propertyKey);
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { NextFunction } from 'express';
|
||||
import { NextHandleFunction } from 'connect';
|
||||
|
||||
export const POST_METADATA_KEY = 'Put';
|
||||
|
||||
/**
|
||||
* Method decorator intended to be used on methods of a class decorated with the `@Controller` decorator.
|
||||
*
|
||||
|
|
@ -14,6 +16,6 @@ import { NextHandleFunction } from 'connect';
|
|||
export function POST(path: string, ...middleware: (NextHandleFunction | NextFunction)[]) {
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
// Define a `Get` metadata key with the path as the value on the target's propertyKey
|
||||
Reflect.defineMetadata('Post', { path, middleware }, target, propertyKey);
|
||||
Reflect.defineMetadata(POST_METADATA_KEY, { path, middleware }, target, propertyKey);
|
||||
};
|
||||
}
|
||||
|
|
@ -1,10 +1,50 @@
|
|||
import { Request, Response } from 'express';
|
||||
|
||||
// Response type guard
|
||||
function isResponse(obj) {
|
||||
return obj && typeof obj.render === 'function';
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Page` decorator allows for easy rendering of a page.
|
||||
*
|
||||
* Note, that the parameters for the function the page decorator is placed on is not restrictive.
|
||||
* This is because JavaScript doesn't have the idea of named arguments so a request handler like:
|
||||
*
|
||||
* ```ts
|
||||
* function handle(req: Request, res: Response)
|
||||
* ```
|
||||
*
|
||||
* Is different from a middleware handler like:
|
||||
*
|
||||
* ```ts
|
||||
* function handle(req: Request, res: Response, next: NextFunction)
|
||||
* ```
|
||||
*
|
||||
* Is different from an error handler like:
|
||||
*
|
||||
* ```ts
|
||||
* function handle(err: Error, req: Request, res: Response, next: NextFunction)
|
||||
* ```
|
||||
*
|
||||
* Despite them all being, more or less, equivalent for the page decorator's usage.
|
||||
*
|
||||
* @param title The title of the page
|
||||
* @param page The name of the page file to render
|
||||
* @param extraScripts Any extra scripts to include in the page
|
||||
* @param extraStyles Any extra styles to include in the page
|
||||
* @param otherParams Any other parameters to pass to the page
|
||||
*/
|
||||
export function Page(title: string, page: string, extraScripts: (string | { script: string, defer: boolean })[] = [], extraStyles: string[] = [], ...otherParams: any[]) {
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const original = descriptor.value;
|
||||
|
||||
descriptor.value = async function (req: Request, res: Response) {
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
if (!args.some(arg => isResponse(arg)) || args.filter(arg => isResponse(arg)).length > 1) {
|
||||
console.warn(`Page decorator: Incorrect number of response objects found in arguments for ${propertyKey}. Should ONLY be one response object.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// We run the original here so that if the decorated method has specific checks it needs to make (ex. if the ID of whatever actually exists) it can make them before rendering the page
|
||||
//
|
||||
// !!!IMPORTANT!!!
|
||||
|
|
@ -14,7 +54,7 @@ export function Page(title: string, page: string, extraScripts: (string | { scri
|
|||
//
|
||||
// The above (this approach) is necessary at time of writing because the `this` (which should be bound to for the instance) seems to be undefined within the context of the decorator function here for some reason
|
||||
// Ideally, would dive into this and do it properly but this seems to be good enough for now.
|
||||
const output = await original.bind(target)(req, res);
|
||||
const output = await original.bind(target)(...args);
|
||||
|
||||
// If the output is false, we don't want to render the page
|
||||
//
|
||||
|
|
@ -39,7 +79,7 @@ export function Page(title: string, page: string, extraScripts: (string | { scri
|
|||
});
|
||||
}
|
||||
|
||||
res.render('base', renderParams);
|
||||
args.find(arg => isResponse(arg)).render('base', renderParams);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,11 +3,17 @@ import { ChildController } from './ChildController';
|
|||
import { Page } from './Page';
|
||||
import { GET } from './GET';
|
||||
import { POST } from './POST';
|
||||
import { PUT } from './PUT';
|
||||
import { DELETE } from './DELETE';
|
||||
import { ErrorHandler } from './ErrorHandler';
|
||||
|
||||
export {
|
||||
Controller,
|
||||
ChildController,
|
||||
Page,
|
||||
GET,
|
||||
POST
|
||||
}
|
||||
POST,
|
||||
PUT,
|
||||
DELETE,
|
||||
ErrorHandler
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { globalTemplateValues } from './GlobalTemplateValuesMiddleware';
|
||||
import { HealthCheckStatus, HealthCheckMiddleware, healthCheckMiddleware } from './HealthCheckMiddleware';
|
||||
|
||||
export {
|
||||
globalTemplateValues
|
||||
}
|
||||
globalTemplateValues,
|
||||
HealthCheckStatus,
|
||||
HealthCheckMiddleware,
|
||||
healthCheckMiddleware
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue