typenexus
Version:
TypeNexus is a good tool for API encapsulation and management. It provides a clean and lightweight way to package TypeORM functionality, helping you build applications faster while reducing template code redundancy and type conversion work.
491 lines (454 loc) • 18.7 kB
text/typescript
import express, { Express, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { TypeormStore } from 'connect-typeorm';
import { parse as cookieParse } from 'cookie';
import multerFn, { DiskStorageOptions, Options } from 'multer';
import bodyParser from 'body-parser';
import compression from 'compression';
import session from 'express-session';
import cors from 'cors';
import pathTemplater from 'path-templater';
import { ActionMetadata } from './metadata/ActionMetadata.js';
import { AuthorizationRequiredError } from './http-error/AuthorizationRequiredError.js';
import { AccessDeniedError } from './http-error/AccessDeniedError.js';
import { HttpError } from './http-error/HttpError.js';
import { MiddlewareMetadata } from './metadata/MiddlewareMetadata.js';
import { ParamMetadata } from './metadata/ParamMetadata.js';
import { UseMetadata } from './metadata/UseMetadata.js';
import { TypeNexusOptions, ExpressMiddlewareInterface, ExpressErrorMiddlewareInterface } from './DriverOptions.js';
import { getFromContainer } from './metadata/ControllerMetadata.js';
import { isPromiseLike } from './utils/isPromiseLike.js';
import { Action } from './Action.js';
import { AuthorizationCheckerNotDefinedError } from './http-error/AuthorizationCheckerNotDefinedError.js';
import { NotFoundError } from './http-error/NotFoundError.js';
export abstract class Driver {
/**
* Global application prefix.
*/
public routePrefix: string = '';
/**
* DataSource is a pre-defined connection configuration to a specific database.
* You can have multiple data sources connected (with multiple connections in it), connected to multiple databases in your application.
*/
public dataSource: DataSource;
public app: Express = express();
public express: Express = this.app;
/**
* Special function used to check user authorization roles per request.
* Must return true or promise with boolean true resolved for authorization to succeed.
*/
public authorizationChecker?: TypeNexusOptions['authorizationChecker'];
/**
* Special function used to get currently authorized user.
*/
public currentUserChecker?: TypeNexusOptions['currentUserChecker'];
constructor(
portOrOptions: number | TypeNexusOptions = 3000,
public options: TypeNexusOptions = {},
) {
if (typeof portOrOptions === 'object') {
this.options = portOrOptions;
}
if (this.options.cors) {
this.express.use(this.options.cors === true ? cors() : cors(this.options.cors));
}
this.routePrefix = options?.routePrefix || this.routePrefix;
this.options.port = typeof portOrOptions === 'number' ? portOrOptions : options.port;
this.options.port && this.express.set('port', this.options.port);
this.options.defaultErrorHandler = options.defaultErrorHandler !== undefined ? options.defaultErrorHandler : true;
this.currentUserChecker = this.options.currentUserChecker || this.currentUserChecker;
this.authorizationChecker = this.options.authorizationChecker || this.authorizationChecker;
if (this.options.bodyParser?.json !== false) {
this.app.use(express.json(this.options.bodyParser?.json));
}
if (this.options.bodyParser?.text) {
this.app.use(express.text(this.options.bodyParser?.text));
}
if (this.options.bodyParser?.raw) {
this.app.use(express.raw(this.options.bodyParser?.raw));
}
if (this.options.compression !== false) {
this.app.use(compression(this.options.compression));
}
if (this.options.bodyParser?.urlencoded !== false) {
this.app.use(express.urlencoded({ extended: false, ...this.options.bodyParser?.urlencoded }));
}
}
public async registerSession() {
if (this.options?.session) {
let sessionOptions =
typeof this.options?.session == 'function'
? this.options?.session({ app: this, dataSource: this.dataSource })
: this.options?.session;
if (sessionOptions.repositoryTarget) {
const reps = this.dataSource.getRepository(sessionOptions.repositoryTarget);
const typeormStoreOptions = {
// limitSubquery: false, // If using MariaDB.
cleanupLimit: 2,
ttl: 86400,
...sessionOptions.typeormStore,
};
sessionOptions.store = new TypeormStore(typeormStoreOptions).connect(reps);
}
this.app.use(session(sessionOptions));
}
}
public registerAction(actionMetadata: ActionMetadata, executeCallback: (options: Action) => any) {
// middlewares required for this action
const defaultMiddleware: ((request: Request, response: Response, next: NextFunction) => void)[] = [];
if (actionMetadata.isBodyUsed) {
if (actionMetadata.isJsonTyped) {
defaultMiddleware.push(bodyParser.json(actionMetadata.bodyExtraOptions));
} else {
defaultMiddleware.push(bodyParser.text(actionMetadata.bodyExtraOptions));
}
}
if (actionMetadata.isAuthorizedUsed) {
defaultMiddleware.push((request, response, next) => {
const action: Action = { request, response, next, dataSource: this.dataSource };
try {
if (!this.authorizationChecker) throw new AuthorizationCheckerNotDefinedError();
const checkResult = this.authorizationChecker(action, actionMetadata.authorizedRoles);
const handleError = (result: any) => {
if (!result) {
const error =
actionMetadata.authorizedRoles.length === 0
? new AuthorizationRequiredError(action)
: new AccessDeniedError(action);
this.handleError(error, actionMetadata, action);
} else {
next();
}
};
if (isPromiseLike(checkResult)) {
checkResult
.then((result) => handleError(result))
.catch((error) => this.handleError(error, actionMetadata, action));
} else {
handleError(checkResult);
}
} catch (error) {
this.handleError(error, actionMetadata, action);
}
});
}
if (actionMetadata.isFileUsed || actionMetadata.isFilesUsed) {
actionMetadata.params
.filter((param) => param.type === 'file')
.forEach((param) => {
defaultMiddleware.push(multerFn(param.extraOptions).single(param.name));
});
actionMetadata.params
.filter((param) => param.type === 'files')
.forEach((param) => {
defaultMiddleware.push(multerFn(param.extraOptions).array(param.name));
});
}
// user used middlewares
const uses = [...actionMetadata.controllerMetadata.uses, ...actionMetadata.uses];
const beforeMiddlewares = this.prepareMiddlewares(uses.filter((use) => !use.afterAction));
const afterMiddlewares = this.prepareMiddlewares(uses.filter((use) => use.afterAction));
// prepare route and route handler function
const route = ActionMetadata.appendBaseRoute(this.routePrefix, actionMetadata.fullRoute);
const routeHandler = (request: Request, response: Response, next: NextFunction) => {
return executeCallback({ request, response, next, dataSource: this.dataSource });
};
// This ensures that a request is only processed once to prevent unhandled rejections saying
// "Can't set headers after they are sent"
// Some examples of reasons a request may cause multiple route calls:
// * Express calls the "get" route automatically when we call the "head" route:
// Reference: https://expressjs.com/en/4x/api.html#router.METHOD
// This causes a double execution on our side.
// * Multiple routes match the request (e.g. GET /users/me matches both @All(/users/me) and @Get(/users/:id)).
// The following middleware only starts an action processing if the request has not been processed before.
const routeGuard = function routeGuard(request: any, response: any, next: Function) {
if (!request.routingControllersStarted) {
request.routingControllersStarted = true;
return next();
}
};
this.app[actionMetadata.type.toLowerCase() as keyof Express](
...[route, routeGuard, ...beforeMiddlewares, ...defaultMiddleware, routeHandler, ...afterMiddlewares],
);
}
/**
* Handles result of successfully executed controller action.
*/
public handleSuccess(result: any, action: ActionMetadata, options: Action) {
/**
* HTTP HEAD requests are a type of request similar to GET requests,
* but the server does not return the entity body of the requested resource.
* Instead, it only returns the metadata of the requested resource,
* such as the response header information. Therefore,
* it is often used to retrieve metadata of resources,
* such as whether a resource exists or its last modification time.
*/
if (result && result === options.response) {
options.next!();
return;
}
if (result === undefined && action.undefinedResultCode) {
if (action.undefinedResultCode instanceof Function) {
throw new (action.undefinedResultCode as any)(options);
}
options.response.status(action.undefinedResultCode);
} else if (result === null) {
if (action.nullResultCode) {
if (action.nullResultCode instanceof Function) {
throw new (action.nullResultCode as any)(options);
}
options.response.status(action.nullResultCode);
} else {
options.response.status(204);
}
} else if (action.successHttpCode) {
options.response.status(action.successHttpCode);
}
// apply http headers
Object.keys(action.headers).forEach((name) => {
options.response.header(name, action.headers[name]);
});
if (action.redirect) {
// if redirect is set then do it
if (typeof result === 'string') {
options.response.redirect(result);
} else if (result instanceof Object) {
options.response.redirect(pathTemplater(action.redirect, result));
} else {
options.response.redirect(action.redirect);
}
options.next();
} else if (action.renderedTemplate) {
// if template is set then render it
const renderOptions = result && result instanceof Object ? result : {};
options.response.render(action.renderedTemplate, renderOptions, (err: any, html: string) => {
if (err && action.isJsonTyped) {
return options.next(err);
} else if (err && !action.isJsonTyped) {
return options.next(err);
} else if (html) {
options.response.send(html);
}
options.next();
});
} else if (result === undefined) {
// throw NotFoundError on undefined response
if (action.undefinedResultCode) {
if (action.isJsonTyped) {
options.response.json();
} else {
options.response.send();
}
options.next();
} else {
throw new NotFoundError();
}
} else if (result === null) {
// send null response
if (action.isJsonTyped) {
options.response.json(null);
} else {
options.response.send(null);
}
options.next();
} else if (result instanceof Buffer) {
// check if it's binary data (Buffer)
options.response.end(result, 'binary');
} else if (result instanceof Uint8Array) {
// check if it's binary data (typed array)
options.response.end(Buffer.from(result as any), 'binary');
} else if (result.pipe instanceof Function) {
result.pipe(options.response);
} else {
if (action.isJsonTyped) {
options.response.json(result);
} else {
options.response.send(result);
}
options.next!();
}
}
/**
* Handles result of failed executed controller action.
*/
handleError(error: any, action: ActionMetadata | undefined, options: Action): any {
if (this.options.defaultErrorHandler) {
const response = options.response;
// set http code
// note that we can't use error instanceof HttpError properly anymore because of new typescript emit process
if (error.httpCode) {
response.status(error.httpCode);
} else {
response.status(500);
}
// send error content
if (action && action.isJsonTyped) {
response.json(this.processJsonError(error));
} else {
response.send(this.processTextError(error)); // todo: no need to do it because express by default does it
}
} else {
options.next!(error);
}
}
/**
* Gets param from the request.
*/
getParamFromRequest(action: Action, param: ParamMetadata): any {
const request: any = action.request;
switch (param.type) {
case 'body':
return request.body;
case 'param':
return request.params[param.name];
case 'params':
return request.params;
case 'query':
return request.query[param.name];
case 'queries':
return request.query;
case 'body-param':
return request.body[param.name];
case 'session-param':
return request.session[param.name];
case 'session':
return request.session;
case 'header':
return request.headers[param.name.toLowerCase()];
case 'headers':
return request.headers;
case 'file':
return request.file;
case 'files':
return request.files;
case 'cookie':
if (!request.headers?.cookie) return;
const cookies = cookieParse(request.headers.cookie);
return cookies[param.name];
case 'cookies':
if (!request.headers?.cookie) return {};
return cookieParse(request.headers.cookie);
}
}
/**
* Creates middlewares from the given "use"-s.
*/
protected prepareMiddlewares(uses: UseMetadata[]) {
const middlewareFunctions: Function[] = [];
uses.forEach((use: UseMetadata) => {
if (use.middleware.prototype && use.middleware.prototype.use) {
// if this is function instance of MiddlewareInterface
middlewareFunctions.push((request: Request, response: Response, next: NextFunction) => {
try {
const useResult = getFromContainer<ExpressMiddlewareInterface>(use.middleware).use(request, response, next);
if (isPromiseLike(useResult)) {
useResult.catch((error: any) => {
this.handleError(error, undefined, { request, response, next, dataSource: this.dataSource });
return error;
});
}
return useResult;
} catch (error) {
this.handleError(error, undefined, { request, response, next, dataSource: this.dataSource });
}
});
} else if (use.middleware.prototype && use.middleware.prototype.error) {
// if this is function instance of ErrorMiddlewareInterface
middlewareFunctions.push(function (error: any, request: Request, response: Response, next: NextFunction) {
return getFromContainer<ExpressErrorMiddlewareInterface>(use.middleware).error(
error,
request,
response,
next,
);
});
} else {
middlewareFunctions.push(use.middleware);
}
});
return middlewareFunctions;
}
/**
* Registers middleware that run before controller actions.
*/
registerMiddleware(middleware: MiddlewareMetadata, options: TypeNexusOptions): void {
let middlewareWrapper;
// if its an error handler then register it with proper signature in express
if ((middleware.instance as ExpressErrorMiddlewareInterface).error) {
middlewareWrapper = (error: any, request: any, response: any, next: NextFunction) => {
(middleware.instance as ExpressErrorMiddlewareInterface).error(error, request, response, next);
};
}
// if its a regular middleware then register it as express middleware
else if ((middleware.instance as ExpressMiddlewareInterface).use) {
middlewareWrapper = (request: Request, response: Response, next: NextFunction) => {
try {
const useResult = (middleware.instance as ExpressMiddlewareInterface).use(request, response, next);
if (isPromiseLike(useResult)) {
useResult.catch((error: any) => {
this.handleError(error, undefined, { request, response, next, dataSource: this.dataSource });
return error;
});
}
} catch (error) {
this.handleError(error, undefined, { request, response, next, dataSource: this.dataSource });
}
};
}
if (middlewareWrapper) {
// Name the function for better debugging
Object.defineProperty(middlewareWrapper, 'name', {
value: middleware.instance.constructor.name,
writable: true,
});
this.express.use(options.routePrefix || '/', middlewareWrapper);
}
}
protected processJsonError(error: any) {
if (!this.options.defaultErrorHandler) return error;
if (typeof error.toJSON === 'function') return error.toJSON();
let processedError: any = {};
if (error instanceof Error) {
const name = error.name && error.name !== 'Error' ? error.name : error.constructor.name;
processedError.name = name;
if (error.message) processedError.message = error.message;
if (error.stack && this.options.developmentMode) processedError.stack = error.stack;
Object.keys(error)
.filter(
(key) =>
key !== 'stack' &&
key !== 'name' &&
key !== 'message' &&
(!(error instanceof HttpError) || key !== 'httpCode'),
)
.forEach((key) => (processedError[key] = (error as any)[key]));
return Object.keys(processedError).length > 0 ? processedError : undefined;
}
return error;
}
protected processTextError(error: any) {
if (!this.options.defaultErrorHandler) return error;
if (error instanceof Error) {
if (this.options.developmentMode && error.stack) {
return error.stack;
} else if (error.message) {
return error.message;
}
}
return error;
}
protected merge(obj1: any, obj2: any): any {
const result: any = {};
for (const i in obj1) {
if (i in obj2 && typeof obj1[i] === 'object' && i !== null) {
result[i] = this.merge(obj1[i], obj2[i]);
} else {
result[i] = obj1[i];
}
}
for (const i in obj2) {
result[i] = obj2[i];
}
return result;
}
}