diff --git a/package.json b/package.json index 653f7326..26ed5710 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ }, "private": true, "dependencies": { + "@koa/cors": "^3.3.0", "dayjs": "^1.11.2", "glob": "^8.0.1", "joi": "^17.6.0", @@ -15,9 +16,11 @@ "koa-bodyparser": "^4.3.0", "koa-compose": "^4.1.0", "koa-router": "^10.1.1", + "koa2-ratelimit": "^1.1.1", "lodash": "^4.17.21", "nunjucks": "^3.2.3", "tslib": "^2.3.0", + "tslog": "^3.3.3", "uuid": "^8.3.2" }, "devDependencies": { @@ -34,6 +37,8 @@ "@types/koa": "^2.13.4", "@types/koa-compose": "^3.2.5", "@types/koa-router": "^7.4.4", + "@types/koa2-ratelimit": "^0.9.3", + "@types/koa__cors": "^3.3.0", "@types/lodash": "^4.14.182", "@types/node": "16.11.7", "@types/supertest": "^2.0.12", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 59abe8c2..58e2c388 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ +export * from './lib/utils'; export * from './lib/validators'; // Export all other modules export * from './models'; diff --git a/packages/core/src/lib/utils/index.ts b/packages/core/src/lib/utils/index.ts index 36d1dbe7..e6bc2a05 100644 --- a/packages/core/src/lib/utils/index.ts +++ b/packages/core/src/lib/utils/index.ts @@ -1 +1,3 @@ export * from './normalizedStringValue'; +export * from './logger'; +export * from './module'; diff --git a/packages/core/src/lib/utils/logger.ts b/packages/core/src/lib/utils/logger.ts new file mode 100644 index 00000000..c698eb62 --- /dev/null +++ b/packages/core/src/lib/utils/logger.ts @@ -0,0 +1,115 @@ +import { Logger } from 'tslog'; +import { AsyncLocalStorage } from 'async_hooks'; +export { Logger as ILogger }; +// The category according to package name +export enum LoggingScope { + CORE = 'CORE', + BUILD = 'BUILD', + SERVE = 'SERVE', + AUDIT = 'AUDIT', +} + +type LoggingScopeTypes = keyof typeof LoggingScope; + +export enum LoggingLevel { + SILLY = 'silly', + TRACE = 'trace', + DEBUG = 'debug', + INFO = 'info', + WARN = 'warn', + ERROR = 'error', + FATAL = 'fatal', +} + +export interface LoggerOptions { + level?: LoggingLevel; + displayRequestId?: boolean; +} + +type LoggerMapConfig = { + [scope in LoggingScope]: LoggerOptions; +}; + +const defaultMapConfig: LoggerMapConfig = { + [LoggingScope.CORE]: { + level: LoggingLevel.DEBUG, + displayRequestId: false, + }, + [LoggingScope.BUILD]: { + level: LoggingLevel.DEBUG, + displayRequestId: false, + }, + [LoggingScope.SERVE]: { + level: LoggingLevel.DEBUG, + displayRequestId: false, + }, + [LoggingScope.AUDIT]: { + level: LoggingLevel.DEBUG, + displayRequestId: false, + }, +}; + +export type AsyncRequestIdStorage = AsyncLocalStorage<{ requestId: string }>; +class LoggerFactory { + private loggerMap: { [scope: string]: Logger }; + public readonly asyncReqIdStorage: AsyncRequestIdStorage; + + constructor() { + this.asyncReqIdStorage = new AsyncLocalStorage(); + + this.loggerMap = { + [LoggingScope.CORE]: this.createLogger(LoggingScope.CORE), + [LoggingScope.BUILD]: this.createLogger(LoggingScope.BUILD), + [LoggingScope.SERVE]: this.createLogger(LoggingScope.SERVE), + }; + } + + public getLogger({ + scopeName, + options, + }: { + scopeName: LoggingScopeTypes; + options?: LoggerOptions; + }) { + if (!(scopeName in LoggingScope)) + throw new Error( + `The ${scopeName} does not belong to ${Object.keys(LoggingScope)}` + ); + // if scope name exist in mapper and not update config + if (scopeName in this.loggerMap) { + if (!options) return this.loggerMap[scopeName]; + // if options existed, update settings. + const logger = this.loggerMap[scopeName]; + this.updateSettings(logger, options); + return logger; + } + // if scope name does not exist in map or exist but would like to update config + const newLogger = this.createLogger(scopeName as LoggingScope, options); + this.loggerMap[scopeName] = newLogger; + return newLogger; + } + + private updateSettings(logger: Logger, options: LoggerOptions) { + const prevSettings = logger.settings; + logger.setSettings({ + minLevel: options.level || prevSettings.minLevel, + displayRequestId: + options.displayRequestId || prevSettings.displayRequestId, + }); + } + + private createLogger(name: LoggingScope, options?: LoggerOptions) { + return new Logger({ + name, + minLevel: options?.level || defaultMapConfig[name].level, + // use function call for requestId, then when logger get requestId, it will get newest store again + requestId: () => this.asyncReqIdStorage.getStore()?.requestId as string, + displayRequestId: + options?.displayRequestId || defaultMapConfig[name].displayRequestId, + }); + } +} + +const factory = new LoggerFactory(); +export const getLogger = factory.getLogger.bind(factory); +export const asyncReqIdStorage = factory.asyncReqIdStorage; diff --git a/packages/core/src/lib/utils/module.ts b/packages/core/src/lib/utils/module.ts new file mode 100644 index 00000000..6293e574 --- /dev/null +++ b/packages/core/src/lib/utils/module.ts @@ -0,0 +1,14 @@ +// The type for class T +export interface ClassType extends Function { + new (...args: any[]): T; +} + +/** + * dynamic import default module. + * @param folderOrFile The folder / file path + * @returns default module + */ +export const defaultImport = async (folderOrFile: string) => { + const module = await import(folderOrFile); + return module.default as T; +}; diff --git a/packages/core/src/lib/validators/data-type-validators/dateTypeValidator.ts b/packages/core/src/lib/validators/data-type-validators/dateTypeValidator.ts index af818d51..a5dc6820 100644 --- a/packages/core/src/lib/validators/data-type-validators/dateTypeValidator.ts +++ b/packages/core/src/lib/validators/data-type-validators/dateTypeValidator.ts @@ -2,7 +2,7 @@ import * as Joi from 'joi'; import { isUndefined } from 'lodash'; import * as dayjs from 'dayjs'; import customParseFormat = require('dayjs/plugin/customParseFormat'); -import IValidator from '../validator'; +import { IValidator } from '../validator'; // Support custom date format -> dayjs.format(...) dayjs.extend(customParseFormat); @@ -13,7 +13,7 @@ export interface DateInputArgs { format?: string; } -export default class DateTypeValidator implements IValidator { +export class DateTypeValidator implements IValidator { public readonly name = 'date'; // Validator for arguments schema in schema.yaml, should match DateInputArgs private argsValidator = Joi.object({ diff --git a/packages/core/src/lib/validators/data-type-validators/index.ts b/packages/core/src/lib/validators/data-type-validators/index.ts new file mode 100644 index 00000000..b4d3d37a --- /dev/null +++ b/packages/core/src/lib/validators/data-type-validators/index.ts @@ -0,0 +1,18 @@ +// export all other non-default objects of validators module +export * from './dateTypeValidator'; +export * from './integerTypeValidator'; +export * from './stringTypeValidator'; +export * from './uuidTypeValidator'; + +// import default objects and export +import { DateTypeValidator } from './dateTypeValidator'; +import { IntegerTypeValidator } from './integerTypeValidator'; +import { StringTypeValidator } from './stringTypeValidator'; +import { UUIDTypeValidator } from './uuidTypeValidator'; + +export default [ + DateTypeValidator, + IntegerTypeValidator, + StringTypeValidator, + UUIDTypeValidator, +]; diff --git a/packages/core/src/lib/validators/data-type-validators/integerTypeValidator.ts b/packages/core/src/lib/validators/data-type-validators/integerTypeValidator.ts index edb91b3f..7871284f 100644 --- a/packages/core/src/lib/validators/data-type-validators/integerTypeValidator.ts +++ b/packages/core/src/lib/validators/data-type-validators/integerTypeValidator.ts @@ -1,6 +1,6 @@ import * as Joi from 'joi'; import { isUndefined } from 'lodash'; -import IValidator from '../validator'; +import { IValidator } from '../validator'; export interface IntInputArgs { // The integer minimum value @@ -13,7 +13,7 @@ export interface IntInputArgs { less?: number; } -export default class IntegerTypeValidator implements IValidator { +export class IntegerTypeValidator implements IValidator { public readonly name = 'integer'; // Validator for arguments schema in schema.yaml, should match IntInputArgs private argsValidator = Joi.object({ diff --git a/packages/core/src/lib/validators/data-type-validators/stringTypeValidator.ts b/packages/core/src/lib/validators/data-type-validators/stringTypeValidator.ts index ebdda769..7df22718 100644 --- a/packages/core/src/lib/validators/data-type-validators/stringTypeValidator.ts +++ b/packages/core/src/lib/validators/data-type-validators/stringTypeValidator.ts @@ -1,6 +1,6 @@ import * as Joi from 'joi'; import { isUndefined } from 'lodash'; -import IValidator from '../validator'; +import { IValidator } from '../validator'; export interface StringInputArgs { // The string regex format pattern @@ -13,7 +13,7 @@ export interface StringInputArgs { max?: number; } -export default class StringTypeValidator implements IValidator { +export class StringTypeValidator implements IValidator { public readonly name = 'string'; // Validator for arguments schema in schema.yaml, should match StringInputArgs private argsValidator = Joi.object({ diff --git a/packages/core/src/lib/validators/data-type-validators/uuidTypeValidator.ts b/packages/core/src/lib/validators/data-type-validators/uuidTypeValidator.ts index f6cd8472..b10e9f45 100644 --- a/packages/core/src/lib/validators/data-type-validators/uuidTypeValidator.ts +++ b/packages/core/src/lib/validators/data-type-validators/uuidTypeValidator.ts @@ -1,7 +1,7 @@ import * as Joi from 'joi'; import { GuidVersions } from 'joi'; import { isUndefined } from 'lodash'; -import IValidator from '../validator'; +import { IValidator } from '../validator'; type UUIDVersion = 'uuid_v1' | 'uuid_v4' | 'uuid_v5'; @@ -10,7 +10,7 @@ export interface UUIDInputArgs { version?: UUIDVersion; } -export default class UUIDTypeValidator implements IValidator { +export class UUIDTypeValidator implements IValidator { public readonly name = 'uuid'; // Validator for arguments schema in schema.yaml, should match UUIDInputArgs private argsValidator = Joi.object({ diff --git a/packages/core/src/lib/validators/index.ts b/packages/core/src/lib/validators/index.ts index 252afcd1..1911f8ce 100644 --- a/packages/core/src/lib/validators/index.ts +++ b/packages/core/src/lib/validators/index.ts @@ -1,21 +1,3 @@ -// export all other non-default objects of validators module -export * from './data-type-validators/dateTypeValidator'; -export * from './data-type-validators/integerTypeValidator'; -export * from './data-type-validators/stringTypeValidator'; -export * from './data-type-validators/uuidTypeValidator'; +export * from './data-type-validators'; export * from './validatorLoader'; - -// import default objects and export -import IValidator from './validator'; -import DateTypeValidator from './data-type-validators/dateTypeValidator'; -import IntegerTypeValidator from './data-type-validators/integerTypeValidator'; -import StringTypeValidator from './data-type-validators/stringTypeValidator'; -import UUIDTypeValidator from './data-type-validators/uuidTypeValidator'; - -export { - IValidator, - DateTypeValidator, - IntegerTypeValidator, - StringTypeValidator, - UUIDTypeValidator, -}; +export * from './validator'; diff --git a/packages/core/src/lib/validators/validator.ts b/packages/core/src/lib/validators/validator.ts index 4881ded3..e3cb6301 100644 --- a/packages/core/src/lib/validators/validator.ts +++ b/packages/core/src/lib/validators/validator.ts @@ -1,4 +1,4 @@ -export default interface IValidator { +export interface IValidator { // validator name readonly name: string; // validate Schema format diff --git a/packages/core/src/lib/validators/validatorLoader.ts b/packages/core/src/lib/validators/validatorLoader.ts index 2ae00fb0..2a67ae3f 100644 --- a/packages/core/src/lib/validators/validatorLoader.ts +++ b/packages/core/src/lib/validators/validatorLoader.ts @@ -1,6 +1,12 @@ -import IValidator from './validator'; -import * as glob from 'glob'; +import { IValidator } from './validator'; + import * as path from 'path'; +import { defaultImport, ClassType } from '../utils'; + +// The extension module interface +export interface ExtensionModule { + validators?: ClassType[]; +} export interface IValidatorLoader { load(validatorName: string): Promise; @@ -8,46 +14,35 @@ export interface IValidatorLoader { export class ValidatorLoader implements IValidatorLoader { // only found built-in validators in sub folders - private builtInFolderPath: string = path.resolve(__dirname, '*', '*.ts'); - private userDefinedFolderPath?: string; + private builtInFolder: string = path.join(__dirname, 'data-type-validators'); + private extensionPath?: string; constructor(folderPath?: string) { - this.userDefinedFolderPath = folderPath; + this.extensionPath = folderPath; } public async load(validatorName: string) { - let validatorFiles = [ - ...(await this.getValidatorFilePaths(this.builtInFolderPath)), - ]; - if (this.userDefinedFolderPath) { - // include sub-folder or non sub-folders - const userDefinedValidatorFiles = await this.getValidatorFilePaths( - path.resolve(this.userDefinedFolderPath, '**', '*.ts') - ); - validatorFiles = validatorFiles.concat(userDefinedValidatorFiles); + // read built-in validators in index.ts, the content is an array middleware class + const builtInClass = await defaultImport[]>( + this.builtInFolder + ); + + // if extension path setup, load extension middlewares classes + let extensionClass: ClassType[] = []; + if (this.extensionPath) { + // import extension which user customized + const module = await defaultImport(this.extensionPath); + extensionClass = module.validators || []; } - for (const file of validatorFiles) { - // import validator files to module - const validatorModule = await import(file); - // get validator class by getting default. - if (validatorModule && validatorModule.default) { - const validatorClass = validatorModule.default; - const validator = new validatorClass() as IValidator; - if (validator.name === validatorName) return validator; - } + // create all middlewares by new it. + for (const validatorClass of [...builtInClass, ...extensionClass]) { + const validator = new validatorClass() as IValidator; + if (validator.name === validatorName) return validator; } + // throw error if not found throw new Error( `The name "${validatorName}" of validator not defined in built-in validators and passed folder path, or the defined validator not export as default.` ); } - - private async getValidatorFilePaths(sourcePath: string): Promise { - return new Promise((resolve, reject) => { - glob(sourcePath, { nodir: true }, (err, files) => { - if (err) return reject(err); - else return resolve(files); - }); - }); - } } diff --git a/packages/core/test/validators/data-type-validators/dataTypeValidator.spec.ts b/packages/core/test/validators/data-type-validators/dataTypeValidator.spec.ts index ee6bceb0..ecd8ab09 100644 --- a/packages/core/test/validators/data-type-validators/dataTypeValidator.spec.ts +++ b/packages/core/test/validators/data-type-validators/dataTypeValidator.spec.ts @@ -13,7 +13,6 @@ describe('Test "date" type validator', () => { const args = JSON.parse(inputArgs); // Act const validator = new DateTypeValidator(); - const result = validator.validateSchema(args); // Assert expect(() => validator.validateSchema(args)).not.toThrow(); } diff --git a/packages/core/test/validators/test-custom-validators/index.ts b/packages/core/test/validators/test-custom-validators/index.ts new file mode 100644 index 00000000..93173244 --- /dev/null +++ b/packages/core/test/validators/test-custom-validators/index.ts @@ -0,0 +1,7 @@ +import { IPTypeValidator } from './ip-type-validator'; + +// Imitate extension for testing +export default { + validators: [IPTypeValidator], + middlewares: [], +}; diff --git a/packages/core/test/validators/test-custom-validators/ip-type-validator.ts b/packages/core/test/validators/test-custom-validators/ip-type-validator.ts index 36f4dafe..a756f0fb 100644 --- a/packages/core/test/validators/test-custom-validators/ip-type-validator.ts +++ b/packages/core/test/validators/test-custom-validators/ip-type-validator.ts @@ -8,22 +8,22 @@ export interface IPInputArgs { version?: IPVersion[]; } -export default class IPTypeValidator implements IValidator { +/* istanbul ignore file */ +export class IPTypeValidator implements IValidator { public readonly name = 'ip'; // Validator for arguments schema in schema.yaml, should match DateInputArgs private argsValidator = Joi.object({ version: Joi.string().optional(), }); - public validateSchema(args: IPInputArgs): boolean { + public validateSchema(args: IPInputArgs): void { try { // validate arguments schema Joi.assert(args, this.argsValidator); - return true; } catch { throw new Error('The arguments schema for date type is incorrect'); } } - public validateData(value: string, args: IPInputArgs): boolean { + public validateData(value: string, args: IPInputArgs): void { let schema = Joi.string().ip(); // if there are args passed if (!isUndefined(args)) { @@ -36,7 +36,6 @@ export default class IPTypeValidator implements IValidator { try { // validate data value Joi.assert(value, schema); - return true; } catch { throw new Error('The input parameter is invalid, it should be ip type'); } diff --git a/packages/core/test/validators/validatorLoader.spec.ts b/packages/core/test/validators/validatorLoader.spec.ts index 3dd867ad..2891bd34 100644 --- a/packages/core/test/validators/validatorLoader.spec.ts +++ b/packages/core/test/validators/validatorLoader.spec.ts @@ -14,7 +14,7 @@ describe('Test validator loader ', () => { 'Should load successfully when loading validator name "$name".', async ({ name, expected }) => { // Arrange - const folderPath = path.resolve(__dirname, 'test-custom-validators'); + const folderPath = path.join(__dirname, 'test-custom-validators'); const validatorLoader = new ValidatorLoader(folderPath); // Act const result = await validatorLoader.load(name); @@ -28,7 +28,7 @@ describe('Test validator loader ', () => { 'Should load failed when loading validator name "$name".', async ({ name }) => { // Arrange - const folderPath = path.resolve(__dirname, 'test-custom-validators'); + const folderPath = path.join(__dirname, 'test-custom-validators'); const validatorLoader = new ValidatorLoader(folderPath); // Act const loadAction = validatorLoader.load(name); diff --git a/packages/serve/src/lib/app.ts b/packages/serve/src/lib/app.ts index 73b8169f..3f9c6c06 100644 --- a/packages/serve/src/lib/app.ts +++ b/packages/serve/src/lib/app.ts @@ -1,43 +1,81 @@ +import { ServeConfig } from '@config'; +import { APISchema, ClassType } from '@vulcan/core'; import { Server } from 'http'; import * as Koa from 'koa'; import * as KoaRouter from 'koa-router'; +import { isEmpty, uniq } from 'lodash'; +import { + AuditLoggingMiddleware, + BaseRouteMiddleware, + CorsMiddleware, + RequestIdMiddleware, + loadExtensions, + RateLimitMiddleware, +} from './middleware'; import { RestfulRoute, BaseRoute, APIProviderType, GraphQLRoute, + RouteGenerator, } from './route'; export type VulcanServer = Server; export class VulcanApplication { private app: Koa; + private config: ServeConfig; + private generator: RouteGenerator; private restfulRouter: KoaRouter; private graphqlRouter: KoaRouter; - constructor() { + constructor({ + config, + generator, + }: { + config: ServeConfig; + generator: RouteGenerator; + }) { + this.config = config; + this.generator = generator; this.app = new Koa(); this.restfulRouter = new KoaRouter(); this.graphqlRouter = new KoaRouter(); } - public listen(port: number): VulcanServer { - return this.app.listen(port); - } + public async run({ + apiTypes, + schemas, + port, + }: { + apiTypes: Array; + schemas: Array; + port?: number; + }): Promise { + // setup middleware on app + await this.setMiddleware(); - public async setRoutes(routes: Array, type: APIProviderType) { - const setRouteMapper = { + // setup API route according to api types and api schemas + const routeMapper = { [APIProviderType.RESTFUL]: (routes: Array) => - this.setRestfulRoutes(routes as Array), + this.setRestful(routes as Array), [APIProviderType.GRAPHQL]: (routes: Array) => - this.setGraphQLRoutes(routes as Array), + this.setGraphQL(routes as Array), }; - if (!(type in setRouteMapper)) - throw new Error(`The API ${type} not provided now`); - // Set Routes to koa router according to API type - await setRouteMapper[type](routes); + // check existed at least one type + const types = uniq(apiTypes); + if (isEmpty(types)) throw new Error(`The API type must provided.`); + + for (const type of types) { + const routes = await this.generator.multiGenerate(schemas, type); + await routeMapper[type](routes); + } + + // open port + return this.app.listen(port); } + // Setup restful routes to server - private async setRestfulRoutes(routes: Array) { + private async setRestful(routes: Array) { await Promise.all( routes.map((route) => { // currently only provide get method @@ -49,10 +87,29 @@ export class VulcanApplication { this.app.use(this.restfulRouter.allowedMethods()); } - private async setGraphQLRoutes(routes: Array) { + private async setGraphQL(routes: Array) { console.log(routes); // TODO: Still building GraphQL... this.app.use(this.graphqlRouter.routes()); this.app.use(this.restfulRouter.allowedMethods()); } + + private async setMiddleware() { + // load built-in middleware + await this.use(CorsMiddleware); + await this.use(RateLimitMiddleware); + await this.use(RequestIdMiddleware); + await this.use(AuditLoggingMiddleware); + + // load extension middleware + const extensions = await loadExtensions(this.config.extension); + await this.use(...extensions); + } + /** add middleware classes for app used */ + private async use(...classes: ClassType[]) { + for (const cls of classes) { + const middleware = new cls(this.config); + this.app.use(middleware.handle.bind(middleware)); + } + } } diff --git a/packages/serve/src/lib/config.ts b/packages/serve/src/lib/config.ts new file mode 100644 index 00000000..a9f5a0c5 --- /dev/null +++ b/packages/serve/src/lib/config.ts @@ -0,0 +1,30 @@ +/** + * The keyName represent to load middleware if it is custom, +/* For the built-in middleware is will load automatically and use default options when not setup keyName and it's options +*/ + +import { LoggerOptions } from '@vulcan/core'; +import { CorsOptions, RateLimitOptions, RequestIdOptions } from './middleware'; + +// built-in options for middleware +export type BuiltInOptions = + | RequestIdOptions + | LoggerOptions + | RateLimitOptions + | CorsOptions; + +export type CustomOptions = string | number | boolean | object; + +export interface MiddlewareConfig { + [keyName: string]: { + [param: string]: BuiltInOptions | CustomOptions; + }; +} + +// The serve package config +export interface ServeConfig { + /** The middleware config options */ + middlewares?: MiddlewareConfig; + /** The extension module name */ + extension?: string; +} diff --git a/packages/serve/src/lib/middleware/built-in-middlewares/auditLogMiddleware.ts b/packages/serve/src/lib/middleware/built-in-middlewares/auditLogMiddleware.ts new file mode 100644 index 00000000..1eaa1be0 --- /dev/null +++ b/packages/serve/src/lib/middleware/built-in-middlewares/auditLogMiddleware.ts @@ -0,0 +1,32 @@ +import { KoaRouterContext } from '@route/.'; +import { getLogger, ILogger, LoggerOptions } from '@vulcan/core'; +import { BuiltInMiddleware, RouteMiddlewareNext } from '../middleware'; +import { ServeConfig } from '@config'; + +export class AuditLoggingMiddleware extends BuiltInMiddleware { + private logger: ILogger; + constructor(config: ServeConfig) { + super('audit-log', config); + + // read logger options from config, if is undefined will set default value + const options = this.getOptions() as LoggerOptions; + this.logger = getLogger({ scopeName: 'AUDIT', options }); + } + + public async handle(context: KoaRouterContext, next: RouteMiddlewareNext) { + if (!this.enabled) return next(); + + const { path, request, params, response } = context; + const { header, query } = request; + /** + * TODO: The response body of our API server might be huge. + * We can let users to set what data they want to record in config in the future. + */ + this.logger.info(`request: path = ${path}`); + this.logger.info(`request: header = ${JSON.stringify(header)}`); + this.logger.info(`request: query = ${JSON.stringify(query)}`); + this.logger.info(`request: params = ${JSON.stringify(params)}.`); + await next(); + this.logger.info(`response: body = ${JSON.stringify(response.body)}`); + } +} diff --git a/packages/serve/src/lib/middleware/built-in-middlewares/corsMiddleware.ts b/packages/serve/src/lib/middleware/built-in-middlewares/corsMiddleware.ts new file mode 100644 index 00000000..a0b181fd --- /dev/null +++ b/packages/serve/src/lib/middleware/built-in-middlewares/corsMiddleware.ts @@ -0,0 +1,21 @@ +import * as Koa from 'koa'; +import * as cors from '@koa/cors'; +import { KoaRouterContext } from '@route/.'; +import { BuiltInMiddleware, RouteMiddlewareNext } from '../middleware'; +import { ServeConfig } from '@config'; + +export type CorsOptions = cors.Options; + +export class CorsMiddleware extends BuiltInMiddleware { + private koaCors: Koa.Middleware; + + constructor(config: ServeConfig) { + super('cors', config); + const options = this.getOptions() as CorsOptions; + this.koaCors = cors(options); + } + public async handle(context: KoaRouterContext, next: RouteMiddlewareNext) { + if (!this.enabled) return next(); + return this.koaCors(context, next); + } +} diff --git a/packages/serve/src/lib/middleware/built-in-middlewares/index.ts b/packages/serve/src/lib/middleware/built-in-middlewares/index.ts new file mode 100644 index 00000000..608f32ba --- /dev/null +++ b/packages/serve/src/lib/middleware/built-in-middlewares/index.ts @@ -0,0 +1,16 @@ +export * from './corsMiddleware'; +export * from './requestIdMiddleware'; +export * from './auditLogMiddleware'; +export * from './rateLimitMiddleware'; + +import { CorsMiddleware } from './corsMiddleware'; +import { RateLimitMiddleware } from './rateLimitMiddleware'; +import { RequestIdMiddleware } from './requestIdMiddleware'; +import { AuditLoggingMiddleware } from './auditLogMiddleware'; + +export default [ + CorsMiddleware, + RateLimitMiddleware, + RequestIdMiddleware, + AuditLoggingMiddleware, +]; diff --git a/packages/serve/src/lib/middleware/built-in-middlewares/rateLimitMiddleware.ts b/packages/serve/src/lib/middleware/built-in-middlewares/rateLimitMiddleware.ts new file mode 100644 index 00000000..7cb295f5 --- /dev/null +++ b/packages/serve/src/lib/middleware/built-in-middlewares/rateLimitMiddleware.ts @@ -0,0 +1,22 @@ +import * as Koa from 'koa'; +import { RateLimit, RateLimitOptions } from 'koa2-ratelimit'; +import { KoaRouterContext } from '@route/.'; +import { BuiltInMiddleware, RouteMiddlewareNext } from '../middleware'; +import { ServeConfig } from '@config'; + +export { RateLimitOptions }; + +export class RateLimitMiddleware extends BuiltInMiddleware { + private koaRateLimit: Koa.Middleware; + constructor(config: ServeConfig) { + super('rate-limit', config); + + const options = this.getOptions() as RateLimitOptions; + this.koaRateLimit = RateLimit.middleware(options); + } + + public async handle(context: KoaRouterContext, next: RouteMiddlewareNext) { + if (!this.enabled) return next(); + return this.koaRateLimit(context, next); + } +} diff --git a/packages/serve/src/lib/middleware/built-in-middlewares/requestIdMiddleware.ts b/packages/serve/src/lib/middleware/built-in-middlewares/requestIdMiddleware.ts new file mode 100644 index 00000000..db11ea6f --- /dev/null +++ b/packages/serve/src/lib/middleware/built-in-middlewares/requestIdMiddleware.ts @@ -0,0 +1,47 @@ +import * as uuid from 'uuid'; +import { FieldInType, asyncReqIdStorage } from '@vulcan/core'; +import { KoaRouterContext } from '@route/.'; +import { BuiltInMiddleware, RouteMiddlewareNext } from '../middleware'; +import { ServeConfig } from '@config'; + +export interface RequestIdOptions { + name: string; + fieldIn: FieldInType.HEADER | FieldInType.QUERY; +} + +export class RequestIdMiddleware extends BuiltInMiddleware { + private options: RequestIdOptions; + + constructor(config: ServeConfig) { + super('request-id', config); + // read request-id options from config. + this.options = (this.getOptions() as RequestIdOptions) || { + name: 'X-Request-ID', + fieldIn: FieldInType.HEADER, + }; + // if options has value, but not exist name / field, add default value. + if (!this.options['name']) this.options['name'] = 'X-Request-ID'; + if (!this.options['fieldIn']) this.options['fieldIn'] = FieldInType.HEADER; + } + public async handle(context: KoaRouterContext, next: RouteMiddlewareNext) { + if (!this.enabled) return next(); + + const { request } = context; + const { name, fieldIn } = this.options; + + // if header or query location not found request id, set default to uuid + const requestId = + (fieldIn === FieldInType.HEADER + ? // make the name to lowercase for consistency in request, because the field name in request will be lowercase + (request.header[name.toLowerCase()] as string) + : (request.query[name.toLowerCase()] as string)) || uuid.v4(); + + /** + * The asyncReqIdStorage.getStore(...) only worked in context of the asyncReqIdStorage.run(...) + * so here it worked if the asyncReqIdStorage.getStore(...) called in the next function or inner scope of asyncReqIdStorage.run(...) + * */ + await asyncReqIdStorage.run({ requestId }, async () => { + await next(); + }); + } +} diff --git a/packages/serve/src/lib/middleware/index.ts b/packages/serve/src/lib/middleware/index.ts new file mode 100644 index 00000000..ef13d295 --- /dev/null +++ b/packages/serve/src/lib/middleware/index.ts @@ -0,0 +1,4 @@ +// export non-default +export * from './middleware'; +export * from './loader'; +export * from './built-in-middlewares'; diff --git a/packages/serve/src/lib/middleware/loader.ts b/packages/serve/src/lib/middleware/loader.ts new file mode 100644 index 00000000..a9dd2511 --- /dev/null +++ b/packages/serve/src/lib/middleware/loader.ts @@ -0,0 +1,17 @@ +import { BaseRouteMiddleware } from './middleware'; +import { defaultImport, ClassType } from '@vulcan/core'; +// The extension module interface +export interface ExtensionModule { + middlewares?: ClassType[]; +} + +export const loadExtensions = async (folder?: string) => { + // if extension path setup, load middlewares classes in the folder + if (folder) { + // import extension which user customized + const module = await defaultImport(folder); + // return middleware classes in folder + return module.middlewares || []; + } + return []; +}; diff --git a/packages/serve/src/lib/middleware/middleware.ts b/packages/serve/src/lib/middleware/middleware.ts new file mode 100644 index 00000000..8dfa5174 --- /dev/null +++ b/packages/serve/src/lib/middleware/middleware.ts @@ -0,0 +1,36 @@ +import { KoaRouterContext } from '@route/route-component'; +import { Next } from 'koa'; +import { BuiltInOptions, ServeConfig } from '../config'; + +export type RouteMiddlewareNext = Next; + +export abstract class BaseRouteMiddleware { + protected config: ServeConfig; + // middleware is enabled or not, default is enabled beside you give "enabled: false" in config. + protected enabled: boolean; + // Is an identifier to check the options set or not in the middlewares section of serve config + public readonly keyName: string; + constructor(keyName: string, config: ServeConfig) { + this.keyName = keyName; + this.config = config; + this.enabled = (this.getConfig()?.['enabled'] as boolean) || true; + } + public abstract handle( + context: KoaRouterContext, + next: RouteMiddlewareNext + ): Promise; + + protected getConfig() { + if (this.config.middlewares && this.config.middlewares[this.keyName]) + return this.config.middlewares[this.keyName]; + return undefined; + } +} + +export abstract class BuiltInMiddleware extends BaseRouteMiddleware { + protected getOptions() { + if (this.getConfig()) + return this.getConfig()?.['options'] as BuiltInOptions; + return undefined; + } +} diff --git a/packages/serve/src/lib/pagination/strategy/cursorBasedStrategy.ts b/packages/serve/src/lib/pagination/strategy/cursorBasedStrategy.ts index e6b710cc..8ac91801 100644 --- a/packages/serve/src/lib/pagination/strategy/cursorBasedStrategy.ts +++ b/packages/serve/src/lib/pagination/strategy/cursorBasedStrategy.ts @@ -9,10 +9,10 @@ export interface CursorPagination { export class CursorBasedStrategy extends PaginationStrategy { public async transform(ctx: KoaRouterContext) { - const checkFelidInHeader = ['limit', 'cursor'].every((field) => + const checkFelidInQueryString = ['limit', 'cursor'].every((field) => Object.keys(ctx.request.query).includes(field) ); - if (!checkFelidInHeader) + if (!checkFelidInQueryString) throw new Error( `The ${PaginationMode.CURSOR} must provide limit and cursor in query string.` ); @@ -20,7 +20,7 @@ export class CursorBasedStrategy extends PaginationStrategy { const cursorVal = ctx.request.query['cursor'] as string; return { limit: normalizeStringValue(limitVal, 'limit', Number.name), - cursor: normalizeStringValue(cursorVal, 'cursor', Number.name), + cursor: normalizeStringValue(cursorVal, 'cursor', String.name), } as CursorPagination; } } diff --git a/packages/serve/src/lib/pagination/strategy/keysetBasedStrategy.ts b/packages/serve/src/lib/pagination/strategy/keysetBasedStrategy.ts index 834af477..b02bd864 100644 --- a/packages/serve/src/lib/pagination/strategy/keysetBasedStrategy.ts +++ b/packages/serve/src/lib/pagination/strategy/keysetBasedStrategy.ts @@ -1,5 +1,4 @@ import { - APISchema, normalizeStringValue, PaginationMode, PaginationSchema, @@ -24,10 +23,10 @@ export class KeysetBasedStrategy extends PaginationStrategy { `The keyset pagination need to set "keyName" in schema for indicate what key need to do filter.` ); const { keyName } = this.pagination; - const checkFelidInHeader = ['limit', keyName].every((field) => + const checkFelidInQueryString = ['limit', keyName].every((field) => Object.keys(ctx.request.query).includes(field) ); - if (!checkFelidInHeader) + if (!checkFelidInQueryString) throw new Error( `The ${PaginationMode.KEYSET} must provide limit and offset in query string.` ); @@ -35,7 +34,7 @@ export class KeysetBasedStrategy extends PaginationStrategy { const keyNameVal = ctx.request.query[keyName] as string; return { limit: normalizeStringValue(limitVal, 'limit', Number.name), - [keyName]: normalizeStringValue(keyNameVal, keyName, Number.name), + [keyName]: normalizeStringValue(keyNameVal, keyName, String.name), } as KeysetPagination; } } diff --git a/packages/serve/src/lib/pagination/strategy/offsetBasedStrategy.ts b/packages/serve/src/lib/pagination/strategy/offsetBasedStrategy.ts index 29e3a098..2e752787 100644 --- a/packages/serve/src/lib/pagination/strategy/offsetBasedStrategy.ts +++ b/packages/serve/src/lib/pagination/strategy/offsetBasedStrategy.ts @@ -9,10 +9,10 @@ export interface OffsetPagination { export class OffsetBasedStrategy extends PaginationStrategy { public async transform(ctx: KoaRouterContext) { - const checkFelidInHeader = ['limit', 'offset'].every((field) => + const checkFelidInQueryString = ['limit', 'offset'].every((field) => Object.keys(ctx.request.query).includes(field) ); - if (!checkFelidInHeader) + if (!checkFelidInQueryString) throw new Error( `The ${PaginationMode.OFFSET} must provide limit and offset in query string.` ); diff --git a/packages/serve/src/lib/route/route-component/graphQLRoute.ts b/packages/serve/src/lib/route/route-component/graphQLRoute.ts index d25703ac..1fab4d31 100644 --- a/packages/serve/src/lib/route/route-component/graphQLRoute.ts +++ b/packages/serve/src/lib/route/route-component/graphQLRoute.ts @@ -1,8 +1,8 @@ import { APISchema, TemplateEngine } from '@vulcan/core'; -import { IRequestTransformer, RequestParameters } from './requestTransformer'; +import { IRequestTransformer } from './requestTransformer'; import { IRequestValidator } from './requestValidator'; import { BaseRoute, KoaRouterContext } from './baseRoute'; -import { IPaginationTransformer, Pagination } from './paginationTransformer'; +import { IPaginationTransformer } from './paginationTransformer'; export class GraphQLRoute extends BaseRoute { public readonly operationName: string; @@ -35,6 +35,13 @@ export class GraphQLRoute extends BaseRoute { // TODO: generate graphql type by api schema } + public async respond(ctx: KoaRouterContext) { + const transformed = await this.prepare(ctx); + await this.handle(transformed); + // TODO: get template engine handled result and return response by checking API schema + return transformed; + } + protected async prepare(ctx: KoaRouterContext) { /** * TODO: the graphql need to transform from body. @@ -45,11 +52,4 @@ export class GraphQLRoute extends BaseRoute { reqParams: {}, }; } - - public async respond(ctx: KoaRouterContext) { - const transformed = await this.prepare(ctx); - await this.handle(transformed); - // TODO: get template engine handled result and return response by checking API schema - return transformed; - } } diff --git a/packages/serve/src/lib/route/route-component/paginationTransformer.ts b/packages/serve/src/lib/route/route-component/paginationTransformer.ts index 374c9923..87e9606f 100644 --- a/packages/serve/src/lib/route/route-component/paginationTransformer.ts +++ b/packages/serve/src/lib/route/route-component/paginationTransformer.ts @@ -22,14 +22,18 @@ export class PaginationTransformer { const { pagination } = apiSchema; if (pagination) { - if (!(pagination.mode in PaginationMode)) + if (!Object.values(PaginationMode).includes(pagination.mode)) throw new Error( `The pagination only support ${Object.keys(PaginationMode)}` ); + + const offset = new OffsetBasedStrategy(); + const cursor = new CursorBasedStrategy(); + const keyset = new KeysetBasedStrategy(pagination); const strategyMapper = { - [PaginationMode.OFFSET]: new OffsetBasedStrategy().transform, - [PaginationMode.CURSOR]: new CursorBasedStrategy().transform, - [PaginationMode.KEYSET]: new KeysetBasedStrategy(pagination).transform, + [PaginationMode.OFFSET]: offset.transform.bind(offset), + [PaginationMode.CURSOR]: cursor.transform.bind(cursor), + [PaginationMode.KEYSET]: keyset.transform.bind(keyset), }; return await strategyMapper[pagination.mode](ctx); } diff --git a/packages/serve/src/lib/route/route-component/requestTransformer.ts b/packages/serve/src/lib/route/route-component/requestTransformer.ts index 54276c7f..5987060e 100644 --- a/packages/serve/src/lib/route/route-component/requestTransformer.ts +++ b/packages/serve/src/lib/route/route-component/requestTransformer.ts @@ -74,15 +74,9 @@ export class RequestTransformer implements IRequestTransformer { value: string, type: FieldDataType ) { - try { - if (!(type in FieldDataType)) - throw new Error(`The ${type} type not been implemented now.`); + if (!Object.values(FieldDataType).includes(type)) + throw new Error(`The ${type} type not been implemented now.`); - return RequestTransformer.convertTypeMapper[type](value, name); - } catch { - throw new Error( - `The value of field "${name}" not belong to ${type} type` - ); - } + return RequestTransformer.convertTypeMapper[type](value, name); } } diff --git a/packages/serve/src/lib/route/route-component/restfulRoute.ts b/packages/serve/src/lib/route/route-component/restfulRoute.ts index a3cd0fa0..64cb9643 100644 --- a/packages/serve/src/lib/route/route-component/restfulRoute.ts +++ b/packages/serve/src/lib/route/route-component/restfulRoute.ts @@ -26,7 +26,6 @@ export class RestfulRoute extends BaseRoute { paginationTransformer, templateEngine, }); - this.urlPath = apiSchema.urlPath; } diff --git a/packages/serve/test/__mocks__/uuid.ts b/packages/serve/test/__mocks__/uuid.ts new file mode 100644 index 00000000..74307db8 --- /dev/null +++ b/packages/serve/test/__mocks__/uuid.ts @@ -0,0 +1,6 @@ +// stub uuid v4 in test. + +import faker from '@faker-js/faker'; +// create fake uuid value and make it fixed. +const uuid = faker.datatype.uuid(); +export const v4 = () => uuid; diff --git a/packages/serve/test/app.spec.ts b/packages/serve/test/app.spec.ts index 96ecab94..f83605c2 100644 --- a/packages/serve/test/app.spec.ts +++ b/packages/serve/test/app.spec.ts @@ -1,9 +1,10 @@ import * as sinon from 'ts-sinon'; import * as supertest from 'supertest'; +import * as path from 'path'; import faker from '@faker-js/faker'; import { Request } from 'koa'; import * as KoaRouter from 'koa-router'; -import { VulcanApplication, VulcanServer } from '@app'; +import { VulcanApplication } from '@app'; import { APISchema, FieldDataType, @@ -24,8 +25,71 @@ import { } from '@route/.'; import { PaginationTransformer } from '@route/.'; -describe('Test vulcan server to call restful APIs', () => { - let server: VulcanServer; +describe('Test vulcan server for practicing middleware', () => { + let generator: RouteGenerator; + let stubTemplateEngine: sinon.StubbedInstance; + + beforeEach(() => { + stubTemplateEngine = sinon.stubInterface(); + + const reqTransformer = new RequestTransformer(); + const reqValidator = new RequestValidator(new ValidatorLoader()); + const paginationTransformer = new PaginationTransformer(); + + generator = new RouteGenerator({ + reqTransformer, + reqValidator, + paginationTransformer, + templateEngine: stubTemplateEngine, + }); + }); + it('Should show test middleware info when given middleware extension path', async () => { + // Arrange + const fakeSchema = { + ...sinon.stubInterface(), + urlPath: '/' + faker.internet.domainName(), + request: [], + } as APISchema; + + const app = new VulcanApplication({ + config: { + middlewares: { + 'test-mode': { + mode: true, + }, + }, + extension: path.resolve( + __dirname, + './middlewares/test-custom-middlewares' + ), + }, + generator, + }); + const server = await app.run({ + apiTypes: [APIProviderType.RESTFUL], + schemas: [fakeSchema], + port: 3000, + }); + + // arrange expected result + const expected = { + 'test-mode': 'true', + }; + + // Act + const reqOperation = supertest(server).get(fakeSchema.urlPath); + + const response = await reqOperation; + // Assert + expect(response.headers).toEqual(expect.objectContaining(expected)); + + // close server + server.close(); + }); +}); + +describe('Test vulcan server for calling restful APIs', () => { + let generator: RouteGenerator; let stubTemplateEngine: sinon.StubbedInstance; const fakeSchemas: Array = [ { @@ -174,29 +238,19 @@ describe('Test vulcan server to call restful APIs', () => { }, ]; - beforeAll(async () => { + beforeEach(() => { + stubTemplateEngine = sinon.stubInterface(); + const reqTransformer = new RequestTransformer(); const reqValidator = new RequestValidator(new ValidatorLoader()); const paginationTransformer = new PaginationTransformer(); - stubTemplateEngine = sinon.stubInterface(); - const generator = new RouteGenerator({ + generator = new RouteGenerator({ reqTransformer, reqValidator, paginationTransformer, templateEngine: stubTemplateEngine, }); - const routes = await generator.multiGenerate( - fakeSchemas, - APIProviderType.RESTFUL - ); - const app = new VulcanApplication(); - await app.setRoutes(routes, APIProviderType.RESTFUL); - server = app.listen(3000); - }); - - afterAll(() => { - server.close(); }); it.each([ @@ -208,7 +262,12 @@ describe('Test vulcan server to call restful APIs', () => { 'Should be correct when given validated koa context request from %p', async (_: string, schema: APISchema, ctx: KoaRouterContext) => { // Arrange - + const app = new VulcanApplication({ config: {}, generator }); + const server = await app.run({ + apiTypes: [APIProviderType.RESTFUL], + schemas: [schema], + port: 3000, + }); // arrange input api url const apiUrl = KoaRouter.url(schema.urlPath, ctx.params); @@ -242,6 +301,8 @@ describe('Test vulcan server to call restful APIs', () => { // Assert expect(response.body.reqParams).toEqual(expected); + // close server + server.close(); } ); }); diff --git a/packages/serve/test/middlewares/built-in-middlewares/auditLogMiddleware.spec.ts b/packages/serve/test/middlewares/built-in-middlewares/auditLogMiddleware.spec.ts new file mode 100644 index 00000000..a4a173a5 --- /dev/null +++ b/packages/serve/test/middlewares/built-in-middlewares/auditLogMiddleware.spec.ts @@ -0,0 +1,140 @@ +import faker from '@faker-js/faker'; +import * as sinon from 'ts-sinon'; +import { Request, Response } from 'koa'; +import { IncomingHttpHeaders } from 'http'; +import { ParsedUrlQuery } from 'querystring'; +import { KoaRouterContext } from '@route/route-component'; +import { + AuditLoggingMiddleware, + RequestIdMiddleware, +} from '@middleware/built-in-middlewares'; +import * as core from '@vulcan/core'; +import * as uuid from 'uuid'; +import { LoggerOptions } from '@vulcan/core'; + +describe('Test audit logging middlewares', () => { + afterEach(() => { + sinon.default.restore(); + }); + it('Should log correct info when when option is default and pass correct koa context', async () => { + // Arrange + const ctx: KoaRouterContext = { + ...sinon.stubInterface(), + path: faker.internet.url(), + params: { + uuid: faker.datatype.uuid(), + }, + request: { + ...sinon.stubInterface(), + header: { + ...sinon.stubInterface(), + 'X-Agent': 'test-normal-client', + }, + query: { + ...sinon.stubInterface(), + sortby: 'name', + }, + }, + response: { + ...sinon.stubInterface(), + body: { + result: 'OK', + }, + }, + }; + const expected = [ + `request: path = ${ctx.path}`, + `request: header = ${JSON.stringify(ctx.request.header)}`, + `request: query = ${JSON.stringify(ctx.request.query)}`, + `request: params = ${JSON.stringify(ctx.params)}.`, + `response: body = ${JSON.stringify(ctx.response.body)}`, + ]; + // Act + const middleware = new AuditLoggingMiddleware({}); + // Use spy to trace the logger from getLogger( scopeName: 'AUDIT' }) to know in logger.info(...) + const spy = sinon.default.spy(core.getLogger({ scopeName: 'AUDIT' })); + await middleware.handle(ctx, async () => Promise.resolve()); + + // Assert + expect(spy.info.getCall(0).args[0]).toEqual(expected[0]); + expect(spy.info.getCall(1).args[0]).toEqual(expected[1]); + expect(spy.info.getCall(2).args[0]).toEqual(expected[2]); + expect(spy.info.getCall(3).args[0]).toEqual(expected[3]); + expect(spy.info.getCall(4).args[0]).toEqual(expected[4]); + }); + + it('Should log correct info when when option "displayRequestId: true" and pass correct koa context', async () => { + // Arrange + const ctx: KoaRouterContext = { + ...sinon.stubInterface(), + path: faker.internet.url(), + params: { + uuid: faker.datatype.uuid(), + }, + request: { + ...sinon.stubInterface(), + header: { + ...sinon.stubInterface(), + 'X-Agent': 'test-school-client', + }, + query: { + ...sinon.stubInterface(), + sortby: 'score', + }, + }, + response: { + ...sinon.stubInterface(), + body: { + result: 'Success', + }, + }, + }; + + const expected = { + requestId: uuid.v4(), + info: [ + `request: path = ${ctx.path}`, + `request: header = ${JSON.stringify(ctx.request.header)}`, + `request: query = ${JSON.stringify(ctx.request.query)}`, + `request: params = ${JSON.stringify(ctx.params)}.`, + `response: body = ${JSON.stringify(ctx.response.body)}`, + ], + }; + + // setup request-id middleware run first. + const stubReqIdMiddleware = new RequestIdMiddleware({}); + const middleware = new AuditLoggingMiddleware({ + middlewares: { + 'audit-log': { + options: { + displayRequestId: true, + } as LoggerOptions, + }, + }, + }); + // Use spy to trace the logger from getLogger( scopeName: 'AUDIT' }) to know in logger.info(...) + // it will get the setting of logger from above new audit logging middleware + const spy = sinon.default.spy( + core.getLogger({ + scopeName: 'AUDIT', + }) + ); + // Act + const next = () => middleware.handle(ctx, async () => Promise.resolve()); + await stubReqIdMiddleware.handle(ctx, next); + + // Assert + // check logger.info message + expect(spy.info.getCall(0).args[0]).toEqual(expected.info[0]); + expect(spy.info.getCall(1).args[0]).toEqual(expected.info[1]); + expect(spy.info.getCall(2).args[0]).toEqual(expected.info[2]); + expect(spy.info.getCall(3).args[0]).toEqual(expected.info[3]); + expect(spy.info.getCall(4).args[0]).toEqual(expected.info[4]); + // check request id + expect(spy.info.returnValues[0].requestId).toEqual(expected.requestId); + expect(spy.info.returnValues[1].requestId).toEqual(expected.requestId); + expect(spy.info.returnValues[2].requestId).toEqual(expected.requestId); + expect(spy.info.returnValues[3].requestId).toEqual(expected.requestId); + expect(spy.info.returnValues[4].requestId).toEqual(expected.requestId); + }); +}); diff --git a/packages/serve/test/middlewares/built-in-middlewares/corsMiddleware.spec.ts b/packages/serve/test/middlewares/built-in-middlewares/corsMiddleware.spec.ts new file mode 100644 index 00000000..6d52185a --- /dev/null +++ b/packages/serve/test/middlewares/built-in-middlewares/corsMiddleware.spec.ts @@ -0,0 +1,52 @@ +import faker from '@faker-js/faker'; +import * as Koa from 'koa'; +import * as supertest from 'supertest'; +import { CorsOptions, CorsMiddleware } from '@middleware/built-in-middlewares'; +import { Server } from 'http'; + +describe('Test cors middlewares', () => { + let server: Server; + const domain = faker.internet.domainName(); + beforeAll(async () => { + // Should use koa app and supertest for testing, because it will call koa context method in cors middleware. + const app = new Koa(); + + const middleware = new CorsMiddleware({ + middlewares: { + cors: { + options: { + origin: domain, + } as CorsOptions, + }, + }, + }); + // use middleware in koa app + app.use(middleware.handle.bind(middleware)); + // Act + server = app.listen(faker.internet.port()); + }); + + afterAll(() => { + server.close(); + }); + it('Should validate successfully when pass correct origin domain', async () => { + // Arrange + const request = supertest(server).get('/').set('Origin', domain); + // Act + const response = await request; + // Assert + expect(response.header['access-control-allow-origin']).toEqual(domain); + }); + + it('Should validate failed when pass incorrect origin domain', async () => { + // Arrange + const incorrectDomain = faker.internet.domainName(); + const request = supertest(server).get('/').set('Origin', incorrectDomain); + // Act + const response = await request; + // Assert + expect(response.header['access-control-allow-origin']).not.toEqual( + incorrectDomain + ); + }); +}); diff --git a/packages/serve/test/middlewares/built-in-middlewares/rateLimitMiddleware.spec.ts b/packages/serve/test/middlewares/built-in-middlewares/rateLimitMiddleware.spec.ts new file mode 100644 index 00000000..b231dc78 --- /dev/null +++ b/packages/serve/test/middlewares/built-in-middlewares/rateLimitMiddleware.spec.ts @@ -0,0 +1,66 @@ +import faker from '@faker-js/faker'; +import * as supertest from 'supertest'; +import { Server } from 'http'; +import * as Koa from 'koa'; +import * as KoaRouter from 'koa-router'; +import { + RateLimitMiddleware, + RateLimitOptions, +} from '@middleware/built-in-middlewares'; + +// Should use koa app and supertest for testing, because it will call koa context method in ratelimit middleware. +describe('Test rate limit middlewares', () => { + let server: Server; + beforeAll(() => { + const app = new Koa(); + const router = new KoaRouter(); + const middleware = new RateLimitMiddleware({ + middlewares: { + 'rate-limit': { + options: { + max: 2, + interval: 2000, + } as RateLimitOptions, + }, + }, + }); + // use middleware in koa app + app.use(middleware.handle.bind(middleware)); + router.get('/', (ctx) => { + ctx.response.body = { + result: 'ok', + }; + }); + app.use(router.routes()); + // Act + server = app.listen(faker.internet.port()); + }); + + afterAll(() => { + server.close(); + }); + + it.each([ + { index: 1, expected: { code: 200, data: { result: 'ok' } } }, + { index: 2, expected: { code: 200, data: { result: 'ok' } } }, + { + index: 3, + expected: { + code: 429, + data: { message: 'Too many requests, please try again later.' }, + }, + }, + ])( + 'Should get status code "$expected.code" when send $index th request ', + async ({ expected }) => { + // Arrange + const request = supertest(server).get('/'); + // Act + const response = await request; + + // Assert + expect(response.statusCode).toEqual(expected.code); + expect(response.body).toEqual(expected.data); + } + ); +}); diff --git a/packages/serve/test/middlewares/built-in-middlewares/requestIdMiddleware.spec.ts b/packages/serve/test/middlewares/built-in-middlewares/requestIdMiddleware.spec.ts new file mode 100644 index 00000000..e75cd8f6 --- /dev/null +++ b/packages/serve/test/middlewares/built-in-middlewares/requestIdMiddleware.spec.ts @@ -0,0 +1,132 @@ +import faker from '@faker-js/faker'; +import * as sinon from 'ts-sinon'; +import { Request } from 'koa'; +import { IncomingHttpHeaders } from 'http'; +import { ParsedUrlQuery } from 'querystring'; +import { asyncReqIdStorage, FieldInType } from '@vulcan/core'; +import { KoaRouterContext } from '@route/route-component'; +import { + RequestIdMiddleware, + RequestIdOptions, +} from '@middleware/built-in-middlewares'; +import * as uuid from 'uuid'; + +describe('Test request-id middlewares', () => { + afterEach(() => { + // restore spying global object asyncReqIdStorage to un-spy. + sinon.default.restore(); + }); + it('Should get same request-id when option is default and pass "x-request-id"', async () => { + // Arrange + const expected = faker.datatype.uuid(); + const ctx: KoaRouterContext = { + ...sinon.stubInterface(), + request: { + ...sinon.stubInterface(), + header: { + ...sinon.stubInterface(), + // simulate koa context, it will transfer to lower case actual when sending request + // https://medium.com/@andrelimamail/http-node-server-lower-casing-headers-365764218527 + 'x-request-id': expected, + }, + }, + }; + // spy the asyncReqIdStorage behavior + const spy = sinon.default.spy(asyncReqIdStorage); + // Act + const middleware = new RequestIdMiddleware({}); + await middleware.handle(ctx, async () => Promise.resolve()); + + // Assert + expect(spy.run.getCall(0).args[0].requestId).toEqual(expected); + }); + + it('Should get same request-id when setup option "Test-Request-ID" in query and pass "test-request-id"', async () => { + // Arrange + const expected = faker.datatype.uuid(); + const ctx: KoaRouterContext = { + ...sinon.stubInterface(), + request: { + ...sinon.stubInterface(), + query: { + ...sinon.stubInterface(), + // simulate koa context, it will transfer to lower case actual when sending request + // https://medium.com/@andrelimamail/http-node-server-lower-casing-headers-365764218527 + 'test-request-id': expected, + }, + }, + }; + // Act + const middleware = new RequestIdMiddleware({ + middlewares: { + 'request-id': { + options: { + name: 'Test-Request-ID', + fieldIn: FieldInType.QUERY, + } as RequestIdOptions, + }, + }, + }); + + // spy the asyncReqIdStorage behavior + const spy = sinon.default.spy(asyncReqIdStorage); + await middleware.handle(ctx, async () => Promise.resolve()); + // Assert, + expect(spy.run.getCall(0).args[0].requestId).toEqual(expected); + }); + + it('Should generate default request-id when setup option field in query', async () => { + // Arrange + // the uuid.v4() result is the mock result in __mocks__/uuid.ts + const expected = uuid.v4(); + const ctx: KoaRouterContext = { + ...sinon.stubInterface(), + request: { + ...sinon.stubInterface(), + query: { + ...sinon.stubInterface(), + }, + }, + }; + // Act + const middleware = new RequestIdMiddleware({ + middlewares: { + 'request-id': { + options: { + fieldIn: FieldInType.QUERY, + } as RequestIdOptions, + }, + }, + }); + + // spy the asyncReqIdStorage behavior + const spy = sinon.default.spy(asyncReqIdStorage); + await middleware.handle(ctx, async () => Promise.resolve()); + // Assert + expect(spy.run.getCall(0).args[0].requestId).toEqual(expected); + }); + + it('Should generate default request-id when request-id does not setup', async () => { + // Arrange + // the uuid.v4() result is the mock result in __mocks__/uuid.ts + const expected = uuid.v4(); + const ctx: KoaRouterContext = { + ...sinon.stubInterface(), + request: { + ...sinon.stubInterface(), + header: { + ...sinon.stubInterface(), + }, + }, + }; + + // spy the asyncReqIdStorage behavior + const spy = sinon.default.spy(asyncReqIdStorage); + // Act + const middleware = new RequestIdMiddleware({}); + await middleware.handle(ctx, async () => Promise.resolve()); + + // Assert, + expect(spy.run.getCall(0).args[0].requestId).toEqual(expected); + }); +}); diff --git a/packages/serve/test/middlewares/loader.spec.ts b/packages/serve/test/middlewares/loader.spec.ts new file mode 100644 index 00000000..672ac480 --- /dev/null +++ b/packages/serve/test/middlewares/loader.spec.ts @@ -0,0 +1,62 @@ +import * as path from 'path'; +import * as sinon from 'ts-sinon'; +import { BaseRouteMiddleware, loadExtensions } from '@middleware/.'; +import middlewares from '@middleware/built-in-middlewares'; +import { TestModeMiddleware } from './test-custom-middlewares'; +import { ClassType, defaultImport } from '@vulcan/core'; +import { ServeConfig } from '@config'; + +// the load Built-in used for tests +const loadBuiltIn = async () => { + // built-in middleware folder + const builtInFolder = path.resolve( + __dirname, + '../../src/lib/middleware', + 'built-in-middlewares' + ); + // read built-in middlewares in index.ts, the content is an array middleware class + return ( + (await defaultImport[]>(builtInFolder)) || [] + ); +}; + +describe('Test middleware loader', () => { + it('Should load successfully when loading built-in middlewares', async () => { + // Arrange + + const expected = [...middlewares] as ClassType[]; + // Act + const actual = await loadBuiltIn(); + // Assert + expect(actual).toEqual(expect.arrayContaining(expected)); + }); + it('Should load successfully when loading extension middlewares', async () => { + // Arrange + const expected = [TestModeMiddleware] as ClassType[]; + + const config = { + extension: path.join(__dirname, 'test-custom-middlewares'), + } as ServeConfig; + + // Act + const actual = await loadExtensions(config.extension); + // Assert + expect(actual).toEqual(expect.arrayContaining(expected)); + }); + + it('Should load failed when loading non-existed middlewares', async () => { + // Arrange + const NonExistedMiddleware = + sinon.stubInterface>(); + const expected = [NonExistedMiddleware] as ClassType[]; + + const config = { + extension: path.join(__dirname, 'test-custom-middlewares'), + } as ServeConfig; + + // Act + const actual = await loadExtensions(config.extension); + // Assert + expect(actual).not.toEqual(expect.arrayContaining(expected)); + }); +}); diff --git a/packages/serve/test/middlewares/test-custom-middlewares/index.ts b/packages/serve/test/middlewares/test-custom-middlewares/index.ts new file mode 100644 index 00000000..4a6a9872 --- /dev/null +++ b/packages/serve/test/middlewares/test-custom-middlewares/index.ts @@ -0,0 +1,8 @@ +export * from './testModeMiddleware'; +import { TestModeMiddleware } from './testModeMiddleware'; + +// Imitate extension for testing +export default { + validators: [], + middlewares: [TestModeMiddleware], +}; diff --git a/packages/serve/test/middlewares/test-custom-middlewares/testModeMiddleware.ts b/packages/serve/test/middlewares/test-custom-middlewares/testModeMiddleware.ts new file mode 100644 index 00000000..318e374a --- /dev/null +++ b/packages/serve/test/middlewares/test-custom-middlewares/testModeMiddleware.ts @@ -0,0 +1,19 @@ +import { ServeConfig } from '@config'; +import { BaseRouteMiddleware, RouteMiddlewareNext } from '@middleware/.'; +import { KoaRouterContext } from '@route/route-component'; + +export interface TestModeOptions { + mode: boolean; +} +/* istanbul ignore file */ +export class TestModeMiddleware extends BaseRouteMiddleware { + private mode: boolean; + constructor(config: ServeConfig) { + super('test-mode', config); + this.mode = (this.getConfig()?.['mode'] as boolean) || false; + } + public async handle(context: KoaRouterContext, next: RouteMiddlewareNext) { + context.response.set('test-mode', String(this.mode)); + await next(); + } +} diff --git a/packages/serve/test/route/route-component/paginationTransformer.spec.ts b/packages/serve/test/route/route-component/paginationTransformer.spec.ts new file mode 100644 index 00000000..256c5b39 --- /dev/null +++ b/packages/serve/test/route/route-component/paginationTransformer.spec.ts @@ -0,0 +1,116 @@ +import * as sinon from 'ts-sinon'; +import { Request } from 'koa'; +import faker from '@faker-js/faker'; +import { APISchema, normalizeStringValue, PaginationMode } from '@vulcan/core'; +import { KoaRouterContext, PaginationTransformer } from '@route/.'; + +describe('Test pagination transformer - transform successfully', () => { + const fakeSchemas: Array = [ + { + ...sinon.stubInterface(), + urlPath: `/${faker.word.noun()}`, + // the pagination no need to set request schema + request: [], + pagination: { + mode: PaginationMode.OFFSET, + }, + }, + { + ...sinon.stubInterface(), + urlPath: `/${faker.word.noun()}`, + // the pagination no need to set request schema + request: [], + pagination: { + mode: PaginationMode.CURSOR, + }, + }, + { + ...sinon.stubInterface(), + urlPath: `/${faker.word.noun()}`, + // the pagination no need to set request schema + request: [], + pagination: { + mode: PaginationMode.KEYSET, + keyName: 'createDate', + }, + }, + ]; + const fakeKoaContexts: Array = [ + { + ...sinon.stubInterface(), + request: { + ...sinon.stubInterface(), + query: { + limit: faker.datatype.number({ max: 100 }).toString(), + offset: faker.datatype.number({ max: 100 }).toString(), + }, + }, + }, + { + ...sinon.stubInterface(), + request: { + ...sinon.stubInterface(), + query: { + limit: faker.datatype.number({ max: 100 }).toString(), + cursor: faker.datatype.number({ max: 100 }).toString(), + }, + }, + }, + { + ...sinon.stubInterface(), + request: { + ...sinon.stubInterface(), + query: { + limit: faker.datatype.number({ max: 100 }).toString(), + createDate: faker.date.recent().toISOString(), + }, + }, + }, + ]; + + it.each([ + ['offset pagination', fakeSchemas[0], fakeKoaContexts[0]], + ['cursor pagination', fakeSchemas[1], fakeKoaContexts[1]], + ['keyset pagination', fakeSchemas[2], fakeKoaContexts[2]], + ])( + 'Should success when give api schema and koa context request from %p', + async (_: string, schema: APISchema, ctx: KoaRouterContext) => { + // Arrange + let expected = {}; + const query = ctx.request.query; + const limit = query['limit'] as string; + + if (schema.pagination?.mode === PaginationMode.OFFSET) { + const offset = query['offset'] as string; + expected = { + limit: normalizeStringValue(limit, 'limit', Number.name), + offset: normalizeStringValue(offset, 'offset', Number.name), + }; + } else if (schema.pagination?.mode === PaginationMode.CURSOR) { + const cursor = query['cursor'] as string; + expected = { + limit: normalizeStringValue(limit, 'limit', Number.name), + cursor: normalizeStringValue(cursor, 'cursor', String.name), + }; + } else if (schema.pagination?.mode === PaginationMode.KEYSET) { + if (schema.pagination.keyName) { + const { keyName } = schema.pagination; + const keyNameVal = query[keyName] as string; + expected = { + limit: normalizeStringValue(limit, 'limit', Number.name), + [keyName]: normalizeStringValue(keyNameVal, keyName, String.name), + }; + } + } + + // Act + const transformer = new PaginationTransformer(); + const result = await transformer.transform(ctx, schema); + + // Assert + expect(result).toEqual(expected); + } + ); +}); + +// TODO: Failed case for transformer diff --git a/packages/serve/tsconfig.json b/packages/serve/tsconfig.json index 76938c8c..6bd064e1 100644 --- a/packages/serve/tsconfig.json +++ b/packages/serve/tsconfig.json @@ -16,6 +16,8 @@ "@data-query/*": ["packages/serve/src/lib/data-query/*"], "@data-source/*": ["packages/serve/src/lib/data-source/*"], "@pagination/*": ["packages/serve/src/lib/pagination/*"], + "@middleware/*": ["packages/serve/src/lib/middleware/*"], + "@config": ["packages/serve/src/lib/config.ts"], "@app": ["packages/serve/src/lib/app.ts"] } }, diff --git a/yarn.lock b/yarn.lock index af1cb8d9..39b286a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -609,6 +609,13 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" +"@koa/cors@^3.3.0": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.3.0.tgz#b4c1c7ee303b7c968c8727f2a638a74675b50bb2" + integrity sha512-lzlkqLlL5Ond8jb6JLnVVDmD2OPym0r5kvZlMgAWiS9xle+Q5ulw1T358oW+RVguxUkANquZQz82i/STIRmsqQ== + dependencies: + vary "^1.1.2" + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -988,6 +995,11 @@ dependencies: "@babel/types" "^7.3.0" +"@types/bluebird@*": + version "3.5.36" + resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.36.tgz#00d9301d4dc35c2f6465a8aec634bb533674c652" + integrity sha512-HBNx4lhkxN7bx6P0++W8E289foSu8kO8GCk2unhuVggO+cE7rh9DhZUyPhUxNRG9m+5B5BTKxZQ5ZP92x/mx9Q== + "@types/body-parser@*": version "1.19.2" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0" @@ -1013,6 +1025,13 @@ resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.4.tgz#de48cf01c79c9f1560bcfd8ae43217ab028657f8" integrity sha512-0mPF08jn9zYI0n0Q/Pnz7C4kThdSt+6LD4amsrYDDpgBfrVWa3TcCOxKX1zkGgYniGagRv8heN2cbh+CAn+uuQ== +"@types/continuation-local-storage@*": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@types/continuation-local-storage/-/continuation-local-storage-3.2.4.tgz#655c8ffd9327aa60fb8ae773a5f2efbc973a7cbb" + integrity sha512-OT32vCVMymU1JMPKDeY0lX3cduAr0Pm/VwIbxygMeDS4lRcv57qYXn9bMwBRcRnEpXKBb/r4xYaZCARTZllP0A== + dependencies: + "@types/node" "*" + "@types/cookiejar@*": version "2.1.2" resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.2.tgz#66ad9331f63fe8a3d3d9d8c6e3906dd10f6446e8" @@ -1133,6 +1152,16 @@ dependencies: "@types/koa" "*" +"@types/koa2-ratelimit@^0.9.3": + version "0.9.3" + resolved "https://registry.yarnpkg.com/@types/koa2-ratelimit/-/koa2-ratelimit-0.9.3.tgz#a01b8bb1fc85ed2cb3273777baa53ee3ed04b912" + integrity sha512-9nQ+jbbcRH+ouuJo3742sp6GpyReDY/RnIr9gZHmicVTrpPivGBOzVw1Lozb2y3nrMu3xwoJM7w1dPmDZd8rpQ== + dependencies: + "@types/koa" "*" + "@types/redis" "^2.8.0" + "@types/sequelize" "*" + mongoose "^6.3.0" + "@types/koa@*", "@types/koa@^2.13.4": version "2.13.4" resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.4.tgz#10620b3f24a8027ef5cbae88b393d1b31205726b" @@ -1147,7 +1176,14 @@ "@types/koa-compose" "*" "@types/node" "*" -"@types/lodash@^4.14.182": +"@types/koa__cors@^3.3.0": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@types/koa__cors/-/koa__cors-3.3.0.tgz#2986b320d3d7ddf05c4e2e472b25a321cb16bd3b" + integrity sha512-FUN8YxcBakIs+walVe3+HcNP+Bxd0SB8BJHBWkglZ5C1XQWljlKcEFDG/dPiCIqwVCUbc5X0nYDlH62uEhdHMA== + dependencies: + "@types/koa" "*" + +"@types/lodash@*", "@types/lodash@^4.14.182": version "4.14.182" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.182.tgz#05301a4d5e62963227eaafe0ce04dd77c54ea5c2" integrity sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q== @@ -1197,6 +1233,23 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== +"@types/redis@^2.8.0": + version "2.8.32" + resolved "https://registry.yarnpkg.com/@types/redis/-/redis-2.8.32.tgz#1d3430219afbee10f8cfa389dad2571a05ecfb11" + integrity sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w== + dependencies: + "@types/node" "*" + +"@types/sequelize@*": + version "4.28.13" + resolved "https://registry.yarnpkg.com/@types/sequelize/-/sequelize-4.28.13.tgz#68eeea275d3e3dc7846dd0955bbc166cf7429952" + integrity sha512-ZR7k22F3xEqbGUWW7ZgysttmOUPIFyTPA2oPPjlT47h2iJtALrtNTXdEF5erW7bHCG2H+Byuy5MbpLj3znE7wQ== + dependencies: + "@types/bluebird" "*" + "@types/continuation-local-storage" "*" + "@types/lodash" "*" + "@types/validator" "*" + "@types/serve-static@*": version "1.13.10" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.10.tgz#f5e0ce8797d2d7cc5ebeda48a52c96c4fa47a8d9" @@ -1257,6 +1310,24 @@ resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc" integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw== +"@types/validator@*": + version "13.7.3" + resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.7.3.tgz#3193c0a3c03a7d1189016c62b4fba4b149ef5e33" + integrity sha512-DNviAE5OUcZ5s+XEQHRhERLg8fOp8gSgvyJ4aaFASx5wwaObm+PBwTIMXiOFm1QrSee5oYwEAYb7LMzX2O88gA== + +"@types/webidl-conversions@*": + version "6.1.1" + resolved "https://registry.yarnpkg.com/@types/webidl-conversions/-/webidl-conversions-6.1.1.tgz#e33bc8ea812a01f63f90481c666334844b12a09e" + integrity sha512-XAahCdThVuCFDQLT7R7Pk/vqeObFNL3YqRyFZg+AqAP/W1/w3xHaIxuW7WszQqTbIBOPRcItYJIou3i/mppu3Q== + +"@types/whatwg-url@^8.2.1": + version "8.2.2" + resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-8.2.2.tgz#749d5b3873e845897ada99be4448041d4cc39e63" + integrity sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA== + dependencies: + "@types/node" "*" + "@types/webidl-conversions" "*" + "@types/yargs-parser@*": version "21.0.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" @@ -1662,12 +1733,19 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" +bson@^4.6.2, bson@^4.6.3: + version "4.6.4" + resolved "https://registry.yarnpkg.com/bson/-/bson-4.6.4.tgz#e66d4a334f1ab230dfcfb9ec4ea9091476dd372e" + integrity sha512-TdQ3FzguAu5HKPPlr0kYQCyrYUYh8tFM+CMTpxjNzVzxeiJY00Rtuj3LXLHSgiGvmaWlZ8PE+4KyM2thqE38pQ== + dependencies: + buffer "^5.6.0" + buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== -buffer@^5.5.0: +buffer@^5.5.0, buffer@^5.6.0: version "5.7.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== @@ -2039,7 +2117,7 @@ dayjs@^1.11.2: resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.2.tgz#fa0f5223ef0d6724b3d8327134890cfe3d72fbe5" integrity sha512-F4LXf1OeU9hrSYRPTTj/6FbO4HTjPKXvEIC1P2kcnFurViINCVk3ZV0xAS3XVx9MkMsXbbqlK6hjseaYbgKEHw== -debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: +debug@4, debug@4.x, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -2086,6 +2164,11 @@ delegates@^1.0.0: resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= +denque@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/denque/-/denque-2.0.1.tgz#bcef4c1b80dc32efe97515744f21a4229ab8934a" + integrity sha512-tfiWc6BQLXNLpNiR5iGd0Ocu3P3VpxfzFiqubLgMfhfOw9WyvgJBd46CClNn9k3qfbjvT//0cf7AlYRX/OslMQ== + depd@2.0.0, depd@^2.0.0, depd@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" @@ -2967,6 +3050,11 @@ inquirer@6.5.2: strip-ansi "^5.1.0" through "^2.3.6" +ip@^1.1.5: + version "1.1.8" + resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.8.tgz#ae05948f6b075435ed3307acce04629da8cdbf48" + integrity sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg== + is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" @@ -3651,6 +3739,11 @@ just-extend@^4.0.2: resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.2.1.tgz#ef5e589afb61e5d66b24eca749409a8939a8c744" integrity sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg== +kareem@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/kareem/-/kareem-2.4.1.tgz#7d81ec518204a48c1cb16554af126806c3cd82b0" + integrity sha512-aJ9opVoXroQUPfovYP5kaj2lM7Jn02Gw13bL0lg9v0V7SaUc0qavPs0Eue7d2DcC3NjqI6QAUElXNsuZSeM+EA== + keygrip@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226" @@ -3695,6 +3788,11 @@ koa-router@^10.1.1: methods "^1.1.2" path-to-regexp "^6.1.0" +koa2-ratelimit@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/koa2-ratelimit/-/koa2-ratelimit-1.1.1.tgz#9c1d8257770e4a0a08063ba2ddcaf690fd457d23" + integrity sha512-IpxGMdZqEhMykW0yYKGVB4vDEacPvSBH4hNpDL38ABj3W2KHNLujAljGEDg7eEjXvrRbXRSWXzANhV3c9v7nyg== + koa@^2.13.4: version "2.13.4" resolved "https://registry.yarnpkg.com/koa/-/koa-2.13.4.tgz#ee5b0cb39e0b8069c38d115139c774833d32462e" @@ -3818,6 +3916,11 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= +memory-pager@^1.0.2: + version "1.5.0" + resolved "https://registry.yarnpkg.com/memory-pager/-/memory-pager-1.5.0.tgz#d8751655d22d384682741c972f2c3d6dfa3e66b5" + integrity sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg== + merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" @@ -3904,11 +4007,61 @@ minimist@^1.2.0, minimist@^1.2.6: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== +mongodb-connection-string-url@^2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/mongodb-connection-string-url/-/mongodb-connection-string-url-2.5.2.tgz#f075c8d529e8d3916386018b8a396aed4f16e5ed" + integrity sha512-tWDyIG8cQlI5k3skB6ywaEA5F9f5OntrKKsT/Lteub2zgwSUlhqEN2inGgBTm8bpYJf8QYBdA/5naz65XDpczA== + dependencies: + "@types/whatwg-url" "^8.2.1" + whatwg-url "^11.0.0" + +mongodb@4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-4.7.0.tgz#99f7323271d93659067695b60e7b4efee2de9bf0" + integrity sha512-HhVar6hsUeMAVlIbwQwWtV36iyjKd9qdhY+s4wcU8K6TOj4Q331iiMy+FoPuxEntDIijTYWivwFJkLv8q/ZgvA== + dependencies: + bson "^4.6.3" + denque "^2.0.1" + mongodb-connection-string-url "^2.5.2" + socks "^2.6.2" + optionalDependencies: + saslprep "^1.0.3" + +mongoose@^6.3.0: + version "6.4.1" + resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-6.4.1.tgz#e50e1a92ccf7764f2cc57b5a801a52918c7c3e72" + integrity sha512-6a3UmHaC2BYdxZT7qqwORqbxDfAa5HaRMidkA8Ll4Rupnl6R8vRu5Av13jx4DaxgJBpPDo4/K9AXxb+OGSD+5w== + dependencies: + bson "^4.6.2" + kareem "2.4.1" + mongodb "4.7.0" + mpath "0.9.0" + mquery "4.0.3" + ms "2.1.3" + sift "16.0.0" + +mpath@0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/mpath/-/mpath-0.9.0.tgz#0c122fe107846e31fc58c75b09c35514b3871904" + integrity sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew== + +mquery@4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/mquery/-/mquery-4.0.3.tgz#4d15f938e6247d773a942c912d9748bd1965f89d" + integrity sha512-J5heI+P08I6VJ2Ky3+33IpCdAvlYGTSUjwTPxkAr8i8EoduPMBX2OY/wa3IKZIQl7MU4SbFk8ndgSKyB/cl1zA== + dependencies: + debug "4.x" + ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + mute-stream@0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" @@ -4419,6 +4572,13 @@ safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +saslprep@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/saslprep/-/saslprep-1.0.3.tgz#4c02f946b56cf54297e347ba1093e7acac4cf226" + integrity sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag== + dependencies: + sparse-bitfield "^3.0.3" + saxes@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/saxes/-/saxes-5.0.1.tgz#eebab953fa3b7608dbe94e5dadb15c888fa6696d" @@ -4471,6 +4631,11 @@ side-channel@^1.0.4: get-intrinsic "^1.0.2" object-inspect "^1.9.0" +sift@16.0.0: + version "16.0.0" + resolved "https://registry.yarnpkg.com/sift/-/sift-16.0.0.tgz#447991577db61f1a8fab727a8a98a6db57a23eb8" + integrity sha512-ILTjdP2Mv9V1kIxWMXeMTIRbOBrqKc4JAXmFMnFq3fKeyQ2Qwa3Dw1ubcye3vR+Y6ofA0b9gNDr/y2t6eUeIzQ== + signal-exit@^3.0.2, signal-exit@^3.0.3: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" @@ -4498,6 +4663,19 @@ slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== +smart-buffer@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" + integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== + +socks@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/socks/-/socks-2.6.2.tgz#ec042d7960073d40d94268ff3bb727dc685f111a" + integrity sha512-zDZhHhZRY9PxRruRMR7kMhnf3I8hDs4S3f9RecfnGxvcBHQcKcIH/oUcEWffsfl1XxdYlA7nnlGbbTvPz9D8gA== + dependencies: + ip "^1.1.5" + smart-buffer "^4.2.0" + source-map-support@0.5.19: version "0.5.19" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61" @@ -4529,6 +4707,13 @@ source-map@^0.7.3: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== +sparse-bitfield@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz#ff4ae6e68656056ba4b3e792ab3334d38273ca11" + integrity sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ== + dependencies: + memory-pager "^1.0.2" + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" @@ -4788,6 +4973,13 @@ tr46@^2.1.0: dependencies: punycode "^2.1.1" +tr46@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-3.0.0.tgz#555c4e297a950617e8eeddef633c87d4d9d6cbf9" + integrity sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA== + dependencies: + punycode "^2.1.1" + tree-kill@1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" @@ -4873,6 +5065,13 @@ tslib@^2.3.0, tslib@^2.3.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== +tslog@^3.3.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/tslog/-/tslog-3.3.3.tgz#751a469e0d36841bd7e03676c27e53e7ffe9bc3d" + integrity sha512-lGrkndwpAohZ9ntQpT+xtUw5k9YFV1DjsksiWQlBSf82TTqsSAWBARPRD9juI730r8o3Awpkjp2aXy9k+6vr+g== + dependencies: + source-map-support "^0.5.21" + tsscmp@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb" @@ -5021,6 +5220,11 @@ webidl-conversions@^6.1.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514" integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== +webidl-conversions@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" + integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== + whatwg-encoding@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0" @@ -5033,6 +5237,14 @@ whatwg-mimetype@^2.3.0: resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g== +whatwg-url@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-11.0.0.tgz#0a849eebb5faf2119b901bb76fd795c2848d4018" + integrity sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ== + dependencies: + tr46 "^3.0.0" + webidl-conversions "^7.0.0" + whatwg-url@^8.0.0, whatwg-url@^8.5.0: version "8.7.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.7.0.tgz#656a78e510ff8f3937bc0bcbe9f5c0ac35941b77"