diff --git a/jest.preset.ts b/jest.preset.ts index c93ed9dc..5f777c00 100644 --- a/jest.preset.ts +++ b/jest.preset.ts @@ -1,3 +1,3 @@ const nxPreset = require('@nrwl/jest/preset'); -module.exports = { ...nxPreset }; +module.exports = { ...nxPreset, setupFilesAfterEnv: ['../../jest.setup.ts'] }; diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 00000000..93fab6a6 --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1,4 @@ +/** + * Add reflect-metadata to collect Inversify IoC decorator information when running jest test. + */ +import 'reflect-metadata'; diff --git a/package.json b/package.json index 67240c28..1fdfdc8b 100644 --- a/package.json +++ b/package.json @@ -2,21 +2,33 @@ "name": "vulcan", "version": "0.0.0", "license": "MIT", - "scripts": {}, + "scripts": { + "test": "jest" + }, "private": true, "dependencies": { "class-validator": "^0.13.2", + "@koa/cors": "^3.3.0", "glob": "^8.0.1", "inversify": "^6.0.1", + "dayjs": "^1.11.2", + "joi": "^17.6.0", "js-yaml": "^4.1.0", + "koa": "^2.13.4", + "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", "openapi3-ts": "^2.0.2", "reflect-metadata": "^0.1.13", - "tslib": "^2.3.0" + "tslib": "^2.3.0", + "tslog": "^3.3.3", + "uuid": "^8.3.2" }, "devDependencies": { + "@faker-js/faker": "^6.3.1", "@nrwl/cli": "14.0.3", "@nrwl/eslint-plugin-nx": "14.0.3", "@nrwl/jest": "14.0.3", @@ -26,9 +38,15 @@ "@types/glob": "^7.2.0", "@types/jest": "27.4.1", "@types/js-yaml": "^4.0.5", + "@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", + "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "~5.18.0", "@typescript-eslint/parser": "~5.18.0", "cz-conventional-changelog": "3.3.0", @@ -37,6 +55,7 @@ "jest": "27.5.1", "nx": "14.0.3", "prettier": "^2.5.1", + "supertest": "^6.2.3", "ts-essentials": "^9.1.2", "ts-jest": "27.1.4", "ts-node": "9.1.1", diff --git a/packages/build/src/lib/schema-parser/middleware/checkValidator.ts b/packages/build/src/lib/schema-parser/middleware/checkValidator.ts index 7e5d569e..92409687 100644 --- a/packages/build/src/lib/schema-parser/middleware/checkValidator.ts +++ b/packages/build/src/lib/schema-parser/middleware/checkValidator.ts @@ -1,9 +1,9 @@ import { SchemaParserMiddleware } from './middleware'; import { chain } from 'lodash'; -import { APISchema, ValidatorLoader } from '@vulcan/core'; +import { APISchema, IValidatorLoader } from '@vulcan/core'; export const checkValidator = - (loader: ValidatorLoader): SchemaParserMiddleware => + (loader: IValidatorLoader): SchemaParserMiddleware => async (schemas, next) => { await next(); const transformedSchemas = schemas as APISchema; @@ -16,11 +16,9 @@ export const checkValidator = throw new Error('Validator name is required'); } - const validator = loader.getLoader(validatorRequest.name); + const validator = await loader.load(validatorRequest.name); // TODO: indicate the detail of error - if (!validator.validateSchema(validatorRequest.args)) { - throw new Error(`Validator ${validatorRequest.name} schema invalid`); - } + validator.validateSchema(validatorRequest.args); } }; diff --git a/packages/build/src/lib/schema-parser/middleware/middleware.ts b/packages/build/src/lib/schema-parser/middleware/middleware.ts index 1cd274ec..b80a02c1 100644 --- a/packages/build/src/lib/schema-parser/middleware/middleware.ts +++ b/packages/build/src/lib/schema-parser/middleware/middleware.ts @@ -1,7 +1,7 @@ import { APISchema, FieldDataType, - RequestParameter, + RequestSchema as RequestParameter, ResponseProperty, ValidatorDefinition, } from '@vulcan/core'; diff --git a/packages/build/src/lib/schema-parser/middleware/setConstraints.ts b/packages/build/src/lib/schema-parser/middleware/setConstraints.ts index 42a9b8fc..901ceb38 100644 --- a/packages/build/src/lib/schema-parser/middleware/setConstraints.ts +++ b/packages/build/src/lib/schema-parser/middleware/setConstraints.ts @@ -1,24 +1,28 @@ -import { APISchema, ValidatorLoader } from '@vulcan/core'; +import { APISchema, IValidatorLoader } from '@vulcan/core'; import { chain } from 'lodash'; import { SchemaParserMiddleware } from './middleware'; export const setConstraints = - (loader: ValidatorLoader): SchemaParserMiddleware => + (loader: IValidatorLoader): SchemaParserMiddleware => async (rawSchema, next) => { await next(); const schema = rawSchema as APISchema; + for (const request of schema.request || []) { - request.constraints = chain(request.validators || []) - .map((validator) => ({ - validator: loader.getLoader(validator.name), + // load validator and keep args + const validatorsWithArgs = await Promise.all( + (request.validators || []).map(async (validator) => ({ + validator: await loader.load(validator.name), args: validator.args, })) + ); + // set constraint by validator and args + request.constraints = chain(validatorsWithArgs) .filter(({ validator }) => !!validator.getConstraints) .flatMap(({ validator, args }) => // eslint-disable-next-line @typescript-eslint/no-non-null-assertion validator.getConstraints!(args) - ) - // Group by constraint class (RequiredConstraint, MinValueConstraint ....) + ) // Group by constraint class (RequiredConstraint, MinValueConstraint ....) .groupBy((constraint) => constraint.constructor.name) .values() .map((constraints) => { diff --git a/packages/build/src/lib/schema-parser/schemaParser.ts b/packages/build/src/lib/schema-parser/schemaParser.ts index 3ad40f46..00b546bf 100644 --- a/packages/build/src/lib/schema-parser/schemaParser.ts +++ b/packages/build/src/lib/schema-parser/schemaParser.ts @@ -2,7 +2,7 @@ import { APISchema, TemplateMetadata, TYPES as CORE_TYPES, - ValidatorLoader, + IValidatorLoader, } from '@vulcan/core'; import { SchemaData, SchemaFormat, SchemaReader } from './schema-reader'; import * as yaml from 'js-yaml'; @@ -41,7 +41,7 @@ export class SchemaParser { @inject(TYPES.Factory_SchemaReader) schemaReaderFactory: interfaces.AutoNamedFactory, @inject(TYPES.SchemaParserOptions) schemaParserOptions: SchemaParserOptions, - @inject(CORE_TYPES.ValidatorLoader) validatorLoader: ValidatorLoader + @inject(CORE_TYPES.ValidatorLoader) validatorLoader: IValidatorLoader ) { this.schemaReader = schemaReaderFactory(schemaParserOptions.reader); diff --git a/packages/build/src/lib/spec-generator/oas3/oas3SpecGenerator.ts b/packages/build/src/lib/spec-generator/oas3/oas3SpecGenerator.ts index 20fa80fd..7ee4fd12 100644 --- a/packages/build/src/lib/spec-generator/oas3/oas3SpecGenerator.ts +++ b/packages/build/src/lib/spec-generator/oas3/oas3SpecGenerator.ts @@ -11,7 +11,7 @@ import { MinLengthConstraint, MinValueConstraint, RegexConstraint, - RequestParameter, + RequestSchema as RequestParameter, RequiredConstraint, ResponseProperty, } from '@vulcan/core'; diff --git a/packages/build/test/builder/builder.spec.ts b/packages/build/test/builder/builder.spec.ts index daeb17ac..e4b352ba 100644 --- a/packages/build/test/builder/builder.spec.ts +++ b/packages/build/test/builder/builder.spec.ts @@ -24,6 +24,7 @@ it('Builder.build should work', async () => { provider: TemplateProviderType.LocalFile, folderPath: path.resolve(__dirname, 'source'), }, + extensions: [], }; // Act, Assert diff --git a/packages/build/test/schema-parser/middleware/checkValidator.spec.ts b/packages/build/test/schema-parser/middleware/checkValidator.spec.ts index 67f07bd2..6aefc79c 100644 --- a/packages/build/test/schema-parser/middleware/checkValidator.spec.ts +++ b/packages/build/test/schema-parser/middleware/checkValidator.spec.ts @@ -1,6 +1,6 @@ import { RawAPISchema } from '@vulcan/build/schema-parser'; import { checkValidator } from '@vulcan/build/schema-parser/middleware/checkValidator'; -import { ValidatorLoader } from '@vulcan/core'; +import { IValidatorLoader } from '@vulcan/core'; import * as sinon from 'ts-sinon'; it('Should pass if there is no error', async () => { @@ -13,11 +13,11 @@ it('Should pass if there is no error', async () => { }, ], }; - const stubValidatorLoader = sinon.stubInterface(); - stubValidatorLoader.getLoader.returns({ + const stubValidatorLoader = sinon.stubInterface(); + stubValidatorLoader.load.resolves({ name: 'validator1', - validateSchema: () => true, - validateData: () => true, + validateSchema: () => null, + validateData: () => null, }); // Act Assert @@ -36,11 +36,11 @@ it('Should throw if some validators have no name', async () => { }, ], }; - const stubValidatorLoader = sinon.stubInterface(); - stubValidatorLoader.getLoader.returns({ + const stubValidatorLoader = sinon.stubInterface(); + stubValidatorLoader.load.resolves({ name: 'validator1', - validateSchema: () => true, - validateData: () => true, + validateSchema: () => null, + validateData: () => null, }); // Act Assert @@ -59,15 +59,17 @@ it('Should throw if the arguments of a validator is invalid', async () => { }, ], }; - const stubValidatorLoader = sinon.stubInterface(); - stubValidatorLoader.getLoader.returns({ + const stubValidatorLoader = sinon.stubInterface(); + stubValidatorLoader.load.resolves({ name: 'validator1', - validateSchema: () => false, - validateData: () => true, + validateSchema: () => { + throw new Error(); + }, + validateData: () => null, }); // Act Assert await expect( checkValidator(stubValidatorLoader)(schema, async () => Promise.resolve()) - ).rejects.toThrow('Validator validator1 schema invalid'); + ).rejects.toThrow(); }); diff --git a/packages/build/test/schema-parser/middleware/generatePathParameters.spec.ts b/packages/build/test/schema-parser/middleware/generatePathParameters.spec.ts index 94f8d498..261ffe1a 100644 --- a/packages/build/test/schema-parser/middleware/generatePathParameters.spec.ts +++ b/packages/build/test/schema-parser/middleware/generatePathParameters.spec.ts @@ -1,4 +1,4 @@ -import { RawAPISchema } from '@vulcan/build/schema-parser/.'; +import { RawAPISchema } from '@vulcan/build/schema-parser'; import { generatePathParameters } from '@vulcan/build/schema-parser/middleware'; import { FieldDataType, FieldInType } from '@vulcan/core'; diff --git a/packages/build/test/schema-parser/middleware/generateTemplateSource.spec.ts b/packages/build/test/schema-parser/middleware/generateTemplateSource.spec.ts index 245c554b..96d07cd2 100644 --- a/packages/build/test/schema-parser/middleware/generateTemplateSource.spec.ts +++ b/packages/build/test/schema-parser/middleware/generateTemplateSource.spec.ts @@ -1,4 +1,4 @@ -import { RawAPISchema } from '@vulcan/build/schema-parser/.'; +import { RawAPISchema } from '@vulcan/build/schema-parser'; import { generateTemplateSource } from '@vulcan/build/schema-parser/middleware/generateTemplateSource'; it('Should keep templateSource in schema', async () => { diff --git a/packages/build/test/schema-parser/middleware/generateUrl.spec.ts b/packages/build/test/schema-parser/middleware/generateUrl.spec.ts index 1a84b397..7b52dbfa 100644 --- a/packages/build/test/schema-parser/middleware/generateUrl.spec.ts +++ b/packages/build/test/schema-parser/middleware/generateUrl.spec.ts @@ -1,4 +1,4 @@ -import { RawAPISchema } from '@vulcan/build/schema-parser/.'; +import { RawAPISchema } from '@vulcan/build/schema-parser'; import { generateUrl } from '@vulcan/build/schema-parser/middleware/generateUrl'; it('Should keep url in schema', async () => { diff --git a/packages/build/test/schema-parser/middleware/setConstraints.spec.ts b/packages/build/test/schema-parser/middleware/setConstraints.spec.ts index 6be0577a..a6f2d469 100644 --- a/packages/build/test/schema-parser/middleware/setConstraints.spec.ts +++ b/packages/build/test/schema-parser/middleware/setConstraints.spec.ts @@ -4,17 +4,17 @@ import { Constraint, MinValueConstraint, RequiredConstraint, - ValidatorLoader, + IValidatorLoader, } from '@vulcan/core'; import * as sinon from 'ts-sinon'; it('Should set and compose constraints', async () => { // Arrange - const stubValidatorLoader = sinon.stubInterface(); - stubValidatorLoader.getLoader.callsFake((name) => ({ + const stubValidatorLoader = sinon.stubInterface(); + stubValidatorLoader.load.callsFake(async (name) => ({ name, - validateData: () => true, - validateSchema: () => true, + validateData: () => null, + validateSchema: () => null, getConstraints: (args) => { if (name === 'required') return [Constraint.Required()]; return [Constraint.MinValue(args.value)]; diff --git a/packages/build/test/schema-parser/middleware/transformValidator.spec.ts b/packages/build/test/schema-parser/middleware/transformValidator.spec.ts index f6a6c58e..a6b134bc 100644 --- a/packages/build/test/schema-parser/middleware/transformValidator.spec.ts +++ b/packages/build/test/schema-parser/middleware/transformValidator.spec.ts @@ -1,4 +1,4 @@ -import { RawAPISchema } from '@vulcan/build/schema-parser/.'; +import { RawAPISchema } from '@vulcan/build/schema-parser'; import { transformValidator } from '@vulcan/build/schema-parser/middleware/transformValidator'; it('Should convert string validator to proper format', async () => { diff --git a/packages/build/test/schema-parser/schemaParser.spec.ts b/packages/build/test/schema-parser/schemaParser.spec.ts index 83ce5d6a..b1d21992 100644 --- a/packages/build/test/schema-parser/schemaParser.spec.ts +++ b/packages/build/test/schema-parser/schemaParser.spec.ts @@ -6,18 +6,18 @@ import { SchemaParser, SchemaReader, } from '@vulcan/build/schema-parser'; -import { ValidatorLoader, TYPES as CORE_TYPES } from '@vulcan/core'; +import { IValidatorLoader, TYPES as CORE_TYPES } from '@vulcan/core'; import { Container } from 'inversify'; import * as sinon from 'ts-sinon'; let container: Container; let stubSchemaReader: sinon.StubbedInstance; -let stubValidatorLoader: sinon.StubbedInstance; +let stubValidatorLoader: sinon.StubbedInstance; beforeEach(() => { container = new Container(); stubSchemaReader = sinon.stubInterface(); - stubValidatorLoader = sinon.stubInterface(); + stubValidatorLoader = sinon.stubInterface(); container .bind(TYPES.Factory_SchemaReader) @@ -59,10 +59,10 @@ request: }; }; stubSchemaReader.readSchema.returns(generator()); - stubValidatorLoader.getLoader.returns({ + stubValidatorLoader.load.resolves({ name: 'validator1', - validateSchema: () => true, - validateData: () => true, + validateSchema: () => null, + validateData: () => null, }); const schemaParser = container.get(TYPES.SchemaParser); diff --git a/packages/build/test/spec-generator/oas3.spec.ts b/packages/build/test/spec-generator/oas3.spec.ts index 93e4e25c..090b23e0 100644 --- a/packages/build/test/spec-generator/oas3.spec.ts +++ b/packages/build/test/spec-generator/oas3.spec.ts @@ -1,7 +1,7 @@ import { OAS3SpecGenerator } from '@vulcan/build/spec-generator'; import { getSchemas, getConfig } from './schema'; import * as jsYaml from 'js-yaml'; -import * as fs from 'fs/promises'; +import { promises as fs } from 'fs'; import * as path from 'path'; import { get } from 'lodash'; diff --git a/packages/build/test/spec-generator/schema.ts b/packages/build/test/spec-generator/schema.ts index 3a7026b9..f63b63eb 100644 --- a/packages/build/test/spec-generator/schema.ts +++ b/packages/build/test/spec-generator/schema.ts @@ -2,13 +2,8 @@ /* istanbul ignore file */ import * as glob from 'glob'; import * as path from 'path'; -import * as fs from 'fs/promises'; -import { - APISchema, - Constraint, - IValidator, - ValidatorLoader, -} from '@vulcan/core'; +import { promises as fs } from 'fs'; +import { APISchema, Constraint, IValidatorLoader } from '@vulcan/core'; import * as jsYaml from 'js-yaml'; import { sortBy } from 'lodash'; import { IBuildOptions } from '@vulcan/build'; @@ -33,56 +28,56 @@ const getSchemaPaths = () => }); const getStubLoader = () => { - const validatorLoader = sinon.stubInterface(); - validatorLoader.getLoader.callsFake((name): IValidator => { + const validatorLoader = sinon.stubInterface(); + validatorLoader.load.callsFake(async (name) => { switch (name) { case 'required': return { name: 'required', - validateSchema: () => true, - validateData: () => true, + validateSchema: () => null, + validateData: () => null, getConstraints: () => [Constraint.Required()], }; case 'minValue': return { name: 'minValue', - validateSchema: () => true, - validateData: () => true, + validateSchema: () => null, + validateData: () => null, getConstraints: (args) => [Constraint.MinValue(args.value)], }; case 'maxValue': return { name: 'maxValue', - validateSchema: () => true, - validateData: () => true, + validateSchema: () => null, + validateData: () => null, getConstraints: (args) => [Constraint.MaxValue(args.value)], }; case 'minLength': return { name: 'minLength', - validateSchema: () => true, - validateData: () => true, + validateSchema: () => null, + validateData: () => null, getConstraints: (args) => [Constraint.MinLength(args.value)], }; case 'maxLength': return { name: 'maxLength', - validateSchema: () => true, - validateData: () => true, + validateSchema: () => null, + validateData: () => null, getConstraints: (args) => [Constraint.MaxLength(args.value)], }; case 'regex': return { name: 'regex', - validateSchema: () => true, - validateData: () => true, + validateSchema: () => null, + validateData: () => null, getConstraints: (args) => [Constraint.Regex(args.value)], }; case 'enum': return { name: 'enum', - validateSchema: () => true, - validateData: () => true, + validateSchema: () => null, + validateData: () => null, getConstraints: (args) => [Constraint.Enum(args.value)], }; default: @@ -123,5 +118,6 @@ export const getConfig = (): IBuildOptions => { template: {} as any, artifact: {} as any, schemaParser: {} as any, + extensions: [] as any, }; }; diff --git a/packages/core/package.json b/packages/core/package.json index 145a1038..6fc297e5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,5 +1,6 @@ { "name": "@vulcan/core", "version": "0.0.1", - "type": "commonjs" + "type": "commonjs", + "dependencies": {} } diff --git a/packages/core/src/containers/container.ts b/packages/core/src/containers/container.ts index 7811c811..57c6f620 100644 --- a/packages/core/src/containers/container.ts +++ b/packages/core/src/containers/container.ts @@ -4,7 +4,7 @@ import { artifactBuilderModule, executorModule, templateEngineModule, - validatorModule, + validatorLoaderModule, } from './modules'; export class Container { @@ -16,11 +16,13 @@ export class Container { public async load(options: ICoreOptions) { this.inversifyContainer.load(artifactBuilderModule(options.artifact)); - this.inversifyContainer.load(executorModule()); + await this.inversifyContainer.loadAsync(executorModule()); await this.inversifyContainer.loadAsync( templateEngineModule(options.template) ); - this.inversifyContainer.load(validatorModule()); + await this.inversifyContainer.loadAsync( + validatorLoaderModule(options.extensions) + ); } public getInversifyContainer() { diff --git a/packages/core/src/containers/modules/executor.ts b/packages/core/src/containers/modules/executor.ts index 6e5492dd..6e7ec446 100644 --- a/packages/core/src/containers/modules/executor.ts +++ b/packages/core/src/containers/modules/executor.ts @@ -1,25 +1,44 @@ -import { ContainerModule } from 'inversify'; -// TODO: Should replace with a real implementation import { - QueryBuilder, - Executor, -} from '../../lib/template-engine/built-in-extensions/query-builder/reqTagRunner'; + IExecutor, + QueryExecutor, + SQLClauseOperation, +} from '@vulcan/core/data-query'; +import { Pagination } from '../../models/pagination'; +import { IDataSource } from '@vulcan/core/data-source'; +import { AsyncContainerModule } from 'inversify'; import { TYPES } from '../types'; -class MockBuilder implements QueryBuilder { - public count() { - return this; - } +/** + * TODO: Mock data source to make data query builder could create by IoC + * need to update after real data source implemented. + * */ - public async value() { - return []; +class MockDataSource implements IDataSource { + public async execute({ + statement, + operations, + pagination, + }: { + statement: string; + operations: SQLClauseOperation; + pagination?: Pagination | undefined; + }) { + return { + statement, + operations, + pagination, + }; } } export const executorModule = () => - new ContainerModule((bind) => { - bind(TYPES.Executor).toConstantValue({ - // TODO: Mock value - createBuilder: async () => new MockBuilder(), + new AsyncContainerModule(async (bind) => { + /** + * TODO: bind mock data source, need to update after real data source implemented. + */ + bind(TYPES.DataSource).toConstantValue(new MockDataSource()); + bind(TYPES.Executor).toDynamicValue((context) => { + const dataSource = context.container.get(TYPES.DataSource); + return new QueryExecutor(dataSource); }); }); diff --git a/packages/core/src/containers/modules/index.ts b/packages/core/src/containers/modules/index.ts index 4d7f5884..d8a74aec 100644 --- a/packages/core/src/containers/modules/index.ts +++ b/packages/core/src/containers/modules/index.ts @@ -1,4 +1,4 @@ export * from './artifactBuilder'; export * from './executor'; export * from './templateEngine'; -export * from './validator'; +export * from './validatorLoader'; diff --git a/packages/core/src/containers/modules/validator.ts b/packages/core/src/containers/modules/validator.ts deleted file mode 100644 index 2b1a7e2a..00000000 --- a/packages/core/src/containers/modules/validator.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ContainerModule } from 'inversify'; -import { ValidatorLoader } from '../../validators/validatorLoader'; -import { TYPES } from '../types'; - -export const validatorModule = () => - new ContainerModule((bind) => { - bind(TYPES.ValidatorLoader).toConstantValue({ - // TODO: Mock value - getLoader: (name: string) => { - return { - name, - validateSchema: () => true, - validateData: () => true, - }; - }, - }); - }); diff --git a/packages/core/src/containers/modules/validatorLoader.ts b/packages/core/src/containers/modules/validatorLoader.ts new file mode 100644 index 00000000..532b56b9 --- /dev/null +++ b/packages/core/src/containers/modules/validatorLoader.ts @@ -0,0 +1,16 @@ +import { AsyncContainerModule } from 'inversify'; +import { IValidatorLoader, ValidatorLoader } from '@vulcan/core/validators'; +import { TYPES } from '../types'; +import { SourceOfExtensions } from '../../models/coreOptions'; + +export const validatorLoaderModule = (extensions?: SourceOfExtensions) => + new AsyncContainerModule(async (bind) => { + // SourceOfExtensions + bind(TYPES.SourceOfExtensions).toConstantValue( + extensions || [] + ); + // Validator Loader + bind(TYPES.ValidatorLoader) + .to(ValidatorLoader) + .inSingletonScope(); + }); diff --git a/packages/core/src/containers/types.ts b/packages/core/src/containers/types.ts index 22ae4086..cd573554 100644 --- a/packages/core/src/containers/types.ts +++ b/packages/core/src/containers/types.ts @@ -19,6 +19,12 @@ export const TYPES = { TemplateEngineInputOptions: Symbol.for('TemplateEngineInputOptions'), // Executor Executor: Symbol.for('Executor'), + // Data Query Builder + DataQueryBuilder: Symbol.for('DataQueryBuilder'), + // Data Source + DataSource: Symbol.for('DataSource'), // Validator ValidatorLoader: Symbol.for('ValidatorLoader'), + // source of extensions + SourceOfExtensions: Symbol.for('SourceOfExtensions'), }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fd7e964f..a576d2e1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,8 @@ +export * from './lib/utils'; +export * from './lib/validators'; export * from './lib/template-engine'; -export * from './models'; -export * from './validators'; export * from './lib/artifact-builder'; +export * from './lib/data-query'; +export * from './lib/data-source'; +export * from './models'; export * from './containers'; diff --git a/packages/core/src/lib/data-query/builder/commonTypes.ts b/packages/core/src/lib/data-query/builder/commonTypes.ts new file mode 100644 index 00000000..0f52a9d8 --- /dev/null +++ b/packages/core/src/lib/data-query/builder/commonTypes.ts @@ -0,0 +1,35 @@ +export enum ComparisonPredicate { + BETWEEN = 'BETWEEN', + IN = 'IN', + IS_NULL = 'IS_NULL', + EXISTS = 'EXISTS', +} + +export enum LogicalOperator { + AND = 'AND', + OR = 'OR', + NOT = 'NOT', +} + +export type ComparisonOperator = '=' | '!=' | '>' | '<' | '>=' | '<='; + +export const isOfComparisonOperator = ( + source: string +): source is ComparisonOperator => { + return ['=', '!=', '>', '<', '>=', '<='].includes(source); +}; + +export interface BetweenPredicateInput { + column: string; + min: number; + max: number; +} + +export interface InPredicateInput { + column: string; + values: string[] | number[]; +} + +export interface NullPredicateInput { + column: string; +} diff --git a/packages/core/src/lib/data-query/builder/dataQueryBuilder.ts b/packages/core/src/lib/data-query/builder/dataQueryBuilder.ts new file mode 100644 index 00000000..813261d7 --- /dev/null +++ b/packages/core/src/lib/data-query/builder/dataQueryBuilder.ts @@ -0,0 +1,1176 @@ +import { IDataSource } from '@vulcan/core/data-source'; +import { Pagination } from '@vulcan/core/models'; + +import { find, isEmpty } from 'lodash'; +import { + ComparisonPredicate, + ComparisonOperator, + LogicalOperator, + BetweenPredicateInput, + InPredicateInput, + NullPredicateInput, + isOfComparisonOperator, +} from './commonTypes'; +import { + IJoinOnClause, + JoinOnClause, + JoinOnClauseOperation, +} from './joinOnClause'; + +export enum SelectCommandType { + SELECT = 'SELECT', + SELECT_DISTINCT = 'SELECT_DISTINCT', +} + +export enum AggregateFuncType { + COUNT = 'COUNT', + SUM = 'SUM', + AVG = 'AVG', + MIN = 'MIN', + MAX = 'MAX', +} + +export enum JoinCommandType { + INNER_JOIN = 'INNER_JOIN', + LEFT_JOIN = 'LEFT_JOIN', + RIGHT_JOIN = 'RIGHT_JOIN', + FULL_JOIN = 'FULL_JOIN', +} + +export enum WherePredicate { + WRAPPED = 'WRAPPED', + LIKE = 'LIKE', +} + +export interface AliasColumn { + // original column name + name: string; + // alias column name + as?: string; +} + +export interface SelectedColumn extends AliasColumn { + aggregateType?: AggregateFuncType; +} + +export interface SelectClauseOperation { + command: SelectCommandType; + columns: Array; +} + +export interface AliasDataQueryBuilder { + // the join builder + builder: IDataQueryBuilder; + // alias builder name for join builder + as: string; +} + +export interface JoinClauseOperation { + command: JoinCommandType; + // the join on clause operations e.g: on, onBetween... + onClauses: Array; + joinBuilder: AliasDataQueryBuilder; +} + +export type JoinClauseCallback = (clause: IJoinOnClause) => void; + +// Where clause Operation +export interface WhereOperatorInput { + column: string; + operator: ComparisonOperator; + value: string | number | boolean | IDataQueryBuilder; +} + +export type BuilderClauseCallback = (builder: IDataQueryBuilder) => void; + +export type WhereInPredicateInput = InPredicateInput & { + values: string[] | number[] | IDataQueryBuilder; +}; + +export interface WhereLikePredicateInput { + column: string; + searchValue: string; +} + +export type WherePredicateInput = + | WhereOperatorInput + | BetweenPredicateInput + | WhereInPredicateInput + | NullPredicateInput + | WhereLikePredicateInput + | IDataQueryBuilder; + +export interface WhereClauseOperation { + // null means using ComparisonOperator + command: WherePredicate | ComparisonPredicate | LogicalOperator | null; + /* If command is LogicalOperator type, data will be undefined or, data multi possible cases, including: + - command is WHERE => data will be NormalWhereClauseOperation type + - command is WHERE_WRAPPED => data will be Array type + - command is WHERE_EXIST => data will be AliasDataQueryBuilder type + */ + data?: + | WherePredicateInput + | Array + | AliasDataQueryBuilder; +} + +// Group by clause Operation +export type GroupByClauseOperations = Array; + +// Having clause Operation +export interface HavingOperatorInput { + column: SelectedColumn; + operator: ComparisonOperator; + value: string | number | boolean | IDataQueryBuilder; +} + +export type HavingInPredicateInput = InPredicateInput & { + column: SelectedColumn; +}; + +export interface HavingBetweenPredicateInput { + column: SelectedColumn; + min: number; + max: number; +} + +export type HavingPredicateInput = + | HavingOperatorInput + | HavingBetweenPredicateInput + | HavingInPredicateInput + | NullPredicateInput + | IDataQueryBuilder; + +export interface HavingClauseOperation { + // null means using ComparisonOperator + command: ComparisonPredicate | LogicalOperator | null; + // data could be normal value, object or wrapped where operations + data?: HavingPredicateInput | AliasDataQueryBuilder; +} + +// Order by clause operation +export enum Direction { + ASC = 'ASCENDING', + DESC = 'DESCENDING', +} +export interface OrderByClauseOperation { + column: string; + direction: Direction; +} + +export interface SQLClauseOperation { + // record select clause operations, null means select * + select: SelectClauseOperation | null; + // record where clause operations + where: Array; + // record join clause operations => ok + join: Array; + // record groupBy clause operations, array is column name + groupBy: GroupByClauseOperations; + // recode having clause operations + having: Array; + // record orderBy operations + orderBy: Array; + // null means not set the value + limit: number | null; + // null means not set the value + offset: number | null; +} + +export interface IDataQueryBuilder { + readonly statement: string; + readonly operations: SQLClauseOperation; + readonly dataSource: IDataSource; + + // Select clause methods + select(...columns: Array): IDataQueryBuilder; + distinct(...columns: Array): IDataQueryBuilder; + column(...columns: Array): IDataQueryBuilder; + first(...columns: Array): IDataQueryBuilder; + count(column: AliasColumn | string): IDataQueryBuilder; + min(column: AliasColumn | string): IDataQueryBuilder; + max(column: AliasColumn | string): IDataQueryBuilder; + sum(column: AliasColumn | string): IDataQueryBuilder; + avg(column: AliasColumn | string): IDataQueryBuilder; + // Join clause methods + innerJoin( + builder: AliasDataQueryBuilder, + joinCallback: JoinClauseCallback + ): IDataQueryBuilder; + leftJoin( + builder: AliasDataQueryBuilder, + joinCallback: JoinClauseCallback + ): IDataQueryBuilder; + rightJoin( + builder: AliasDataQueryBuilder, + joinCallback: JoinClauseCallback + ): IDataQueryBuilder; + fullJoin( + builder: AliasDataQueryBuilder, + joinCallback: JoinClauseCallback + ): IDataQueryBuilder; + // Where clause methods + where( + column: string, + operator: string, + value: string | number | boolean | IDataQueryBuilder + ): IDataQueryBuilder; + whereNot( + column: string, + operator: string, + value: string | number | boolean | IDataQueryBuilder + ): IDataQueryBuilder; + whereWrapped(builderCallback: BuilderClauseCallback): IDataQueryBuilder; + whereNotWrapped(builderCallback: BuilderClauseCallback): IDataQueryBuilder; + whereBetween(column: string, min: number, max: number): IDataQueryBuilder; + whereNotBetween(column: string, min: number, max: number): IDataQueryBuilder; + whereIn( + column: string, + values: string[] | number[] | IDataQueryBuilder + ): IDataQueryBuilder; + whereNotIn( + column: string, + values: string[] | number[] | IDataQueryBuilder + ): IDataQueryBuilder; + whereNull(column: string): IDataQueryBuilder; + whereNotNull(column: string): IDataQueryBuilder; + whereLike(column: string, searchValue: string): IDataQueryBuilder; + whereExists(subQueryBuilder: AliasDataQueryBuilder): IDataQueryBuilder; + whereNotExists(subQueryBuilder: AliasDataQueryBuilder): IDataQueryBuilder; + // And Where clause methods + andWhere( + column: string, + operator: string, + value: string | number | boolean | IDataQueryBuilder + ): IDataQueryBuilder; + andWhereNot( + column: string, + operator: string, + value: string | number | boolean | IDataQueryBuilder + ): IDataQueryBuilder; + andWhereWrapped(builderCallback: BuilderClauseCallback): IDataQueryBuilder; + andWhereNotWrapped(builderCallback: BuilderClauseCallback): IDataQueryBuilder; + andWhereBetween(column: string, min: number, max: number): IDataQueryBuilder; + andWhereNotBetween( + column: string, + min: number, + max: number + ): IDataQueryBuilder; + andWhereIn( + column: string, + values: string[] | number[] | IDataQueryBuilder + ): IDataQueryBuilder; + andWhereNotIn( + column: string, + values: string[] | number[] | IDataQueryBuilder + ): IDataQueryBuilder; + andWhereNull(column: string): IDataQueryBuilder; + andWhereNotNull(column: string): IDataQueryBuilder; + andWhereLike(column: string, searchValue: string): IDataQueryBuilder; + andWhereExists(subQueryBuilder: AliasDataQueryBuilder): IDataQueryBuilder; + andWhereNotExists(subQueryBuilder: AliasDataQueryBuilder): IDataQueryBuilder; + // Or Where clause methods + orWhere( + column: string, + operator: string, + value: string | number | boolean | IDataQueryBuilder + ): IDataQueryBuilder; + orWhereNot( + column: string, + operator: string, + value: string | number | boolean | IDataQueryBuilder + ): IDataQueryBuilder; + orWhereWrapped(builderCallback: BuilderClauseCallback): IDataQueryBuilder; + orWhereNotWrapped(builderCallback: BuilderClauseCallback): IDataQueryBuilder; + orWhereBetween(column: string, min: number, max: number): IDataQueryBuilder; + orWhereNotBetween( + column: string, + min: number, + max: number + ): IDataQueryBuilder; + orWhereIn( + column: string, + values: string[] | number[] | IDataQueryBuilder + ): IDataQueryBuilder; + orWhereNotIn( + column: string, + values: string[] | number[] | IDataQueryBuilder + ): IDataQueryBuilder; + orWhereNull(column: string): IDataQueryBuilder; + orWhereNotNull(column: string): IDataQueryBuilder; + orWhereLike(column: string, searchValue: string): IDataQueryBuilder; + orWhereExists(subQueryBuilder: AliasDataQueryBuilder): IDataQueryBuilder; + orWhereNotExists(subQueryBuilder: AliasDataQueryBuilder): IDataQueryBuilder; + + // Group by clause method + groupBy(...columns: string[]): IDataQueryBuilder; + + // Having clause methods + having( + column: SelectedColumn | string, + operator: string, + value: string | number | boolean | IDataQueryBuilder + ): IDataQueryBuilder; + havingIn( + column: SelectedColumn | string, + values: number[] | string[] + ): IDataQueryBuilder; + havingNotIn( + column: SelectedColumn | string, + values: number[] | string[] + ): IDataQueryBuilder; + havingBetween( + column: SelectedColumn | string, + min: number, + max: number + ): IDataQueryBuilder; + havingNotBetween( + column: SelectedColumn | string, + min: number, + max: number + ): IDataQueryBuilder; + havingNull(column: string): IDataQueryBuilder; + havingNotNull(column: string): IDataQueryBuilder; + havingExists(subQueryBuilder: AliasDataQueryBuilder): IDataQueryBuilder; + havingNotExists(subQueryBuilder: AliasDataQueryBuilder): IDataQueryBuilder; + // And Having clause methods + andHaving( + column: SelectedColumn | string, + operator: string, + value: string | number | boolean | IDataQueryBuilder + ): IDataQueryBuilder; + andHavingIn( + column: SelectedColumn | string, + values: number[] | string[] + ): IDataQueryBuilder; + andHavingNotIn( + column: SelectedColumn | string, + values: number[] | string[] + ): IDataQueryBuilder; + andHavingBetween( + column: SelectedColumn | string, + min: number, + max: number + ): IDataQueryBuilder; + andHavingNotBetween( + column: SelectedColumn | string, + min: number, + max: number + ): IDataQueryBuilder; + andHavingNull(column: string): IDataQueryBuilder; + andHavingNotNull(column: string): IDataQueryBuilder; + andHavingExists(subQueryBuilder: AliasDataQueryBuilder): IDataQueryBuilder; + andHavingNotExists(subQueryBuilder: AliasDataQueryBuilder): IDataQueryBuilder; + // Or Having clause methods + orHaving( + column: SelectedColumn | string, + operator: string, + value: string | number | boolean | IDataQueryBuilder + ): IDataQueryBuilder; + orHavingIn( + column: SelectedColumn | string, + values: number[] | string[] + ): IDataQueryBuilder; + orHavingNotIn( + column: SelectedColumn | string, + values: number[] | string[] + ): IDataQueryBuilder; + orHavingBetween( + column: SelectedColumn | string, + min: number, + max: number + ): IDataQueryBuilder; + orHavingNotBetween( + column: SelectedColumn | string, + min: number, + max: number + ): IDataQueryBuilder; + orHavingNull(column: string): IDataQueryBuilder; + orHavingNotNull(column: string): IDataQueryBuilder; + orHavingExists(subQueryBuilder: AliasDataQueryBuilder): IDataQueryBuilder; + orHavingNotExists(subQueryBuilder: AliasDataQueryBuilder): IDataQueryBuilder; + // Order by clause method + orderBy(column: string, direction: Direction): IDataQueryBuilder; + // Limit and Offset clause method + limit(size: number): IDataQueryBuilder; + offset(move: number): IDataQueryBuilder; + take(size: number, move: number): IDataQueryBuilder; + // paginate + paginate(pagination: Pagination): void; + value(): Promise; + clone(): IDataQueryBuilder; +} + +export class DataQueryBuilder implements IDataQueryBuilder { + public readonly statement: string; + // record all operations for different SQL clauses + public readonly operations: SQLClauseOperation; + public readonly dataSource: IDataSource; + public pagination?: Pagination; + constructor({ + statement, + operations, + dataSource, + }: { + statement: string; + operations?: SQLClauseOperation; + dataSource: IDataSource; + }) { + this.statement = statement; + this.dataSource = dataSource; + this.operations = operations || { + select: null, + where: [], + join: [], + groupBy: [], + having: [], + orderBy: [], + limit: null, + offset: null, + }; + } + + // Select clause methods + public select(...columns: Array) { + // if columns is empty, set to select all + if (isEmpty(columns)) columns = ['*']; + // skip current select if select only '*' value and has existed select '*' in record. + const isSelectAllExist = (column: SelectedColumn) => column.name === '*'; + const isAllColumns = (column: string | SelectedColumn) => + ['*', ''].includes(column as string) || + [{ name: '*' }, { name: '' }].includes(column as SelectedColumn); + if ( + columns.length === 1 && + isAllColumns(columns[0]) && + this.operations.select && + find(this.operations.select.columns, isSelectAllExist) + ) { + return this; + } + + this.recordSelect({ command: SelectCommandType.SELECT, columns }); + return this; + } + public distinct(...columns: Array) { + this.recordSelect({ command: SelectCommandType.SELECT_DISTINCT, columns }); + return this; + } + + // alias name method for select + public column(...columns: Array) { + return this.select(...columns); + } + // select and limit 1 + public first(...columns: Array) { + this.select(...columns); + this.limit(1); + return this; + } + + public count(column: AliasColumn | string = '*') { + const normalized: SelectedColumn = + typeof column === 'string' + ? { + name: '*', + aggregateType: AggregateFuncType.COUNT, + } + : { + ...column, + aggregateType: AggregateFuncType.COUNT, + }; + this.select(normalized); + return this; + } + + public min(column: AliasColumn | string) { + const normalized: SelectedColumn = + typeof column === 'string' + ? { + name: column, + aggregateType: AggregateFuncType.MIN, + } + : { + ...column, + aggregateType: AggregateFuncType.MIN, + }; + this.select(normalized); + return this; + } + + public max(column: AliasColumn | string) { + const normalized: SelectedColumn = + typeof column === 'string' + ? { + name: column, + aggregateType: AggregateFuncType.MAX, + } + : { + ...column, + aggregateType: AggregateFuncType.MAX, + }; + this.select(normalized); + return this; + } + public avg(column: AliasColumn | string) { + const normalized: SelectedColumn = + typeof column === 'string' + ? { + name: column, + aggregateType: AggregateFuncType.AVG, + } + : { + ...column, + aggregateType: AggregateFuncType.AVG, + }; + this.select(normalized); + return this; + } + public sum(column: AliasColumn | string) { + const normalized: SelectedColumn = + typeof column === 'string' + ? { + name: column, + aggregateType: AggregateFuncType.SUM, + } + : { + ...column, + aggregateType: AggregateFuncType.SUM, + }; + this.select(normalized); + return this; + } + // Join clause methods + public innerJoin( + builder: AliasDataQueryBuilder, + joinCallback: JoinClauseCallback + ) { + this.recordJoin({ + command: JoinCommandType.INNER_JOIN, + builder, + joinCallback, + }); + return this; + } + + public leftJoin( + builder: AliasDataQueryBuilder, + joinCallback: JoinClauseCallback + ) { + this.recordJoin({ + command: JoinCommandType.LEFT_JOIN, + builder, + joinCallback, + }); + return this; + } + + public rightJoin( + builder: AliasDataQueryBuilder, + joinCallback: JoinClauseCallback + ) { + this.recordJoin({ + command: JoinCommandType.RIGHT_JOIN, + builder, + joinCallback, + }); + return this; + } + public fullJoin( + builder: AliasDataQueryBuilder, + joinCallback: JoinClauseCallback + ) { + this.recordJoin({ + command: JoinCommandType.FULL_JOIN, + builder, + joinCallback, + }); + return this; + } + + // Where clause methods + public where( + column: string, + operator: string, + value: string | number | boolean | IDataQueryBuilder + ) { + if (!isOfComparisonOperator(operator)) + throw new Error(`'There is no ${operator} operator.`); + + this.recordWhere({ + command: null, + data: { + column, + operator, + value, + } as WherePredicateInput, + }); + return this; + } + public whereNot( + column: string, + operator: string, + value: string | number | boolean | IDataQueryBuilder + ) { + this.recordWhere({ command: LogicalOperator.NOT }); + return this.where(column, operator, value); + } + + public whereWrapped(builderCallback: BuilderClauseCallback) { + const wrappedBuilder = new DataQueryBuilder({ + statement: '', + dataSource: this.dataSource, + }); + builderCallback(wrappedBuilder); + this.recordWhere({ + command: WherePredicate.WRAPPED, + data: wrappedBuilder.operations.where, + }); + return this; + } + public whereNotWrapped(builderCallback: BuilderClauseCallback) { + this.recordWhere({ command: LogicalOperator.NOT }); + return this.whereWrapped(builderCallback); + } + public whereBetween(column: string, min: number, max: number) { + this.recordWhere({ + command: ComparisonPredicate.BETWEEN, + data: { column, min, max } as BetweenPredicateInput, + }); + return this; + } + + public whereNotBetween(column: string, min: number, max: number) { + this.recordWhere({ command: LogicalOperator.NOT }); + return this.whereBetween(column, min, max); + } + public whereIn( + column: string, + values: string[] | number[] | IDataQueryBuilder + ) { + this.recordWhere({ + command: ComparisonPredicate.IN, + data: { column, values: values } as WhereInPredicateInput, + }); + return this; + } + public whereNotIn( + column: string, + values: string[] | number[] | IDataQueryBuilder + ) { + this.recordWhere({ command: LogicalOperator.NOT }); + return this.whereIn(column, values); + } + + public whereNull(column: string) { + this.recordWhere({ + command: ComparisonPredicate.IS_NULL, + data: { column } as NullPredicateInput, + }); + return this; + } + public whereNotNull(column: string) { + this.recordWhere({ command: LogicalOperator.NOT }); + return this.whereNull(column); + } + public whereLike(column: string, searchValue: string) { + this.recordWhere({ + command: WherePredicate.LIKE, + data: { column, searchValue } as WhereLikePredicateInput, + }); + return this; + } + public whereExists(subQueryBuilder: AliasDataQueryBuilder) { + this.recordWhere({ + command: ComparisonPredicate.EXISTS, + data: subQueryBuilder, + }); + return this; + } + public whereNotExists(subQueryBuilder: AliasDataQueryBuilder) { + this.recordWhere({ command: LogicalOperator.NOT }); + return this.whereExists(subQueryBuilder); + } + + // and + public andWhere( + column: string, + operator: string, + value: string | number | boolean | IDataQueryBuilder + ) { + this.recordWhere({ command: LogicalOperator.AND }); + return this.where(column, operator, value); + } + public andWhereNot( + column: string, + operator: string, + value: string | number | boolean | IDataQueryBuilder + ) { + this.recordWhere({ command: LogicalOperator.AND }); + return this.whereNot(column, operator, value); + } + public andWhereWrapped(builderCallback: BuilderClauseCallback) { + this.recordWhere({ command: LogicalOperator.AND }); + return this.whereWrapped(builderCallback); + } + public andWhereNotWrapped(builderCallback: BuilderClauseCallback) { + this.recordWhere({ command: LogicalOperator.AND }); + return this.whereNotWrapped(builderCallback); + } + public andWhereBetween(column: string, min: number, max: number) { + this.recordWhere({ command: LogicalOperator.AND }); + return this.whereBetween(column, min, max); + } + public andWhereNotBetween(column: string, min: number, max: number) { + this.recordWhere({ command: LogicalOperator.AND }); + return this.whereNotBetween(column, min, max); + } + public andWhereIn( + column: string, + values: string[] | number[] | IDataQueryBuilder + ) { + this.recordWhere({ command: LogicalOperator.AND }); + return this.whereIn(column, values); + } + public andWhereNotIn( + column: string, + values: string[] | number[] | IDataQueryBuilder + ) { + this.recordWhere({ command: LogicalOperator.AND }); + return this.whereNotIn(column, values); + } + public andWhereNull(column: string) { + this.recordWhere({ command: LogicalOperator.AND }); + return this.whereNull(column); + } + public andWhereNotNull(column: string) { + this.recordWhere({ command: LogicalOperator.AND }); + return this.whereNotNull(column); + } + public andWhereLike(column: string, searchValue: string) { + this.recordWhere({ command: LogicalOperator.AND }); + return this.whereLike(column, searchValue); + } + public andWhereExists(subQueryBuilder: AliasDataQueryBuilder) { + this.recordWhere({ command: LogicalOperator.AND }); + return this.whereExists(subQueryBuilder); + } + public andWhereNotExists(subQueryBuilder: AliasDataQueryBuilder) { + this.recordWhere({ command: LogicalOperator.AND }); + return this.whereNotExists(subQueryBuilder); + } + + // or + public orWhere( + column: string, + operator: string, + value: string | number | boolean | IDataQueryBuilder + ) { + this.recordWhere({ command: LogicalOperator.OR }); + return this.where(column, operator, value); + } + public orWhereNot( + column: string, + operator: string, + value: string | number | boolean | IDataQueryBuilder + ) { + this.recordWhere({ command: LogicalOperator.OR }); + return this.whereNot(column, operator, value); + } + public orWhereWrapped(builderCallback: BuilderClauseCallback) { + this.recordWhere({ command: LogicalOperator.OR }); + return this.whereWrapped(builderCallback); + } + public orWhereNotWrapped(builderCallback: BuilderClauseCallback) { + this.recordWhere({ command: LogicalOperator.OR }); + return this.whereNotWrapped(builderCallback); + } + public orWhereBetween(column: string, min: number, max: number) { + this.recordWhere({ command: LogicalOperator.OR }); + return this.whereBetween(column, min, max); + } + public orWhereNotBetween(column: string, min: number, max: number) { + this.recordWhere({ command: LogicalOperator.OR }); + return this.whereNotBetween(column, min, max); + } + public orWhereIn( + column: string, + values: string[] | number[] | IDataQueryBuilder + ) { + this.recordWhere({ command: LogicalOperator.OR }); + return this.whereIn(column, values); + } + public orWhereNotIn( + column: string, + values: string[] | number[] | IDataQueryBuilder + ) { + this.recordWhere({ command: LogicalOperator.OR }); + return this.whereNotIn(column, values); + } + public orWhereNull(column: string) { + this.recordWhere({ command: LogicalOperator.OR }); + return this.whereNull(column); + } + public orWhereNotNull(column: string) { + this.recordWhere({ command: LogicalOperator.OR }); + return this.whereNotNull(column); + } + public orWhereLike(column: string, searchValue: string) { + this.recordWhere({ command: LogicalOperator.OR }); + return this.whereLike(column, searchValue); + } + public orWhereExists(subQueryBuilder: AliasDataQueryBuilder) { + this.recordWhere({ command: LogicalOperator.OR }); + return this.whereExists(subQueryBuilder); + } + public orWhereNotExists(subQueryBuilder: AliasDataQueryBuilder) { + this.recordWhere({ command: LogicalOperator.OR }); + return this.whereNotExists(subQueryBuilder); + } + + // Group by clause method + public groupBy(...columns: string[]) { + this.operations.groupBy = this.operations.groupBy.concat(columns); + return this; + } + + public having( + column: SelectedColumn | string, + operator: string, + value: string | number | boolean | IDataQueryBuilder + ) { + const normalized = typeof column === 'string' ? { name: column } : column; + if (!isOfComparisonOperator(operator)) + throw new Error(`'There is no ${operator} operator.`); + + this.recordHaving({ + command: null, + data: { + column: normalized, + operator, + value, + } as HavingOperatorInput, + }); + return this; + } + + public havingIn( + column: SelectedColumn | string, + values: number[] | string[] + ) { + const normalized = typeof column === 'string' ? { name: column } : column; + this.recordHaving({ + command: ComparisonPredicate.IN, + data: { + column: normalized, + values, + } as HavingInPredicateInput, + }); + return this; + } + + public havingNotIn( + column: SelectedColumn | string, + values: number[] | string[] + ) { + this.recordHaving({ command: LogicalOperator.NOT }); + return this.havingIn(column, values); + } + + public havingBetween( + column: SelectedColumn | string, + min: number, + max: number + ) { + const normalized = typeof column === 'string' ? { name: column } : column; + this.recordHaving({ + command: ComparisonPredicate.BETWEEN, + data: { + column: normalized, + min, + max, + } as HavingBetweenPredicateInput, + }); + return this; + } + + public havingNotBetween( + column: SelectedColumn | string, + min: number, + max: number + ) { + this.recordHaving({ command: LogicalOperator.NOT }); + return this.havingBetween(column, min, max); + } + + public havingNull(column: string) { + this.recordHaving({ + command: ComparisonPredicate.IS_NULL, + data: { column } as NullPredicateInput, + }); + return this; + } + public havingNotNull(column: string) { + this.recordHaving({ command: LogicalOperator.NOT }); + return this.havingNull(column); + } + public havingExists(subQueryBuilder: AliasDataQueryBuilder) { + this.recordHaving({ + command: ComparisonPredicate.EXISTS, + data: subQueryBuilder, + }); + return this; + } + public havingNotExists(subQueryBuilder: AliasDataQueryBuilder) { + this.recordHaving({ command: LogicalOperator.NOT }); + return this.havingExists(subQueryBuilder); + } + + // And Having clause + public andHaving( + column: SelectedColumn | string, + operator: string, + value: string | number | boolean | IDataQueryBuilder + ) { + this.recordHaving({ command: LogicalOperator.AND }); + return this.having(column, operator, value); + } + public andHavingIn( + column: SelectedColumn | string, + values: number[] | string[] + ) { + this.recordHaving({ command: LogicalOperator.AND }); + return this.havingIn(column, values); + } + public andHavingNotIn( + column: SelectedColumn | string, + values: number[] | string[] + ) { + this.recordHaving({ command: LogicalOperator.AND }); + return this.havingNotIn(column, values); + } + public andHavingBetween( + column: SelectedColumn | string, + min: number, + max: number + ) { + this.recordHaving({ command: LogicalOperator.AND }); + return this.havingBetween(column, min, max); + } + public andHavingNotBetween( + column: SelectedColumn | string, + min: number, + max: number + ) { + this.recordHaving({ command: LogicalOperator.AND }); + return this.havingNotBetween(column, min, max); + } + public andHavingNull(column: string) { + this.recordHaving({ command: LogicalOperator.AND }); + return this.havingNull(column); + } + public andHavingNotNull(column: string) { + this.recordHaving({ command: LogicalOperator.AND }); + return this.havingNotNull(column); + } + public andHavingExists(subQueryBuilder: AliasDataQueryBuilder) { + this.recordHaving({ command: LogicalOperator.AND }); + return this.havingExists(subQueryBuilder); + } + public andHavingNotExists(subQueryBuilder: AliasDataQueryBuilder) { + this.recordHaving({ command: LogicalOperator.AND }); + return this.havingNotExists(subQueryBuilder); + } + // Or Having clause + public orHaving( + column: SelectedColumn | string, + operator: string, + value: string | number | boolean | IDataQueryBuilder + ) { + this.recordHaving({ command: LogicalOperator.OR }); + return this.having(column, operator, value); + } + public orHavingIn( + column: SelectedColumn | string, + values: number[] | string[] + ) { + this.recordHaving({ command: LogicalOperator.OR }); + return this.havingIn(column, values); + } + public orHavingNotIn( + column: SelectedColumn | string, + values: number[] | string[] + ) { + this.recordHaving({ command: LogicalOperator.OR }); + return this.havingNotIn(column, values); + } + public orHavingBetween( + column: SelectedColumn | string, + min: number, + max: number + ) { + this.recordHaving({ command: LogicalOperator.OR }); + return this.havingBetween(column, min, max); + } + public orHavingNotBetween( + column: SelectedColumn | string, + min: number, + max: number + ) { + this.recordHaving({ command: LogicalOperator.OR }); + return this.havingNotBetween(column, min, max); + } + public orHavingNull(column: string) { + this.recordHaving({ command: LogicalOperator.OR }); + return this.havingNull(column); + } + public orHavingNotNull(column: string) { + this.recordHaving({ command: LogicalOperator.OR }); + return this.havingNotNull(column); + } + public orHavingExists(subQueryBuilder: AliasDataQueryBuilder) { + this.recordHaving({ command: LogicalOperator.OR }); + return this.havingExists(subQueryBuilder); + } + public orHavingNotExists(subQueryBuilder: AliasDataQueryBuilder) { + this.recordHaving({ command: LogicalOperator.OR }); + return this.havingNotExists(subQueryBuilder); + } + // Order by clause method + public orderBy(column: string, direction: Direction = Direction.ASC) { + this.operations.orderBy.push({ + column, + direction, + }); + return this; + } + + // Limit and Offset clause + public limit(size: number) { + this.operations.limit = size; + return this; + } + + public offset(move: number) { + this.operations.offset = move; + return this; + } + + public take(size: number, move: number) { + this.operations.limit = size; + this.operations.offset = move; + return this; + } + + public clone() { + return new DataQueryBuilder({ + statement: this.statement, + dataSource: this.dataSource, + operations: this.operations, + }); + } + + // setup pagination if would like to do paginate + public paginate(pagination: Pagination) { + this.pagination = pagination; + } + + public async value() { + // call data source + const result = await this.dataSource.execute({ + statement: this.statement, + operations: this.operations, + pagination: this.pagination, + }); + + // Reset operations + await this.resetOperations(); + + return result; + } + + // record Select-On related operations + private recordSelect({ + command, + columns, + }: { + command: SelectCommandType; + columns: Array; + }) { + const normalized = columns.map((column) => { + if (typeof column === 'string') return { name: column }; + return column as SelectedColumn; + }); + if (this.operations.select === null) { + this.operations.select = { + command, + columns: normalized, + }; + return; + } + // distinct will replace previous select command, + // even builder call select method in later of the distinct method, it not influence distinct. + if (command === SelectCommandType.SELECT_DISTINCT) + this.operations.select.command = command; + + this.operations.select.columns = + this.operations.select.columns.concat(normalized); + } + + private recordJoin({ + command, + builder, + joinCallback, + }: { + command: JoinCommandType; + builder: AliasDataQueryBuilder; + joinCallback: JoinClauseCallback; + }) { + const joinOnClause = new JoinOnClause(); + joinCallback(joinOnClause); + this.operations.join.push({ + command, + onClauses: joinOnClause.operations, + joinBuilder: builder, + }); + } + + private recordWhere({ + command, + data, + }: { + command: WherePredicate | ComparisonPredicate | LogicalOperator | null; + data?: + | WherePredicateInput + | Array + | AliasDataQueryBuilder; + }) { + this.operations.where.push({ + command, + data, + }); + } + + private recordHaving({ + command, + data, + }: { + command: ComparisonPredicate | LogicalOperator | null; + data?: HavingPredicateInput | AliasDataQueryBuilder; + }) { + this.operations.having.push({ + command, + data, + }); + } + + private resetOperations() { + this.operations.select = null; + this.operations.where = []; + this.operations.join = []; + this.operations.groupBy = []; + this.operations.having = []; + this.operations.orderBy = []; + this.operations.limit = null; + this.operations.offset = null; + } +} diff --git a/packages/core/src/lib/data-query/builder/index.ts b/packages/core/src/lib/data-query/builder/index.ts new file mode 100644 index 00000000..5ab0ec80 --- /dev/null +++ b/packages/core/src/lib/data-query/builder/index.ts @@ -0,0 +1,3 @@ +export * from './commonTypes'; +export * from './joinOnClause'; +export * from './dataQueryBuilder'; diff --git a/packages/core/src/lib/data-query/builder/joinOnClause.ts b/packages/core/src/lib/data-query/builder/joinOnClause.ts new file mode 100644 index 00000000..bb61e353 --- /dev/null +++ b/packages/core/src/lib/data-query/builder/joinOnClause.ts @@ -0,0 +1,187 @@ +import { + ComparisonPredicate, + ComparisonOperator, + LogicalOperator, + BetweenPredicateInput, + InPredicateInput, + NullPredicateInput, + isOfComparisonOperator, +} from './commonTypes'; + +export interface JoinOnOperatorInput { + leftColumn: string; + operator: ComparisonOperator; + rightColumn: string; +} + +export interface JoinOnClauseOperation { + // null means using ComparisonOperator + command: ComparisonPredicate | LogicalOperator | null; + data?: + | JoinOnOperatorInput + | BetweenPredicateInput + | InPredicateInput + | NullPredicateInput; +} + +export interface IJoinOnClause { + operations: Array; + on(leftColumn: string, operator: string, rightColumn: string): JoinOnClause; + onBetween(column: string, min: number, max: number): JoinOnClause; + onNotBetween(column: string, min: number, max: number): JoinOnClause; + onIn(column: string, values: string[] | number[]): JoinOnClause; + onNotIn(column: string, values: string[] | number[]): JoinOnClause; + onNull(column: string): JoinOnClause; + onNotNull(column: string): JoinOnClause; + // and + andOn( + leftColumn: string, + operator: string, + rightColumn: string + ): JoinOnClause; + andOnBetween(column: string, min: number, max: number): JoinOnClause; + andOnNotBetween(column: string, min: number, max: number): JoinOnClause; + andOnIn(column: string, values: string[] | number[]): JoinOnClause; + andOnNotIn(column: string, values: string[] | number[]): JoinOnClause; + andOnNull(column: string): JoinOnClause; + andOnNotNull(column: string): JoinOnClause; + // or + orOn(leftColumn: string, operator: string, rightColumn: string): JoinOnClause; + orOnBetween(column: string, min: number, max: number): JoinOnClause; + orOnNotBetween(column: string, min: number, max: number): JoinOnClause; + orOnIn(column: string, values: string[] | number[]): JoinOnClause; + orOnNotIn(column: string, values: string[] | number[]): JoinOnClause; + orOnNull(column: string): JoinOnClause; + orOnNotNull(column: string): JoinOnClause; +} + +export class JoinOnClause implements IJoinOnClause { + private _operations: Array; + + constructor() { + this._operations = []; + } + + get operations() { + return this._operations; + } + + public on(leftColumn: string, operator: string, rightColumn: string) { + if (!isOfComparisonOperator(operator)) + throw new Error(`'There is no ${operator} operator.`); + + this.recordOn({ + command: null, + data: { leftColumn, operator, rightColumn }, + }); + return this; + } + + public onBetween(column: string, min: number, max: number) { + if (min > max) + throw new Error(`min value ${min} not smaller than max value ${max}.`); + + this.recordOn({ + command: ComparisonPredicate.BETWEEN, + data: { column, min, max }, + }); + return this; + } + public onNotBetween(column: string, min: number, max: number) { + this.recordOn({ command: LogicalOperator.NOT }); + return this.onBetween(column, min, max); + } + + public onIn(column: string, values: number[] | string[]) { + this.recordOn({ + command: ComparisonPredicate.IN, + data: { column, values }, + }); + return this; + } + public onNotIn(column: string, values: number[] | string[]) { + this.recordOn({ command: LogicalOperator.NOT }); + return this.onIn(column, values); + } + + public onNull(column: string) { + this.recordOn({ + command: ComparisonPredicate.IS_NULL, + data: { column }, + }); + return this; + } + public onNotNull(column: string) { + this.recordOn({ command: LogicalOperator.NOT }); + return this.onNull(column); + } + + // and + public andOn(leftColumn: string, operator: string, rightColumn: string) { + this.recordOn({ command: LogicalOperator.AND }); + return this.on(leftColumn, operator, rightColumn); + } + public andOnBetween(column: string, min: number, max: number) { + this.recordOn({ command: LogicalOperator.AND }); + return this.onBetween(column, min, max); + } + public andOnNotBetween(column: string, min: number, max: number) { + this.recordOn({ command: LogicalOperator.AND }); + return this.onNotBetween(column, min, max); + } + public andOnIn(column: string, values: number[] | string[]) { + this.recordOn({ command: LogicalOperator.AND }); + return this.onIn(column, values); + } + public andOnNotIn(column: string, values: number[] | string[]) { + this.recordOn({ command: LogicalOperator.AND }); + return this.onNotIn(column, values); + } + public andOnNull(column: string) { + this.recordOn({ command: LogicalOperator.AND }); + return this.onNull(column); + } + public andOnNotNull(column: string) { + this.recordOn({ command: LogicalOperator.AND }); + return this.onNotNull(column); + } + + // or + public orOn(leftColumn: string, operator: string, rightColumn: string) { + this.recordOn({ command: LogicalOperator.OR }); + return this.on(leftColumn, operator, rightColumn); + } + public orOnBetween(column: string, min: number, max: number) { + this.recordOn({ command: LogicalOperator.OR }); + return this.onBetween(column, min, max); + } + public orOnNotBetween(column: string, min: number, max: number) { + this.recordOn({ command: LogicalOperator.OR }); + return this.onNotBetween(column, min, max); + } + public orOnIn(column: string, values: number[] | string[]) { + this.recordOn({ command: LogicalOperator.OR }); + return this.onIn(column, values); + } + public orOnNotIn(column: string, values: number[] | string[]) { + this.recordOn({ command: LogicalOperator.OR }); + return this.onNotIn(column, values); + } + public orOnNull(column: string) { + this.recordOn({ command: LogicalOperator.OR }); + return this.onNull(column); + } + public orOnNotNull(column: string) { + this.recordOn({ command: LogicalOperator.OR }); + return this.onNotNull(column); + } + + // record Join-On related operations + private recordOn(operation: JoinOnClauseOperation) { + const { command, data } = operation; + this._operations.push({ + command, + data, + }); + } +} diff --git a/packages/core/src/lib/data-query/executor.ts b/packages/core/src/lib/data-query/executor.ts new file mode 100644 index 00000000..82af2165 --- /dev/null +++ b/packages/core/src/lib/data-query/executor.ts @@ -0,0 +1,26 @@ +import { TYPES } from '@vulcan/core'; +import { inject, injectable } from 'inversify'; +import { DataQueryBuilder, IDataQueryBuilder } from './builder'; +import { IDataSource } from '../data-source'; + +export interface IExecutor { + createBuilder(query: string): Promise; +} + +export class QueryExecutor implements IExecutor { + private dataSource: IDataSource; + constructor(dataSource: IDataSource) { + this.dataSource = dataSource; + } + /** + * create data query builder + * @param query the sql statement for query + * @returns + */ + public async createBuilder(query: string) { + return new DataQueryBuilder({ + statement: query, + dataSource: this.dataSource, + }); + } +} diff --git a/packages/core/src/lib/data-query/index.ts b/packages/core/src/lib/data-query/index.ts new file mode 100644 index 00000000..e96846a7 --- /dev/null +++ b/packages/core/src/lib/data-query/index.ts @@ -0,0 +1,2 @@ +export * from './builder'; +export * from './executor'; diff --git a/packages/core/src/lib/data-source/dataSource.ts b/packages/core/src/lib/data-source/dataSource.ts new file mode 100644 index 00000000..fffc7eef --- /dev/null +++ b/packages/core/src/lib/data-source/dataSource.ts @@ -0,0 +1,14 @@ +import { SQLClauseOperation } from '@vulcan/core/data-query'; +import { Pagination } from '@vulcan/core/models'; + +export interface IDataSource { + execute({ + statement, + operations, + pagination, + }: { + statement: string; + operations: SQLClauseOperation; + pagination?: Pagination; + }): Promise; +} diff --git a/packages/core/src/lib/data-source/index.ts b/packages/core/src/lib/data-source/index.ts new file mode 100644 index 00000000..f80a6659 --- /dev/null +++ b/packages/core/src/lib/data-source/index.ts @@ -0,0 +1 @@ +export * from './dataSource'; diff --git a/packages/core/src/lib/template-engine/built-in-extensions/query-builder/executeBuilder.ts b/packages/core/src/lib/template-engine/built-in-extensions/query-builder/executorBuilder.ts similarity index 100% rename from packages/core/src/lib/template-engine/built-in-extensions/query-builder/executeBuilder.ts rename to packages/core/src/lib/template-engine/built-in-extensions/query-builder/executorBuilder.ts diff --git a/packages/core/src/lib/template-engine/built-in-extensions/query-builder/executorRunner.ts b/packages/core/src/lib/template-engine/built-in-extensions/query-builder/executorRunner.ts index 16e621ae..647497b5 100644 --- a/packages/core/src/lib/template-engine/built-in-extensions/query-builder/executorRunner.ts +++ b/packages/core/src/lib/template-engine/built-in-extensions/query-builder/executorRunner.ts @@ -1,12 +1,12 @@ +import { IDataQueryBuilder } from '@vulcan/core/data-query'; import { FilterRunner } from '../../extension-loader'; import { EXECUTE_FILTER_NAME } from './constants'; -import { QueryBuilder } from './reqTagRunner'; export class ExecutorRunner extends FilterRunner { public filterName = EXECUTE_FILTER_NAME; public async transform({ value }: { value: any; args: any[] }): Promise { - const builder: QueryBuilder = value; + const builder: IDataQueryBuilder = value; return builder.value(); } } diff --git a/packages/core/src/lib/template-engine/built-in-extensions/query-builder/index.ts b/packages/core/src/lib/template-engine/built-in-extensions/query-builder/index.ts index 7c02c185..50e08551 100644 --- a/packages/core/src/lib/template-engine/built-in-extensions/query-builder/index.ts +++ b/packages/core/src/lib/template-engine/built-in-extensions/query-builder/index.ts @@ -1,6 +1,6 @@ import { ReqTagBuilder } from './reqTagBuilder'; import { ReqTagRunner } from './reqTagRunner'; import { ExecutorRunner } from './executorRunner'; -import { ExecutorBuilder } from './executeBuilder'; +import { ExecutorBuilder } from './executorBuilder'; export default [ReqTagBuilder, ReqTagRunner, ExecutorRunner, ExecutorBuilder]; diff --git a/packages/core/src/lib/template-engine/built-in-extensions/query-builder/reqTagRunner.ts b/packages/core/src/lib/template-engine/built-in-extensions/query-builder/reqTagRunner.ts index 49c3d4ee..67955242 100644 --- a/packages/core/src/lib/template-engine/built-in-extensions/query-builder/reqTagRunner.ts +++ b/packages/core/src/lib/template-engine/built-in-extensions/query-builder/reqTagRunner.ts @@ -1,23 +1,14 @@ import { TYPES } from '@vulcan/core/containers'; +import { IExecutor } from '@vulcan/core/data-query'; import { inject } from 'inversify'; import { TagRunnerOptions, TagRunner } from '../../extension-loader'; import { FINIAL_BUILDER_NAME } from './constants'; -// TODO: temporary interface -export interface QueryBuilder { - count(): QueryBuilder; - value(): Promise; -} - -export interface Executor { - createBuilder(query: string): Promise; -} - export class ReqTagRunner extends TagRunner { public tags = ['req']; - private executor: Executor; + private executor: IExecutor; - constructor(@inject(TYPES.Executor) executor: Executor) { + constructor(@inject(TYPES.Executor) executor: IExecutor) { super(); this.executor = executor; } diff --git a/packages/core/src/lib/template-engine/extension-loader/loader.ts b/packages/core/src/lib/template-engine/extension-loader/loader.ts index a127e8e0..49a6855d 100644 --- a/packages/core/src/lib/template-engine/extension-loader/loader.ts +++ b/packages/core/src/lib/template-engine/extension-loader/loader.ts @@ -1,4 +1,4 @@ -import * as fs from 'fs/promises'; +import { promises as fs } from 'fs'; import * as path from 'path'; import { flatten } from 'lodash'; import { interfaces } from 'inversify'; diff --git a/packages/core/src/lib/template-engine/nunjucksCompiler.ts b/packages/core/src/lib/template-engine/nunjucksCompiler.ts index e3a495cd..78b4d9fa 100644 --- a/packages/core/src/lib/template-engine/nunjucksCompiler.ts +++ b/packages/core/src/lib/template-engine/nunjucksCompiler.ts @@ -18,8 +18,7 @@ import { TagRunner, walkAst, } from './extension-loader'; -// TODO: Should replace with a real implementation -import { QueryBuilder } from './built-in-extensions/query-builder/reqTagRunner'; +import { IDataQueryBuilder } from '../data-query'; @injectable() export class NunjucksCompiler implements Compiler { @@ -144,8 +143,8 @@ export class NunjucksCompiler implements Compiler { private renderAndGetMainBuilder(templateName: string, data: any) { const template = this.runtimeEnv.getTemplate(templateName, true); - return new Promise((resolve, reject) => { - template.getExported<{ FINAL_BUILDER: QueryBuilder }>( + return new Promise((resolve, reject) => { + template.getExported<{ FINAL_BUILDER: IDataQueryBuilder }>( data, (err, res) => { if (err) return reject(err); diff --git a/packages/core/src/lib/utils/index.ts b/packages/core/src/lib/utils/index.ts new file mode 100644 index 00000000..e6bc2a05 --- /dev/null +++ b/packages/core/src/lib/utils/index.ts @@ -0,0 +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..fc97d3d6 --- /dev/null +++ b/packages/core/src/lib/utils/module.ts @@ -0,0 +1,49 @@ +// The type for class T +export interface ClassType extends Function { + new (...args: any[]): T; +} + +/** + * dynamic import default module. + * @param foldersOrFiles The folders / files + * @returns default module + */ +export const defaultImport = async ( + ...foldersOrFiles: Array +) => { + const modules = [] as Array; + for (const folderOrFile of foldersOrFiles) { + const module = await import(folderOrFile); + // if module default is undefined, then set the all export context to default + const imported = module.default ? (module.default as T) : (module as T); + modules.push(imported); + } + return modules; +}; + +export interface ModuleProperties { + [property: string]: any[]; +} +/** + * merged multiple properties of each modules to the one module object + * @param modules: multiple module objects which include properties e.g: [{ module1Property1: [] }, { module2Property1: [] }] + * @returns the merged properties in one module object + */ +export const mergedModules = async ( + modules: Array +) => { + const module = modules.reduce( + (merged: ModuleProperties, current: ModuleProperties, _) => { + for (const extension of Object.keys(current)) { + // if current extension property has been existed in merged module, concat it. + if (extension in merged) + merged[extension] = [...merged[extension], ...current[extension]]; + // if extension not in merged module, add new extension property + else merged[extension] = current[extension]; + } + return merged; + }, + {} as T + ); + return module; +}; diff --git a/packages/core/src/lib/utils/normalizedStringValue.ts b/packages/core/src/lib/utils/normalizedStringValue.ts new file mode 100644 index 00000000..21fe9cfa --- /dev/null +++ b/packages/core/src/lib/utils/normalizedStringValue.ts @@ -0,0 +1,46 @@ +export const canBeNormalized = (type: string) => { + return ( + ['number', 'boolean', 'string', 'date'].indexOf(type.toLowerCase()) !== -1 + ); +}; + +export const normalizeStringValue = ( + value: string, + dataName: string, + dataType: string +) => { + switch (dataType.toLowerCase()) { + case 'number': { + if (value === '') { + throw new Error(`${dataName} must be number`); + } + const valueNumber = +value; + if (isNaN(valueNumber)) { + throw new Error(`${dataName} must be number`); + } + return valueNumber; + } + + case 'boolean': { + if (value === 'true' || value === '1' || value === '') { + return true; + } else if (value === 'false' || value === '0') { + return false; + } else { + throw new Error(`${dataName} must be boolean`); + } + } + + case 'date': { + const parsedDate = new Date(value); + if (Number.isNaN(parsedDate.getTime())) { + throw new Error(`${dataName} must be date`); + } + return parsedDate; + } + + case 'string': + default: + return value; + } +}; diff --git a/packages/core/src/lib/validators/built-in-validators/dateTypeValidator.ts b/packages/core/src/lib/validators/built-in-validators/dateTypeValidator.ts new file mode 100644 index 00000000..a5dc6820 --- /dev/null +++ b/packages/core/src/lib/validators/built-in-validators/dateTypeValidator.ts @@ -0,0 +1,44 @@ +import * as Joi from 'joi'; +import { isUndefined } from 'lodash'; +import * as dayjs from 'dayjs'; +import customParseFormat = require('dayjs/plugin/customParseFormat'); +import { IValidator } from '../validator'; + +// Support custom date format -> dayjs.format(...) +dayjs.extend(customParseFormat); + +export interface DateInputArgs { + // The date needed format, supported ISO_8601 token, ref: https://www.w3.org/TR/NOTE-datetime + // e.g: "YYYYMMDD", "YYYY-MM-DD", "YYYY-MM-DD HH:mm", + format?: string; +} + +export class DateTypeValidator implements IValidator { + public readonly name = 'date'; + // Validator for arguments schema in schema.yaml, should match DateInputArgs + private argsValidator = Joi.object({ + format: Joi.string().optional(), + }); + + public validateSchema(args: DateInputArgs) { + try { + // validate arguments schema + Joi.assert(args, this.argsValidator); + } catch { + throw new Error( + 'The arguments schema for "date" type validator is incorrect' + ); + } + } + + public validateData(value: string, args?: DateInputArgs) { + let valid = dayjs(value).isValid(); + // if there are args passed + if (!isUndefined(args)) { + // validate date, support format validator if input field existed + valid = args.format ? dayjs(value, args.format, true).isValid() : valid; + } + if (!valid) + throw new Error('The input parameter is invalid, it should be date type'); + } +} diff --git a/packages/core/src/lib/validators/built-in-validators/index.ts b/packages/core/src/lib/validators/built-in-validators/index.ts new file mode 100644 index 00000000..96e7b13a --- /dev/null +++ b/packages/core/src/lib/validators/built-in-validators/index.ts @@ -0,0 +1,21 @@ +// export all other non-default objects of validators module +export * from './dateTypeValidator'; +export * from './integerTypeValidator'; +export * from './stringTypeValidator'; +export * from './uuidTypeValidator'; +export * from './requiredValidator'; + +// import default objects and export +import { DateTypeValidator } from './dateTypeValidator'; +import { IntegerTypeValidator } from './integerTypeValidator'; +import { StringTypeValidator } from './stringTypeValidator'; +import { UUIDTypeValidator } from './uuidTypeValidator'; +import { RequiredValidator } from './requiredValidator'; + +export default [ + DateTypeValidator, + IntegerTypeValidator, + StringTypeValidator, + UUIDTypeValidator, + RequiredValidator, +]; diff --git a/packages/core/src/lib/validators/built-in-validators/integerTypeValidator.ts b/packages/core/src/lib/validators/built-in-validators/integerTypeValidator.ts new file mode 100644 index 00000000..3038b4b6 --- /dev/null +++ b/packages/core/src/lib/validators/built-in-validators/integerTypeValidator.ts @@ -0,0 +1,59 @@ +import * as Joi from 'joi'; +import { isUndefined } from 'lodash'; +import { IValidator } from '../validator'; + +export interface IntInputArgs { + // The integer minimum value + min?: number; + // The integer maximum value + max?: number; + // The integer should greater than value + greater?: number; + // The integer should less than value + less?: number; +} + +export class IntegerTypeValidator implements IValidator { + public readonly name = 'integer'; + // Validator for arguments schema in schema.yaml, should match IntInputArgs + private argsValidator = Joi.object({ + min: Joi.number().integer().optional(), + max: Joi.number().integer().optional(), + greater: Joi.number().integer().optional(), + less: Joi.number().integer().optional(), + }); + + public validateSchema(args: IntInputArgs) { + try { + // validate arguments schema + Joi.assert(args, this.argsValidator); + } catch { + throw new Error( + 'The arguments schema for "integer" type validator is incorrect' + ); + } + } + + public validateData(value: string | number, args?: IntInputArgs) { + // parse arguments + + // schema is integer type + let schema = Joi.number().integer(); + + // if there are args passed + if (!isUndefined(args)) { + // support min, max, greater, less validator if input field existed + schema = args.min ? schema.min(args.min) : schema; + schema = args.max ? schema.max(args.max) : schema; + schema = args.greater ? schema.greater(args.greater) : schema; + schema = args.less ? schema.less(args.less) : schema; + } + try { + Joi.assert(value, schema); + } catch { + throw new Error( + 'The input parameter is invalid, it should be integer type' + ); + } + } +} diff --git a/packages/core/src/lib/validators/built-in-validators/requiredValidator.ts b/packages/core/src/lib/validators/built-in-validators/requiredValidator.ts new file mode 100644 index 00000000..e3a73d76 --- /dev/null +++ b/packages/core/src/lib/validators/built-in-validators/requiredValidator.ts @@ -0,0 +1,47 @@ +import * as Joi from 'joi'; +import { IValidator } from '../validator'; + +export interface RequiredInputArgs { + /** + * Beside undefined not be required, which input also not as required value. + * e.g: disallow: ['', {}] means, undefined,'', {} also disallow + * */ + disallow?: string[]; +} + +// required means disallow undefined as value +export class RequiredValidator implements IValidator { + public readonly name = 'required'; + // Validator for arguments schema in schema.yaml, should match RequiredInputArgs + private argsValidator = Joi.object({ + disallow: Joi.array().items(Joi.any()).optional(), + }); + + public validateSchema(args: RequiredInputArgs) { + try { + // validate arguments schema + Joi.assert(args, this.argsValidator); + } catch { + throw new Error( + 'The arguments schema for "required" type validator is incorrect' + ); + } + } + + public validateData( + value?: string | boolean | number | null, + args?: RequiredInputArgs + ) { + let schema = Joi.any().required(); + + try { + // if args.exclude existed, check value is + if (args?.disallow) { + schema = schema.invalid(...args.disallow); + } + Joi.assert(value, schema); + } catch { + throw new Error('The input parameter is invalid, it should be required'); + } + } +} diff --git a/packages/core/src/lib/validators/built-in-validators/stringTypeValidator.ts b/packages/core/src/lib/validators/built-in-validators/stringTypeValidator.ts new file mode 100644 index 00000000..7df22718 --- /dev/null +++ b/packages/core/src/lib/validators/built-in-validators/stringTypeValidator.ts @@ -0,0 +1,59 @@ +import * as Joi from 'joi'; +import { isUndefined } from 'lodash'; +import { IValidator } from '../validator'; + +export interface StringInputArgs { + // The string regex format pattern + format?: string; + // The string length + length?: number; + // The string minimum value + min?: number; + // The string maximum value + max?: number; +} + +export class StringTypeValidator implements IValidator { + public readonly name = 'string'; + // Validator for arguments schema in schema.yaml, should match StringInputArgs + private argsValidator = Joi.object({ + format: Joi.string().optional(), + length: Joi.number().optional(), + min: Joi.number().optional(), + max: Joi.number().optional(), + }); + + public validateSchema(args: StringInputArgs) { + try { + // validate arguments schema + Joi.assert(args, this.argsValidator); + } catch { + throw new Error( + 'The arguments schema for "string" type validator is incorrect' + ); + } + } + + public validateData(value: string, args?: StringInputArgs) { + // schema is string type + let schema = Joi.string(); + + // if there are args passed + if (!isUndefined(args)) { + // support length, min, max validator if input field existed + schema = args.length ? schema.length(args.length) : schema; + schema = args.min ? schema.min(args.min) : schema; + schema = args.max ? schema.max(args.max) : schema; + // support regular expression pattern when input field existed + schema = args.format ? schema.pattern(new RegExp(args.format)) : schema; + } + try { + // validate data value + Joi.assert(value, schema); + } catch { + throw new Error( + 'The input parameter is invalid, it should be string type' + ); + } + } +} diff --git a/packages/core/src/lib/validators/built-in-validators/uuidTypeValidator.ts b/packages/core/src/lib/validators/built-in-validators/uuidTypeValidator.ts new file mode 100644 index 00000000..b10e9f45 --- /dev/null +++ b/packages/core/src/lib/validators/built-in-validators/uuidTypeValidator.ts @@ -0,0 +1,52 @@ +import * as Joi from 'joi'; +import { GuidVersions } from 'joi'; +import { isUndefined } from 'lodash'; +import { IValidator } from '../validator'; + +type UUIDVersion = 'uuid_v1' | 'uuid_v4' | 'uuid_v5'; + +export interface UUIDInputArgs { + // The uuid supported version, including uuidv1, uuidv4, uuidv5 + version?: UUIDVersion; +} + +export class UUIDTypeValidator implements IValidator { + public readonly name = 'uuid'; + // Validator for arguments schema in schema.yaml, should match UUIDInputArgs + private argsValidator = Joi.object({ + version: Joi.string().optional(), + }); + + validateSchema(args: UUIDInputArgs) { + try { + // validate arguments schema + Joi.assert(args, this.argsValidator); + } catch { + throw new Error( + 'The arguments schema for "uuid" type validator is incorrect' + ); + } + } + + public validateData(value: string, args: UUIDInputArgs) { + // schema is string type + let schema = Joi.string().uuid(); + + // if there are args passed + if (!isUndefined(args)) { + // support uuid version if input field existed + schema = args.version + ? Joi.string().uuid({ + // remove "_" and convert to Joi supported type + version: args.version.replace('_', '') as GuidVersions, + }) + : Joi.string().uuid(); + } + try { + // validate data value + Joi.assert(value, schema); + } catch { + throw new Error('The input parameter is invalid, it should be uuid type'); + } + } +} diff --git a/packages/core/src/validators/constraints.ts b/packages/core/src/lib/validators/constraints.ts similarity index 100% rename from packages/core/src/validators/constraints.ts rename to packages/core/src/lib/validators/constraints.ts diff --git a/packages/core/src/lib/validators/index.ts b/packages/core/src/lib/validators/index.ts new file mode 100644 index 00000000..b54d55d0 --- /dev/null +++ b/packages/core/src/lib/validators/index.ts @@ -0,0 +1,4 @@ +export * from './built-in-validators'; +export * from './validatorLoader'; +export * from './validator'; +export * from './constraints'; diff --git a/packages/core/src/validators/interface.ts b/packages/core/src/lib/validators/validator.ts similarity index 50% rename from packages/core/src/validators/interface.ts rename to packages/core/src/lib/validators/validator.ts index 7d7b9013..d5375474 100644 --- a/packages/core/src/validators/interface.ts +++ b/packages/core/src/lib/validators/validator.ts @@ -1,12 +1,13 @@ import { Constraint } from './constraints'; -export interface IValidator { +// U generic type will be any from RequestParameters +export interface IValidator { // validator name - name: string; + readonly name: string; // validate Schema format - validateSchema(args: T): boolean; - // validate input data - validateData(data: string, args: T): boolean; + validateSchema(args: T): void; + // validate input value + validateData(value: U, args?: T): void; // TODO: Find a better way to get constraints. // Get the constraints of this validator getConstraints?(args: T): Constraint[]; diff --git a/packages/core/src/lib/validators/validatorLoader.ts b/packages/core/src/lib/validators/validatorLoader.ts new file mode 100644 index 00000000..ca933114 --- /dev/null +++ b/packages/core/src/lib/validators/validatorLoader.ts @@ -0,0 +1,78 @@ +import { IValidator } from './validator'; +import * as path from 'path'; +import { inject, injectable, optional } from 'inversify'; +import { + defaultImport, + ClassType, + mergedModules, + ModuleProperties, +} from '../utils'; +import { TYPES } from '../../containers/types'; +import { SourceOfExtensions } from '../../models/coreOptions'; +import { flatten } from 'lodash'; + +export interface ExtensionModule extends ModuleProperties { + ['validators']: ClassType[]; +} + +export interface IValidatorLoader { + load(validatorName: string): Promise; +} + +@injectable() +export class ValidatorLoader implements IValidatorLoader { + // only found built-in validators in sub folders + private builtInFolder: string = path.join(__dirname, 'built-in-validators'); + private extensions: Array; + + constructor( + @inject(TYPES.SourceOfExtensions) + @optional() + extensions?: SourceOfExtensions + ) { + this.extensions = extensions || []; + } + public async load(validatorName: string) { + // read built-in validators in index.ts, the content is an array middleware class + const builtInClasses = flatten( + await defaultImport[]>(this.builtInFolder) + ); + + // if extension path setup, load extension middlewares classes + let extensionClasses: ClassType[] = []; + if (this.extensions) { + // import extension which user customized + const modules = await defaultImport(...this.extensions); + const module = await mergedModules(modules); + extensionClasses = module['validators'] || []; + // check same name validator does exist or not, if exist, throw error. + this.checkSameNameValidator(extensionClasses); + } + + // reverse the array to make the extensions priority higher than built-in validators if has the duplicate name. + const validatorClasses = [...builtInClasses, ...extensionClasses].reverse(); + for (const validatorClass of validatorClasses) { + // create all middlewares by new it + const validator = new validatorClass() as IValidator; + if (validator.name === validatorName) return validator; + } + + // throw error if not found + throw new Error( + `The identifier name "${validatorName}" of validator not defined in built-in validators and passed folder path, or the defined validator not export as default.` + ); + } + + private checkSameNameValidator(classes: ClassType[]) { + const map: { [name: string]: IValidator } = {}; + for (const cls of classes) { + const validator = new cls() as IValidator; + if (validator.name in map) { + throw new Error( + `The identifier name "${validator.name}" of validator class ${cls.name} has been defined in other extensions` + ); + } + map[validator.name] = validator; + } + } +} diff --git a/packages/core/src/models/artifact.ts b/packages/core/src/models/artifact.ts index 727779ca..505e435d 100644 --- a/packages/core/src/models/artifact.ts +++ b/packages/core/src/models/artifact.ts @@ -5,10 +5,11 @@ url: /user/:id request: parameters: id: - in: query # path / query / header + in: query # three source: path / query / header description: user id + type: integer # three types: boolean / number / string validators: - - name: Date + - name: date args: format: 'yyyy-MM-dd' - name: required @@ -17,7 +18,13 @@ error: message: 'You are not allowed to access this resource' */ -import { Constraint } from '../validators'; +import { Constraint } from '../lib/validators/constraints'; + +export enum PaginationMode { + CURSOR = 'CURSOR', + OFFSET = 'OFFSET', + KEYSET = 'KEYSET', +} export enum FieldInType { QUERY = 'QUERY', @@ -36,7 +43,7 @@ export interface ValidatorDefinition { args: T; } -export interface RequestParameter { +export interface RequestSchema { fieldName: string; // the field put in query parameter or headers fieldIn: FieldInType; @@ -53,6 +60,12 @@ export interface ResponseProperty { required?: boolean; } +export interface PaginationSchema { + mode: PaginationMode; + // The key name used for do filtering by key for keyset pagination. + keyName?: string; +} + export interface ErrorInfo { code: string; message: string; @@ -65,10 +78,13 @@ export interface APISchema { urlPath: string; // template, could be name or path templateSource: string; - request: Array; + request: Array; errors: Array; response: Array; description?: string; + // The pagination strategy that do paginate when querying + // If not set pagination, then API request not provide the field to do it + pagination?: PaginationSchema; } export interface BuiltArtifact { diff --git a/packages/core/src/models/coreOptions.ts b/packages/core/src/models/coreOptions.ts index 861915f5..7a64c802 100644 --- a/packages/core/src/models/coreOptions.ts +++ b/packages/core/src/models/coreOptions.ts @@ -1,7 +1,14 @@ import { IArtifactBuilderOptions } from './artifactBuilderOptions'; import { ITemplateEngineOptions } from './templateEngineOptions'; +export type SourceOfExtensions = Array; + export interface ICoreOptions { artifact: IArtifactBuilderOptions; template: ITemplateEngineOptions; + /** + * The extensions, could be module name or folder path (which need index.ts) + * E.g: [ 'extensionModule1', '/usr/extensions2' ] + * */ + extensions?: SourceOfExtensions; } diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts index 689d071a..e53e6b52 100644 --- a/packages/core/src/models/index.ts +++ b/packages/core/src/models/index.ts @@ -1,4 +1,5 @@ export * from './artifact'; +export * from './pagination'; export * from './artifactBuilderOptions'; export * from './coreOptions'; export * from './templateEngineOptions'; diff --git a/packages/core/src/models/pagination.ts b/packages/core/src/models/pagination.ts new file mode 100644 index 00000000..26f7c4ce --- /dev/null +++ b/packages/core/src/models/pagination.ts @@ -0,0 +1,16 @@ +export interface OffsetPagination { + limit: number; + offset: number; +} + +export interface CursorPagination { + limit: number; + cursor: string; +} + +export interface KeysetPagination { + limit: number; + [keyName: string]: string | number; +} + +export type Pagination = CursorPagination | OffsetPagination | KeysetPagination; diff --git a/packages/core/src/validators/index.ts b/packages/core/src/validators/index.ts deleted file mode 100644 index 7b63342f..00000000 --- a/packages/core/src/validators/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './interface'; -export * from './validatorLoader'; -export * from './constraints'; diff --git a/packages/core/src/validators/validatorLoader.ts b/packages/core/src/validators/validatorLoader.ts deleted file mode 100644 index a919add7..00000000 --- a/packages/core/src/validators/validatorLoader.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { IValidator } from '.'; - -export interface ValidatorLoader { - getLoader(name: string): IValidator; -} diff --git a/packages/core/test/containers/continer.spec.ts b/packages/core/test/containers/continer.spec.ts index 5d3b39c2..f43e222f 100644 --- a/packages/core/test/containers/continer.spec.ts +++ b/packages/core/test/containers/continer.spec.ts @@ -26,6 +26,7 @@ it('Container should load options and resolve all dependencies', async () => { provider: TemplateProviderType.LocalFile, folderPath: path.resolve(__dirname, 'test-template'), }, + extensions: [], }); // Act const templateEngine = container.get(TYPES.TemplateEngine); diff --git a/packages/core/test/data-query/builder/group-by-clause.spec.ts b/packages/core/test/data-query/builder/group-by-clause.spec.ts new file mode 100644 index 00000000..eb7cc598 --- /dev/null +++ b/packages/core/test/data-query/builder/group-by-clause.spec.ts @@ -0,0 +1,67 @@ +import * as sinon from 'ts-sinon'; +import faker from '@faker-js/faker'; +import { + GroupByClauseOperations, + DataQueryBuilder, +} from '@vulcan/core/data-query'; +import { IDataSource } from '@vulcan/core/data-source'; + +describe('Test data query builder > group by clause', () => { + let stubDataSource: sinon.StubbedInstance; + + beforeEach(() => { + stubDataSource = sinon.stubInterface(); + }); + + it.each([ + { + columns: [faker.database.column()], + }, + { + columns: [faker.database.column(), faker.database.column()], + }, + ])( + 'Should record successfully when call group by with $columns', + async ({ columns }) => { + // Arrange + const expected: GroupByClauseOperations = columns; + + // Act + let builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + columns.map((column) => { + builder = builder.groupBy(column); + }); + + // Assert + expect(JSON.stringify(builder.operations.groupBy)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + [faker.database.column(), faker.database.column(), faker.database.column()], + [faker.database.column(), faker.database.column(), faker.database.column()], + ])( + 'Should record successfully when call group by with %p, %p, %p', + async (first: string, second: string, third: string) => { + // Arrange + const expected: GroupByClauseOperations = [first, second, third]; + + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + builder.groupBy(first, second, third); + + // Assert + expect(JSON.stringify(builder.operations.groupBy)).toEqual( + JSON.stringify(expected) + ); + } + ); +}); diff --git a/packages/core/test/data-query/builder/having-clause.spec.ts b/packages/core/test/data-query/builder/having-clause.spec.ts new file mode 100644 index 00000000..234048b7 --- /dev/null +++ b/packages/core/test/data-query/builder/having-clause.spec.ts @@ -0,0 +1,908 @@ +import * as sinon from 'ts-sinon'; +import faker from '@faker-js/faker'; +import { + DataQueryBuilder, + WhereClauseOperation, + LogicalOperator, + ComparisonPredicate, + AliasDataQueryBuilder, + SelectedColumn, + AggregateFuncType, + HavingClauseOperation, + HavingPredicateInput, +} from '@vulcan/core/data-query'; +import { IDataSource } from '@vulcan/core/data-source'; + +const normalized = (column: string | SelectedColumn) => { + if (typeof column === 'string') return { name: column }; + return column as SelectedColumn; +}; + +describe('Test data query builder > having clause', () => { + let stubDataSource: sinon.StubbedInstance; + + beforeEach(() => { + stubDataSource = sinon.stubInterface(); + }); + + it.each([ + { + having: { + column: faker.database.column(), + operator: '!=', + value: faker.datatype.boolean(), + }, + and: { + column: faker.database.column(), + operator: '=', + value: new DataQueryBuilder({ + statement: 'select * from products', + dataSource: sinon.stubInterface(), + }), + }, + }, + { + having: { + column: { + name: faker.database.column(), + as: faker.random.word(), + aggregateType: AggregateFuncType.AVG, + } as SelectedColumn, + operator: '=', + value: new DataQueryBuilder({ + statement: 'select avg(*) from users', + dataSource: sinon.stubInterface(), + }), + }, + and: { + column: faker.database.column(), + operator: '>=', + value: faker.random.word(), + }, + }, + { + having: { + column: faker.database.column(), + operator: '=', + value: faker.random.word(), + }, + and: { + column: { + name: faker.database.column(), + as: faker.random.word(), + } as SelectedColumn, + operator: '>=', + value: faker.datatype.number({ max: 1000 }), + }, + }, + ])( + 'Should record successfully when call having(...).andHaving(...)', + async ({ having, and }) => { + // Arrange + + const expected: Array = [ + { + command: null, + data: { + column: normalized(having.column), + operator: having.operator, + value: having.value, + } as HavingPredicateInput, + }, + { command: LogicalOperator.AND }, + { + command: null, + data: { + column: normalized(and.column), + operator: and.operator, + value: and.value, + } as HavingPredicateInput, + }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (having) builder.having(having.column, having.operator, having.value); + if (and) builder.andHaving(and.column, and.operator, and.value); + + // Asset + expect(JSON.stringify(builder.operations.having)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + having: { + column: faker.database.column(), + operator: '!=', + value: faker.datatype.boolean(), + }, + or: { + column: faker.database.column(), + operator: '=', + value: new DataQueryBuilder({ + statement: 'select * from products', + dataSource: sinon.stubInterface(), + }), + }, + }, + { + having: { + column: { + name: faker.database.column(), + as: faker.random.word(), + aggregateType: AggregateFuncType.AVG, + } as SelectedColumn, + operator: '=', + value: new DataQueryBuilder({ + statement: 'select avg(*) from users', + dataSource: sinon.stubInterface(), + }), + }, + or: { + column: faker.database.column(), + operator: '>=', + value: faker.random.word(), + }, + }, + { + having: { + column: faker.database.column(), + operator: '=', + value: faker.random.word(), + }, + or: { + column: { + name: faker.database.column(), + as: faker.random.word(), + } as SelectedColumn, + operator: '>=', + value: faker.datatype.number({ max: 1000 }), + }, + }, + ])( + 'Should record successfully when call having(...).orHaving(...)', + async ({ having, or }) => { + // Arrange + + const expected: Array = [ + { + command: null, + data: { + column: normalized(having.column), + operator: having.operator, + value: having.value, + } as HavingPredicateInput, + }, + { command: LogicalOperator.OR }, + { + command: null, + data: { + column: normalized(or.column), + operator: or.operator, + value: or.value, + } as HavingPredicateInput, + }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (having) builder.having(having.column, having.operator, having.value); + if (or) builder.orHaving(or.column, or.operator, or.value); + + // Asset + expect(JSON.stringify(builder.operations.having)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + havingIn: { + column: { + name: faker.database.column(), + as: faker.random.word(), + } as SelectedColumn, + values: faker.helpers.arrayElements([ + faker.datatype.number({ max: 100 }), + faker.datatype.number({ max: 100 }), + faker.datatype.number({ max: 100 }), + ]), + }, + and: { + column: { + name: faker.database.column(), + as: faker.random.word(), + aggregateType: AggregateFuncType.SUM, + } as SelectedColumn, + values: faker.helpers.arrayElements([ + faker.random.word(), + faker.random.word(), + faker.random.word(), + ]), + }, + andNot: { + column: faker.database.column(), + values: faker.helpers.arrayElements([ + faker.datatype.number({ precision: 0.01 }), + faker.datatype.number({ precision: 0.01 }), + faker.datatype.number({ precision: 0.01 }), + ]), + }, + }, + { + havingIn: { + column: faker.database.column(), + values: faker.helpers.arrayElements([ + faker.random.word(), + faker.random.word(), + faker.random.word(), + ]), + }, + and: { + column: { + name: faker.database.column(), + as: faker.random.word(), + } as SelectedColumn, + values: faker.helpers.arrayElements([ + faker.random.word(), + faker.random.word(), + faker.random.word(), + ]), + }, + andNot: { + column: { + name: faker.database.column(), + as: faker.random.word(), + aggregateType: AggregateFuncType.AVG, + } as SelectedColumn, + values: faker.helpers.arrayElements([ + faker.datatype.number({ max: 100 }), + faker.datatype.number({ max: 100 }), + faker.datatype.number({ max: 100 }), + ]), + }, + }, + ])( + 'Should record successfully when call havingIn(...).andHavingIn(...).andHavingNotIn(...)', + async ({ havingIn, and, andNot }) => { + // Arrange + const expected: Array = [ + { + command: ComparisonPredicate.IN, + data: { + column: normalized(havingIn.column), + values: havingIn.values, + } as HavingPredicateInput, + }, + { command: LogicalOperator.AND }, + { + command: ComparisonPredicate.IN, + data: { + column: normalized(and.column), + values: and.values, + } as HavingPredicateInput, + }, + { command: LogicalOperator.AND }, + { command: LogicalOperator.NOT }, + { + command: ComparisonPredicate.IN, + data: { + column: normalized(andNot.column), + values: andNot.values, + } as HavingPredicateInput, + }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (havingIn) builder.havingIn(havingIn.column, havingIn.values); + if (and) builder.andHavingIn(and.column, and.values); + if (andNot) builder.andHavingNotIn(andNot.column, andNot.values); + // Asset + expect(JSON.stringify(builder.operations.having)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + notIn: { + column: { + name: faker.database.column(), + as: faker.random.word(), + } as SelectedColumn, + values: faker.helpers.arrayElements([ + faker.datatype.number({ max: 100 }), + faker.datatype.number({ max: 100 }), + faker.datatype.number({ max: 100 }), + ]), + }, + or: { + column: { + name: faker.database.column(), + as: faker.random.word(), + aggregateType: AggregateFuncType.SUM, + } as SelectedColumn, + values: faker.helpers.arrayElements([ + faker.random.word(), + faker.random.word(), + faker.random.word(), + ]), + }, + orNot: { + column: faker.database.column(), + values: faker.helpers.arrayElements([ + faker.datatype.number({ precision: 0.01 }), + faker.datatype.number({ precision: 0.01 }), + faker.datatype.number({ precision: 0.01 }), + ]), + }, + }, + { + notIn: { + column: faker.database.column(), + values: faker.helpers.arrayElements([ + faker.random.word(), + faker.random.word(), + faker.random.word(), + ]), + }, + or: { + column: { + name: faker.database.column(), + as: faker.random.word(), + } as SelectedColumn, + values: faker.helpers.arrayElements([ + faker.random.word(), + faker.random.word(), + faker.random.word(), + ]), + }, + orNot: { + column: { + name: faker.database.column(), + as: faker.random.word(), + aggregateType: AggregateFuncType.AVG, + } as SelectedColumn, + values: faker.helpers.arrayElements([ + faker.datatype.number({ max: 100 }), + faker.datatype.number({ max: 100 }), + faker.datatype.number({ max: 100 }), + ]), + }, + }, + ])( + 'Should record successfully when call havingNotIn(...).orHavingIn(...).orHavingNotIn(...)', + async ({ notIn, or, orNot }) => { + // Arrange + const expected: Array = [ + { command: LogicalOperator.NOT }, + { + command: ComparisonPredicate.IN, + data: { + column: normalized(notIn.column), + values: notIn.values, + } as HavingPredicateInput, + }, + { command: LogicalOperator.OR }, + { + command: ComparisonPredicate.IN, + data: { + column: normalized(or.column), + values: or.values, + } as HavingPredicateInput, + }, + { command: LogicalOperator.OR }, + { command: LogicalOperator.NOT }, + { + command: ComparisonPredicate.IN, + data: { + column: normalized(orNot.column), + values: orNot.values, + } as HavingPredicateInput, + }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (notIn) builder.havingNotIn(notIn.column, notIn.values); + if (or) builder.orHavingIn(or.column, or.values); + if (orNot) builder.orHavingNotIn(orNot.column, orNot.values); + // Asset + expect(JSON.stringify(builder.operations.having)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + between: { + column: { + name: faker.database.column(), + as: faker.random.word(), + aggregateType: AggregateFuncType.MIN, + } as SelectedColumn, + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + and: { + column: faker.database.column(), + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + andNot: { + column: faker.database.column(), + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + }, + { + between: { + column: faker.database.column(), + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + and: { + column: { + name: faker.database.column(), + as: faker.random.word(), + aggregateType: AggregateFuncType.SUM, + } as SelectedColumn, + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + andNot: { + column: { + name: faker.database.column(), + as: faker.random.word(), + aggregateType: AggregateFuncType.AVG, + } as SelectedColumn, + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + }, + ])( + 'Should record successfully when call havingBetween(...).andHavingBetween(...).andHavingNotBetween(...)', + async ({ between, and, andNot }) => { + // Arrange + const expected: Array = [ + { + command: ComparisonPredicate.BETWEEN, + data: { + column: normalized(between.column), + min: between.min, + max: between.max, + } as HavingPredicateInput, + }, + { command: LogicalOperator.AND }, + { + command: ComparisonPredicate.BETWEEN, + data: { + column: normalized(and.column), + min: and.min, + max: and.max, + } as HavingPredicateInput, + }, + { command: LogicalOperator.AND }, + { command: LogicalOperator.NOT }, + { + command: ComparisonPredicate.BETWEEN, + data: { + column: normalized(andNot.column), + min: andNot.min, + max: andNot.max, + } as HavingPredicateInput, + }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (between) + builder.havingBetween(between.column, between.min, between.max); + if (and) builder.andHavingBetween(and.column, and.min, and.max); + if (andNot) + builder.andHavingNotBetween(andNot.column, andNot.min, andNot.max); + // Asset + expect(JSON.stringify(builder.operations.having)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + notBetween: { + column: { + name: faker.database.column(), + as: faker.random.word(), + aggregateType: AggregateFuncType.MIN, + } as SelectedColumn, + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + or: { + column: faker.database.column(), + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + orNot: { + column: faker.database.column(), + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + }, + { + notBetween: { + column: faker.database.column(), + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + or: { + column: { + name: faker.database.column(), + as: faker.random.word(), + aggregateType: AggregateFuncType.SUM, + } as SelectedColumn, + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + orNot: { + column: { + name: faker.database.column(), + as: faker.random.word(), + aggregateType: AggregateFuncType.AVG, + } as SelectedColumn, + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + }, + ])( + 'Should record successfully when call havingNotBetween(...).orHavingBetween(...).orHavingNotBetween(...)', + async ({ notBetween, or, orNot }) => { + // Arrange + const expected: Array = [ + { command: LogicalOperator.NOT }, + { + command: ComparisonPredicate.BETWEEN, + data: { + column: normalized(notBetween.column), + min: notBetween.min, + max: notBetween.max, + } as HavingPredicateInput, + }, + { command: LogicalOperator.OR }, + { + command: ComparisonPredicate.BETWEEN, + data: { + column: normalized(or.column), + min: or.min, + max: or.max, + } as HavingPredicateInput, + }, + { command: LogicalOperator.OR }, + { command: LogicalOperator.NOT }, + { + command: ComparisonPredicate.BETWEEN, + data: { + column: normalized(orNot.column), + min: orNot.min, + max: orNot.max, + } as HavingPredicateInput, + }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (notBetween) + builder.havingNotBetween( + notBetween.column, + notBetween.min, + notBetween.max + ); + if (or) builder.orHavingBetween(or.column, or.min, or.max); + if (orNot) builder.orHavingNotBetween(orNot.column, orNot.min, orNot.max); + // Asset + expect(JSON.stringify(builder.operations.having)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + isNull: { + column: faker.database.column(), + }, + and: { + column: faker.database.column(), + }, + andNot: { + column: faker.database.column(), + }, + }, + { + isNull: { + column: faker.database.column(), + }, + and: { + column: faker.database.column(), + }, + andNot: { + column: faker.database.column(), + }, + }, + ])( + 'Should record successfully when call havingNull(...).andHavingNull(...).andHavingNotNull(...)', + async ({ isNull, and, andNot }) => { + // Arrange + const expected: Array = [ + { + command: ComparisonPredicate.IS_NULL, + data: { + column: isNull.column, + } as HavingPredicateInput, + }, + { command: LogicalOperator.AND }, + { + command: ComparisonPredicate.IS_NULL, + data: { + column: and.column, + } as HavingPredicateInput, + }, + { command: LogicalOperator.AND }, + { command: LogicalOperator.NOT }, + { + command: ComparisonPredicate.IS_NULL, + data: { + column: andNot.column, + } as HavingPredicateInput, + }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (isNull) builder.havingNull(isNull.column); + if (and) builder.andHavingNull(and.column); + if (andNot) builder.andHavingNotNull(andNot.column); + // Asset + expect(JSON.stringify(builder.operations.having)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + notNull: { + column: faker.database.column(), + }, + or: { + column: faker.database.column(), + }, + orNot: { + column: faker.database.column(), + }, + }, + { + notNull: { + column: faker.database.column(), + }, + or: { + column: faker.database.column(), + }, + orNot: { + column: faker.database.column(), + }, + }, + ])( + 'Should record successfully when call havingNotNull(...).orHavingNull(...).orHavingNotNull(...)', + async ({ notNull, or, orNot }) => { + // Arrange + const expected: Array = [ + { command: LogicalOperator.NOT }, + { + command: ComparisonPredicate.IS_NULL, + data: { + column: notNull.column, + } as HavingPredicateInput, + }, + { command: LogicalOperator.OR }, + { + command: ComparisonPredicate.IS_NULL, + data: { + column: or.column, + } as HavingPredicateInput, + }, + { command: LogicalOperator.OR }, + { command: LogicalOperator.NOT }, + { + command: ComparisonPredicate.IS_NULL, + data: { + column: orNot.column, + } as HavingPredicateInput, + }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (notNull) builder.havingNotNull(notNull.column); + if (or) builder.orHavingNull(or.column); + if (orNot) builder.orHavingNotNull(orNot.column); + // Asset + expect(JSON.stringify(builder.operations.having)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + exists: { + builder: new DataQueryBuilder({ + statement: 'select * from products', + dataSource: sinon.stubInterface(), + }), + as: 'products', + } as AliasDataQueryBuilder, + and: { + builder: new DataQueryBuilder({ + statement: 'select * from users', + dataSource: sinon.stubInterface(), + }), + as: 'users', + } as AliasDataQueryBuilder, + andNot: { + builder: new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: sinon.stubInterface(), + }), + as: 'orders', + } as AliasDataQueryBuilder, + }, + { + exists: { + builder: new DataQueryBuilder({ + statement: 'select * from products', + dataSource: sinon.stubInterface(), + }), + as: 'products', + } as AliasDataQueryBuilder, + and: { + builder: new DataQueryBuilder({ + statement: 'select * from users', + dataSource: sinon.stubInterface(), + }), + as: 'users', + } as AliasDataQueryBuilder, + andNot: { + builder: new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: sinon.stubInterface(), + }), + as: 'orders', + } as AliasDataQueryBuilder, + }, + ])( + 'Should record successfully when call havingExists(...).andHavingExists(...).andHavingNotExists(...)', + async ({ exists, and, andNot }) => { + // Arrange + const expected: Array = [ + { command: ComparisonPredicate.EXISTS, data: exists }, + { command: LogicalOperator.AND }, + { command: ComparisonPredicate.EXISTS, data: and }, + { command: LogicalOperator.AND }, + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.EXISTS, data: andNot }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (exists) builder.havingExists(exists); + if (and) builder.andHavingExists(and); + if (andNot) builder.andHavingNotExists(andNot); + // Asset + expect(JSON.stringify(builder.operations.having)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + exists: { + builder: new DataQueryBuilder({ + statement: 'select * from products', + dataSource: sinon.stubInterface(), + }), + as: 'products', + } as AliasDataQueryBuilder, + or: { + builder: new DataQueryBuilder({ + statement: 'select * from users', + dataSource: sinon.stubInterface(), + }), + as: 'users', + } as AliasDataQueryBuilder, + orNot: { + builder: new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: sinon.stubInterface(), + }), + as: 'orders', + } as AliasDataQueryBuilder, + }, + { + exists: { + builder: new DataQueryBuilder({ + statement: 'select * from products', + dataSource: sinon.stubInterface(), + }), + as: 'products', + } as AliasDataQueryBuilder, + or: { + builder: new DataQueryBuilder({ + statement: 'select * from users', + dataSource: sinon.stubInterface(), + }), + as: 'users', + } as AliasDataQueryBuilder, + orNot: { + builder: new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: sinon.stubInterface(), + }), + as: 'orders', + } as AliasDataQueryBuilder, + }, + ])( + 'Should record successfully when call havingNotExists(...).orHavingExists(...).orHavingExists(...)', + async ({ exists, or, orNot }) => { + // Arrange + const expected: Array = [ + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.EXISTS, data: exists }, + { command: LogicalOperator.OR }, + { command: ComparisonPredicate.EXISTS, data: or }, + { command: LogicalOperator.OR }, + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.EXISTS, data: orNot }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (exists) builder.havingNotExists(exists); + if (or) builder.orHavingExists(or); + if (orNot) builder.orHavingNotExists(orNot); + // Asset + expect(JSON.stringify(builder.operations.having)).toEqual( + JSON.stringify(expected) + ); + } + ); +}); diff --git a/packages/core/test/data-query/builder/join-clause.spec.ts b/packages/core/test/data-query/builder/join-clause.spec.ts new file mode 100644 index 00000000..d040053d --- /dev/null +++ b/packages/core/test/data-query/builder/join-clause.spec.ts @@ -0,0 +1,144 @@ +import * as sinon from 'ts-sinon'; +import { + BetweenPredicateInput, + ComparisonPredicate, + DataQueryBuilder, + IDataQueryBuilder, + IJoinOnClause, + InPredicateInput, + JoinClauseOperation, + JoinCommandType, + JoinOnClauseOperation, + JoinOnOperatorInput, + LogicalOperator, + NullPredicateInput, +} from '@vulcan/core/data-query'; +import { IDataSource } from '@vulcan/core'; + +describe('Test data query builder > join clause', () => { + let stubDataSource: sinon.StubbedInstance; + + beforeEach(() => { + stubDataSource = sinon.stubInterface(); + }); + + const joinBuilder = new DataQueryBuilder({ + statement: 'select * from products', + dataSource: sinon.stubInterface(), + }); + const alias = 'products'; + const joinOnClauseOperations: Array = [ + { + command: null, + data: { + leftColumn: 'orders.product_id', + operator: '=', + rightColumn: 'products.id', + } as JoinOnOperatorInput, + }, + { command: LogicalOperator.AND }, + { + command: ComparisonPredicate.BETWEEN, + data: { + column: 'orders.price', + min: 1000, + max: 2000, + } as BetweenPredicateInput, + }, + { command: LogicalOperator.AND }, + { command: LogicalOperator.NOT }, + { + command: ComparisonPredicate.IN, + data: { + column: 'orders.payment', + values: ['cash', 'e-pay'], + } as InPredicateInput, + }, + { command: LogicalOperator.AND }, + { command: LogicalOperator.NOT }, + { + command: ComparisonPredicate.IS_NULL, + data: { + column: 'phone', + } as NullPredicateInput, + }, + ]; + + it.each([ + { + command: JoinCommandType.INNER_JOIN, + }, + { + command: JoinCommandType.LEFT_JOIN, + }, + { + command: JoinCommandType.RIGHT_JOIN, + }, + { + command: JoinCommandType.FULL_JOIN, + }, + ])( + 'Should record successfully when call $command', + async ({ command }: { command: JoinCommandType }) => { + // Arrange + const statement = 'select * from orders'; + + const expected: JoinClauseOperation = { + command, + onClauses: joinOnClauseOperations, + joinBuilder: { + builder: joinBuilder, + as: alias, + }, + }; + + const joinOnInput = joinOnClauseOperations[0].data as JoinOnOperatorInput; + const joinOnBetweenInput = joinOnClauseOperations[2] + .data as BetweenPredicateInput; + const joinOnNotInInput = joinOnClauseOperations[5] + .data as InPredicateInput; + const joinOnNotNullInput = joinOnClauseOperations[8] + .data as NullPredicateInput; + const joinParameters = { + joinBuilder: { builder: joinBuilder, as: alias }, + clause: (clause: IJoinOnClause) => { + clause + .on( + joinOnInput.leftColumn, + joinOnInput.operator, + joinOnInput.rightColumn + ) + .andOnBetween( + joinOnBetweenInput.column, + joinOnBetweenInput.min, + joinOnBetweenInput.max + ) + .andOnNotIn(joinOnNotInInput.column, joinOnNotInInput.values) + .andOnNotNull(joinOnNotNullInput.column); + }, + }; + + // Act + const queryBuilder = new DataQueryBuilder({ + statement, + dataSource: stubDataSource, + }); + const joinCallMapper = { + [JoinCommandType.INNER_JOIN]: (builder: IDataQueryBuilder) => + builder.innerJoin(joinParameters.joinBuilder, joinParameters.clause), + [JoinCommandType.LEFT_JOIN]: (builder: IDataQueryBuilder) => + builder.leftJoin(joinParameters.joinBuilder, joinParameters.clause), + [JoinCommandType.RIGHT_JOIN]: (builder: IDataQueryBuilder) => + builder.rightJoin(joinParameters.joinBuilder, joinParameters.clause), + [JoinCommandType.FULL_JOIN]: (builder: IDataQueryBuilder) => + builder.fullJoin(joinParameters.joinBuilder, joinParameters.clause), + }; + joinCallMapper[command](queryBuilder); + + // Assert + expect(JSON.stringify(queryBuilder.operations.join[0])).toEqual( + JSON.stringify(expected) + ); + } + ); +}); diff --git a/packages/core/test/data-query/builder/limit-offset-clause.spec.ts b/packages/core/test/data-query/builder/limit-offset-clause.spec.ts new file mode 100644 index 00000000..ef1043ca --- /dev/null +++ b/packages/core/test/data-query/builder/limit-offset-clause.spec.ts @@ -0,0 +1,134 @@ +import * as sinon from 'ts-sinon'; +import faker from '@faker-js/faker'; +import { DataQueryBuilder, IDataSource } from '@vulcan/core'; + +describe('Test data query builder > limit-offset by clause', () => { + let stubDataSource: sinon.StubbedInstance; + + beforeEach(() => { + stubDataSource = sinon.stubInterface(); + }); + it.each([ + { + limit: faker.datatype.number({ max: 10 }), + offset: faker.datatype.number({ max: 1000 }), + }, + { + limit: faker.datatype.number({ max: 10 }), + offset: faker.datatype.number({ max: 1000 }), + }, + ])( + 'Should record successfully when call limit($limit).offset($offset)', + async ({ limit, offset }) => { + // Arrange + const expected = { + limit, + offset, + }; + + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + builder.limit(limit).offset(offset); + + // Assert + expect(builder.operations.limit).toEqual(expected.limit); + expect(builder.operations.offset).toEqual(expected.offset); + } + ); + + it.each([ + { + first: { + limit: faker.datatype.number({ max: 10 }), + offset: faker.datatype.number({ max: 1000 }), + }, + second: { + limit: faker.datatype.number({ max: 10 }), + offset: faker.datatype.number({ max: 1000 }), + }, + }, + { + first: { + limit: faker.datatype.number({ max: 10 }), + offset: faker.datatype.number({ max: 1000 }), + }, + second: { + limit: faker.datatype.number({ max: 10 }), + offset: faker.datatype.number({ max: 1000 }), + }, + }, + ])( + 'Should record successfully when call limit($first.limit).offset($first.offset).offset($second.offset).limit($second.limit)', + async ({ first, second }) => { + // Arrange + const expected = { + limit: second.limit, + offset: second.offset, + }; + + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + builder + .limit(first.limit) + .offset(first.offset) + .offset(second.offset) + .limit(second.limit); + + // Assert + expect(builder.operations.limit).toEqual(expected.limit); + expect(builder.operations.offset).toEqual(expected.offset); + } + ); + + it.each([ + { + first: { + limit: faker.datatype.number({ max: 10 }), + offset: faker.datatype.number({ max: 1000 }), + }, + second: { + limit: faker.datatype.number({ max: 10 }), + offset: faker.datatype.number({ max: 1000 }), + }, + }, + { + first: { + limit: faker.datatype.number({ max: 10 }), + offset: faker.datatype.number({ max: 1000 }), + }, + second: { + limit: faker.datatype.number({ max: 10 }), + offset: faker.datatype.number({ max: 1000 }), + }, + }, + ])( + 'Should record successfully when call limit($first.limit).offset($first.offset).take($second.limit, $second.offset)', + async ({ first, second }) => { + // Arrange + const expected = { + limit: second.limit, + offset: second.offset, + }; + + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + builder + .limit(first.limit) + .offset(first.offset) + .take(second.limit, second.offset); + + // Assert + expect(builder.operations.limit).toEqual(expected.limit); + expect(builder.operations.offset).toEqual(expected.offset); + } + ); +}); diff --git a/packages/core/test/data-query/builder/order-by-clause.spec.ts b/packages/core/test/data-query/builder/order-by-clause.spec.ts new file mode 100644 index 00000000..13cf1c94 --- /dev/null +++ b/packages/core/test/data-query/builder/order-by-clause.spec.ts @@ -0,0 +1,91 @@ +import * as sinon from 'ts-sinon'; +import faker from '@faker-js/faker'; +import { + DataQueryBuilder, + Direction, + OrderByClauseOperation, +} from '@vulcan/core/data-query'; +import { IDataSource } from '@vulcan/core'; +describe('Test data query builder > order by clause', () => { + let stubDataSource: sinon.StubbedInstance; + + beforeEach(() => { + stubDataSource = sinon.stubInterface(); + }); + it.each([ + { + column: faker.database.column(), + direction: Direction.ASC, + }, + { + column: faker.database.column(), + direction: Direction.DESC, + }, + ])( + 'Should record successfully when call order by with $column, $direction', + async ({ column, direction }) => { + // Arrange + const expected: Array = [ + { + column, + direction, + }, + ]; + + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + builder.orderBy(column, direction); + + // Assert + expect(JSON.stringify(builder.operations.orderBy)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + [ + { + column: faker.database.column(), + direction: Direction.DESC, + }, + { + column: faker.database.column(), + direction: Direction.ASC, + }, + ], + [ + { + column: faker.database.column(), + direction: Direction.ASC, + }, + { + column: faker.database.column(), + direction: Direction.DESC, + }, + ], + ])( + 'Should record successfully when call orderBy(%p).orderBy(%p)', + async (first, second) => { + // Arrange + const expected: Array = [first, second]; + + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + builder + .orderBy(first.column, first.direction) + .orderBy(second.column, second.direction); + + // Assert + expect(JSON.stringify(builder.operations.orderBy)).toEqual( + JSON.stringify(expected) + ); + } + ); +}); diff --git a/packages/core/test/data-query/builder/select-clause.spec.ts b/packages/core/test/data-query/builder/select-clause.spec.ts new file mode 100644 index 00000000..245949bb --- /dev/null +++ b/packages/core/test/data-query/builder/select-clause.spec.ts @@ -0,0 +1,602 @@ +import * as sinon from 'ts-sinon'; +import faker from '@faker-js/faker'; +import { + AggregateFuncType, + AliasColumn, + DataQueryBuilder, + SelectClauseOperation, + SelectCommandType, + SelectedColumn, +} from '@vulcan/core/data-query'; +import { find, isEmpty } from 'lodash'; +import { IDataSource } from '@vulcan/core/data-source'; + +// Use to generate select record expected results +const generateSelectRecords = ( + command: SelectCommandType, + columns: Array +) => { + // prepared used function for generating expected result + const normalized = (column: string | SelectedColumn) => { + if (typeof column === 'string') return { name: column }; + return column as SelectedColumn; + }; + const isSelectAllExist = (column: SelectedColumn) => column.name === '*'; + const result: SelectClauseOperation = { + command, + columns: columns.reduce( + (operations, currColumn: string | SelectedColumn | undefined) => { + if (!currColumn || currColumn === '') currColumn = '*'; + if (!(currColumn === '*' && find(operations, isSelectAllExist))) + operations.push(normalized(currColumn)); + return operations; + }, + [] as Array + ), + }; + return result; +}; + +describe('Test data query builder > select clause', () => { + let stubDataSource: sinon.StubbedInstance; + beforeEach(() => { + stubDataSource = sinon.stubInterface(); + }); + it.each([ + ['*', '*', '*'], + [undefined, '*', '*'], + [{ name: '*' }, '*'], + [{ name: '' }, '*'], + ['*', { name: '' }], + [{ name: '*' }, ''], + [`*`, undefined, ''], + ['', '', ''], + [undefined, undefined, undefined], + ['*', `${faker.database.column()}`, '*'], + ['*', `${faker.database.column()}`, undefined], + [undefined, `${faker.database.column()}`, '*'], + [`${faker.database.column()}`, `${faker.database.column()}`, undefined], + [`${faker.database.column()}`, '*', '*'], + [ + `${faker.database.column()}`, + '*', + { name: faker.database.column(), as: 'alias' } as SelectedColumn, + ], + [ + `${faker.database.column()}`, + '*', + { + name: faker.database.column(), + aggregateType: AggregateFuncType.COUNT, + } as SelectedColumn, + ], + ])( + 'Should record successfully when call select %p, %p, %p', + async (...columns: Array) => { + // Arrange + const statement = 'select * from table1'; + const expected: SelectClauseOperation = generateSelectRecords( + SelectCommandType.SELECT, + columns + ); + // Act + let builder = new DataQueryBuilder({ + statement, + dataSource: stubDataSource, + }); + columns.map((column) => { + builder = column ? builder.select(column) : builder.select(); + }); + // Assert + expect(JSON.stringify(builder.operations.select)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + select: ['*', faker.database.column()], + column: [faker.database.column()], + }, + + { + select: [faker.database.column(), faker.database.column()], + column: ['*'], + }, + + { + select: [faker.database.column(), faker.database.column()], + column: [], + }, + + { + select: [], + column: [faker.database.column()], + }, + + { + select: [ + { + name: faker.database.column(), + aggregateType: AggregateFuncType.COUNT, + } as SelectedColumn, + '*', + ], + column: [ + { + name: faker.database.column(), + } as SelectedColumn, + faker.database.column(), + ], + }, + ])( + 'Should record successfully when call select($select).column($column)', + async (fakeInputParam: { + select: Array; + column: Array; + }) => { + // Arrange + const { select, column } = fakeInputParam; + const statement = 'select * from table1'; + const selectParam = isEmpty(select) ? ['*'] : select; + const columnParam = isEmpty(column) ? ['*'] : column; + const expected = generateSelectRecords( + SelectCommandType.SELECT, + selectParam.concat(columnParam) + ); + + // Act + let builder = new DataQueryBuilder({ + statement, + dataSource: stubDataSource, + }); + + builder = !isEmpty(selectParam) + ? builder.select(...selectParam) + : builder.select(); + builder = !isEmpty(columnParam) + ? builder.column(...columnParam) + : builder.column(); + + // Assert + expect(JSON.stringify(builder.operations.select)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + select: ['*', faker.database.column()], + first: [faker.database.column()], + }, + + { + select: [faker.database.column(), faker.database.column()], + first: ['*'], + }, + + { + select: [faker.database.column(), faker.database.column()], + first: [], + }, + + { + select: [], + first: [faker.database.column()], + }, + + { + select: [ + { + name: faker.database.column(), + aggregateType: AggregateFuncType.COUNT, + } as SelectedColumn, + '*', + ], + first: [ + { + name: faker.database.column(), + } as SelectedColumn, + faker.database.column(), + ], + }, + ])( + 'Should record successfully when call select($select).first($first)', + async (fakeInputParam: { + select: Array; + first: Array; + }) => { + // Arrange + const { select, first } = fakeInputParam; + const statement = 'select * from table1'; + const selectParam = isEmpty(select) ? ['*'] : select; + const firstParam = isEmpty(first) ? ['*'] : first; + const expected = generateSelectRecords( + SelectCommandType.SELECT, + selectParam.concat(firstParam) + ); + + // Act + let builder = new DataQueryBuilder({ + statement, + dataSource: stubDataSource, + }); + + builder = !isEmpty(selectParam) + ? builder.select(...selectParam) + : builder.select(); + builder = !isEmpty(firstParam) + ? builder.first(...firstParam) + : builder.first(); + + // Assert + expect(JSON.stringify(builder.operations.select)).toEqual( + JSON.stringify(expected) + ); + expect(builder.operations.limit).toEqual(1); + } + ); + + it.each([ + { + select: ['*', faker.database.column()], + count: faker.database.column(), + }, + + { + select: [faker.database.column(), faker.database.column()], + count: '*', + }, + + { + select: [faker.database.column(), faker.database.column()], + count: undefined, + }, + + { + select: [], + count: faker.database.column(), + }, + + { + select: ['*'], + count: { + name: faker.database.column(), + as: 'alias', + } as AliasColumn, + }, + ])( + 'Should record successfully when call select($select).count($count)', + async (fakeInputParam: { + select: Array; + count?: string | AliasColumn; + }) => { + // Arrange + const { select, count } = fakeInputParam; + const statement = 'select * from table1'; + const selectParam = isEmpty(select) ? ['*'] : select; + const countParam: SelectedColumn = count + ? typeof count === 'string' + ? { + name: count, + aggregateType: AggregateFuncType.COUNT, + } + : { + ...(count as AliasColumn), + aggregateType: AggregateFuncType.COUNT, + } + : { name: '*', aggregateType: AggregateFuncType.COUNT }; + + const expected = generateSelectRecords( + SelectCommandType.SELECT, + selectParam.concat(countParam) + ); + + // Act + let builder = new DataQueryBuilder({ + statement, + dataSource: stubDataSource, + }); + + builder = !isEmpty(selectParam) + ? builder.select(...selectParam) + : builder.select(); + builder = countParam ? builder.count(countParam) : builder.count(); + + // Assert + expect(JSON.stringify(builder.operations.select)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + select: ['*', faker.database.column()], + max: faker.database.column(), + }, + + { + select: [faker.database.column(), faker.database.column()], + max: '*', + }, + + { + select: [faker.database.column(), faker.database.column()], + max: '', + }, + + { + select: [], + max: faker.database.column(), + }, + + { + select: ['*'], + max: { + name: faker.database.column(), + as: 'alias', + } as AliasColumn, + }, + ])( + 'Should record successfully when call select($select).max($max)', + async (fakeInputParam: { + select: Array; + max: string | AliasColumn; + }) => { + // Arrange + const { select, max } = fakeInputParam; + const statement = 'select * from table1'; + const selectParam = isEmpty(select) ? ['*'] : select; + const maxParam: SelectedColumn = max + ? typeof max === 'string' + ? { + name: max, + aggregateType: AggregateFuncType.MAX, + } + : { + ...(max as AliasColumn), + aggregateType: AggregateFuncType.MAX, + } + : { name: '*', aggregateType: AggregateFuncType.MAX }; + + const expected = generateSelectRecords( + SelectCommandType.SELECT, + selectParam.concat(maxParam) + ); + + // Act + let builder = new DataQueryBuilder({ + statement, + dataSource: stubDataSource, + }); + + builder = !isEmpty(selectParam) + ? builder.select(...selectParam) + : builder.select(); + builder.max(maxParam); + // Assert + expect(JSON.stringify(builder.operations.select)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + select: ['*', faker.database.column()], + min: faker.database.column(), + }, + + { + select: [faker.database.column(), faker.database.column()], + min: '*', + }, + + { + select: [faker.database.column(), faker.database.column()], + min: '', + }, + + { + select: [], + min: faker.database.column(), + }, + + { + select: ['*'], + min: { + name: faker.database.column(), + as: 'alias', + } as AliasColumn, + }, + ])( + 'Should record successfully when call select($select).min($min)', + async (fakeInputParam: { + select: Array; + min: string | AliasColumn; + }) => { + // Arrange + const { select, min } = fakeInputParam; + const statement = 'select * from table1'; + const selectParam = isEmpty(select) ? ['*'] : select; + const minParam: SelectedColumn = min + ? typeof min === 'string' + ? { + name: min, + aggregateType: AggregateFuncType.MIN, + } + : { + ...(min as AliasColumn), + aggregateType: AggregateFuncType.MIN, + } + : { name: '*', aggregateType: AggregateFuncType.MIN }; + + const expected = generateSelectRecords( + SelectCommandType.SELECT, + selectParam.concat(minParam) + ); + + // Act + let builder = new DataQueryBuilder({ + statement, + dataSource: stubDataSource, + }); + + builder = !isEmpty(selectParam) + ? builder.select(...selectParam) + : builder.select(); + builder.min(minParam); + // Assert + expect(JSON.stringify(builder.operations.select)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + select: ['*', faker.database.column()], + avg: faker.database.column(), + }, + + { + select: [faker.database.column(), faker.database.column()], + avg: '*', + }, + + { + select: [faker.database.column(), faker.database.column()], + avg: '', + }, + + { + select: [], + avg: faker.database.column(), + }, + + { + select: ['*'], + avg: { + name: faker.database.column(), + as: 'alias', + } as AliasColumn, + }, + ])( + 'Should record successfully when call select($select).avg($avg)', + async (fakeInputParam: { + select: Array; + avg: string | AliasColumn; + }) => { + // Arrange + const { select, avg } = fakeInputParam; + const statement = 'select * from table1'; + const selectParam = isEmpty(select) ? ['*'] : select; + const avgParam: SelectedColumn = avg + ? typeof avg === 'string' + ? { + name: avg, + aggregateType: AggregateFuncType.AVG, + } + : { + ...(avg as AliasColumn), + aggregateType: AggregateFuncType.AVG, + } + : { name: '*', aggregateType: AggregateFuncType.AVG }; + + const expected = generateSelectRecords( + SelectCommandType.SELECT, + selectParam.concat(avgParam) + ); + + // Act + let builder = new DataQueryBuilder({ + statement, + dataSource: stubDataSource, + }); + + builder = !isEmpty(selectParam) + ? builder.select(...selectParam) + : builder.select(); + builder.avg(avgParam); + // Assert + expect(JSON.stringify(builder.operations.select)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + select: ['*', faker.database.column()], + sum: faker.database.column(), + }, + + { + select: [faker.database.column(), faker.database.column()], + sum: '*', + }, + + { + select: [faker.database.column(), faker.database.column()], + sum: '', + }, + + { + select: [], + sum: faker.database.column(), + }, + + { + select: ['*'], + sum: { + name: faker.database.column(), + as: 'alias', + } as AliasColumn, + }, + ])( + 'Should record successfully when call select($select).sum($sum)', + async (fakeInputParam: { + select: Array; + sum: string | AliasColumn; + }) => { + // Arrange + const { select, sum } = fakeInputParam; + const statement = 'select * from table1'; + const selectParam = isEmpty(select) ? ['*'] : select; + const sumParam: SelectedColumn = sum + ? typeof sum === 'string' + ? { + name: sum, + aggregateType: AggregateFuncType.SUM, + } + : { + ...(sum as AliasColumn), + aggregateType: AggregateFuncType.SUM, + } + : { name: '*', aggregateType: AggregateFuncType.SUM }; + + const expected = generateSelectRecords( + SelectCommandType.SELECT, + selectParam.concat(sumParam) + ); + + // Act + let builder = new DataQueryBuilder({ + statement, + dataSource: stubDataSource, + }); + + builder = !isEmpty(selectParam) + ? builder.select(...selectParam) + : builder.select(); + builder.sum(sumParam); + // Assert + expect(JSON.stringify(builder.operations.select)).toEqual( + JSON.stringify(expected) + ); + } + ); +}); diff --git a/packages/core/test/data-query/builder/where-clause.spec.ts b/packages/core/test/data-query/builder/where-clause.spec.ts new file mode 100644 index 00000000..89c674ff --- /dev/null +++ b/packages/core/test/data-query/builder/where-clause.spec.ts @@ -0,0 +1,969 @@ +import * as sinon from 'ts-sinon'; +import faker from '@faker-js/faker'; +import { + DataQueryBuilder, + WhereClauseOperation, + LogicalOperator, + ComparisonPredicate, + WherePredicate, + AliasDataQueryBuilder, + IDataQueryBuilder, +} from '@vulcan/core/data-query'; +import { IDataSource } from '@vulcan/core/data-source'; + +describe('Test data query builder > where clause', () => { + let stubDataSource: sinon.StubbedInstance; + + beforeEach(() => { + stubDataSource = sinon.stubInterface(); + }); + + it.each([ + { + where: { + column: faker.database.column(), + operator: '!=', + value: faker.random.word(), + }, + and: { + column: faker.database.column(), + operator: '=', + value: new DataQueryBuilder({ + statement: 'select * from products', + dataSource: sinon.stubInterface(), + }), + }, + andNot: { + column: faker.database.column(), + operator: '>', + value: faker.datatype.number({ max: 100 }), + }, + }, + { + where: { + column: faker.database.column(), + operator: '=', + value: new DataQueryBuilder({ + statement: 'select avg(*) from users', + dataSource: sinon.stubInterface(), + }), + }, + and: { + column: faker.database.column(), + operator: '>=', + value: faker.datatype.number({ precision: 0.01 }), + }, + andNot: { + column: faker.database.column(), + operator: '<=', + value: faker.datatype.number({ max: 100 }), + }, + }, + ])( + 'Should record successfully when call where(...).andWhere(...).andNotWhere(...)', + async ({ where, and, andNot }) => { + // Arrange + const expected: Array = [ + { command: null, data: where }, + { command: LogicalOperator.AND }, + { command: null, data: and }, + { command: LogicalOperator.AND }, + { command: LogicalOperator.NOT }, + { command: null, data: andNot }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (where) builder.where(where.column, where.operator, where.value); + if (and) builder.andWhere(and.column, and.operator, and.value); + if (andNot) + builder.andWhereNot(andNot.column, andNot.operator, andNot.value); + // Asset + expect(JSON.stringify(builder.operations.where)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + whereNot: { + column: faker.database.column(), + operator: '!=', + value: faker.random.word(), + }, + or: { + column: faker.database.column(), + operator: '=', + value: new DataQueryBuilder({ + statement: 'select * from products', + dataSource: sinon.stubInterface(), + }), + }, + orNot: { + column: faker.database.column(), + operator: '>', + value: faker.datatype.number({ max: 100 }), + }, + }, + { + whereNot: { + column: faker.database.column(), + operator: '=', + value: new DataQueryBuilder({ + statement: 'select avg(*) from users', + dataSource: sinon.stubInterface(), + }), + }, + or: { + column: faker.database.column(), + operator: '>=', + value: faker.datatype.number({ precision: 0.01 }), + }, + orNot: { + column: faker.database.column(), + operator: '<=', + value: faker.datatype.number({ max: 100 }), + }, + }, + ])( + 'Should record successfully when call whereNot(...).orWhere(...).orWhereNot(...)', + async ({ whereNot, or, orNot }) => { + // Arrange + const expected: Array = [ + { command: LogicalOperator.NOT }, + { command: null, data: whereNot }, + { command: LogicalOperator.OR }, + { command: null, data: or }, + { command: LogicalOperator.OR }, + { command: LogicalOperator.NOT }, + { command: null, data: orNot }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (whereNot) + builder.whereNot(whereNot.column, whereNot.operator, whereNot.value); + if (or) builder.orWhere(or.column, or.operator, or.value); + if (orNot) builder.orWhereNot(orNot.column, orNot.operator, orNot.value); + // Asset + expect(JSON.stringify(builder.operations.where)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + whereIn: { + column: faker.database.column(), + values: faker.helpers.arrayElements([ + faker.datatype.number({ max: 100 }), + faker.datatype.number({ max: 100 }), + faker.datatype.number({ max: 100 }), + ]), + }, + and: { + column: faker.database.column(), + values: faker.helpers.arrayElements([ + faker.random.word(), + faker.random.word(), + faker.random.word(), + ]), + }, + andNot: { + column: faker.database.column(), + values: faker.helpers.arrayElements([ + faker.datatype.number({ precision: 0.01 }), + faker.datatype.number({ precision: 0.01 }), + faker.datatype.number({ precision: 0.01 }), + ]), + }, + }, + { + whereIn: { + column: faker.database.column(), + values: new DataQueryBuilder({ + statement: 'select type from products', + dataSource: sinon.stubInterface(), + }), + }, + and: { + column: faker.database.column(), + values: faker.helpers.arrayElements([ + faker.random.word(), + faker.random.word(), + faker.random.word(), + ]), + }, + andNot: { + column: faker.database.column(), + values: new DataQueryBuilder({ + statement: 'select age from users', + dataSource: sinon.stubInterface(), + }), + }, + }, + ])( + 'Should record successfully when call whereIn(...).andWhereIn(...).andWhereNotIn(...)', + async ({ whereIn, and, andNot }) => { + // Arrange + const expected: Array = [ + { command: ComparisonPredicate.IN, data: whereIn }, + { command: LogicalOperator.AND }, + { command: ComparisonPredicate.IN, data: and }, + { command: LogicalOperator.AND }, + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.IN, data: andNot }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (whereIn) builder.whereIn(whereIn.column, whereIn.values); + if (and) builder.andWhereIn(and.column, and.values); + if (andNot) builder.andWhereNotIn(andNot.column, andNot.values); + // Asset + expect(JSON.stringify(builder.operations.where)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + notIn: { + column: faker.database.column(), + values: faker.helpers.arrayElements([ + faker.datatype.number({ max: 100 }), + faker.datatype.number({ max: 100 }), + faker.datatype.number({ max: 100 }), + ]), + }, + or: { + column: faker.database.column(), + values: faker.helpers.arrayElements([ + faker.random.word(), + faker.random.word(), + faker.random.word(), + ]), + }, + orNot: { + column: faker.database.column(), + values: faker.helpers.arrayElements([ + faker.datatype.number({ precision: 0.01 }), + faker.datatype.number({ precision: 0.01 }), + faker.datatype.number({ precision: 0.01 }), + ]), + }, + }, + { + notIn: { + column: faker.database.column(), + values: new DataQueryBuilder({ + statement: 'select type from products', + dataSource: sinon.stubInterface(), + }), + }, + or: { + column: faker.database.column(), + values: faker.helpers.arrayElements([ + faker.random.word(), + faker.random.word(), + faker.random.word(), + ]), + }, + orNot: { + column: faker.database.column(), + values: new DataQueryBuilder({ + statement: 'select age from users', + dataSource: sinon.stubInterface(), + }), + }, + }, + ])( + 'Should record successfully when call whereNotIn(...).orWhereIn(...).orWhereNotIn(...)', + async ({ notIn, or, orNot }) => { + // Arrange + const expected: Array = [ + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.IN, data: notIn }, + { command: LogicalOperator.OR }, + { command: ComparisonPredicate.IN, data: or }, + { command: LogicalOperator.OR }, + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.IN, data: orNot }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (notIn) builder.whereNotIn(notIn.column, notIn.values); + if (or) builder.orWhereIn(or.column, or.values); + if (orNot) builder.orWhereNotIn(orNot.column, orNot.values); + // Asset + expect(JSON.stringify(builder.operations.where)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + between: { + column: faker.database.column(), + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + and: { + column: faker.database.column(), + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + andNot: { + column: faker.database.column(), + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + }, + { + between: { + column: faker.database.column(), + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + and: { + column: faker.database.column(), + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + andNot: { + column: faker.database.column(), + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + }, + ])( + 'Should record successfully when call whereBetween(...).andWhereBetween(...).andWhereNotBetween(...)', + async ({ between, and, andNot }) => { + // Arrange + const expected: Array = [ + { command: ComparisonPredicate.BETWEEN, data: between }, + { command: LogicalOperator.AND }, + { command: ComparisonPredicate.BETWEEN, data: and }, + { command: LogicalOperator.AND }, + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.BETWEEN, data: andNot }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (between) + builder.whereBetween(between.column, between.min, between.max); + if (and) builder.andWhereBetween(and.column, and.min, and.max); + if (andNot) + builder.andWhereNotBetween(andNot.column, andNot.min, andNot.max); + // Asset + expect(JSON.stringify(builder.operations.where)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + notBetween: { + column: faker.database.column(), + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + or: { + column: faker.database.column(), + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + orNot: { + column: faker.database.column(), + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + }, + { + notBetween: { + column: faker.database.column(), + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + or: { + column: faker.database.column(), + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + orNot: { + column: faker.database.column(), + min: faker.datatype.number({ min: 0, max: 10 }), + max: faker.datatype.number({ min: 10, max: 100 }), + }, + }, + ])( + 'Should record successfully when call whereNotBetween(...).orWhereBetween(...).orWhereNotBetween(...)', + async ({ notBetween, or, orNot }) => { + // Arrange + const expected: Array = [ + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.BETWEEN, data: notBetween }, + { command: LogicalOperator.OR }, + { command: ComparisonPredicate.BETWEEN, data: or }, + { command: LogicalOperator.OR }, + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.BETWEEN, data: orNot }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (notBetween) + builder.whereNotBetween( + notBetween.column, + notBetween.min, + notBetween.max + ); + if (or) builder.orWhereBetween(or.column, or.min, or.max); + if (orNot) builder.orWhereNotBetween(orNot.column, orNot.min, orNot.max); + // Asset + expect(JSON.stringify(builder.operations.where)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + isNull: { + column: faker.database.column(), + }, + and: { + column: faker.database.column(), + }, + andNot: { + column: faker.database.column(), + }, + }, + { + isNull: { + column: faker.database.column(), + }, + and: { + column: faker.database.column(), + }, + andNot: { + column: faker.database.column(), + }, + }, + ])( + 'Should record successfully when call whereNull(...).andWhereNull(...).andWhereNotNull(...)', + async ({ isNull, and, andNot }) => { + // Arrange + const expected: Array = [ + { command: ComparisonPredicate.IS_NULL, data: isNull }, + { command: LogicalOperator.AND }, + { command: ComparisonPredicate.IS_NULL, data: and }, + { command: LogicalOperator.AND }, + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.IS_NULL, data: andNot }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (isNull) builder.whereNull(isNull.column); + if (and) builder.andWhereNull(and.column); + if (andNot) builder.andWhereNotNull(andNot.column); + // Asset + expect(JSON.stringify(builder.operations.where)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + notNull: { + column: faker.database.column(), + }, + or: { + column: faker.database.column(), + }, + orNot: { + column: faker.database.column(), + }, + }, + { + notNull: { + column: faker.database.column(), + }, + or: { + column: faker.database.column(), + }, + orNot: { + column: faker.database.column(), + }, + }, + ])( + 'Should record successfully when call whereNotNull(...).orWhereNull(...).orWhereNotNull(...)', + async ({ notNull, or, orNot }) => { + // Arrange + const expected: Array = [ + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.IS_NULL, data: notNull }, + { command: LogicalOperator.OR }, + { command: ComparisonPredicate.IS_NULL, data: or }, + { command: LogicalOperator.OR }, + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.IS_NULL, data: orNot }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (notNull) builder.whereNotNull(notNull.column); + if (or) builder.orWhereNull(or.column); + if (orNot) builder.orWhereNotNull(orNot.column); + // Asset + expect(JSON.stringify(builder.operations.where)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + like: { + column: faker.database.column(), + searchValue: faker.random.word() + '%', + }, + and: { + column: faker.database.column(), + searchValue: '%' + faker.random.word() + '%', + }, + }, + { + like: { + column: faker.database.column(), + searchValue: '%' + faker.random.word() + '%', + }, + and: { + column: faker.database.column(), + searchValue: '%' + faker.random.word(), + }, + }, + ])( + 'Should record successfully when call whereLike(...).andWhereLike(...)', + async ({ like, and }) => { + // Arrange + const expected: Array = [ + { command: WherePredicate.LIKE, data: like }, + { command: LogicalOperator.AND }, + { command: WherePredicate.LIKE, data: and }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (like) builder.whereLike(like.column, like.searchValue); + if (and) builder.andWhereLike(and.column, and.searchValue); + + // Asset + expect(JSON.stringify(builder.operations.where)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + like: { + column: faker.database.column(), + searchValue: faker.random.word() + '%', + }, + or: { + column: faker.database.column(), + searchValue: '%' + faker.random.word() + '%', + }, + }, + { + like: { + column: faker.database.column(), + searchValue: '%' + faker.random.word() + '%', + }, + or: { + column: faker.database.column(), + searchValue: '%' + faker.random.word(), + }, + }, + ])( + 'Should record successfully when call whereLike(...).orWhereLike(...)', + async ({ like, or }) => { + // Arrange + const expected: Array = [ + { command: WherePredicate.LIKE, data: like }, + { command: LogicalOperator.OR }, + { command: WherePredicate.LIKE, data: or }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (like) builder.whereLike(like.column, like.searchValue); + if (or) builder.orWhereLike(or.column, or.searchValue); + + // Asset + expect(JSON.stringify(builder.operations.where)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + exists: { + builder: new DataQueryBuilder({ + statement: 'select * from products', + dataSource: sinon.stubInterface(), + }), + as: 'products', + } as AliasDataQueryBuilder, + and: { + builder: new DataQueryBuilder({ + statement: 'select * from users', + dataSource: sinon.stubInterface(), + }), + as: 'users', + } as AliasDataQueryBuilder, + andNot: { + builder: new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: sinon.stubInterface(), + }), + as: 'orders', + } as AliasDataQueryBuilder, + }, + { + exists: { + builder: new DataQueryBuilder({ + statement: 'select * from products', + dataSource: sinon.stubInterface(), + }), + as: 'products', + } as AliasDataQueryBuilder, + + and: { + builder: new DataQueryBuilder({ + statement: 'select * from users', + dataSource: sinon.stubInterface(), + }), + as: 'users', + } as AliasDataQueryBuilder, + + andNot: { + builder: new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: sinon.stubInterface(), + }), + as: 'orders', + } as AliasDataQueryBuilder, + }, + ])( + 'Should record successfully when call whereExists(...).andWhereExists(...).andWhereNotExists(...)', + async ({ exists, and, andNot }) => { + // Arrange + const expected: Array = [ + { command: ComparisonPredicate.EXISTS, data: exists }, + { command: LogicalOperator.AND }, + { command: ComparisonPredicate.EXISTS, data: and }, + { command: LogicalOperator.AND }, + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.EXISTS, data: andNot }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (exists) builder.whereExists(exists); + if (and) builder.andWhereExists(and); + if (andNot) builder.andWhereNotExists(andNot); + // Asset + expect(JSON.stringify(builder.operations.where)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + notExists: { + builder: new DataQueryBuilder({ + statement: 'select * from products', + dataSource: sinon.stubInterface(), + }), + as: 'products', + } as AliasDataQueryBuilder, + or: { + builder: new DataQueryBuilder({ + statement: 'select * from users', + dataSource: sinon.stubInterface(), + }), + as: 'users', + } as AliasDataQueryBuilder, + orNot: { + builder: new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: sinon.stubInterface(), + }), + as: 'orders', + } as AliasDataQueryBuilder, + }, + { + notExists: { + builder: new DataQueryBuilder({ + statement: 'select * from products', + dataSource: sinon.stubInterface(), + }), + as: 'products', + } as AliasDataQueryBuilder, + or: { + builder: new DataQueryBuilder({ + statement: 'select * from users', + dataSource: sinon.stubInterface(), + }), + as: 'users', + } as AliasDataQueryBuilder, + orNot: { + builder: new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: sinon.stubInterface(), + }), + as: 'orders', + } as AliasDataQueryBuilder, + }, + ])( + 'Should record successfully when call whereNotExists(...).orWhereExists(...).orWhereNotExists(...)', + async ({ notExists, or, orNot }) => { + // Arrange + const expected: Array = [ + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.EXISTS, data: notExists }, + { command: LogicalOperator.OR }, + { command: ComparisonPredicate.EXISTS, data: or }, + { command: LogicalOperator.OR }, + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.EXISTS, data: orNot }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (notExists) builder.whereNotExists(notExists); + if (or) builder.orWhereExists(or); + if (orNot) builder.orWhereNotExists(orNot); + // Asset + expect(JSON.stringify(builder.operations.where)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + wrapped: (builder: IDataQueryBuilder) => { + builder.where('items', '>', 5).andWhereNull('expired'); + }, + and: (builder: IDataQueryBuilder) => { + builder + .whereIn('type', ['3C', 'cloth', 'book']) + .andWhereNotNull('expired'); + }, + andNot: (builder: IDataQueryBuilder) => { + builder + .whereBetween('price', 1000, 3000) + .andWhereLike('comments', '%refund%'); + }, + }, + { + wrapped: (builder: IDataQueryBuilder) => { + builder + .whereNot( + 'tags', + '>', + new DataQueryBuilder({ + statement: 'select count(*) from tags', + dataSource: sinon.stubInterface(), + }) + ) + .orWhereNotBetween('price', 1, 1000); + }, + and: (builder: IDataQueryBuilder) => { + builder + .whereIn('tags', ['disney', 'marvel', 'jump']) + .andWhereNotBetween('price', 1, 1000); + }, + andNot: (builder: IDataQueryBuilder) => { + builder + .whereNotIn('type', ['3C', 'cloth', 'book']) + .orWhereLike('comments', '%face to face%'); + }, + }, + ])( + 'Should record successfully when call whereWrapped(...).andWhereWrapped(...).andWhereNotWrapped(...)', + async ({ wrapped, and, andNot }) => { + // Arrange + const wrappedBuilder = new DataQueryBuilder({ + statement: '', + dataSource: stubDataSource, + }); + wrapped(wrappedBuilder); + const andBuilder = new DataQueryBuilder({ + statement: '', + dataSource: stubDataSource, + }); + and(andBuilder); + const andNotBuilder = new DataQueryBuilder({ + statement: '', + dataSource: stubDataSource, + }); + andNot(andNotBuilder); + + const expected: Array = [ + { + command: WherePredicate.WRAPPED, + data: wrappedBuilder.operations.where, + }, + { command: LogicalOperator.AND }, + { command: WherePredicate.WRAPPED, data: andBuilder.operations.where }, + { command: LogicalOperator.AND }, + { command: LogicalOperator.NOT }, + { + command: WherePredicate.WRAPPED, + data: andNotBuilder.operations.where, + }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (wrapped) builder.whereWrapped(wrapped); + if (and) builder.andWhereWrapped(and); + if (andNot) builder.andWhereNotWrapped(andNot); + // Asset + expect(JSON.stringify(builder.operations.where)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + { + notWrapped: (builder: IDataQueryBuilder) => { + builder.where('items', '>', 5).andWhereNull('expired'); + }, + or: (builder: IDataQueryBuilder) => { + builder + .whereIn('type', ['3C', 'cloth', 'book']) + .andWhereNotNull('expired'); + }, + orNot: (builder: IDataQueryBuilder) => { + builder + .whereBetween('price', 1000, 3000) + .andWhereLike('comments', '%refund%'); + }, + }, + { + notWrapped: (builder: IDataQueryBuilder) => { + builder + .whereNot( + 'tags', + '>', + new DataQueryBuilder({ + statement: 'select count(*) from tags', + dataSource: sinon.stubInterface(), + }) + ) + .orWhereNotBetween('price', 1, 1000); + }, + or: (builder: IDataQueryBuilder) => { + builder + .whereIn('tags', ['disney', 'marvel', 'jump']) + .andWhereNotBetween('price', 1, 1000); + }, + orNot: (builder: IDataQueryBuilder) => { + builder + .whereNotIn('type', ['3C', 'cloth', 'book']) + .orWhereLike('comments', '%face to face%'); + }, + }, + ])( + 'Should record successfully when call whereNotWrapped(...).orWhereWrapped(...).orWhereNotWrapped(...)', + async ({ notWrapped, or, orNot }) => { + // Arrange + const notWrappedBuilder = new DataQueryBuilder({ + statement: '', + dataSource: stubDataSource, + }); + notWrapped(notWrappedBuilder); + const orBuilder = new DataQueryBuilder({ + statement: '', + dataSource: stubDataSource, + }); + or(orBuilder); + const orNotBuilder = new DataQueryBuilder({ + statement: '', + dataSource: stubDataSource, + }); + orNot(orNotBuilder); + + const expected: Array = [ + { command: LogicalOperator.NOT }, + { + command: WherePredicate.WRAPPED, + data: notWrappedBuilder.operations.where, + }, + { command: LogicalOperator.OR }, + { command: WherePredicate.WRAPPED, data: orBuilder.operations.where }, + { command: LogicalOperator.OR }, + { command: LogicalOperator.NOT }, + { + command: WherePredicate.WRAPPED, + data: orNotBuilder.operations.where, + }, + ]; + // Act + const builder = new DataQueryBuilder({ + statement: 'select * from orders', + dataSource: stubDataSource, + }); + if (notWrapped) builder.whereNotWrapped(notWrapped); + if (or) builder.orWhereWrapped(or); + if (orNot) builder.orWhereNotWrapped(orNot); + // Asset + expect(JSON.stringify(builder.operations.where)).toEqual( + JSON.stringify(expected) + ); + } + ); +}); diff --git a/packages/core/test/data-query/joinOnClause.spec.ts b/packages/core/test/data-query/joinOnClause.spec.ts new file mode 100644 index 00000000..3fd2f851 --- /dev/null +++ b/packages/core/test/data-query/joinOnClause.spec.ts @@ -0,0 +1,352 @@ +import faker from '@faker-js/faker'; +import { + JoinOnClause, + JoinOnClauseOperation, + JoinOnOperatorInput, + LogicalOperator, + ComparisonPredicate, +} from '@vulcan/core/data-query'; + +describe('Test join on clause > on operations', () => { + it.each([ + [ + `table1.${faker.database.column()}`, + '=', + `table2.${faker.database.column()}`, + ], + [ + `table1.${faker.database.column()}`, + '>=', + `table2.${faker.database.column()}`, + ], + [ + `table1.${faker.database.column()}`, + '>', + `table2.${faker.database.column()}`, + ], + [ + `table1.${faker.database.column()}`, + '<=', + `table2.${faker.database.column()}`, + ], + [ + `table1.${faker.database.column()}`, + '<', + `table2.${faker.database.column()}`, + ], + [ + `table1.${faker.database.column()}`, + '!=', + `table2.${faker.database.column()}`, + ], + ])( + 'Should record successfully when join on %p %p %p', + async (leftColumn: string, operator: string, rightColumn: string) => { + // Arrange + const expected: Array = [ + { + command: null, + data: { + leftColumn, + operator, + rightColumn, + } as JoinOnOperatorInput, + }, + ]; + // Act + const clause = new JoinOnClause(); + clause.on(leftColumn, operator, rightColumn); + // Asset + expect(JSON.stringify(clause.operations)).toEqual( + JSON.stringify(expected) + ); + } + ); + + it.each([ + [ + `table1.${faker.database.column()}`, + '<>', + `table2.${faker.database.column()}`, + ], + [ + `table1.${faker.database.column()}`, + '!', + `table2.${faker.database.column()}`, + ], + ])( + 'Should throw error when join on %p %p %p', + async (leftColumn: string, operator: string, rightColumn: string) => { + // Act + const clause = new JoinOnClause(); + const callJoinOn = () => clause.on(leftColumn, operator, rightColumn); + // Asset + expect(callJoinOn).toThrow(Error); + } + ); + it('Should record successfully when call join on(...).andOn(...).OrOn(...)', async () => { + // Arrange + const fakeInputParams: Array = [ + { + leftColumn: `table1.${faker.database.column()}`, + operator: '=', + rightColumn: `table2.${faker.database.column()}`, + }, + { + leftColumn: `table3.${faker.database.column()}`, + operator: '>', + rightColumn: `table4.${faker.database.column()}`, + }, + { + leftColumn: `table1.${faker.database.column()}`, + operator: '!=', + rightColumn: `table2.${faker.database.column()}`, + }, + ]; + const expected: Array = [ + { command: null, data: fakeInputParams[0] }, + { command: LogicalOperator.AND }, + { command: null, data: fakeInputParams[1] }, + { command: LogicalOperator.OR }, + { command: null, data: fakeInputParams[2] }, + ]; + + // Act + const clause = new JoinOnClause(); + clause + .on( + fakeInputParams[0].leftColumn, + fakeInputParams[0].operator, + fakeInputParams[0].rightColumn + ) + .andOn( + fakeInputParams[1].leftColumn, + fakeInputParams[1].operator, + fakeInputParams[1].rightColumn + ) + .orOn( + fakeInputParams[2].leftColumn, + fakeInputParams[2].operator, + fakeInputParams[2].rightColumn + ); + // Asset + expect(JSON.stringify(clause.operations)).toEqual(JSON.stringify(expected)); + }); +}); + +describe('Test join on clause > between operations', () => { + const fakeInputParams = [ + { + column: `${faker.database.column()}`, + min: 14, + max: 30, + }, + { + column: `${faker.database.column()}`, + min: -15, + max: 20, + }, + { + column: `${faker.database.column()}`, + min: 1, + max: 100, + }, + ]; + it('Should throw error when call join onBetween with max value smaller than min value', async () => { + // Arrange + const fakeInputParam = { + column: `${faker.database.column()}`, + min: 30, + max: 1, + }; + // Act + const clause = new JoinOnClause(); + const joinOnBetween = () => + clause.onBetween( + fakeInputParam.column, + fakeInputParam.min, + fakeInputParam.max + ); + + // Asset + expect(joinOnBetween).toThrow(Error); + }); + it('Should record successfully when call join onBetween(...).andBetween(...).OrBetween(...)', async () => { + // Arrange + const expected: Array = [ + { command: ComparisonPredicate.BETWEEN, data: fakeInputParams[0] }, + { command: LogicalOperator.AND }, + { command: ComparisonPredicate.BETWEEN, data: fakeInputParams[1] }, + { command: LogicalOperator.OR }, + { command: ComparisonPredicate.BETWEEN, data: fakeInputParams[2] }, + ]; + + // Act + const clause = new JoinOnClause(); + clause + .onBetween( + fakeInputParams[0].column, + fakeInputParams[0].min, + fakeInputParams[0].max + ) + .andOnBetween( + fakeInputParams[1].column, + fakeInputParams[1].min, + fakeInputParams[1].max + ) + .orOnBetween( + fakeInputParams[2].column, + fakeInputParams[2].min, + fakeInputParams[2].max + ); + // Asset + expect(JSON.stringify(clause.operations)).toEqual(JSON.stringify(expected)); + }); + + it('Should record successfully when call join onNotBetween(...).andNotBetween(...).OrNotBetween(...)', async () => { + // Arrange + const expected: Array = [ + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.BETWEEN, data: fakeInputParams[0] }, + { command: LogicalOperator.AND }, + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.BETWEEN, data: fakeInputParams[1] }, + { command: LogicalOperator.OR }, + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.BETWEEN, data: fakeInputParams[2] }, + ]; + // Act + const clause = new JoinOnClause(); + clause + .onNotBetween( + fakeInputParams[0].column, + fakeInputParams[0].min, + fakeInputParams[0].max + ) + .andOnNotBetween( + fakeInputParams[1].column, + fakeInputParams[1].min, + fakeInputParams[1].max + ) + .orOnNotBetween( + fakeInputParams[2].column, + fakeInputParams[2].min, + fakeInputParams[2].max + ); + // Asset + expect(JSON.stringify(clause.operations)).toEqual(JSON.stringify(expected)); + }); +}); + +describe('Test join on clause > in operations', () => { + const fakeInputParams = [ + { + column: `${faker.database.column()}`, + values: faker.helpers.arrayElements(['US', 'TW', 'JP', 'CN'], 3), + }, + { + column: `${faker.database.column()}`, + values: faker.helpers.arrayElements(['US', 'TW', 'JP', 'CN'], 3), + }, + { + column: `${faker.database.column()}`, + values: faker.helpers.arrayElements([1, 50, 30, 100, 120], 3), + }, + ]; + it('Should record successfully when call join onIn(...).andIn(...).OrIn(...)', async () => { + // Arrange + const expected: Array = [ + { command: ComparisonPredicate.IN, data: fakeInputParams[0] }, + { command: LogicalOperator.AND }, + { command: ComparisonPredicate.IN, data: fakeInputParams[1] }, + { command: LogicalOperator.OR }, + { command: ComparisonPredicate.IN, data: fakeInputParams[2] }, + ]; + + // Act + const clause = new JoinOnClause(); + clause + .onIn(fakeInputParams[0].column, fakeInputParams[0].values) + .andOnIn(fakeInputParams[1].column, fakeInputParams[1].values) + .orOnIn(fakeInputParams[2].column, fakeInputParams[2].values); + // Asset + expect(JSON.stringify(clause.operations)).toEqual(JSON.stringify(expected)); + }); + + it('Should record successfully when call join onNotIn(...).andNotIn(...).OrNotIn(...)', async () => { + // Arrange + const expected: Array = [ + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.IN, data: fakeInputParams[0] }, + { command: LogicalOperator.AND }, + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.IN, data: fakeInputParams[1] }, + { command: LogicalOperator.OR }, + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.IN, data: fakeInputParams[2] }, + ]; + + // Act + const clause = new JoinOnClause(); + clause + .onNotIn(fakeInputParams[0].column, fakeInputParams[0].values) + .andOnNotIn(fakeInputParams[1].column, fakeInputParams[1].values) + .orOnNotIn(fakeInputParams[2].column, fakeInputParams[2].values); + // Asset + expect(JSON.stringify(clause.operations)).toEqual(JSON.stringify(expected)); + }); +}); + +describe('Test join on clause > null operations', () => { + const fakeInputParams = [ + { + column: `${faker.database.column()}`, + }, + { + column: `${faker.database.column()}`, + }, + { + column: `${faker.database.column()}`, + }, + ]; + it('Should record successfully when call join onNull(...).andNull(...).OrNull(...)', async () => { + // Arrange + const expected: Array = [ + { command: ComparisonPredicate.IS_NULL, data: fakeInputParams[0] }, + { command: LogicalOperator.AND }, + { command: ComparisonPredicate.IS_NULL, data: fakeInputParams[1] }, + { command: LogicalOperator.OR }, + { command: ComparisonPredicate.IS_NULL, data: fakeInputParams[2] }, + ]; + // Act + const clause = new JoinOnClause(); + clause + .onNull(fakeInputParams[0].column) + .andOnNull(fakeInputParams[1].column) + .orOnNull(fakeInputParams[2].column); + // Asset + expect(JSON.stringify(clause.operations)).toEqual(JSON.stringify(expected)); + }); + + it('Should record successfully when call join onNotNull(...).andNotNull(...).OrNotNull(...)', async () => { + // Arrange + const expected: Array = [ + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.IS_NULL, data: fakeInputParams[0] }, + { command: LogicalOperator.AND }, + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.IS_NULL, data: fakeInputParams[1] }, + { command: LogicalOperator.OR }, + { command: LogicalOperator.NOT }, + { command: ComparisonPredicate.IS_NULL, data: fakeInputParams[2] }, + ]; + // Act + const clause = new JoinOnClause(); + clause + .onNotNull(fakeInputParams[0].column) + .andOnNotNull(fakeInputParams[1].column) + .orOnNotNull(fakeInputParams[2].column); + // Asset + expect(JSON.stringify(clause.operations)).toEqual(JSON.stringify(expected)); + }); +}); diff --git a/packages/core/test/template-engine/testCompiler.ts b/packages/core/test/template-engine/testCompiler.ts index 607907c0..9db3b0dd 100644 --- a/packages/core/test/template-engine/testCompiler.ts +++ b/packages/core/test/template-engine/testCompiler.ts @@ -7,25 +7,21 @@ import { bindExtensions } from '@vulcan/core/template-engine/extension-loader'; import { Container } from 'inversify'; import * as sinon from 'ts-sinon'; import * as nunjucks from 'nunjucks'; -// TODO: Should replace with a real implementation -import { - QueryBuilder, - Executor, -} from '@vulcan/core/template-engine/built-in-extensions/query-builder/reqTagRunner'; +import { IDataQueryBuilder, IExecutor } from '@vulcan/core/data-query'; export const createTestCompiler = async () => { const container = new Container(); - const stubBuilder = sinon.stubInterface(); - stubBuilder.count.returns(stubBuilder); - const stubExecutor = sinon.stubInterface(); - stubExecutor.createBuilder.resolves(stubBuilder); + const stubQueryBuilder = sinon.stubInterface(); + stubQueryBuilder.count.returns(stubQueryBuilder); + const stubExecutor = sinon.stubInterface(); + stubExecutor.createBuilder.resolves(stubQueryBuilder); container .bind(TYPES.CompilerLoader) .to(InMemoryCodeLoader) .inSingletonScope(); await bindExtensions(container.bind.bind(container)); - container.bind(TYPES.Executor).toConstantValue(stubExecutor); + container.bind(TYPES.Executor).toConstantValue(stubExecutor); container.bind(TYPES.Compiler).to(NunjucksCompiler).inSingletonScope(); @@ -50,7 +46,7 @@ export const createTestCompiler = async () => { .whenTargetNamed('compileTime'); return { - builder: stubBuilder, + builder: stubQueryBuilder, executor: stubExecutor, compiler: container.get(TYPES.Compiler), loader: container.get(TYPES.CompilerLoader), diff --git a/packages/core/test/validators/built-in-validators/dataTypeValidator.spec.ts b/packages/core/test/validators/built-in-validators/dataTypeValidator.spec.ts new file mode 100644 index 00000000..b4f0b739 --- /dev/null +++ b/packages/core/test/validators/built-in-validators/dataTypeValidator.spec.ts @@ -0,0 +1,77 @@ +import { DateTypeValidator } from '@vulcan/core/validators'; + +describe('Test "date" type validator', () => { + it.each([ + ['{}'], + ['{"format": "123"}'], + ['{"format": "DD/MM/YYYY"}'], + ['{"format": "YYYY-MM-DD"}'], + ])( + 'Should be valid when validate args schema %p', + async (inputArgs: string) => { + // Arrange + const args = JSON.parse(inputArgs); + // Act + const validator = new DateTypeValidator(); + // Assert + expect(() => validator.validateSchema(args)).not.toThrow(); + } + ); + + it.each([ + ['[]'], + ['{"non-key": "non-value"}'], + ['{"key1": "value1"}'], + ['{"key2": "value2"}'], + ])( + 'Should be invalid when validate args schema %p', + async (inputArgs: string) => { + // Arrange + const args = JSON.parse(inputArgs); + // Act + const validator = new DateTypeValidator(); + + // Assert + expect(() => validator.validateSchema(args)).toThrow(); + } + ); + + it.each([ + ['2022', '{"format": "YYYY"}'], + ['202210', '{"format": "YYYYMM"}'], + ['10/10/2021', '{"format": "DD/MM/YYYY"}'], + ['2021-10-10', '{"format": "YYYY-MM-DD"}'], + ['2021 10 10', '{"format": "YYYY MM DD"}'], + ['24 12 2019 09:15:00', '{"format": "DD MM YYYY hh:mm:ss"}'], + ])( + 'Should be valid when validate data %p with args is %p', + async (data: string, inputArgs: string) => { + // Arrange + const args = JSON.parse(inputArgs); + + // Act + const validator = new DateTypeValidator(); + + // Assert + expect(() => validator.validateData(data, args)).not.toThrow(); + } + ); + + it.each([ + ['2021-10-10', '{"format": "DD/MM/YYYY"}'], + ['2021/10/10', '{"format": "YYYY-MM-DD"}'], + ['2021/10', '{"format": "YYYY-MM-DD"}'], + ])( + 'Should be invalid when validate data %p with args is %p', + async (data: string, inputArgs: string) => { + // Arrange + const args = JSON.parse(inputArgs); + + // Act + const validator = new DateTypeValidator(); + + // Assert + expect(() => validator.validateData(data, args)).toThrow(); + } + ); +}); diff --git a/packages/core/test/validators/built-in-validators/integerTypeValidator.spec.ts b/packages/core/test/validators/built-in-validators/integerTypeValidator.spec.ts new file mode 100644 index 00000000..a716f508 --- /dev/null +++ b/packages/core/test/validators/built-in-validators/integerTypeValidator.spec.ts @@ -0,0 +1,98 @@ +import { IntegerTypeValidator } from '@vulcan/core/validators'; + +describe('Test "integer" type validator', () => { + it.each([ + ['{}'], + ['{"min": 1}'], + ['{"min": "1"}'], + ['{"max": 100}'], + ['{"max": "100"}'], + ['{"less": 10}'], + ['{"less": "10"}'], + ['{"greater": 50}'], + ['{"greater": "50"}'], + ['{"min": 1, "max": 100}'], + ['{"min": 1, "less": 10}'], + ['{"min": 1, "greater": 50}'], + ['{"less": 10, "max": 100}'], + ['{"greater": 50, "max": 100}'], + ['{"less": 10, "greater": 50, "max": 100}'], + ['{"min": 10 ,"less": 10, "greater": 50}'], + ['{"min": 10 ,"less": 10, "greater": 50, "max": 100}'], + ['{"min": "10", "less": "10", "greater": "50", "max": "100"}'], + ])( + 'Should be valid when validate args schema %p', + async (inputArgs: string) => { + // Arrange + const args = JSON.parse(inputArgs); + // Act + const validator = new IntegerTypeValidator(); + + // Assert + expect(() => validator.validateSchema(args)).not.toThrow(); + } + ); + + it.each([ + ['[]'], + ['{"non-key": 1}'], + ['{"key1": 1}'], + ['{"key2": 2}'], + ['{"key3": "value3"}'], + ])( + 'Should be invalid when validate args schema %p', + async (inputArgs: string) => { + // Arrange + const args = JSON.parse(inputArgs); + // Act + const validator = new IntegerTypeValidator(); + + // Assert + expect(() => validator.validateSchema(args)).toThrow(); + } + ); + + it.each([ + ['1', '{"min": 1}'], + [1, '{"min": 1}'], + ['100', '{"max": 100}'], + [100, '{"max": 100}'], + ['3', '{"min": 1, "max":3}'], + ['9', '{"less": 10}'], + ['51', '{"greater": 50}'], + ['2', '{"min": 1, "less":3}'], + ['2', '{"greater": 1, "max":3}'], + ])( + 'Should be valid when validate data %p with args is %p', + async (data: string | number, inputArgs: string) => { + // Arrange + const args = JSON.parse(inputArgs); + // Act + const validator = new IntegerTypeValidator(); + + // Assert + expect(() => validator.validateData(data, args)).not.toThrow(); + } + ); + it.each([ + ['0', '{"min": 1}'], + [0, '{"min": 1}'], + ['101', '{"max": 100}'], + [101, '{"max": 100}'], + ['10', '{"less": 10}'], + ['50', '{"greater": 50}'], + ['3', '{"min": 1, "less":3}'], + ['1', '{"greater": 1, "max":3}'], + ])( + 'Should be invalid when validate data %p with args is %p', + async (data: string | number, inputArgs: string) => { + // Arrange + const args = JSON.parse(inputArgs); + // Act + const validator = new IntegerTypeValidator(); + + // Assert + expect(() => validator.validateData(data, args)).toThrow(); + } + ); +}); diff --git a/packages/core/test/validators/built-in-validators/requiredValidator.spec.ts b/packages/core/test/validators/built-in-validators/requiredValidator.spec.ts new file mode 100644 index 00000000..7aed6dbd --- /dev/null +++ b/packages/core/test/validators/built-in-validators/requiredValidator.spec.ts @@ -0,0 +1,88 @@ +import { RequiredValidator } from '@vulcan/core/validators'; + +describe('Test "required" type validator', () => { + it.each([ + ['{}'], + ['{"disallow": []}'], + ['{"disallow": [""]}'], + ['{"disallow": [{}]}'], + ['{"disallow": [0]}'], + ['{"disallow": [false]}'], + ['{"disallow": [0, {}, ""]}'], + ])( + 'Should be valid when validate args schema %p', + async (inputArgs: string) => { + // Arrange + const args = JSON.parse(inputArgs); + // Act + const validator = new RequiredValidator(); + + // Assert + expect(() => validator.validateSchema(args)).not.toThrow(); + } + ); + + it.each([ + ['[]'], + ['{"non-key": 1}'], + ['{"key1": 1}'], + ['{"key2": 2}'], + ['{"key3": "value3"}'], + ])( + 'Should be invalid when validate args schema %p', + async (inputArgs: string) => { + // Arrange + const args = JSON.parse(inputArgs); + // Act + const validator = new RequiredValidator(); + + // Assert + expect(() => validator.validateSchema(args)).toThrow(); + } + ); + + it.each([ + ['', '{}'], + [null, '{}'], + ['0', '{"disallow": []}'], + ['false', '{"disallow": []}'], + [false, '{"disallow": [true]}'], + [' ', '{"disallow": []}'], + ['none', '{"disallow": ["null"]}'], + ])( + 'Should be valid when validate data %p with args is %p', + async (data: string | boolean | number | null, inputArgs: string) => { + // Arrange + const args = JSON.parse(inputArgs); + // Act + const validator = new RequiredValidator(); + + // Assert + expect(() => validator.validateData(data, args)).not.toThrow(); + } + ); + it.each([ + [undefined, '{}'], + [null, '{"disallow": [null]}'], + [0, '{"disallow": [0]}'], + ['0', '{"disallow": ["0"]}'], + ['', '{"disallow": [""]}'], + [' ', '{"disallow": [" "]}'], + [false, '{"disallow": [false]}'], + ['{}', '{"disallow": ["{}"]}'], + ])( + 'Should be invalid when validate data %p with args is %p', + async ( + data: string | boolean | number | null | undefined, + inputArgs: string + ) => { + // Arrange + const args = JSON.parse(inputArgs); + // Act + const validator = new RequiredValidator(); + + // Assert + expect(() => validator.validateData(data, args)).toThrow(); + } + ); +}); diff --git a/packages/core/test/validators/built-in-validators/stringTypeValidator.spec.ts b/packages/core/test/validators/built-in-validators/stringTypeValidator.spec.ts new file mode 100644 index 00000000..780c2806 --- /dev/null +++ b/packages/core/test/validators/built-in-validators/stringTypeValidator.spec.ts @@ -0,0 +1,81 @@ +import faker from '@faker-js/faker'; +import { StringTypeValidator } from '@vulcan/core/validators'; + +describe('Test "string" type validator', () => { + it.each([ + ['{}'], + ['{"format": "[a-z]"}'], + ['{"length": 10}'], + ['{"length": "10"}'], + ['{"min": 2}'], + ['{"max": 10}'], + ['{"format": "[A-Z]", "length": 10}'], + ['{"format": "[A-Z]", "min": "2"}'], + ['{"format": "[A-Z]", "max": 10}'], + ])( + 'Should be valid when validate args schema %p', + async (inputArgs: string) => { + // Arrange + const args = JSON.parse(inputArgs); + // Act + const validator = new StringTypeValidator(); + + // Assert + expect(() => validator.validateSchema(args)).not.toThrow(); + } + ); + + it.each([ + ['[]'], + ['{"non-key": 1}'], + ['{"key1": 1}'], + ['{"key2": 2}'], + ['{"key3": "value3"}'], + ])( + 'Should be invalid when validate args schema %p', + async (inputArgs: string) => { + // Arrange + const args = JSON.parse(inputArgs); + // Act + const validator = new StringTypeValidator(); + + // Assert + expect(() => validator.validateSchema(args)).toThrow(); + } + ); + + it.each([ + [faker.datatype.string(), '{}'], + ['abc', '{"format": "[a-z]"}'], + ['a123456789', '{"length": 10}'], + ['ABCDEFGHIJ', '{"format": "[A-Z]", "length": 10}'], + ])( + 'Should be valid when validate data %p with args is %p', + async (data: string, inputArgs: string) => { + // Arrange + const args = JSON.parse(inputArgs); + // Act + const validator = new StringTypeValidator(); + + // Assert + expect(() => validator.validateData(data, args)).not.toThrow(); + } + ); + it.each([ + ['ABC', '{"format": "[a-z]"}'], + ['ab123456789', '{"length": "10"}'], + ['ABCDEFGHIJK', '{"format": "[A-Z]", "length": 10}'], + ['abcdefghijk', '{"format": "[A-Z]", "length": 10}'], + ])( + 'Should be invalid when validate data %p with args is %p', + async (data: string, inputArgs: string) => { + // Arrange + const args = JSON.parse(inputArgs); + // Act + const validator = new StringTypeValidator(); + + // Assert + expect(() => validator.validateData(data, args)).toThrow(); + } + ); +}); diff --git a/packages/core/test/validators/built-in-validators/uuidTypeValidator.spec.ts b/packages/core/test/validators/built-in-validators/uuidTypeValidator.spec.ts new file mode 100644 index 00000000..84536d74 --- /dev/null +++ b/packages/core/test/validators/built-in-validators/uuidTypeValidator.spec.ts @@ -0,0 +1,81 @@ +import * as uuid from 'uuid'; +import { UUIDTypeValidator } from '@vulcan/core/validators'; + +describe('Test "uuid" type validator ', () => { + it.each([ + ['{}'], + ['{"version": "uuid_v1"}'], + ['{"version": "uuid_v4"}'], + ['{"version": "uuid_v5"}'], + ])( + 'Should be valid when validate args schema %p', + async (inputArgs: string) => { + // Arrange + const args = JSON.parse(inputArgs); + // Act + const validator = new UUIDTypeValidator(); + + // Assert + expect(() => validator.validateSchema(args)).not.toThrow(); + } + ); + + it.each([ + ['[]'], + ['{"non-key": 1}'], + ['{"key1": 1}'], + ['{"key2": 2}'], + ['{"key3": "value3"}'], + ])( + 'Should be invalid when validate args schema %p', + async (inputArgs: string) => { + // Arrange + const args = JSON.parse(inputArgs); + // Act + const validator = new UUIDTypeValidator(); + + // Assert + expect(() => validator.validateSchema(args)).toThrow(); + } + ); + + it.each([ + [uuid.v1(), '{}'], + [uuid.v4(), '{}'], + [uuid.v1(), '{"version": "uuid_v1"}'], + [uuid.v4(), '{"version": "uuid_v4"}'], + [ + uuid.v5('canner.com', 'da327b91-b802-4f1f-9d25-91d23eecca32'), + '{"version": "uuid_v5"}', + ], + ])( + 'Should be valid when validate data %p with args is %p', + async (data: string, inputArgs: string) => { + // Arrange + const args = JSON.parse(inputArgs); + // Act + const validator = new UUIDTypeValidator(); + + // Assert + expect(() => validator.validateData(data, args)).not.toThrow(); + } + ); + it.each([ + [uuid.v4(), '{"version": "uuid_v1"}'], + [ + uuid.v5('canner.com', 'da327b91-b802-4f1f-9d25-91d23eecca32'), + '{"version": "uuid_v1"}', + ], + ])( + 'Should be invalid when validate data %p with args is %p', + async (data: string, inputArgs: string) => { + // Arrange + const args = JSON.parse(inputArgs); + // Act + const validator = new UUIDTypeValidator(); + + // Assert + expect(() => validator.validateData(data, args)).toThrow(); + } + ); +}); diff --git a/packages/core/test/validators/constraint.spec.ts b/packages/core/test/validators/constraint.spec.ts index 502eaec1..4025da39 100644 --- a/packages/core/test/validators/constraint.spec.ts +++ b/packages/core/test/validators/constraint.spec.ts @@ -6,7 +6,7 @@ import { MinLengthConstraint, MinValueConstraint, RequiredConstraint, -} from '../../src/validators'; +} from '@vulcan/core/validators'; it('Required constraint compose should always return required constraint', async () => { // Arrange diff --git a/packages/core/test/validators/test-custom-validators/custom-validators1/index.ts b/packages/core/test/validators/test-custom-validators/custom-validators1/index.ts new file mode 100644 index 00000000..101be358 --- /dev/null +++ b/packages/core/test/validators/test-custom-validators/custom-validators1/index.ts @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { IValidator } from '@vulcan/core'; + +// Imitate extension for testing +export default { + validators: [ + class Validator implements IValidator { + name = 'v1-1'; + validateSchema() {} + validateData() {} + }, + class Validator implements IValidator { + name = 'v1-2'; + validateSchema() {} + validateData() {} + }, + ], + middlewares: [], +}; diff --git a/packages/core/test/validators/test-custom-validators/custom-validators2/index.ts b/packages/core/test/validators/test-custom-validators/custom-validators2/index.ts new file mode 100644 index 00000000..e945355f --- /dev/null +++ b/packages/core/test/validators/test-custom-validators/custom-validators2/index.ts @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { IValidator } from '@vulcan/core'; + +// Imitate extension for testing +export default { + validators: [ + class Validator implements IValidator { + name = 'v2-1'; + validateSchema() {} + validateData() {} + }, + class Validator implements IValidator { + name = 'v2-2'; + validateSchema() {} + validateData() {} + }, + ], + others: [], +}; diff --git a/packages/core/test/validators/test-custom-validators/custom-validators3/index.ts b/packages/core/test/validators/test-custom-validators/custom-validators3/index.ts new file mode 100644 index 00000000..d5bddf49 --- /dev/null +++ b/packages/core/test/validators/test-custom-validators/custom-validators3/index.ts @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { IValidator } from '@vulcan/core'; + +export default { + validators: [ + class Validator implements IValidator { + name = 'v3-1'; + validateSchema() {} + validateData() {} + }, + class Validator implements IValidator { + name = 'v1-1'; + validateSchema() {} + validateData() {} + }, + ], + others: [], +}; diff --git a/packages/core/test/validators/validatorLoader.spec.ts b/packages/core/test/validators/validatorLoader.spec.ts new file mode 100644 index 00000000..63fcf804 --- /dev/null +++ b/packages/core/test/validators/validatorLoader.spec.ts @@ -0,0 +1,173 @@ +import { SourceOfExtensions, TYPES } from '@vulcan/core'; +import { IValidatorLoader, ValidatorLoader } from '@vulcan/core/validators'; +import { Container } from 'inversify'; +import * as path from 'path'; +import faker from '@faker-js/faker'; + +describe('Test validator loader for built-in validators', () => { + let container: Container; + + beforeEach(() => { + container = new Container(); + container + .bind>(TYPES.SourceOfExtensions) + .toConstantValue([]); + container + .bind(TYPES.ValidatorLoader) + .to(ValidatorLoader) + .inSingletonScope(); + }); + + afterEach(() => { + container.unbindAll(); + }); + it.each([ + // built-in validator + { name: 'date', expected: 'date' }, + { name: 'uuid', expected: 'uuid' }, + { name: 'integer', expected: 'integer' }, + { name: 'string', expected: 'string' }, + ])( + 'Should load successfully when loading validator name "$name".', + async ({ name, expected }) => { + // Arrange + const validatorLoader = container.get( + TYPES.ValidatorLoader + ); + // Act + const result = await validatorLoader.load(name); + + // Assert + expect(result.name).toEqual(expected); + } + ); + + it.each([{ name: 'not-existed-validator' }])( + 'Should load failed when loading validator name "$name".', + async ({ name }) => { + // Arrange + const validatorLoader = container.get( + TYPES.ValidatorLoader + ); + // Act + const loadAction = validatorLoader.load(name); + + // Asset + await expect(loadAction).rejects.toThrow(Error); + } + ); +}); + +describe('Test validator loader for extension validators with one module', () => { + let container: Container; + + beforeEach(() => { + container = new Container(); + container + .bind>(TYPES.SourceOfExtensions) + .toConstantValue([ + path.resolve(__dirname, 'test-custom-validators/custom-validators1'), + ]); + container + .bind(TYPES.ValidatorLoader) + .to(ValidatorLoader) + .inSingletonScope(); + }); + + afterEach(() => { + container.unbindAll(); + }); + it.each([ + // custom validator + { name: 'v1-1', expected: 'v1-1' }, + { name: 'v1-2', expected: 'v1-2' }, + ])( + 'Should load successfully when loading validator name "$name".', + async ({ name, expected }) => { + // Arrange + const validatorLoader = container.get( + TYPES.ValidatorLoader + ); + // Act + const result = await validatorLoader.load(name); + + // Assert + expect(result.name).toEqual(expected); + } + ); + + it.each([{ name: 'not-existed-validator' }])( + 'Should load failed when loading validator name "$name".', + async ({ name }) => { + // Arrange + const validatorLoader = container.get( + TYPES.ValidatorLoader + ); + // Act + const loadAction = validatorLoader.load(name); + + // Asset + await expect(loadAction).rejects.toThrow(Error); + } + ); +}); + +describe('Test validator loader for extension validators in multiple module', () => { + let container: Container; + + beforeEach(() => { + container = new Container(); + }); + + afterEach(() => { + container.unbindAll(); + }); + it('Should success when loading unique identifier of validators in multiple modules.', async () => { + // Arrange + container + .bind>(TYPES.SourceOfExtensions) + .toConstantValue([ + path.resolve(__dirname, 'test-custom-validators/custom-validators1'), + path.resolve(__dirname, 'test-custom-validators/custom-validators2'), + ]); + container + .bind(TYPES.ValidatorLoader) + .to(ValidatorLoader) + .inSingletonScope(); + + const validatorLoader = container.get( + TYPES.ValidatorLoader + ); + // Act + const v1 = await validatorLoader.load('v1-1'); + const v2 = await validatorLoader.load('v2-1'); + // Assert + expect(v1.name).toBe('v1-1'); + expect(v2.name).toBe('v2-1'); + }); + + it('Should load failed when found duplicate identifier of validators in multiple modules.', async () => { + // Arrange + container + .bind>(TYPES.SourceOfExtensions) + .toConstantValue([ + path.resolve(__dirname, 'test-custom-validators/custom-validators1'), + // the custom-validators3 also contains same identifier v1-1 + path.resolve(__dirname, 'test-custom-validators/custom-validators3'), + ]); + container + .bind(TYPES.ValidatorLoader) + .to(ValidatorLoader) + .inSingletonScope(); + + const validatorLoader = container.get( + TYPES.ValidatorLoader + ); + // Act + const action = validatorLoader.load(faker.random.word()); + // Assert + expect(action).rejects.toThrow( + 'The identifier name "v1-1" of validator class Validator has been defined in other extensions' + ); + }); +}); diff --git a/packages/serve/.eslintrc.json b/packages/serve/.eslintrc.json new file mode 100644 index 00000000..9d9c0db5 --- /dev/null +++ b/packages/serve/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/packages/serve/README.md b/packages/serve/README.md new file mode 100644 index 00000000..c3d79888 --- /dev/null +++ b/packages/serve/README.md @@ -0,0 +1,11 @@ +# serve + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build serve` to build the library. + +## Running unit tests + +Run `nx test serve` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/packages/serve/jest.config.ts b/packages/serve/jest.config.ts new file mode 100644 index 00000000..25fe8d70 --- /dev/null +++ b/packages/serve/jest.config.ts @@ -0,0 +1,14 @@ +module.exports = { + displayName: 'serve', + preset: '../../jest.preset.ts', + globals: { + 'ts-jest': { + tsconfig: '/tsconfig.spec.json', + }, + }, + transform: { + '^.+\\.[tj]s$': 'ts-jest', + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/packages/serve', +}; diff --git a/packages/serve/package.json b/packages/serve/package.json new file mode 100644 index 00000000..480dbd13 --- /dev/null +++ b/packages/serve/package.json @@ -0,0 +1,7 @@ +{ + "name": "@vulcan/serve", + "version": "0.0.1", + "type": "commonjs", + "dependencies": {}, + "devDependencies": {} +} diff --git a/packages/serve/project.json b/packages/serve/project.json new file mode 100644 index 00000000..2108d3ec --- /dev/null +++ b/packages/serve/project.json @@ -0,0 +1,39 @@ +{ + "root": "packages/serve", + "sourceRoot": "packages/serve/src", + "targets": { + "start": { + "executor": "@nrwl/workspace:run-commands", + "outputs": [], + "options": { + "command": "ts-node --project packages/serve/tsconfig.json packages/serve/src/index.ts" + } + }, + "build": { + "executor": "@nrwl/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/packages/serve", + "main": "packages/serve/src/index.ts", + "tsConfig": "packages/serve/tsconfig.lib.json", + "assets": ["packages/serve/*.md"] + } + }, + "lint": { + "executor": "@nrwl/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["packages/serve/**/*.ts"] + } + }, + "test": { + "executor": "@nrwl/jest:jest", + "outputs": ["coverage/packages/serve"], + "options": { + "jestConfig": "packages/serve/jest.config.ts", + "passWithNoTests": true + } + } + }, + "tags": [] +} diff --git a/packages/serve/src/containers/container.ts b/packages/serve/src/containers/container.ts new file mode 100644 index 00000000..2ee51c4c --- /dev/null +++ b/packages/serve/src/containers/container.ts @@ -0,0 +1,28 @@ +import { Container as InversifyContainer } from 'inversify'; +import { Container as CoreContainer } from '@vulcan/core'; +import { routeGeneratorModule } from './modules'; +import { ServeConfig } from '../models'; + +export class Container { + private inversifyContainer = new InversifyContainer(); + + public get(type: symbol) { + return this.inversifyContainer.get(type); + } + + public async load(config: ServeConfig) { + const coreContainer = new CoreContainer(); + await coreContainer.load(config); + this.inversifyContainer.parent = coreContainer.getInversifyContainer(); + this.inversifyContainer.load(routeGeneratorModule()); + } + + public unload() { + this.inversifyContainer.parent?.unbindAll(); + this.inversifyContainer.unbindAll(); + } + + public getInversifyContainer() { + return this.inversifyContainer; + } +} diff --git a/packages/serve/src/containers/index.ts b/packages/serve/src/containers/index.ts new file mode 100644 index 00000000..2f90771a --- /dev/null +++ b/packages/serve/src/containers/index.ts @@ -0,0 +1,3 @@ +import 'reflect-metadata'; +export * from './types'; +export * from './container'; diff --git a/packages/serve/src/containers/modules/index.ts b/packages/serve/src/containers/modules/index.ts new file mode 100644 index 00000000..6e0209ca --- /dev/null +++ b/packages/serve/src/containers/modules/index.ts @@ -0,0 +1 @@ +export * from './routeGeneratorModule'; diff --git a/packages/serve/src/containers/modules/routeGeneratorModule.ts b/packages/serve/src/containers/modules/routeGeneratorModule.ts new file mode 100644 index 00000000..7d173fee --- /dev/null +++ b/packages/serve/src/containers/modules/routeGeneratorModule.ts @@ -0,0 +1,34 @@ +import { ContainerModule } from 'inversify'; +import { + IPaginationTransformer, + IRequestTransformer, + IRequestValidator, + PaginationTransformer, + RequestTransformer, + RequestValidator, + RouteGenerator, +} from '@vulcan/serve'; +import { TYPES } from '../types'; + +export const routeGeneratorModule = () => + new ContainerModule((bind) => { + // Request Transformer + bind(TYPES.RequestTransformer) + .to(RequestTransformer) + .inSingletonScope(); + + // Request Transformer + bind(TYPES.RequestValidator) + .to(RequestValidator) + .inSingletonScope(); + + // Pagination Transformer + bind(TYPES.PaginationTransformer) + .to(PaginationTransformer) + .inSingletonScope(); + + // Roue Generator + bind(TYPES.RouteGenerator) + .to(RouteGenerator) + .inSingletonScope(); + }); diff --git a/packages/serve/src/containers/types.ts b/packages/serve/src/containers/types.ts new file mode 100644 index 00000000..84927da5 --- /dev/null +++ b/packages/serve/src/containers/types.ts @@ -0,0 +1,11 @@ +export const TYPES = { + // Route Generator + RequestValidator: Symbol.for('RequestValidator'), + RequestTransformer: Symbol.for('RequestTransformer'), + PaginationTransformer: Symbol.for('PaginationTransformer'), + Route: Symbol.for('Route'), + RouteGenerator: Symbol.for('RouteGenerator'), + // Application + AppConfig: Symbol.for('AppConfig'), + VulcanApplication: Symbol.for('VulcanApplication'), +}; diff --git a/packages/serve/src/index.ts b/packages/serve/src/index.ts new file mode 100644 index 00000000..22348702 --- /dev/null +++ b/packages/serve/src/index.ts @@ -0,0 +1,6 @@ +export * from './lib/route'; +export * from './lib/middleware'; +export * from './lib/app'; +export * from './models'; +export * from './models'; +export * from './containers'; diff --git a/packages/serve/src/lib/app.ts b/packages/serve/src/lib/app.ts new file mode 100644 index 00000000..bfa966d1 --- /dev/null +++ b/packages/serve/src/lib/app.ts @@ -0,0 +1,112 @@ +import { APISchema, ClassType } from '@vulcan/core'; +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'; +import { AppConfig } from '../models'; + +export class VulcanApplication { + private app: Koa; + private config: AppConfig; + private restfulRouter: KoaRouter; + private graphqlRouter: KoaRouter; + private generator: RouteGenerator; + constructor(config: AppConfig, generator: RouteGenerator) { + this.config = config; + this.generator = generator; + this.app = new Koa(); + this.restfulRouter = new KoaRouter(); + this.graphqlRouter = new KoaRouter(); + } + + /** + * Get request handler callback function, used in createServer function in http / https. + */ + public getHandler() { + return this.app.callback(); + } + public async buildRoutes( + schemas: Array, + apiTypes: Array + ) { + // setup API route according to api types and api schemas + const routeMapper = { + [APIProviderType.RESTFUL]: (routes: Array) => + this.setRestful(routes as Array), + [APIProviderType.GRAPHQL]: (routes: Array) => + this.setGraphQL(routes as Array), + }; + + // 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); + } + } + + // Setup restful routes to server + private async setRestful(routes: Array) { + await Promise.all( + routes.map((route) => { + // currently only provide get method + this.restfulRouter.get(route.urlPath, route.respond.bind(route)); + }) + ); + + this.app.use(this.restfulRouter.routes()); + this.app.use(this.restfulRouter.allowedMethods()); + } + + private async setGraphQL(routes: Array) { + console.log(routes); + // TODO: Still building GraphQL... + this.app.use(this.graphqlRouter.routes()); + this.app.use(this.restfulRouter.allowedMethods()); + } + + public async buildMiddleware() { + // 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.extensions); + await this.use(...extensions); + } + /** add middleware classes for app used */ + private async use(...classes: ClassType[]) { + const map: { [name: string]: BaseRouteMiddleware } = {}; + for (const cls of classes) { + const middleware = new cls(this.config.middlewares); + if (middleware.name in map) { + throw new Error( + `The identifier name "${middleware.name}" of middleware class ${cls.name} has been defined in other extensions` + ); + } + map[middleware.name] = middleware; + } + for (const name of Object.keys(map)) { + const middleware = map[name]; + this.app.use(middleware.handle.bind(middleware)); + } + } +} diff --git a/packages/serve/src/lib/middleware/built-in-middleware/auditLogMiddleware.ts b/packages/serve/src/lib/middleware/built-in-middleware/auditLogMiddleware.ts new file mode 100644 index 00000000..d3b1c20d --- /dev/null +++ b/packages/serve/src/lib/middleware/built-in-middleware/auditLogMiddleware.ts @@ -0,0 +1,32 @@ +import { getLogger, ILogger, LoggerOptions } from '@vulcan/core'; +import { BuiltInMiddleware, RouteMiddlewareNext } from '../middleware'; +import { KoaRouterContext } from '@vulcan/serve/route'; +import { MiddlewareConfig } from '@vulcan/serve/models'; + +export class AuditLoggingMiddleware extends BuiltInMiddleware { + private logger: ILogger; + constructor(config: MiddlewareConfig) { + 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-middleware/corsMiddleware.ts b/packages/serve/src/lib/middleware/built-in-middleware/corsMiddleware.ts new file mode 100644 index 00000000..e60a9503 --- /dev/null +++ b/packages/serve/src/lib/middleware/built-in-middleware/corsMiddleware.ts @@ -0,0 +1,21 @@ +import * as Koa from 'koa'; +import * as cors from '@koa/cors'; +import { KoaRouterContext } from '@vulcan/serve/route'; +import { BuiltInMiddleware, RouteMiddlewareNext } from '../middleware'; +import { MiddlewareConfig } from '@vulcan/serve/models'; + +export type CorsOptions = cors.Options; + +export class CorsMiddleware extends BuiltInMiddleware { + private koaCors: Koa.Middleware; + + constructor(config: MiddlewareConfig) { + 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-middleware/index.ts b/packages/serve/src/lib/middleware/built-in-middleware/index.ts new file mode 100644 index 00000000..608f32ba --- /dev/null +++ b/packages/serve/src/lib/middleware/built-in-middleware/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-middleware/rateLimitMiddleware.ts b/packages/serve/src/lib/middleware/built-in-middleware/rateLimitMiddleware.ts new file mode 100644 index 00000000..99ba1a74 --- /dev/null +++ b/packages/serve/src/lib/middleware/built-in-middleware/rateLimitMiddleware.ts @@ -0,0 +1,22 @@ +import * as Koa from 'koa'; +import { RateLimit, RateLimitOptions } from 'koa2-ratelimit'; +import { KoaRouterContext } from '@vulcan/serve/route'; +import { BuiltInMiddleware, RouteMiddlewareNext } from '../middleware'; +import { MiddlewareConfig } from '@vulcan/serve/models'; + +export { RateLimitOptions }; + +export class RateLimitMiddleware extends BuiltInMiddleware { + private koaRateLimit: Koa.Middleware; + constructor(config: MiddlewareConfig) { + 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-middleware/requestIdMiddleware.ts b/packages/serve/src/lib/middleware/built-in-middleware/requestIdMiddleware.ts new file mode 100644 index 00000000..8455fb55 --- /dev/null +++ b/packages/serve/src/lib/middleware/built-in-middleware/requestIdMiddleware.ts @@ -0,0 +1,47 @@ +import * as uuid from 'uuid'; +import { FieldInType, asyncReqIdStorage } from '@vulcan/core'; +import { KoaRouterContext } from '@vulcan/serve/route'; +import { BuiltInMiddleware, RouteMiddlewareNext } from '../middleware'; +import { MiddlewareConfig } from '@vulcan/serve/models'; + +export interface RequestIdOptions { + name: string; + fieldIn: FieldInType.HEADER | FieldInType.QUERY; +} + +export class RequestIdMiddleware extends BuiltInMiddleware { + private options: RequestIdOptions; + + constructor(config: MiddlewareConfig) { + 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..7d1d75c5 --- /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-middleware'; diff --git a/packages/serve/src/lib/middleware/loader.ts b/packages/serve/src/lib/middleware/loader.ts new file mode 100644 index 00000000..379e70ab --- /dev/null +++ b/packages/serve/src/lib/middleware/loader.ts @@ -0,0 +1,24 @@ +import { BaseRouteMiddleware } from './middleware'; +import { + defaultImport, + ClassType, + ModuleProperties, + mergedModules, + SourceOfExtensions, +} from '@vulcan/core'; +// The extension module interface +export interface ExtensionModule extends ModuleProperties { + ['middlewares']: ClassType[]; +} + +export const loadExtensions = async (extensions?: SourceOfExtensions) => { + // if extensions setup, load middlewares classes in the extensions + if (extensions) { + // import extension which user customized + const modules = await defaultImport(...extensions); + const module = await mergedModules(modules); + // 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..a0fdc2f3 --- /dev/null +++ b/packages/serve/src/lib/middleware/middleware.ts @@ -0,0 +1,39 @@ +import { + BuiltInOptions, + AppConfig, + MiddlewareConfig, +} from '@vulcan/serve/models'; +import { KoaRouterContext } from '@vulcan/serve/route'; +import { Next } from 'koa'; + +export type RouteMiddlewareNext = Next; + +export abstract class BaseRouteMiddleware { + protected config: MiddlewareConfig; + // middleware is enabled or not, default is enabled beside you give "enabled: false" in config. + protected enabled: boolean; + // An identifier to check the options set or not in the middlewares section of serve config + public readonly name: string; + constructor(name: string, config: MiddlewareConfig) { + this.name = name; + this.config = config; + this.enabled = (this.getConfig()?.['enabled'] as boolean) || true; + } + public abstract handle( + context: KoaRouterContext, + next: RouteMiddlewareNext + ): Promise; + + protected getConfig() { + if (this.config && this.config[this.name]) return this.config[this.name]; + 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/index.ts b/packages/serve/src/lib/pagination/index.ts new file mode 100644 index 00000000..b7b40acd --- /dev/null +++ b/packages/serve/src/lib/pagination/index.ts @@ -0,0 +1 @@ +export * from './strategy'; diff --git a/packages/serve/src/lib/pagination/strategy/cursorBasedStrategy.ts b/packages/serve/src/lib/pagination/strategy/cursorBasedStrategy.ts new file mode 100644 index 00000000..a270f171 --- /dev/null +++ b/packages/serve/src/lib/pagination/strategy/cursorBasedStrategy.ts @@ -0,0 +1,25 @@ +import { RouterContext as KoaRouterContext } from 'koa-router'; +import { + normalizeStringValue, + PaginationMode, + CursorPagination, +} from '@vulcan/core'; +import { PaginationStrategy } from './strategy'; + +export class CursorBasedStrategy extends PaginationStrategy { + public async transform(ctx: KoaRouterContext) { + const checkFelidInQueryString = ['limit', 'cursor'].every((field) => + Object.keys(ctx.request.query).includes(field) + ); + if (!checkFelidInQueryString) + throw new Error( + `The ${PaginationMode.CURSOR} must provide limit and cursor in query string.` + ); + const limitVal = ctx.request.query['limit'] as string; + const cursorVal = ctx.request.query['cursor'] as string; + return { + limit: normalizeStringValue(limitVal, 'limit', Number.name), + cursor: normalizeStringValue(cursorVal, 'cursor', String.name), + } as CursorPagination; + } +} diff --git a/packages/serve/src/lib/pagination/strategy/index.ts b/packages/serve/src/lib/pagination/strategy/index.ts new file mode 100644 index 00000000..a399285c --- /dev/null +++ b/packages/serve/src/lib/pagination/strategy/index.ts @@ -0,0 +1,4 @@ +export * from './strategy'; +export * from './offsetBasedStrategy'; +export * from './cursorBasedStrategy'; +export * from './keysetBasedStrategy'; diff --git a/packages/serve/src/lib/pagination/strategy/keysetBasedStrategy.ts b/packages/serve/src/lib/pagination/strategy/keysetBasedStrategy.ts new file mode 100644 index 00000000..1f82fb0b --- /dev/null +++ b/packages/serve/src/lib/pagination/strategy/keysetBasedStrategy.ts @@ -0,0 +1,36 @@ +import { + normalizeStringValue, + PaginationMode, + PaginationSchema, + KeysetPagination, +} from '@vulcan/core'; +import { KoaRouterContext } from '@vulcan/serve/route'; +import { PaginationStrategy } from './strategy'; + +export class KeysetBasedStrategy extends PaginationStrategy { + private pagination: PaginationSchema; + constructor(pagination: PaginationSchema) { + super(); + this.pagination = pagination; + } + public async transform(ctx: KoaRouterContext) { + if (!this.pagination.keyName) + throw new Error( + `The keyset pagination need to set "keyName" in schema for indicate what key need to do filter.` + ); + const { keyName } = this.pagination; + const checkFelidInQueryString = ['limit', keyName].every((field) => + Object.keys(ctx.request.query).includes(field) + ); + if (!checkFelidInQueryString) + throw new Error( + `The ${PaginationMode.KEYSET} must provide limit and offset in query string.` + ); + const limitVal = ctx.request.query['limit'] as string; + const keyNameVal = ctx.request.query[keyName] as string; + return { + limit: normalizeStringValue(limitVal, 'limit', 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 new file mode 100644 index 00000000..aee3303d --- /dev/null +++ b/packages/serve/src/lib/pagination/strategy/offsetBasedStrategy.ts @@ -0,0 +1,25 @@ +import { RouterContext as KoaRouterContext } from 'koa-router'; +import { + normalizeStringValue, + PaginationMode, + OffsetPagination, +} from '@vulcan/core'; +import { PaginationStrategy } from './strategy'; + +export class OffsetBasedStrategy extends PaginationStrategy { + public async transform(ctx: KoaRouterContext) { + const checkFelidInQueryString = ['limit', 'offset'].every((field) => + Object.keys(ctx.request.query).includes(field) + ); + if (!checkFelidInQueryString) + throw new Error( + `The ${PaginationMode.OFFSET} must provide limit and offset in query string.` + ); + const limitVal = ctx.request.query['limit'] as string; + const offsetVal = ctx.request.query['offset'] as string; + return { + limit: normalizeStringValue(limitVal, 'limit', Number.name), + offset: normalizeStringValue(offsetVal, 'offset', Number.name), + } as OffsetPagination; + } +} diff --git a/packages/serve/src/lib/pagination/strategy/strategy.ts b/packages/serve/src/lib/pagination/strategy/strategy.ts new file mode 100644 index 00000000..d4bd74c8 --- /dev/null +++ b/packages/serve/src/lib/pagination/strategy/strategy.ts @@ -0,0 +1,5 @@ +import { KoaRouterContext } from '@vulcan/serve/route'; + +export abstract class PaginationStrategy { + public abstract transform(ctx: KoaRouterContext): Promise; +} diff --git a/packages/serve/src/lib/route/index.ts b/packages/serve/src/lib/route/index.ts new file mode 100644 index 00000000..aebeeeca --- /dev/null +++ b/packages/serve/src/lib/route/index.ts @@ -0,0 +1,2 @@ +export * from './route-component'; +export * from './routeGenerator'; diff --git a/packages/serve/src/lib/route/route-component/baseRoute.ts b/packages/serve/src/lib/route/route-component/baseRoute.ts new file mode 100644 index 00000000..bced2cd6 --- /dev/null +++ b/packages/serve/src/lib/route/route-component/baseRoute.ts @@ -0,0 +1,61 @@ +import { Next as KoaNext } from 'koa'; +import { RouterContext as KoaRouterContext } from 'koa-router'; +import { APISchema, TemplateEngine, Pagination } from '@vulcan/core'; +import { IRequestValidator } from './requestValidator'; +import { IRequestTransformer, RequestParameters } from './requestTransformer'; +import { IPaginationTransformer } from './paginationTransformer'; + +export { KoaRouterContext, KoaNext }; + +export interface TransformedRequest { + reqParams: RequestParameters; + pagination?: Pagination; +} + +export interface IRoute { + respond(ctx: KoaRouterContext): Promise; +} + +export abstract class BaseRoute implements IRoute { + public readonly apiSchema: APISchema; + protected readonly reqTransformer: IRequestTransformer; + protected readonly reqValidator: IRequestValidator; + protected readonly templateEngine: TemplateEngine; + protected readonly paginationTransformer: IPaginationTransformer; + constructor({ + apiSchema, + reqTransformer, + reqValidator, + paginationTransformer, + templateEngine, + }: { + apiSchema: APISchema; + reqTransformer: IRequestTransformer; + reqValidator: IRequestValidator; + paginationTransformer: IPaginationTransformer; + templateEngine: TemplateEngine; + }) { + this.apiSchema = apiSchema; + this.reqTransformer = reqTransformer; + this.reqValidator = reqValidator; + this.paginationTransformer = paginationTransformer; + this.templateEngine = templateEngine; + } + + public abstract respond(ctx: KoaRouterContext): Promise; + + protected abstract prepare( + ctx: KoaRouterContext + ): Promise; + + protected async handle(transformed: TransformedRequest) { + const { reqParams } = transformed; + // could template name or template path, use for template engine + const { templateSource } = this.apiSchema; + const statement = await this.templateEngine.execute( + templateSource, + reqParams + ); + return statement; + } +} diff --git a/packages/serve/src/lib/route/route-component/graphQLRoute.ts b/packages/serve/src/lib/route/route-component/graphQLRoute.ts new file mode 100644 index 00000000..5e2758e9 --- /dev/null +++ b/packages/serve/src/lib/route/route-component/graphQLRoute.ts @@ -0,0 +1,56 @@ +import { BaseRoute, KoaRouterContext } from './baseRoute'; + +import { APISchema, TemplateEngine } from '@vulcan/core'; +import { IRequestTransformer } from './requestTransformer'; +import { IRequestValidator } from './requestValidator'; +import { IPaginationTransformer } from './paginationTransformer'; + +export class GraphQLRoute extends BaseRoute { + public readonly operationName: string; + + constructor({ + apiSchema, + reqTransformer, + reqValidator, + paginationTransformer, + templateEngine, + }: { + apiSchema: APISchema; + reqTransformer: IRequestTransformer; + reqValidator: IRequestValidator; + paginationTransformer: IPaginationTransformer; + templateEngine: TemplateEngine; + }) { + super({ + apiSchema, + reqTransformer, + reqValidator, + paginationTransformer, + templateEngine, + }); + + this.operationName = apiSchema.operationName; + } + + public async makeTypeDefs() { + // 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. + * Therefore, current request and pagination transformer not suitable (need to provide another graphql transform method or class) + */ + + return { + reqParams: {}, + }; + } +} diff --git a/packages/serve/src/lib/route/route-component/index.ts b/packages/serve/src/lib/route/route-component/index.ts new file mode 100644 index 00000000..9bbdf465 --- /dev/null +++ b/packages/serve/src/lib/route/route-component/index.ts @@ -0,0 +1,6 @@ +export * from './requestValidator'; +export * from './requestTransformer'; +export * from './paginationTransformer'; +export * from './baseRoute'; +export * from './restfulRoute'; +export * from './graphQLRoute'; diff --git a/packages/serve/src/lib/route/route-component/paginationTransformer.ts b/packages/serve/src/lib/route/route-component/paginationTransformer.ts new file mode 100644 index 00000000..d8b1b4d3 --- /dev/null +++ b/packages/serve/src/lib/route/route-component/paginationTransformer.ts @@ -0,0 +1,40 @@ +import { KoaRouterContext } from '@vulcan/serve/route'; +import { APISchema, PaginationMode, Pagination } from '@vulcan/core'; +import { + CursorBasedStrategy, + OffsetBasedStrategy, + KeysetBasedStrategy, +} from '@vulcan/serve/pagination'; +import { injectable } from 'inversify'; + +export interface IPaginationTransformer { + transform( + ctx: KoaRouterContext, + apiSchema: APISchema + ): Promise; +} + +@injectable() +export class PaginationTransformer { + public async transform(ctx: KoaRouterContext, apiSchema: APISchema) { + const { pagination } = apiSchema; + + if (pagination) { + 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]: offset.transform.bind(offset), + [PaginationMode.CURSOR]: cursor.transform.bind(cursor), + [PaginationMode.KEYSET]: keyset.transform.bind(keyset), + }; + return await strategyMapper[pagination.mode](ctx); + } + return undefined; + } +} diff --git a/packages/serve/src/lib/route/route-component/requestTransformer.ts b/packages/serve/src/lib/route/route-component/requestTransformer.ts new file mode 100644 index 00000000..0e2b2b19 --- /dev/null +++ b/packages/serve/src/lib/route/route-component/requestTransformer.ts @@ -0,0 +1,84 @@ +import { + APISchema, + FieldDataType, + FieldInType, + normalizeStringValue, + RequestSchema, +} from '@vulcan/core'; +import { injectable } from 'inversify'; +import { assign } from 'lodash'; +import { KoaRouterContext } from './baseRoute'; + +export interface RequestParameters { + [name: string]: any; +} + +export interface IRequestTransformer { + transform( + ctx: KoaRouterContext, + apiSchema: APISchema + ): Promise; +} + +@injectable() +export class RequestTransformer implements IRequestTransformer { + public static readonly fieldInMapper: { + [type in FieldInType]: (ctx: KoaRouterContext, fieldName: string) => string; + } = { + [FieldInType.HEADER]: (ctx: KoaRouterContext, fieldName: string) => + ctx.request.header[fieldName] as string, + [FieldInType.QUERY]: (ctx: KoaRouterContext, fieldName: string) => + ctx.request.query[fieldName] as string, + [FieldInType.PATH]: (ctx: KoaRouterContext, fieldName: string) => + ctx.params[fieldName] as string, + }; + + public static readonly convertTypeMapper: { + [type in FieldDataType]: (value: string, name: string) => any; + } = { + [FieldDataType.NUMBER]: (value: string, name: string) => + normalizeStringValue(value, name, Number.name), + [FieldDataType.STRING]: (value: string, name: string) => + normalizeStringValue(value, name, String.name), + [FieldDataType.BOOLEAN]: (value: string, name: string) => + normalizeStringValue(value, name, Boolean.name), + }; + + public async transform( + ctx: KoaRouterContext, + apiSchema: APISchema + ): Promise { + const paramList = await Promise.all( + apiSchema.request.map(async (schemaReqParam: RequestSchema) => { + const { fieldName, fieldIn, type } = schemaReqParam; + // Get request value according field-in type + const fieldValue = RequestTransformer.fieldInMapper[fieldIn]( + ctx, + fieldName + ); + const formattedValue = await this.convertDataType( + fieldName, + fieldValue, + type + ); + // transform format to { name: value } + return { [fieldName]: formattedValue }; + }) + ); + // combine all param list to one object for { name: value } format + const params = assign({}, ...paramList); + return params; + } + + // check data type of one parameter by input type and convert it + private async convertDataType( + name: string, + value: string, + type: FieldDataType + ) { + if (!Object.values(FieldDataType).includes(type)) + throw new Error(`The ${type} type not been implemented now.`); + + return RequestTransformer.convertTypeMapper[type](value, name); + } +} diff --git a/packages/serve/src/lib/route/route-component/requestValidator.ts b/packages/serve/src/lib/route/route-component/requestValidator.ts new file mode 100644 index 00000000..718759a7 --- /dev/null +++ b/packages/serve/src/lib/route/route-component/requestValidator.ts @@ -0,0 +1,43 @@ +import { + APISchema, + IValidatorLoader, + RequestSchema, + ValidatorDefinition, +} from '@vulcan/core'; +import { inject, injectable } from 'inversify'; +import { TYPES as CORE_TYPES } from '@vulcan/core/containers'; +import { RequestParameters } from './requestTransformer'; + +export interface IRequestValidator { + validate(reqParams: RequestParameters, apiSchema: APISchema): Promise; +} + +@injectable() +export class RequestValidator implements IRequestValidator { + private validatorLoader: IValidatorLoader; + constructor(@inject(CORE_TYPES.ValidatorLoader) loader: IValidatorLoader) { + this.validatorLoader = loader; + } + // validate each parameters of request and transform the request content of koa ctx to "RequestParameters" format + public async validate(reqParams: RequestParameters, apiSchema: APISchema) { + await Promise.all( + apiSchema.request.map(async (schemaParam: RequestSchema) => { + const { fieldName, validators } = schemaParam; + // validate format through validators + await this.validateFieldFormat(reqParams[fieldName], validators); + }) + ); + } + // validate one parameter by input validator + private async validateFieldFormat( + fieldValue: any, + schemaValidators: Array + ) { + await Promise.all( + schemaValidators.map(async (schemaValidator) => { + const validator = await this.validatorLoader.load(schemaValidator.name); + validator.validateData(fieldValue, schemaValidator.args); + }) + ); + } +} diff --git a/packages/serve/src/lib/route/route-component/restfulRoute.ts b/packages/serve/src/lib/route/route-component/restfulRoute.ts new file mode 100644 index 00000000..fdf941f8 --- /dev/null +++ b/packages/serve/src/lib/route/route-component/restfulRoute.ts @@ -0,0 +1,59 @@ +import { BaseRoute, KoaRouterContext } from './baseRoute'; +import { inject, injectable } from 'inversify'; +import { TYPES } from '@vulcan/serve/containers'; +import { APISchema, TemplateEngine } from '@vulcan/core'; +import { IRequestTransformer } from './requestTransformer'; +import { IRequestValidator } from './requestValidator'; +import { IPaginationTransformer } from './paginationTransformer'; + +export class RestfulRoute extends BaseRoute { + public readonly urlPath: string; + + constructor({ + apiSchema, + reqTransformer, + reqValidator, + paginationTransformer, + templateEngine, + }: { + apiSchema: APISchema; + reqTransformer: IRequestTransformer; + reqValidator: IRequestValidator; + paginationTransformer: IPaginationTransformer; + templateEngine: TemplateEngine; + }) { + super({ + apiSchema, + reqTransformer, + reqValidator, + paginationTransformer, + templateEngine, + }); + this.urlPath = apiSchema.urlPath; + } + + 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 + ctx.response.body = { + ...transformed, + }; + } + + protected async prepare(ctx: KoaRouterContext) { + // get request data from context + const reqParams = await this.reqTransformer.transform(ctx, this.apiSchema); + // validate request format + await this.reqValidator.validate(reqParams, this.apiSchema); + // get pagination data from context + const pagination = await this.paginationTransformer.transform( + ctx, + this.apiSchema + ); + return { + reqParams, + pagination, + }; + } +} diff --git a/packages/serve/src/lib/route/routeGenerator.ts b/packages/serve/src/lib/route/routeGenerator.ts new file mode 100644 index 00000000..81374582 --- /dev/null +++ b/packages/serve/src/lib/route/routeGenerator.ts @@ -0,0 +1,69 @@ +import { IPaginationTransformer } from '@vulcan/serve/route'; +import { APISchema, TemplateEngine } from '@vulcan/core'; +import { + RestfulRoute, + GraphQLRoute, + IRequestValidator, + IRequestTransformer, +} from './route-component'; +import { inject, injectable } from 'inversify'; +import { TYPES as CORE_TYPES } from '@vulcan/core/containers'; +import { TYPES } from '../../containers/types'; + +export enum APIProviderType { + RESTFUL = 'RESTFUL', + GRAPHQL = 'GRAPHQL', +} + +type RouteComponentType = typeof RestfulRoute | typeof GraphQLRoute; + +type APIRouteBuilderOption = { + [K in APIProviderType]: RouteComponentType; +}; + +@injectable() +export class RouteGenerator { + private reqValidator: IRequestValidator; + private reqTransformer: IRequestTransformer; + private paginationTransformer: IPaginationTransformer; + private templateEngine: TemplateEngine; + private apiOptions: APIRouteBuilderOption = { + [APIProviderType.RESTFUL]: RestfulRoute, + [APIProviderType.GRAPHQL]: GraphQLRoute, + }; + + constructor( + @inject(TYPES.RequestTransformer) reqTransformer: IRequestTransformer, + @inject(TYPES.RequestValidator) reqValidator: IRequestValidator, + @inject(TYPES.PaginationTransformer) + paginationTransformer: IPaginationTransformer, + @inject(CORE_TYPES.TemplateEngine) templateEngine: TemplateEngine + ) { + this.reqValidator = reqValidator; + this.reqTransformer = reqTransformer; + this.paginationTransformer = paginationTransformer; + this.templateEngine = templateEngine; + } + + public async generate(apiSchema: APISchema, optionType: APIProviderType) { + if (!(optionType in this.apiOptions)) + throw new Error(`The API type: ${optionType} currently not provided now`); + + return new this.apiOptions[optionType]({ + apiSchema, + reqTransformer: this.reqTransformer, + reqValidator: this.reqValidator, + paginationTransformer: this.paginationTransformer, + templateEngine: this.templateEngine, + }); + } + + public async multiGenerate( + schemas: Array, + optionType: APIProviderType + ) { + return Promise.all( + schemas.map(async (schema) => await this.generate(schema, optionType)) + ); + } +} diff --git a/packages/serve/src/lib/server.ts b/packages/serve/src/lib/server.ts new file mode 100644 index 00000000..faffe659 --- /dev/null +++ b/packages/serve/src/lib/server.ts @@ -0,0 +1,38 @@ +import { omit } from 'lodash'; +import * as http from 'http'; +import { Container, TYPES } from '../containers'; +import { ServeConfig } from '../models'; +import { VulcanApplication } from './app'; +import { RouteGenerator } from './route'; +import { APISchema } from '@vulcan/core'; + +export class VulcanServer { + private config: ServeConfig; + private container: Container; + private server?: http.Server; + private schemas: Array; + constructor(config: ServeConfig, schemas: Array) { + this.config = config; + this.schemas = schemas; + this.container = new Container(); + } + public async start(port = 3000) { + if (this.server) + throw new Error('Server has created, please close it first.'); + + // Get generator + await this.container.load(this.config); + const generator = this.container.get(TYPES.RouteGenerator); + + // Create application + const app = new VulcanApplication(omit(this.config, 'template'), generator); + await app.buildMiddleware(); + await app.buildRoutes(this.schemas, this.config.types); + // Run server + this.server = http.createServer(app.getHandler()).listen(port); + } + public async close() { + if (this.server) this.server.close(); + this.container.unload(); + } +} diff --git a/packages/serve/src/models/appConfig.ts b/packages/serve/src/models/appConfig.ts new file mode 100644 index 00000000..38a47864 --- /dev/null +++ b/packages/serve/src/models/appConfig.ts @@ -0,0 +1,3 @@ +import { ServeConfig } from './serveConfig'; + +export type AppConfig = Omit; diff --git a/packages/serve/src/models/index.ts b/packages/serve/src/models/index.ts new file mode 100644 index 00000000..313ead8d --- /dev/null +++ b/packages/serve/src/models/index.ts @@ -0,0 +1,3 @@ +export * from './serveConfig'; +export * from './appConfig'; +export * from './middlewareConfig'; diff --git a/packages/serve/src/models/middlewareConfig.ts b/packages/serve/src/models/middlewareConfig.ts new file mode 100644 index 00000000..177903bb --- /dev/null +++ b/packages/serve/src/models/middlewareConfig.ts @@ -0,0 +1,25 @@ +import { LoggerOptions } from '@vulcan/core'; +import { + CorsOptions, + RateLimitOptions, + RequestIdOptions, +} from '../lib/middleware'; + +// built-in options for middleware +export type BuiltInOptions = + | RequestIdOptions + | LoggerOptions + | RateLimitOptions + | CorsOptions; + +export type CustomOptions = string | number | boolean | object; + +/** + * The identifier name represent to load middleware if it is custom, + * For the built-in middleware is will load automatically and use default options when not setup name and it's options + */ +export interface MiddlewareConfig { + [name: string]: { + [param: string]: BuiltInOptions | CustomOptions; + }; +} diff --git a/packages/serve/src/models/serveConfig.ts b/packages/serve/src/models/serveConfig.ts new file mode 100644 index 00000000..46f58847 --- /dev/null +++ b/packages/serve/src/models/serveConfig.ts @@ -0,0 +1,11 @@ +import { ICoreOptions } from '@vulcan/core'; +import { APIProviderType } from '@vulcan/serve/route'; +import { MiddlewareConfig } from './middlewareConfig'; + +// The serve package config +export interface ServeConfig extends ICoreOptions { + /** The middleware config options */ + middlewares?: MiddlewareConfig; + /* The API types would like to build */ + types: Array; +} 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 new file mode 100644 index 00000000..35e67d88 --- /dev/null +++ b/packages/serve/test/app.spec.ts @@ -0,0 +1,320 @@ +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 * as http from 'http'; +import { VulcanApplication } from '@vulcan/serve/app'; +import { + APISchema, + FieldDataType, + FieldInType, + RequestSchema, + TemplateEngine, + ValidatorDefinition, + ValidatorLoader, +} from '@vulcan/core'; +import { + RouteGenerator, + APIProviderType, + KoaRouterContext, + RequestParameters, + RequestTransformer, + RequestValidator, + PaginationTransformer, +} from '@vulcan/serve/route'; +import { Container } from 'inversify'; +import { TYPES as CORE_TYPES } from '@vulcan/core/containers'; +import { TYPES } from '../src/containers/types'; + +describe('Test vulcan server for practicing middleware', () => { + let container: Container; + let stubTemplateEngine: sinon.StubbedInstance; + beforeEach(() => { + container = new Container(); + stubTemplateEngine = sinon.stubInterface(); + + container.bind(CORE_TYPES.ValidatorLoader).to(ValidatorLoader); + container.bind(TYPES.PaginationTransformer).to(PaginationTransformer); + container.bind(TYPES.RequestTransformer).to(RequestTransformer); + container.bind(TYPES.RequestValidator).to(RequestValidator); + container + .bind(CORE_TYPES.TemplateEngine) + .toConstantValue(stubTemplateEngine); + container.bind(TYPES.RouteGenerator).to(RouteGenerator); + }); + + afterEach(() => { + container.unbindAll(); + }); + 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( + { + middlewares: { + 'test-mode': { + mode: true, + }, + }, + extensions: [ + path.resolve(__dirname, './middlewares/test-custom-middlewares'), + ], + }, + container.get(TYPES.RouteGenerator) + ); + await app.buildMiddleware(); + await app.buildRoutes([fakeSchema], [APIProviderType.RESTFUL]); + + const server = http + .createServer(app.getHandler()) + .listen(faker.internet.port()); + + // 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 container: Container; + let stubTemplateEngine: sinon.StubbedInstance; + const fakeSchemas: Array = [ + { + ...sinon.stubInterface(), + urlPath: `/department/:id/employees/:uuid`, + request: [ + { + ...sinon.stubInterface(), + fieldName: 'id', + type: FieldDataType.NUMBER, + fieldIn: FieldInType.PATH, + validators: [ + { + name: 'integer', + }, + ] as Array, + }, + { + ...sinon.stubInterface(), + fieldName: 'uuid', + type: FieldDataType.STRING, + fieldIn: FieldInType.PATH, + validators: [ + { + name: 'uuid', + }, + ] as Array, + }, + ], + }, + { + ...sinon.stubInterface(), + urlPath: `/orders/:uuid`, + request: [ + { + ...sinon.stubInterface(), + fieldName: 'uuid', + type: FieldDataType.STRING, + fieldIn: FieldInType.PATH, + validators: [ + { + name: 'uuid', + }, + ] as Array, + }, + { + ...sinon.stubInterface(), + fieldName: 'domain', + type: FieldDataType.STRING, + fieldIn: FieldInType.HEADER, + validators: [ + { + name: 'string', + }, + ] as Array, + }, + ], + }, + { + ...sinon.stubInterface(), + urlPath: `/searchOrders`, + request: [ + { + ...sinon.stubInterface(), + fieldName: 'keywords', + type: FieldDataType.STRING, + fieldIn: FieldInType.QUERY, + validators: [ + { + name: 'string', + }, + ] as Array, + }, + { + ...sinon.stubInterface(), + fieldName: 'domain', + type: FieldDataType.STRING, + fieldIn: FieldInType.HEADER, + validators: [ + { + name: 'string', + }, + ] as Array, + }, + ], + }, + { + ...sinon.stubInterface(), + urlPath: `/searchProducts`, + request: [ + { + ...sinon.stubInterface(), + fieldName: 'keywords', + type: FieldDataType.STRING, + fieldIn: FieldInType.QUERY, + validators: [ + { + name: 'string', + }, + ] as Array, + }, + ], + }, + ]; + + const fakeKoaContexts: Array = [ + { + ...sinon.stubInterface(), + params: { + id: faker.datatype.number().toString(), + uuid: faker.datatype.uuid(), + }, + }, + { + ...sinon.stubInterface(), + params: { + uuid: faker.datatype.uuid(), + }, + request: { + ...sinon.stubInterface(), + header: { + domain: faker.internet.domainName(), + }, + }, + }, + { + ...sinon.stubInterface(), + request: { + ...sinon.stubInterface(), + header: { + domain: faker.internet.domainName(), + }, + query: { + keywords: faker.random.words(), + }, + }, + }, + { + ...sinon.stubInterface(), + request: { + ...sinon.stubInterface(), + query: { + keywords: faker.random.words(), + }, + }, + }, + ]; + + beforeEach(() => { + container = new Container(); + stubTemplateEngine = sinon.stubInterface(); + + container.bind(CORE_TYPES.ValidatorLoader).to(ValidatorLoader); + container.bind(TYPES.PaginationTransformer).to(PaginationTransformer); + container.bind(TYPES.RequestTransformer).to(RequestTransformer); + container.bind(TYPES.RequestValidator).to(RequestValidator); + container + .bind(CORE_TYPES.TemplateEngine) + .toConstantValue(stubTemplateEngine); + container.bind(TYPES.RouteGenerator).to(RouteGenerator); + }); + + afterEach(() => { + container.unbindAll(); + }); + + it.each([ + ['path nested parameters', fakeSchemas[0], fakeKoaContexts[0]], + ['path & header parameters', fakeSchemas[1], fakeKoaContexts[1]], + ['query & header parameters', fakeSchemas[2], fakeKoaContexts[2]], + ['query parameters', fakeSchemas[3], fakeKoaContexts[3]], + ])( + 'Should be correct when given validated koa context request from %p', + async (_: string, schema: APISchema, ctx: KoaRouterContext) => { + // Arrange + const app = new VulcanApplication( + {}, + container.get(TYPES.RouteGenerator) + ); + await app.buildMiddleware(); + await app.buildRoutes([schema], [APIProviderType.RESTFUL]); + const server = http + .createServer(app.getHandler()) + .listen(faker.internet.port()); + + // arrange input api url + const apiUrl = KoaRouter.url(schema.urlPath, ctx.params); + + // arrange expected result + const expected: RequestParameters = {}; + schema.request.map((param: RequestSchema) => { + const fieldValue = RequestTransformer.fieldInMapper[param.fieldIn]( + ctx, + param.fieldName + ); + expected[param.fieldName] = RequestTransformer.convertTypeMapper[ + param.type + ](fieldValue, param.fieldName); + }); + + // Act + let reqOperation = supertest(server).get(apiUrl); + + // if request context exist setting the request input + if (ctx.request) { + // set query data to request if exist + reqOperation = ctx.request.query + ? reqOperation.query(ctx.request.query) + : reqOperation; + // set header data to request if exist + reqOperation = ctx.request.header + ? reqOperation.set(ctx.request.header) + : reqOperation; + } + const response = await reqOperation; + + // 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..2eb2e76b --- /dev/null +++ b/packages/serve/test/middlewares/built-in-middlewares/auditLogMiddleware.spec.ts @@ -0,0 +1,138 @@ +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 '@vulcan/serve/route'; +import { + AuditLoggingMiddleware, + RequestIdMiddleware, +} from '@vulcan/serve/middleware'; +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({ + '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..e857ec92 --- /dev/null +++ b/packages/serve/test/middlewares/built-in-middlewares/corsMiddleware.spec.ts @@ -0,0 +1,50 @@ +import faker from '@faker-js/faker'; +import * as Koa from 'koa'; +import * as supertest from 'supertest'; +import { CorsOptions, CorsMiddleware } from '@vulcan/serve/middleware'; +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({ + 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..3349d64a --- /dev/null +++ b/packages/serve/test/middlewares/built-in-middlewares/rateLimitMiddleware.spec.ts @@ -0,0 +1,64 @@ +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 '@vulcan/serve/middleware'; + +// 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({ + '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..45c33e65 --- /dev/null +++ b/packages/serve/test/middlewares/built-in-middlewares/requestIdMiddleware.spec.ts @@ -0,0 +1,128 @@ +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 '@vulcan/serve/route'; +import { + RequestIdMiddleware, + RequestIdOptions, +} from '@vulcan/serve/middleware'; +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({ + '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({ + '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..773215e9 --- /dev/null +++ b/packages/serve/test/middlewares/loader.spec.ts @@ -0,0 +1,65 @@ +import * as path from 'path'; +import * as sinon from 'ts-sinon'; +import { BaseRouteMiddleware, loadExtensions } from '@vulcan/serve/middleware'; +import middlewares from '@vulcan/serve/middleware/built-in-middleware'; +import { TestModeMiddleware } from './test-custom-middlewares'; +import { ClassType, defaultImport } from '@vulcan/core'; +import { AppConfig } from '@vulcan/serve/models'; +import { flatten } from 'lodash'; + +// the load Built-in used for tests +const loadBuiltIn = async () => { + // built-in middleware folder + const builtInFolder = path.resolve( + __dirname, + '../../src/lib/middleware', + 'built-in-middleware' + ); + // read built-in middlewares in index.ts, the content is an array middleware class + const modules = + flatten( + await defaultImport[]>(builtInFolder) + ) || []; + return modules || []; +}; + +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 = { + extensions: [path.join(__dirname, 'test-custom-middlewares')], + } as AppConfig; + + // Act + const actual = await loadExtensions(config.extensions); + // 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 = { + extensions: [path.join(__dirname, 'test-custom-middlewares')], + } as AppConfig; + + // Act + const actual = await loadExtensions(config.extensions); + // 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..124e07f7 --- /dev/null +++ b/packages/serve/test/middlewares/test-custom-middlewares/testModeMiddleware.ts @@ -0,0 +1,22 @@ +import { MiddlewareConfig } from '@vulcan/serve/models'; +import { + BaseRouteMiddleware, + RouteMiddlewareNext, +} from '@vulcan/serve/middleware'; +import { KoaRouterContext } from '@vulcan/serve/route'; + +export interface TestModeOptions { + mode: boolean; +} +/* istanbul ignore file */ +export class TestModeMiddleware extends BaseRouteMiddleware { + private mode: boolean; + constructor(config: MiddlewareConfig) { + 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..3f41d159 --- /dev/null +++ b/packages/serve/test/route/route-component/paginationTransformer.spec.ts @@ -0,0 +1,134 @@ +import * as sinon from 'ts-sinon'; +import { Request } from 'koa'; +import faker from '@faker-js/faker'; +import { APISchema, normalizeStringValue, PaginationMode } from '@vulcan/core'; +import { + IPaginationTransformer, + KoaRouterContext, + PaginationTransformer, +} from '@vulcan/serve/route'; +import { Container } from 'inversify'; +import { TYPES } from '@vulcan/serve/containers'; + +describe('Test pagination transformer - transform successfully', () => { + let container: Container; + + 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(), + }, + }, + }, + ]; + + beforeEach(() => { + container = new Container(); + container.bind(TYPES.PaginationTransformer).to(PaginationTransformer); + }); + + afterEach(() => { + container.unbindAll(); + }); + + 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 = container.get( + TYPES.PaginationTransformer + ); + const result = await transformer.transform(ctx, schema); + + // Assert + expect(result).toEqual(expected); + } + ); +}); + +// TODO: Failed case for transformer diff --git a/packages/serve/test/route/route-component/requestTransformer.spec.ts b/packages/serve/test/route/route-component/requestTransformer.spec.ts new file mode 100644 index 00000000..0cbdbf1e --- /dev/null +++ b/packages/serve/test/route/route-component/requestTransformer.spec.ts @@ -0,0 +1,172 @@ +import * as sinon from 'ts-sinon'; +import { Request } from 'koa'; +import faker from '@faker-js/faker'; +import { + APISchema, + FieldDataType, + FieldInType, + RequestSchema, +} from '@vulcan/core'; +import { + IRequestTransformer, + KoaRouterContext, + RequestParameters, + RequestTransformer, +} from '@vulcan/serve/route'; +import { Container } from 'inversify'; +import { TYPES } from '@vulcan/serve/containers'; + +describe('Test request transformer - transform successfully', () => { + let container: Container; + + const fakeSchemas: Array = [ + { + ...sinon.stubInterface(), + urlPath: `/${faker.word.noun()}/:id/${faker.word.noun()}/:uuid`, + request: [ + { + ...sinon.stubInterface(), + fieldName: 'id', + type: FieldDataType.NUMBER, + fieldIn: FieldInType.PATH, + }, + { + ...sinon.stubInterface(), + fieldName: 'uuid', + type: FieldDataType.STRING, + fieldIn: FieldInType.PATH, + }, + ], + }, + { + ...sinon.stubInterface(), + urlPath: `/${faker.word.noun()}/:uuid`, + request: [ + { + ...sinon.stubInterface(), + fieldName: 'uuid', + type: FieldDataType.STRING, + fieldIn: FieldInType.PATH, + }, + { + ...sinon.stubInterface(), + fieldName: 'domain', + type: FieldDataType.STRING, + fieldIn: FieldInType.HEADER, + }, + ], + }, + { + ...sinon.stubInterface(), + urlPath: `/${faker.word.noun()}`, + request: [ + { + ...sinon.stubInterface(), + fieldName: 'keywords', + type: FieldDataType.STRING, + fieldIn: FieldInType.QUERY, + }, + { + ...sinon.stubInterface(), + fieldName: 'domain', + type: FieldDataType.STRING, + fieldIn: FieldInType.HEADER, + }, + ], + }, + { + ...sinon.stubInterface(), + urlPath: `/${faker.word.noun()}`, + request: [ + { + ...sinon.stubInterface(), + fieldName: 'sort', + type: FieldDataType.BOOLEAN, + fieldIn: FieldInType.QUERY, + }, + ], + }, + ]; + const fakeKoaContexts: Array = [ + { + ...sinon.stubInterface(), + params: { + id: faker.datatype.number().toString(), + uuid: faker.datatype.uuid(), + }, + }, + { + ...sinon.stubInterface(), + params: { + uuid: faker.datatype.uuid(), + }, + request: { + ...sinon.stubInterface(), + header: { + domain: faker.internet.domainName(), + }, + }, + }, + { + ...sinon.stubInterface(), + request: { + ...sinon.stubInterface(), + header: { + domain: faker.internet.domainName(), + }, + query: { + keywords: faker.random.words(), + }, + }, + }, + { + ...sinon.stubInterface(), + request: { + ...sinon.stubInterface(), + query: { + sort: faker.helpers.arrayElement(['true', 'false']), + }, + }, + }, + ]; + beforeEach(() => { + container = new Container(); + container.bind(TYPES.RequestTransformer).to(RequestTransformer); + }); + + afterEach(() => { + container.unbindAll(); + }); + it.each([ + ['path nested parameters', fakeSchemas[0], fakeKoaContexts[0]], + ['path & header parameters', fakeSchemas[1], fakeKoaContexts[1]], + ['query & header parameters', fakeSchemas[2], fakeKoaContexts[2]], + ['query parameters', fakeSchemas[3], fakeKoaContexts[3]], + ])( + 'Should success when give api schema and koa context request from %p', + async (_: string, schema: APISchema, ctx: KoaRouterContext) => { + // Arrange + const expected: RequestParameters = {}; + schema.request.map((param: RequestSchema) => { + const fieldValue = RequestTransformer.fieldInMapper[param.fieldIn]( + ctx, + param.fieldName + ); + expected[param.fieldName] = RequestTransformer.convertTypeMapper[ + param.type + ](fieldValue, param.fieldName); + }); + // Act + + const transformer = container.get( + TYPES.RequestTransformer + ); + const result = await transformer.transform(ctx, schema); + + // Assert + expect(result).toEqual(expected); + } + ); +}); + +// TODO: Failed case for transformer diff --git a/packages/serve/test/route/route-component/requestValidator.spec.ts b/packages/serve/test/route/route-component/requestValidator.spec.ts new file mode 100644 index 00000000..e8cb5c24 --- /dev/null +++ b/packages/serve/test/route/route-component/requestValidator.spec.ts @@ -0,0 +1,173 @@ +import * as sinon from 'ts-sinon'; +import faker from '@faker-js/faker'; +import { + RequestValidator, + RequestParameters, + IRequestValidator, +} from '@vulcan/serve/route'; +import { + APISchema, + FieldDataType, + FieldInType, + RequestSchema, + ValidatorDefinition, + ValidatorLoader, +} from '@vulcan/core'; +import { Container } from 'inversify'; +import { TYPES } from '@vulcan/serve/containers'; +import { TYPES as CORE_TYPES } from '@vulcan/core/containers'; + +describe('Test request validator - validate successfully', () => { + let container: Container; + + const fakeSchemas: Array = [ + { + ...sinon.stubInterface(), + urlPath: `/${faker.word.noun()}/:id/${faker.word.noun()}/:uuid`, + request: [ + { + ...sinon.stubInterface(), + fieldName: 'id', + type: FieldDataType.NUMBER, + fieldIn: FieldInType.PATH, + validators: [ + { + name: 'integer', + }, + ] as Array, + }, + { + ...sinon.stubInterface(), + fieldName: 'uuid', + type: FieldDataType.STRING, + fieldIn: FieldInType.PATH, + validators: [ + { + name: 'uuid', + }, + ] as Array, + }, + ], + }, + { + ...sinon.stubInterface(), + urlPath: `/${faker.word.noun()}/:uuid`, + request: [ + { + ...sinon.stubInterface(), + fieldName: 'uuid', + type: FieldDataType.STRING, + fieldIn: FieldInType.PATH, + validators: [ + { + name: 'uuid', + }, + ] as Array, + }, + { + ...sinon.stubInterface(), + fieldName: 'domain', + type: FieldDataType.STRING, + fieldIn: FieldInType.HEADER, + validators: [ + { + name: 'string', + }, + ] as Array, + }, + ], + }, + { + ...sinon.stubInterface(), + urlPath: `/${faker.word.noun()}`, + request: [ + { + ...sinon.stubInterface(), + fieldName: 'keywords', + type: FieldDataType.STRING, + fieldIn: FieldInType.QUERY, + validators: [ + { + name: 'string', + }, + ] as Array, + }, + { + ...sinon.stubInterface(), + fieldName: 'domain', + type: FieldDataType.STRING, + fieldIn: FieldInType.HEADER, + validators: [ + { + name: 'string', + }, + ] as Array, + }, + ], + }, + { + ...sinon.stubInterface(), + urlPath: `/${faker.word.noun()}`, + request: [ + { + ...sinon.stubInterface(), + fieldName: 'sort', + type: FieldDataType.STRING, + fieldIn: FieldInType.QUERY, + validators: [ + { + name: 'string', + args: {}, + }, + ] as Array, + }, + ], + }, + ]; + const fakeKoaContexts: Array = [ + { + id: faker.datatype.number(), + uuid: faker.datatype.uuid(), + }, + { + uuid: faker.datatype.uuid(), + domain: faker.internet.domainName(), + }, + { + domain: faker.internet.domainName(), + keywords: faker.random.words(), + }, + { + sort: faker.helpers.arrayElement(['ASC', 'DESC']), + }, + ]; + + beforeEach(() => { + container = new Container(); + container.bind(CORE_TYPES.ValidatorLoader).to(ValidatorLoader); + container.bind(TYPES.RequestValidator).to(RequestValidator); + }); + + afterEach(() => { + container.unbindAll(); + }); + it.each([ + [fakeSchemas[0], fakeKoaContexts[0]], + [fakeSchemas[1], fakeKoaContexts[1]], + [fakeSchemas[2], fakeKoaContexts[2]], + [fakeSchemas[3], fakeKoaContexts[3]], + ])( + 'Should success when give matched request parameters and api schema', + async (schema: APISchema, reqParams: RequestParameters) => { + // Act + const validator = container.get( + TYPES.RequestValidator + ); + const validateAction = validator.validate(reqParams, schema); + const result = expect(validateAction).resolves; + await result.not.toThrow(); + } + ); +}); + +// TODO: Failed case for validator diff --git a/packages/serve/test/route/routeGenerator.spec.ts b/packages/serve/test/route/routeGenerator.spec.ts new file mode 100644 index 00000000..f9bacfb1 --- /dev/null +++ b/packages/serve/test/route/routeGenerator.spec.ts @@ -0,0 +1,120 @@ +import faker from '@faker-js/faker'; +import * as sinon from 'ts-sinon'; +import { APISchema, TemplateEngine } from '@vulcan/core'; +import { + APIProviderType, + GraphQLRoute, + IRequestTransformer, + IRequestValidator, + RestfulRoute, + RouteGenerator, + IPaginationTransformer, +} from '@vulcan/serve/route'; +import { Container } from 'inversify'; +import { TYPES } from '@vulcan/serve/containers'; +import { TYPES as CORE_TYPES } from '@vulcan/core/containers'; + +describe('Test route generator ', () => { + let container: Container; + let stubReqTransformer: sinon.StubbedInstance; + let stubReqValidator: sinon.StubbedInstance; + let stubPaginationTransformer: sinon.StubbedInstance; + let stubTemplateEngine: sinon.StubbedInstance; + const fakeSchemas: Array = Array( + faker.datatype.number({ min: 2, max: 4 }) + ).fill(sinon.stubInterface()); + + beforeEach(() => { + container = new Container(); + stubReqTransformer = sinon.stubInterface(); + stubReqValidator = sinon.stubInterface(); + stubPaginationTransformer = sinon.stubInterface(); + stubTemplateEngine = sinon.stubInterface(); + + container + .bind(TYPES.PaginationTransformer) + .toConstantValue(stubPaginationTransformer); + container + .bind(TYPES.RequestTransformer) + .toConstantValue(stubReqTransformer); + container.bind(TYPES.RequestValidator).toConstantValue(stubReqValidator); + container + .bind(CORE_TYPES.TemplateEngine) + .toConstantValue(stubTemplateEngine); + container.bind(TYPES.RouteGenerator).to(RouteGenerator); + }); + + afterEach(() => { + container.unbindAll(); + }); + + it.each(fakeSchemas)( + 'Should generate restful routes when input schemas and provide restful type', + async (apiSchema: APISchema) => { + // Arrange + + const expectedRoute: RestfulRoute = new RestfulRoute({ + apiSchema, + reqTransformer: container.get( + TYPES.RequestTransformer + ), + reqValidator: container.get(TYPES.RequestValidator), + paginationTransformer: container.get( + TYPES.PaginationTransformer + ), + templateEngine: container.get( + CORE_TYPES.TemplateEngine + ), + }); + + // Act + const routeGenerator = container.get( + TYPES.RouteGenerator + ); + const resultRoute = await routeGenerator.generate( + apiSchema, + APIProviderType.RESTFUL + ); + + // Assert + // Need to become json format for matching a object of class + expect(JSON.stringify(resultRoute)).toEqual( + JSON.stringify(expectedRoute) + ); + } + ); + + it.each(fakeSchemas)( + 'Should generate graphQL routes when input schemas and provide graphQL type', + async (apiSchema: APISchema) => { + // Arrange + const expectedRoute: GraphQLRoute = new GraphQLRoute({ + apiSchema, + reqTransformer: container.get( + TYPES.RequestTransformer + ), + reqValidator: container.get(TYPES.RequestValidator), + paginationTransformer: container.get( + TYPES.PaginationTransformer + ), + templateEngine: container.get( + CORE_TYPES.TemplateEngine + ), + }); + + // Act + const routeGenerator = container.get( + TYPES.RouteGenerator + ); + const resultRoute = await routeGenerator.generate( + apiSchema, + APIProviderType.GRAPHQL + ); + // Assert + // Need to become json format for matching a object of class + expect(JSON.stringify(resultRoute)).toEqual( + JSON.stringify(expectedRoute) + ); + } + ); +}); diff --git a/packages/serve/tsconfig.json b/packages/serve/tsconfig.json new file mode 100644 index 00000000..9566f5c1 --- /dev/null +++ b/packages/serve/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "typeRoots": ["../../node_modules/@types"] + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + // make above paths configuration could run ts-node + "ts-node": { + "require": ["tsconfig-paths/register"] + } +} diff --git a/packages/serve/tsconfig.lib.json b/packages/serve/tsconfig.lib.json new file mode 100644 index 00000000..1925baa1 --- /dev/null +++ b/packages/serve/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": [] + }, + "include": ["**/*.ts", "../../types/*.d.ts"], + "exclude": ["jest.config.ts", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/packages/serve/tsconfig.spec.json b/packages/serve/tsconfig.spec.json new file mode 100644 index 00000000..eb72f635 --- /dev/null +++ b/packages/serve/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "**/*.test.ts", + "**/*.spec.ts", + "**/*.d.ts", + "../../types/*.d.ts" + ] +} diff --git a/tools/scripts/replaceAlias.ts b/tools/scripts/replaceAlias.ts index b0d1d521..14689190 100644 --- a/tools/scripts/replaceAlias.ts +++ b/tools/scripts/replaceAlias.ts @@ -1,6 +1,6 @@ import * as glob from 'glob'; import * as path from 'path'; -import * as fs from 'fs/promises'; +import { promises as fs } from 'fs'; const requireRegex = (packageName: string) => new RegExp(`require\\(['"](@vulcan\/${packageName}[^'"]*)['"]\\)`, 'g'); diff --git a/tsconfig.base.json b/tsconfig.base.json index 587bd73d..0fe86ea0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -16,8 +16,9 @@ "baseUrl": ".", "types": ["reflect-metadata"], "paths": { - "@vulcan/build": ["packages/build/src/index.ts"], - "@vulcan/core": ["packages/core/src/index.ts"], + "@vulcan/build": ["packages/build/src/index"], + "@vulcan/core": ["packages/core/src/index"], + "@vulcan/serve": ["packages/serve/src/index"], "@vulcan/core/artifact-builder": [ "packages/core/src/lib/artifact-builder/index" ], @@ -30,8 +31,21 @@ "@vulcan/core/template-engine/*": [ "packages/core/src/lib/template-engine/*" ], + "@vulcan/core/validators": ["packages/core/src/lib/validators/index"], + "@vulcan/core/validators/*": ["packages/core/src/lib/validators/*"], + "@vulcan/core/validators/built-in-validators": [ + "packages/core/src/lib/validators/built-in-validators/index" + ], + "@vulcan/core/validators/built-in-validators/*": [ + "packages/core/src/lib/validators/built-in-validators/*" + ], + "@vulcan/core/data-query": ["packages/core/src/lib/data-query/index"], + "@vulcan/core/data-query/*": ["packages/core/src/lib/data-query/*"], + "@vulcan/core/data-source": ["packages/core/src/lib/data-source/index"], + "@vulcan/core/data-source/*": ["packages/core/src/lib/data-source/*"], "@vulcan/core/containers": ["packages/core/src/containers/index"], "@vulcan/core/models": ["packages/core/src/models/index"], + "@vulcan/core/utils": ["packages/core/src/lib/utils/index"], "@vulcan/build/schema-parser": [ "packages/build/src/lib/schema-parser/index" ], @@ -46,7 +60,18 @@ ], "@vulcan/build/models": ["packages/build/src/models/index"], "@vulcan/build/containers": ["packages/build/src/containers/index"], - "@vulcan/build/options": ["packages/build/src/options/index"] + "@vulcan/build/options": ["packages/build/src/options/index"], + "@vulcan/serve/models": ["packages/serve/src/models/index"], + "@vulcan/serve/containers": ["packages/serve/src/containers/index"], + "@vulcan/serve/options": ["packages/serve/src/options/index"], + "@vulcan/serve/route": ["packages/serve/src/lib/route/index"], + "@vulcan/serve/route/*": ["packages/serve/src/lib/route/*"], + "@vulcan/serve/app": ["packages/serve/src/lib/app.ts"], + "@vulcan/serve/pagination": ["packages/serve/src/lib/pagination/index"], + "@vulcan/serve/pagination/*": ["packages/serve/src/lib/pagination/*"], + "@vulcan/serve/middleware": ["packages/serve/src/lib/middleware/index"], + "@vulcan/serve/middleware/*": ["packages/serve/src/lib/middleware/*"], + "@vulcan/serve/config": ["packages/serve/src/lib/config"] } }, "exclude": ["node_modules", "tmp"] diff --git a/workspace.json b/workspace.json index f847a057..099ae337 100644 --- a/workspace.json +++ b/workspace.json @@ -2,6 +2,7 @@ "version": 2, "projects": { "build": "packages/build", - "core": "packages/core" + "core": "packages/core", + "serve": "packages/serve" } } diff --git a/yarn.lock b/yarn.lock index 8eb29411..9236c94f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -362,6 +362,23 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" +"@faker-js/faker@^6.3.1": + version "6.3.1" + resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-6.3.1.tgz#1ae963dd40405450a2945408cba553e1afa3e0fb" + integrity sha512-8YXBE2ZcU/pImVOHX7MWrSR/X5up7t6rPWZlk34RwZEcdr3ua6X+32pSd6XuOQRN+vbuvYNfA6iey8NbrjuMFQ== + +"@hapi/hoek@^9.0.0": + version "9.3.0" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" + integrity sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ== + +"@hapi/topo@^5.0.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.1.0.tgz#dc448e332c6c6e37a4dc02fd84ba8d44b9afb012" + integrity sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg== + dependencies: + "@hapi/hoek" "^9.0.0" + "@humanwhocodes/config-array@^0.9.2": version "0.9.5" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.9.5.tgz#2cbaf9a89460da24b5ca6531b8bbfc23e1df50c7" @@ -592,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" @@ -743,6 +767,23 @@ dependencies: esquery "^1.0.1" +"@sideway/address@^4.1.3": + version "4.1.4" + resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.4.tgz#03dccebc6ea47fdc226f7d3d1ad512955d4783f0" + integrity sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@sideway/formula@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.0.tgz#fe158aee32e6bd5de85044be615bc08478a0a13c" + integrity sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg== + +"@sideway/pinpoint@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" + integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== + "@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.1": version "1.8.3" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" @@ -954,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" @@ -979,6 +1025,18 @@ 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" + integrity sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog== + "@types/cookies@*": version "0.7.7" resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.7.tgz#7a92453d1d16389c05a5301eef566f34946cfd81" @@ -1087,7 +1145,24 @@ dependencies: "@types/koa" "*" -"@types/koa@*": +"@types/koa-router@^7.4.4": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@types/koa-router/-/koa-router-7.4.4.tgz#db72bde3616365d74f00178d5f243c4fce7da572" + integrity sha512-3dHlZ6CkhgcWeF6wafEUvyyqjWYfKmev3vy1PtOmr0mBc3wpXPU5E8fBBd4YQo5bRpHPfmwC5yDaX7s4jhIN6A== + 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" integrity sha512-dfHYMfU+z/vKtQB7NUrthdAEiSvnLebvBjwHtfFmpZmB7em2N3WVQdHgnFq+xvyVgxW5jKDmjWfLD3lw4g4uTw== @@ -1101,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== @@ -1151,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" @@ -1191,6 +1290,44 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== +"@types/superagent@*": + version "4.1.15" + resolved "https://registry.yarnpkg.com/@types/superagent/-/superagent-4.1.15.tgz#63297de457eba5e2bc502a7609426c4cceab434a" + integrity sha512-mu/N4uvfDN2zVQQ5AYJI/g4qxn2bHB6521t1UuH09ShNWjebTqN0ZFuYK9uYjcgmI0dTQEs+Owi1EO6U0OkOZQ== + dependencies: + "@types/cookiejar" "*" + "@types/node" "*" + +"@types/supertest@^2.0.12": + version "2.0.12" + resolved "https://registry.yarnpkg.com/@types/supertest/-/supertest-2.0.12.tgz#ddb4a0568597c9aadff8dbec5b2e8fddbe8692fc" + integrity sha512-X3HPWTwXRerBZS7Mo1k6vMVR1Z6zmJcDVn5O/31whe0tnjE4te6ZJSJGq1RiqHPjzPdMTfjCFogDJmwng9xHaQ== + dependencies: + "@types/superagent" "*" + +"@types/uuid@^8.3.4": + version "8.3.4" + 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" @@ -1300,6 +1437,14 @@ abab@^2.0.3, abab@^2.0.5: resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== +accepts@^1.3.5: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + acorn-globals@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-6.0.0.tgz#46cdd39f0f8ff08a876619b55f5ac8a6dc770b45" @@ -1431,7 +1576,7 @@ array-union@^2.1.0: resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== -asap@^2.0.3: +asap@^2.0.0, asap@^2.0.3: version "2.0.6" resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= @@ -1588,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== @@ -1601,11 +1753,32 @@ buffer@^5.5.0: base64-js "^1.3.1" ieee754 "^1.1.13" +bytes@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +cache-content-type@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/cache-content-type/-/cache-content-type-1.0.1.tgz#035cde2b08ee2129f4a8315ea8f00a00dba1453c" + integrity sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA== + dependencies: + mime-types "^2.1.18" + ylru "^1.2.0" + cachedir@2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/cachedir/-/cachedir-2.2.0.tgz#19afa4305e05d79e417566882e0c8f960f62ff0e" integrity sha512-VvxA0xhNqIIfg0V9AmJkDg91DaJwryutH5rVEZAhcNi4iJFj9f+QxmAjgK1LT9I8OgToX27fypX6/MeCXVbBjQ== +call-bind@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" + integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== + dependencies: + function-bind "^1.1.1" + get-intrinsic "^1.0.2" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -1727,6 +1900,16 @@ cliui@^7.0.2: strip-ansi "^6.0.0" wrap-ansi "^7.0.0" +co-body@^6.0.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/co-body/-/co-body-6.1.0.tgz#d87a8efc3564f9bfe3aced8ef5cd04c7a8766547" + integrity sha512-m7pOT6CdLN7FuXUcpuz/8lfQ/L77x8SchHCF4G0RBTJO20Wzmhn5Sp4/5WsKy8OSpifBSUrmg83qEqaDHdyFuQ== + dependencies: + inflation "^2.0.0" + qs "^6.5.2" + raw-body "^2.3.3" + type-is "^1.6.16" + co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -1793,6 +1976,11 @@ commitizen@^4.0.3: strip-bom "4.0.0" strip-json-comments "3.0.1" +component-emitter@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" + integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -1803,6 +1991,18 @@ confusing-browser-globals@^1.0.9: resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz#ae40e9b57cdd3915408a2805ebd3a5585608dc81" integrity sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA== +content-disposition@~0.5.2: + version "0.5.4" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" + integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== + dependencies: + safe-buffer "5.2.1" + +content-type@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" + integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== + conventional-commit-types@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/conventional-commit-types/-/conventional-commit-types-3.0.0.tgz#7c9214e58eae93e85dd66dbfbafe7e4fffa2365b" @@ -1815,6 +2015,24 @@ convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: dependencies: safe-buffer "~5.1.1" +cookiejar@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.3.tgz#fc7a6216e408e74414b90230050842dacda75acc" + integrity sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ== + +cookies@~0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.8.0.tgz#1293ce4b391740a8406e3c9870e828c4b54f3f90" + integrity sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow== + dependencies: + depd "~2.0.0" + keygrip "~1.1.0" + +copy-to@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/copy-to/-/copy-to-2.0.1.tgz#2680fbb8068a48d08656b6098092bdafc906f4a5" + integrity sha1-JoD7uAaKSNCGVrYJgJK9r8kG9KU= + cosmiconfig-typescript-loader@^1.0.0: version "1.0.9" resolved "https://registry.yarnpkg.com/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-1.0.9.tgz#69c523f7e8c3d9f27f563d02bbeadaf2f27212d3" @@ -1902,7 +2120,12 @@ data-urls@^2.0.0: whatwg-mimetype "^2.3.0" whatwg-url "^8.0.0" -debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.3: +dayjs@^1.11.2: + version "1.11.2" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.2.tgz#fa0f5223ef0d6724b3d8327134890cfe3d72fbe5" + integrity sha512-F4LXf1OeU9hrSYRPTTj/6FbO4HTjPKXvEIC1P2kcnFurViINCVk3ZV0xAS3XVx9MkMsXbbqlK6hjseaYbgKEHw== + +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== @@ -1919,6 +2142,11 @@ dedent@0.7.0, dedent@^0.7.0: resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" integrity sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw= +deep-equal@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" + integrity sha1-9dJgKStmDghO/0zbyfCK0yR0SLU= + deep-is@^0.1.3, deep-is@~0.1.3: version "0.1.4" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" @@ -1939,6 +2167,31 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= +delegates@^1.0.0: + version "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" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +depd@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" + integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= + +destroy@^1.0.4: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + detect-file@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7" @@ -1954,6 +2207,14 @@ detect-newline@^3.0.0: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== +dezalgo@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.3.tgz#7f742de066fc748bc8db820569dddce49bf0d456" + integrity sha1-f3Qt4Gb8dIvI24IFad3c5Jvw1FY= + dependencies: + asap "^2.0.0" + wrappy "1" + diff-sequences@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.5.1.tgz#eaecc0d327fd68c8d9672a1e64ab8dccb2ef5327" @@ -1990,6 +2251,11 @@ dotenv@~10.0.0: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81" integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q== +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= + ejs@^3.1.5: version "3.1.7" resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.7.tgz#c544d9c7f715783dd92f0bddcf73a59e6962d006" @@ -2012,6 +2278,11 @@ emoji-regex@^8.0.0: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== +encodeurl@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= + end-of-stream@^1.4.1: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -2038,6 +2309,11 @@ escalade@^3.1.1: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== +escape-html@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= + escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" @@ -2270,6 +2546,11 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= +fast-safe-stringify@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" + integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== + fastq@^1.6.0: version "1.13.0" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c" @@ -2377,6 +2658,30 @@ form-data@^3.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + +formidable@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-2.0.1.tgz#4310bc7965d185536f9565184dee74fbb75557ff" + integrity sha512-rjTMNbp2BpfQShhFbR3Ruk3qk2y9jKpvMW78nJgx8QKtxjDVrwbZG+wvDOmVbifHyOUOQJXxqEy6r0faRrPzTQ== + dependencies: + dezalgo "1.0.3" + hexoid "1.0.0" + once "1.4.0" + qs "6.9.3" + +fresh@~0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= + fs-constants@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" @@ -2431,6 +2736,15 @@ get-caller-file@^2.0.5: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== +get-intrinsic@^1.0.2: + version "1.1.1" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6" + integrity sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q== + dependencies: + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + get-package-type@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" @@ -2562,6 +2876,18 @@ has-flag@^4.0.0: resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== +has-symbols@^1.0.1, has-symbols@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + +has-tostringtag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25" + integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ== + dependencies: + has-symbols "^1.0.2" + has@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" @@ -2569,6 +2895,11 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" +hexoid@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/hexoid/-/hexoid-1.0.0.tgz#ad10c6573fb907de23d9ec63a711267d9dc9bc18" + integrity sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g== + homedir-polyfill@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8" @@ -2588,6 +2919,36 @@ html-escaper@^2.0.0: resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== +http-assert@^1.3.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/http-assert/-/http-assert-1.5.0.tgz#c389ccd87ac16ed2dfa6246fd73b926aa00e6b8f" + integrity sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w== + dependencies: + deep-equal "~1.0.1" + http-errors "~1.8.0" + +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + +http-errors@^1.6.3, http-errors@^1.7.3, http-errors@~1.8.0: + version "1.8.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c" + integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g== + dependencies: + depd "~1.1.2" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses ">= 1.5.0 < 2" + toidentifier "1.0.1" + http-proxy-agent@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz#8a8c8ef7f5932ccf953c296ca8291b95aa74aa3a" @@ -2655,6 +3016,11 @@ imurmurhash@^0.1.4: resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= +inflation@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/inflation/-/inflation-2.0.0.tgz#8b417e47c28f925a45133d914ca1fd389107f30f" + integrity sha1-i0F+R8KPklpFEz2RTKH9OJEH8w8= + inflight@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" @@ -2663,7 +3029,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.3, inherits@^2.0.4: +inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -2697,6 +3063,11 @@ inversify@^6.0.1: resolved "https://registry.yarnpkg.com/inversify/-/inversify-6.0.1.tgz#b20d35425d5d8c5cd156120237aad0008d969f02" integrity sha512-B3ex30927698TJENHR++8FfEaJGqoWOgI6ZY5Ht/nLUsFCwHn6akbwtnUAPCgUepAnTpe2qHxhDNjoKLyz6rgQ== +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" @@ -2741,6 +3112,13 @@ is-generator-fn@^2.0.0: resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== +is-generator-function@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72" + integrity sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A== + dependencies: + has-tostringtag "^1.0.0" + is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" @@ -3252,6 +3630,17 @@ jest@27.5.1: import-local "^3.0.2" jest-cli "^27.5.1" +joi@^17.6.0: + version "17.6.0" + resolved "https://registry.yarnpkg.com/joi/-/joi-17.6.0.tgz#0bb54f2f006c09a96e75ce687957bd04290054b2" + integrity sha512-OX5dG6DTbcr/kbMFj0KGYxuew69HPcAE3K/sZpEV2nP6e/j/C0HV+HNiBPCASxdx5T7DMoa0s8UeHWMnb6n2zw== + dependencies: + "@hapi/hoek" "^9.0.0" + "@hapi/topo" "^5.0.0" + "@sideway/address" "^4.1.3" + "@sideway/formula" "^3.0.0" + "@sideway/pinpoint" "^2.0.0" + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -3363,16 +3752,89 @@ 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" + integrity sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ== + dependencies: + tsscmp "1.0.6" + kleur@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== +koa-bodyparser@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/koa-bodyparser/-/koa-bodyparser-4.3.0.tgz#274c778555ff48fa221ee7f36a9fbdbace22759a" + integrity sha512-uyV8G29KAGwZc4q/0WUAjH+Tsmuv9ImfBUF2oZVyZtaeo0husInagyn/JH85xMSxM0hEk/mbCII5ubLDuqW/Rw== + dependencies: + co-body "^6.0.0" + copy-to "^2.0.1" + koa-compose@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-4.1.0.tgz#507306b9371901db41121c812e923d0d67d3e877" integrity sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw== +koa-convert@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/koa-convert/-/koa-convert-2.0.0.tgz#86a0c44d81d40551bae22fee6709904573eea4f5" + integrity sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA== + dependencies: + co "^4.6.0" + koa-compose "^4.1.0" + +koa-router@^10.1.1: + version "10.1.1" + resolved "https://registry.yarnpkg.com/koa-router/-/koa-router-10.1.1.tgz#20809f82648518b84726cd445037813cd99f17ff" + integrity sha512-z/OzxVjf5NyuNO3t9nJpx7e1oR3FSBAauiwXtMQu4ppcnuNZzTaQ4p21P8A6r2Es8uJJM339oc4oVW+qX7SqnQ== + dependencies: + debug "^4.1.1" + http-errors "^1.7.3" + koa-compose "^4.1.0" + 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" + integrity sha512-43zkIKubNbnrULWlHdN5h1g3SEKXOEzoAlRsHOTFpnlDu8JlAOZSMJBLULusuXRequboiwJcj5vtYXKB3k7+2g== + dependencies: + accepts "^1.3.5" + cache-content-type "^1.0.0" + content-disposition "~0.5.2" + content-type "^1.0.4" + cookies "~0.8.0" + debug "^4.3.2" + delegates "^1.0.0" + depd "^2.0.0" + destroy "^1.0.4" + encodeurl "^1.0.2" + escape-html "^1.0.3" + fresh "~0.5.2" + http-assert "^1.3.0" + http-errors "^1.6.3" + is-generator-function "^1.0.7" + koa-compose "^4.1.0" + koa-convert "^2.0.0" + on-finished "^2.3.0" + only "~0.0.2" + parseurl "^1.3.2" + statuses "^1.5.0" + type-is "^1.6.16" + vary "^1.1.2" + leven@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" @@ -3467,6 +3929,16 @@ makeerror@1.0.12: dependencies: tmpl "1.0.5" +media-typer@0.3.0: + version "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" @@ -3482,6 +3954,11 @@ merge@^2.1.1: resolved "https://registry.yarnpkg.com/merge/-/merge-2.1.1.tgz#59ef4bf7e0b3e879186436e8481c06a6c162ca98" integrity sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w== +methods@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= + micromatch@^4.0.2, micromatch@^4.0.4: version "4.0.5" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" @@ -3495,13 +3972,18 @@ mime-db@1.52.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-types@^2.1.12: +mime-types@^2.1.12, mime-types@^2.1.18, mime-types@~2.1.24, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== dependencies: mime-db "1.52.0" +mime@^2.5.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" + integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== + mimic-fn@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" @@ -3543,11 +4025,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" @@ -3558,6 +4090,11 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + nise@^4.0.4: version "4.1.0" resolved "https://registry.yarnpkg.com/nise/-/nise-4.1.0.tgz#8fb75a26e90b99202fa1e63f448f58efbcdedaf6" @@ -3654,7 +4191,19 @@ nx@14.0.3: yargs "^17.4.0" yargs-parser "21.0.1" -once@^1.3.0, once@^1.4.0: +object-inspect@^1.9.0: + version "1.12.0" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.0.tgz#6e2c120e868fd1fd18cb4f18c31741d0d6e776f0" + integrity sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g== + +on-finished@^2.3.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +once@1.4.0, once@^1.3.0, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= @@ -3675,6 +4224,11 @@ onetime@^5.1.0, onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +only@~0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4" + integrity sha1-Kv3oTQPlC5qO3EROMGEKcCle37Q= + open@^8.4.0: version "8.4.0" resolved "https://registry.yarnpkg.com/open/-/open-8.4.0.tgz#345321ae18f8138f82565a910fdc6b39e8c244f8" @@ -3766,6 +4320,11 @@ parse5@6.0.1: resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== +parseurl@^1.3.2: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -3793,6 +4352,11 @@ path-to-regexp@^1.7.0: dependencies: isarray "0.0.1" +path-to-regexp@^6.1.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.1.tgz#d54934d6798eb9e5ef14e7af7962c945906918e5" + integrity sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw== + path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" @@ -3862,17 +4426,39 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +qs@6.9.3: + version "6.9.3" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.3.tgz#bfadcd296c2d549f1dffa560619132c977f5008e" + integrity sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw== + +qs@^6.10.3, qs@^6.5.2: + version "6.10.3" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.3.tgz#d6cde1b2ffca87b5aa57889816c5f81535e22e8e" + integrity sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ== + dependencies: + side-channel "^1.0.4" + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +raw-body@^2.3.3: + version "2.5.1" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" + integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + react-is@^17.0.1: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -readable-stream@^3.1.1, readable-stream@^3.4.0: +readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== @@ -4001,21 +4587,28 @@ rxjs@^6.4.0, rxjs@^6.5.4: dependencies: tslib "^1.9.0" +safe-buffer@5.2.1, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@~5.2.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - "safer-buffer@>= 2.1.2 < 3": version "2.1.2" 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" @@ -4030,7 +4623,7 @@ semver@7.3.4: dependencies: lru-cache "^6.0.0" -semver@7.x, semver@^7.3.2, semver@^7.3.5: +semver@7.x, semver@^7.3.2, semver@^7.3.5, semver@^7.3.7: version "7.3.7" resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f" integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g== @@ -4042,6 +4635,11 @@ semver@^6.0.0, semver@^6.3.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -4054,6 +4652,20 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== +side-channel@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" + integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== + dependencies: + call-bind "^1.0.0" + 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" @@ -4081,6 +4693,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" @@ -4112,6 +4737,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" @@ -4124,6 +4756,16 @@ stack-utils@^2.0.3: dependencies: escape-string-regexp "^2.0.0" +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + +"statuses@>= 1.5.0 < 2", statuses@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" + integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= + string-length@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" @@ -4202,6 +4844,31 @@ strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +superagent@^7.1.3: + version "7.1.3" + resolved "https://registry.yarnpkg.com/superagent/-/superagent-7.1.3.tgz#783ff8330e7c2dad6ad8f0095edc772999273b6b" + integrity sha512-WA6et4nAvgBCS73lJvv1D0ssI5uk5Gh+TGN/kNe+B608EtcVs/yzfl+OLXTzDs7tOBDIpvgh/WUs1K2OK1zTeQ== + dependencies: + component-emitter "^1.3.0" + cookiejar "^2.1.3" + debug "^4.3.4" + fast-safe-stringify "^2.1.1" + form-data "^4.0.0" + formidable "^2.0.1" + methods "^1.1.2" + mime "^2.5.0" + qs "^6.10.3" + readable-stream "^3.6.0" + semver "^7.3.7" + +supertest@^6.2.3: + version "6.2.3" + resolved "https://registry.yarnpkg.com/supertest/-/supertest-6.2.3.tgz#291b220126e5faa654d12abe1ada3658757c8c67" + integrity sha512-3GSdMYTMItzsSYjnIcljxMVZKPW1J9kYHZY+7yLfD0wpPwww97GeImZC1oOk0S5+wYl2niJwuFusBJqwLqYM3g== + dependencies: + methods "^1.1.2" + superagent "^7.1.3" + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" @@ -4315,6 +4982,11 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + tough-cookie@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" @@ -4331,6 +5003,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" @@ -4416,6 +5095,18 @@ 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" + integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -4452,6 +5143,14 @@ type-fest@^0.21.3: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== +type-is@^1.6.16: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + typedarray-to-buffer@^3.1.5: version "3.1.5" resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" @@ -4474,6 +5173,11 @@ universalify@^2.0.0: resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== +unpipe@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= + uri-js@^4.2.2: version "4.4.1" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" @@ -4486,6 +5190,11 @@ util-deprecate@^1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= +uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + v8-compile-cache-lib@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" @@ -4509,6 +5218,10 @@ validator@^13.7.0: version "13.7.0" resolved "https://registry.yarnpkg.com/validator/-/validator-13.7.0.tgz#4f9658ba13ba8f3d82ee881d3516489ea85c0857" integrity sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw== +vary@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= w3c-hr-time@^1.0.2: version "1.0.2" @@ -4541,6 +5254,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" @@ -4553,6 +5271,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" @@ -4671,6 +5397,11 @@ yargs@^17.4.0: y18n "^5.0.5" yargs-parser "^21.0.0" +ylru@^1.2.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.3.2.tgz#0de48017473275a4cbdfc83a1eaf67c01af8a785" + integrity sha512-RXRJzMiK6U2ye0BlGGZnmpwJDPgakn6aNQ0A7gHRbD4I0uvK4TW6UqkK1V0pp9jskjJBAXd3dRrbzWkqJ+6cxA== + yn@3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"