diff --git a/.nvmrc b/.nvmrc index 5c088ddb..66df3b7a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -12.14.1 +12.16.1 diff --git a/_examples/simple-express-rest-api/src/hyperview/hyperview.controller.ts b/_examples/simple-express-rest-api/src/hyperview/hyperview.controller.ts index 7b9b86c6..9a85ff39 100644 --- a/_examples/simple-express-rest-api/src/hyperview/hyperview.controller.ts +++ b/_examples/simple-express-rest-api/src/hyperview/hyperview.controller.ts @@ -1,4 +1,4 @@ -import { RequestBody } from '@dandi/http-model' +import { RequestModel } from '@dandi/http-model' import { Property, Required } from '@dandi/model' import { Controller, HttpGet, HttpPost } from '@dandi/mvc' import { View } from '@dandi/mvc-view' @@ -26,7 +26,7 @@ export class HyperviewController { @HttpPost('detail.xml') @View('detail.pug', { xml: true }) - public detail(@RequestBody(FormModel) form: FormModel): any { + public detail(@RequestModel(FormModel) form: FormModel): any { return form } diff --git a/_examples/simple-express-rest-api/src/lists/list.controller.ts b/_examples/simple-express-rest-api/src/lists/list.controller.ts index 4ba5a6a5..4b4d0966 100644 --- a/_examples/simple-express-rest-api/src/lists/list.controller.ts +++ b/_examples/simple-express-rest-api/src/lists/list.controller.ts @@ -1,6 +1,6 @@ import { Uuid } from '@dandi/common' import { Inject } from '@dandi/core' -import { PathParam, RequestBody } from '@dandi/http-model' +import { PathParam, RequestModel } from '@dandi/http-model' import { Controller, Cors, HttpGet, HttpPost, HttpPut } from '@dandi/mvc' import { AccessorResourceId, ResourceAccessor, ResourceListAccessor } from '@dandi/mvc-hal' @@ -22,7 +22,7 @@ export class ListController { } @HttpPost() - public async addList(@RequestBody(ListRequest) listRequest): Promise { + public async addList(@RequestModel(ListRequest) listRequest): Promise { return new ListResource(await this.listManager.addList(listRequest)) } @@ -30,7 +30,7 @@ export class ListController { @Cors({ allowOrigin: ['this-should-never-get accessed-via-cors'], }) - public async putList(@RequestBody(ListRequest) listRequest): Promise { + public async putList(@RequestModel(ListRequest) listRequest): Promise { return new ListResource(await this.listManager.addList(listRequest)) } @@ -55,7 +55,7 @@ export class ListController { } @HttpPost(':listId/task') - public addTask(@PathParam(Uuid) listId, @RequestBody(TaskRequest) taskRequest): Promise { + public addTask(@PathParam(Uuid) listId, @RequestModel(TaskRequest) taskRequest): Promise { return this.listManager.addTask(listId, taskRequest) } } diff --git a/_examples/simple-express-rest-api/src/tasks/task.controller.ts b/_examples/simple-express-rest-api/src/tasks/task.controller.ts index ed66cff9..683ca918 100644 --- a/_examples/simple-express-rest-api/src/tasks/task.controller.ts +++ b/_examples/simple-express-rest-api/src/tasks/task.controller.ts @@ -1,6 +1,6 @@ import { Uuid } from '@dandi/common' import { Inject } from '@dandi/core' -import { PathParam, RequestBody } from '@dandi/http-model' +import { PathParam, RequestModel } from '@dandi/http-model' import { Controller, HttpGet, HttpPatch } from '@dandi/mvc' import { AccessorResourceId, ResourceAccessor } from '@dandi/mvc-hal' @@ -22,7 +22,7 @@ export class TaskController { } @HttpPatch(':taskId') - public async updateTask(@PathParam(Uuid) taskId, @RequestBody(Task) task): Promise { + public async updateTask(@PathParam(Uuid) taskId, @RequestModel(Task) task): Promise { if (taskId !== task.taskId) { throw new Error('taskId on path did not match taskId on model') } diff --git a/package.json b/package.json index b0c1f214..201e2301 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@types/chai-as-promised": "^7.1.2", "@types/colors": "^1.2.1", "@types/luxon": "^1.15.2", - "@types/mocha": "^5.2.7", + "@types/mocha": "7.0.2", "@types/node": "^12.12.24", "@types/sinon-chai": "^3.2.3", "@typescript-eslint/eslint-plugin": "^2.19.2", @@ -52,12 +52,12 @@ "nyc": "^15.0.0", "rimraf": "^3.0.0", "rxjs": "^6.4.0", - "sinon": "^8.0.2", + "sinon": "^9.0.1", "sinon-chai": "^3.4.0", "ts-custom-error-shim": "^1.0.2", "ts-node": "^8.5.4", "tsconfig-paths": "^3.9.0", "typescript": "^3.7.4", - "uuid": "^3.3.3" + "uuid": "^7.0.2" } } diff --git a/packages/dandi-contrib/aws-lambda/src/aws-lambda-http.int-spec.ts b/packages/dandi-contrib/aws-lambda/src/aws-lambda-http.int-spec.ts index 583e3c55..1dc85e05 100644 --- a/packages/dandi-contrib/aws-lambda/src/aws-lambda-http.int-spec.ts +++ b/packages/dandi-contrib/aws-lambda/src/aws-lambda-http.int-spec.ts @@ -2,7 +2,7 @@ import { AwsLambdaHttpModule, LambdaHandler, LambdaHandlerFn, Lambda } from '@da import { APIGatewayProxyEvent } from '@dandi-contrib/aws-lambda/node_modules/@types/aws-lambda' import { Injectable } from '@dandi/core' import { HttpHeader, HttpMethod, HttpModule, HttpStatusCode, MimeType } from '@dandi/http' -import { QueryParam, RequestBody } from '@dandi/http-model' +import { QueryParam, RequestModel } from '@dandi/http-model' import { HttpPipelineModule, NativeJsonObjectRenderer } from '@dandi/http-pipeline' import { Property, Required } from '@dandi/model' import { ModelBuilderModule } from '@dandi/model-builder' @@ -34,7 +34,7 @@ describe('AWS Lambda Http Events', () => { @Injectable() class TestPostHandler implements LambdaHandler { - public handleEvent(@RequestBody(TestModel) model: TestModel): any { + public handleEvent(@RequestModel(TestModel) model: TestModel): any { return { message: model.message, } diff --git a/packages/dandi-contrib/aws-lambda/src/lambda.ts b/packages/dandi-contrib/aws-lambda/src/lambda.ts index 26979bb0..d9e80f34 100644 --- a/packages/dandi-contrib/aws-lambda/src/lambda.ts +++ b/packages/dandi-contrib/aws-lambda/src/lambda.ts @@ -9,7 +9,6 @@ import { Provider, Registerable, } from '@dandi/core' -import { createHttpRequestScope } from '@dandi/http' import { DefaultHttpRequestInfo, HttpPipeline, @@ -74,9 +73,7 @@ export class Lambda { public async handleEvent(event: TEvent, context: Context): Promise { const providers = this.createProviders(event, context) - return Disposable.useAsync(this.injector.createChild(createHttpRequestScope(event as any), providers), async (injector) => { - return await injector.invoke(this.httpPipeline, 'handleRequest', ...providers) - }) + return await this.injector.invoke(this.httpPipeline, 'handleRequest', ...providers) } private createProviders(event: TEvent, context: Context): Provider[] { diff --git a/packages/dandi/common/index.ts b/packages/dandi/common/index.ts index 1764213f..b9c6f40e 100644 --- a/packages/dandi/common/index.ts +++ b/packages/dandi/common/index.ts @@ -11,7 +11,7 @@ export * from './src/invalid-access-error' export * from './src/error.util' export * from './src/jsonable' export * from './src/metadata' -export * from './src/method.target' +export * from './src/method-target' export * from './src/package.global.symbol' export * from './src/parse-boolean' export * from './src/primitive' diff --git a/packages/dandi/common/package.json b/packages/dandi/common/package.json index 5e2378f3..66997675 100644 --- a/packages/dandi/common/package.json +++ b/packages/dandi/common/package.json @@ -2,6 +2,6 @@ "name": "@dandi/common", "peerDependencies": { "luxon": "^1.4.3", - "uuid": "^3.3.2" + "uuid": "^7.0.0" } } diff --git a/packages/dandi/common/src/constructor.ts b/packages/dandi/common/src/constructor.ts index 72d26371..6873a86c 100644 --- a/packages/dandi/common/src/constructor.ts +++ b/packages/dandi/common/src/constructor.ts @@ -8,9 +8,19 @@ export type PrimitiveConstructor = T extends string ? StringConstructor : never +// const IS_CONSTRUCTOR_PATTERN = /^class\s+\w+\s*\{/ +// const IS_CONSTRUCTOR = globalSymbol('IS_CONSTRUCTOR') + export function isConstructor(obj: any): obj is Constructor { if (typeof obj !== 'function') { return false } return !!obj.prototype + // const cachedResult = Reflect.get(obj, IS_CONSTRUCTOR) + // if (typeof cachedResult !== 'undefined') { + // return cachedResult + // } + // const isConstructor = IS_CONSTRUCTOR_PATTERN.test(obj.name) + // Reflect.set(obj, IS_CONSTRUCTOR, isConstructor) + // return isConstructor } diff --git a/packages/dandi/common/src/method.target.ts b/packages/dandi/common/src/method-target.ts similarity index 71% rename from packages/dandi/common/src/method.target.ts rename to packages/dandi/common/src/method-target.ts index 606367d4..e844e0ef 100644 --- a/packages/dandi/common/src/method.target.ts +++ b/packages/dandi/common/src/method-target.ts @@ -2,6 +2,6 @@ import { Constructor } from './constructor' export type ClassMethods = { [TProp in keyof T]?: T[TProp] } -export type MethodTarget = ClassMethods & { +export type MethodTarget = ClassMethods & { constructor: Constructor } diff --git a/packages/dandi/common/src/uuid.ts b/packages/dandi/common/src/uuid.ts index bbd07b88..784f10b4 100644 --- a/packages/dandi/common/src/uuid.ts +++ b/packages/dandi/common/src/uuid.ts @@ -1,4 +1,4 @@ -import * as uuid from 'uuid/v4' +import { v4 as uuid } from 'uuid' const UUID = new Map() diff --git a/packages/dandi/core/decorators/index.ts b/packages/dandi/core/decorators/index.ts index 76d4f8db..cac8e509 100644 --- a/packages/dandi/core/decorators/index.ts +++ b/packages/dandi/core/decorators/index.ts @@ -1,3 +1,4 @@ export * from './src/inject-decorator' export * from './src/injectable-decorator' export * from './src/optional-decorator' +export * from './src/scope-decorator' diff --git a/packages/dandi/core/decorators/src/scope-decorator.ts b/packages/dandi/core/decorators/src/scope-decorator.ts new file mode 100644 index 00000000..2ea8a7aa --- /dev/null +++ b/packages/dandi/core/decorators/src/scope-decorator.ts @@ -0,0 +1,12 @@ +import { CreateScopeFn, getInjectableMetadata } from '@dandi/core/internal/util' + +export function Scope(scopeFn: CreateScopeFn): ClassDecorator & MethodDecorator { + return function scopeDecorator(target: any, propertyKey?: string): void { + const metaTarget = propertyKey ? target[propertyKey] : target.constructor + const meta = getInjectableMetadata(metaTarget) + if (meta.scopeFn && meta.scopeFn !== scopeFn) { + throw Error(`Decorator Error: ${metaTarget} already has scopeFn ${scopeFn} defined`) + } + meta.scopeFn = scopeFn + } +} diff --git a/packages/dandi/core/errors/index.ts b/packages/dandi/core/errors/index.ts index 08e21ba2..5dbe307c 100644 --- a/packages/dandi/core/errors/index.ts +++ b/packages/dandi/core/errors/index.ts @@ -1,4 +1,5 @@ export * from './src/dandi-application-error' export * from './src/dandi-injection-error' +export * from './src/metadata-provider-error' export * from './src/missing-provider-error' export * from './src/provider-type-error' diff --git a/packages/dandi/core/errors/src/dandi-injection-error.ts b/packages/dandi/core/errors/src/dandi-injection-error.ts index afc20e7b..adf5f5ba 100644 --- a/packages/dandi/core/errors/src/dandi-injection-error.ts +++ b/packages/dandi/core/errors/src/dandi-injection-error.ts @@ -1,10 +1,24 @@ -import { AppError, CUSTOM_INSPECTOR } from '@dandi/common' +import { AppError, Constructor, CUSTOM_INSPECTOR } from '@dandi/common' import { InjectionToken, InjectorContext } from '@dandi/core/types' import { getTokenString } from '../../internal/util/src/injection-token-util' +import { globalSymbol } from '../../src/global-symbol' + +const WRAP_INJECTION_ERROR = globalSymbol('WRAP_INJECTION_ERROR') export class DandiInjectionError extends AppError { + public static doNotWrap(errClass: Constructor): void { + Object.defineProperty(errClass.prototype, WRAP_INJECTION_ERROR, { + value: false, + configurable: false, + }) + } + + public static shouldWrap(err: any): boolean { + return !(err instanceof DandiInjectionError) && err[WRAP_INJECTION_ERROR] !== false + } + /** * @internal */ @@ -12,3 +26,4 @@ export class DandiInjectionError extends AppError { super(`${messageStart} ${getTokenString(token)} \nfor ${context[CUSTOM_INSPECTOR]()}`, innerError) } } +DandiInjectionError.doNotWrap(DandiInjectionError) diff --git a/packages/dandi/core/errors/src/metadata-provider-error.ts b/packages/dandi/core/errors/src/metadata-provider-error.ts new file mode 100644 index 00000000..f2867327 --- /dev/null +++ b/packages/dandi/core/errors/src/metadata-provider-error.ts @@ -0,0 +1,7 @@ +import { AppError } from '@dandi/common' + +export class MetadataProviderError extends AppError { + constructor(message: string, innerError?: Error) { + super(message, innerError) + } +} diff --git a/packages/dandi/core/internal/src/dandi-generator.ts b/packages/dandi/core/internal/src/dandi-generator.ts index cc4cd9a2..e93d1cf3 100644 --- a/packages/dandi/core/internal/src/dandi-generator.ts +++ b/packages/dandi/core/internal/src/dandi-generator.ts @@ -15,11 +15,12 @@ import { FactoryParamInjectionScope, FactoryProvider, GeneratingProvider, + InjectionScope, Injector, + InjectorContext, InstanceGenerator, Provider, ResolverContext, - InjectionScope, InjectorContext, } from '@dandi/core/types' import { DandiResolverContext } from './dandi-resolver-context' @@ -80,6 +81,7 @@ export class DandiGenerator implements InstanceGenerator { ? await Promise.all( provider.deps.map(async (paramToken) => { const paramScope: FactoryParamInjectionScope = { + // target: provider, target: provider, paramToken, } diff --git a/packages/dandi/core/internal/src/dandi-injector.ts b/packages/dandi/core/internal/src/dandi-injector.ts index 646b2066..e7929dec 100644 --- a/packages/dandi/core/internal/src/dandi-injector.ts +++ b/packages/dandi/core/internal/src/dandi-injector.ts @@ -1,10 +1,11 @@ import { Disposable } from '@dandi/common' import { DandiInjectionError, - MissingTokenError, InvalidTokenError, InvalidTokenScopeError, + MetadataProviderError, MissingProviderError, + MissingTokenError, } from '@dandi/core/errors' import { getInjectableMetadata, @@ -14,6 +15,7 @@ import { getScopeRestriction, isInjectionToken, scopesAreCompatible, + ParamMetadata, } from '@dandi/core/internal/util' import { DependencyInjectionScope, @@ -28,6 +30,7 @@ import { InvokableFn, InvokeInjectionScope, OpinionatedToken, + Provider, Registerable, ResolvedProvider, ResolverContext, @@ -51,6 +54,7 @@ type KnownArgs = { [TProp in keyof Args]?: Args[TProp] } export class DandiInjector implements Injector, Disposable { public readonly context: DandiInjectorContext + public readonly app: Injector protected generator: InstanceGenerator protected readonly generatorReady: Promise @@ -72,7 +76,9 @@ export class DandiInjector implements Injector, Disposable { }, ...providers || [], ) + this.app = parent.app || this } + this.generatorReady = this.initGeneratorFactory(generatorFactory) } @@ -100,10 +106,10 @@ export class DandiInjector implements Injector, Disposable { } return resolverContext.resolveValue(result) } catch (err) { - if (err instanceof DandiInjectionError) { - throw err + if (DandiInjectionError.shouldWrap(err)) { + throw new DandiInjectionError(token, this.context, `${err.message} while injecting`, err) } - throw new DandiInjectionError(token, this.context, `${err.message} while injecting`, err) + throw err } } @@ -112,8 +118,13 @@ export class DandiInjector implements Injector, Disposable { methodName: InstanceInvokableFn, ...providers: Registerable[] ): Promise { - const invokeInjectionScope: InvokeInjectionScope = { instance, methodName: methodName.toString() } - return await Disposable.useAsync(this.createChild(invokeInjectionScope, providers), async (injector) => { + const methodMeta = getInjectableMetadata(instance[methodName.toString()]) + const invokeInjectionScope: InvokeInjectionScope = methodMeta.scopeFn ? + methodMeta.scopeFn(instance, methodName) : + { instance, methodName: methodName.toString() } + const methodProviders = this.getMethodProviders(instance, methodName.toString()) + const scopeProviders = providers.concat(methodProviders) + return await Disposable.useAsync(this.createChild(invokeInjectionScope, scopeProviders), async (injector) => { return await injector.invokeInternal(instance, methodName) }) } @@ -157,6 +168,30 @@ export class DandiInjector implements Injector, Disposable { return await method.apply(instance, invokeTargetArgs) } + private getMethodProviders(instance: any, methodName: string): Provider[] { + const method = instance[methodName] + const meta = getInjectableMetadata(method) + const methodProviders = meta.params.reduce((result, paramMeta) => { + if (paramMeta.methodProviders) { + paramMeta.methodProviders.forEach(provider => { + const existing = result.get(provider.provide) + if (existing) { + // FIXME: allow multi providers/tokens + const [originalParam] = existing + throw new MetadataProviderError( + `Parameter ${paramMeta.name} of ${instance.constructor.name}.${methodName} is attempting to ` + + `register a provider for token ${provider.provide}, but it is already registered by param ` + + `${originalParam.name}`, + ) + } + result.set(provider.provide, [paramMeta, provider]) + }) + } + return result + }, new Map, [ParamMetadata, Provider]>()) + return [...methodProviders.values()].map(([, provider]) => provider) + } + private async initGeneratorFactory(generatorFactory: InstanceGeneratorFactory): Promise { this.generator = await (typeof generatorFactory === 'function' ? generatorFactory() : generatorFactory) } diff --git a/packages/dandi/core/internal/util/src/injectable-metadata.ts b/packages/dandi/core/internal/util/src/injectable-metadata.ts index 5dabf42e..340fc66e 100644 --- a/packages/dandi/core/internal/util/src/injectable-metadata.ts +++ b/packages/dandi/core/internal/util/src/injectable-metadata.ts @@ -1,5 +1,5 @@ import { Constructor, MetadataAccessor, MethodTarget, getMetadata } from '@dandi/common' -import { InjectionToken, Provider } from '@dandi/core/types' +import { InjectionToken, InvokeInjectionScope, Provider } from '@dandi/core/types' import { globalSymbol } from '../../../src/global-symbol' @@ -11,16 +11,20 @@ export function methodTarget(target: Constructor): MethodTarget { return target.prototype as MethodTarget } +export type CreateScopeFn = (instance: any, methodName, ...context: any[]) => InvokeInjectionScope + export interface ParamMetadata { name: string token?: InjectionToken providers?: Provider[] + methodProviders?: Provider[] optional?: boolean } export interface InjectableMetadata { paramNames?: string[] - params: Array> + params: ParamMetadata[] + scopeFn?: CreateScopeFn } export const getInjectableMetadata: MetadataAccessor = getMetadata.bind(null, META_KEY, () => ({ diff --git a/packages/dandi/core/internal/util/src/injection-scope-util.ts b/packages/dandi/core/internal/util/src/injection-scope-util.ts index 52ce0adc..dd55c321 100644 --- a/packages/dandi/core/internal/util/src/injection-scope-util.ts +++ b/packages/dandi/core/internal/util/src/injection-scope-util.ts @@ -95,6 +95,10 @@ export function scopesAreCompatible(test: InjectionScope, restriction: Injection return test.target === restriction } + if (isFactoryParamInjectionScope(test)) { + // return test.target.provide + } + if (aType !== bType) { return false } diff --git a/packages/dandi/core/testing/index.ts b/packages/dandi/core/testing/index.ts index b816e5d6..04c81f2a 100644 --- a/packages/dandi/core/testing/index.ts +++ b/packages/dandi/core/testing/index.ts @@ -1,4 +1,5 @@ export * from './src/create-stub-object' +export * from './src/get-provider-value' export * from './src/logger-fixture' export * from './src/sandbox' export * from './src/stub-provider' diff --git a/packages/dandi/core/testing/src/get-provider-value.ts b/packages/dandi/core/testing/src/get-provider-value.ts new file mode 100644 index 00000000..0b9a572e --- /dev/null +++ b/packages/dandi/core/testing/src/get-provider-value.ts @@ -0,0 +1,17 @@ +import { AsyncFactoryProvider, Provider, ProviderTypeError } from '@dandi/core' +import { isClassProvider, isFactoryProvider, isValueProvider } from '@dandi/core/internal/util' + +export function getProviderValue(provider: AsyncFactoryProvider, ...args: any[]): Promise +export function getProviderValue(provider: Provider, ...args: any[]): TProvide +export function getProviderValue(provider: Provider, ...args: any[]): TProvide | Promise { + if (isValueProvider(provider)) { + return provider.useValue + } + if (isFactoryProvider(provider)) { + return provider.useFactory(...args) + } + if (isClassProvider(provider)) { + return new provider.useClass(...args) + } + throw new ProviderTypeError(provider) +} diff --git a/packages/dandi/core/types/src/injection-scope.ts b/packages/dandi/core/types/src/injection-scope.ts index a4866b46..ba2baa98 100644 --- a/packages/dandi/core/types/src/injection-scope.ts +++ b/packages/dandi/core/types/src/injection-scope.ts @@ -1,4 +1,5 @@ import { Constructor, isConstructor } from '@dandi/common' +import { isFactoryProvider, isInjectionToken } from '@dandi/core/internal/util' import { localToken } from '../../src/local-token' @@ -30,6 +31,13 @@ export interface FactoryParamInjectionScope { paramToken: InjectionToken } +/** + * @internal + */ +export function isFactoryParamInjectionScope(obj: any): obj is FactoryParamInjectionScope { + return obj && isFactoryProvider(obj.target) && isInjectionToken(obj.paramToken) +} + export class DependencyInjectionScope { public readonly value: string diff --git a/packages/dandi/core/types/src/injector.ts b/packages/dandi/core/types/src/injector.ts index 4723b254..5116c7d4 100644 --- a/packages/dandi/core/types/src/injector.ts +++ b/packages/dandi/core/types/src/injector.ts @@ -50,6 +50,7 @@ export interface Invoker { export interface TokenInjector { readonly parent: Injector + readonly app: Injector readonly context: InjectorContext inject(token: InjectionToken, optional?: boolean): Promise> diff --git a/packages/dandi/core/types/src/local-token-factory.ts b/packages/dandi/core/types/src/local-token-factory.ts index 4d2b5d0c..e230c9ab 100644 --- a/packages/dandi/core/types/src/local-token-factory.ts +++ b/packages/dandi/core/types/src/local-token-factory.ts @@ -5,14 +5,14 @@ import { SymbolToken } from './symbol-token' export interface LocalTokenFactory { symbol(target: string): InjectionToken - opinionated(target: string, options?: InjectionOptions): InjectionToken + opinionated(target: string, options?: InjectionOptions): OpinionatedToken PKG: string } export function localTokenFactory(pkg: string): LocalTokenFactory { return { symbol: (target: string): InjectionToken => SymbolToken.local(pkg, target), - opinionated(target: string, options: InjectionOptions): InjectionToken { + opinionated(target: string, options: InjectionOptions): OpinionatedToken { return OpinionatedToken.local(pkg, target, options) }, PKG: pkg, diff --git a/packages/dandi/core/types/src/opinionated-token.ts b/packages/dandi/core/types/src/opinionated-token.ts index 926cda9a..2a83861e 100644 --- a/packages/dandi/core/types/src/opinionated-token.ts +++ b/packages/dandi/core/types/src/opinionated-token.ts @@ -1,11 +1,10 @@ import { AppError } from '@dandi/common' -import { InjectionToken } from './injection-token' import { InjectionOptions, Provider } from './provider' import { SymbolTokenBase } from './symbol-token' export class OpinionatedToken extends SymbolTokenBase { - public static local(pkg: string, target: string, options: InjectionOptions): InjectionToken { + public static local(pkg: string, target: string, options: InjectionOptions): OpinionatedToken { return new OpinionatedToken(`${pkg}#${target}`, options) } diff --git a/packages/dandi/http-model/index.ts b/packages/dandi/http-model/index.ts index 0cbee2b9..0e680715 100644 --- a/packages/dandi/http-model/index.ts +++ b/packages/dandi/http-model/index.ts @@ -1,7 +1,12 @@ export * from './src/condition' export * from './src/errors' +export * from './src/handle-model-errors-decorator' +export * from './src/http-model.module' +export * from './src/http-request-model' export * from './src/path-param.decorator' export * from './src/query-param.decorator' -export * from './src/request-body.decorator' +export * from './src/request-model-decorator' export * from './src/request-header.decorator' -export * from './src/request-param.decorator' +export * from './src/request-model-errors-collector' +export * from './src/request-model-errors-decorator' +export * from './src/request-param-decorator' diff --git a/packages/dandi/http-model/package.json b/packages/dandi/http-model/package.json index 0c036a70..0bfa6ee4 100644 --- a/packages/dandi/http-model/package.json +++ b/packages/dandi/http-model/package.json @@ -4,6 +4,7 @@ "@dandi/common": "*", "@dandi/core": "*", "@dandi/http": "*", + "@dandi/model": "*", "@dandi/model-builder": "*" } } diff --git a/packages/dandi/http-model/src/errors.ts b/packages/dandi/http-model/src/errors.ts index cd906b8a..ad896f5e 100644 --- a/packages/dandi/http-model/src/errors.ts +++ b/packages/dandi/http-model/src/errors.ts @@ -1,10 +1,13 @@ +import { DandiInjectionError } from '@dandi/core' import { HttpStatusCode, RequestError } from '@dandi/http' +import { ModelErrors } from '@dandi/model-builder' export class ModelBindingError extends RequestError { - constructor(innerError: Error) { - super(HttpStatusCode.badRequest, null, innerError.message, innerError) + constructor(public readonly errors: ModelErrors, innerError?: Error) { + super(HttpStatusCode.badRequest, innerError?.message, errors.toString(), innerError) } } +DandiInjectionError.doNotWrap(ModelBindingError) export class ParamError extends RequestError { constructor(public readonly paramName: string, message: string, innerError?: Error) { diff --git a/packages/dandi/http-model/src/handle-model-errors-decorator.spec.ts b/packages/dandi/http-model/src/handle-model-errors-decorator.spec.ts new file mode 100644 index 00000000..30c0bf6f --- /dev/null +++ b/packages/dandi/http-model/src/handle-model-errors-decorator.spec.ts @@ -0,0 +1,68 @@ +import { AppError } from '@dandi/common' +import { getInjectableMetadata } from '@dandi/core/internal/util' +import { getProviderValue, stub } from '@dandi/core/testing' +import { createHttpRequestHandlerScope } from '@dandi/http' +import { HandleModelErrors, RequestModel, RequestModelErrors, RequestModelErrorsMetadata } from '@dandi/http-model' +import { Property } from '@dandi/model' + +import { expect } from 'chai' + +describe('@HandleModelErrors', () => { + + class TestModel { + @Property(String) + public foo: string + } + + class TestHandler { + @HandleModelErrors() + public testMethodValid( + @RequestModelErrors() errors: RequestModelErrors, + @RequestModel(TestModel) body: TestModel, + ): any { + return { errors, body } + } + } + + // This has to be done in a function because unlike the corresponding check in @RequestMethodErrors, the check to make + // sure that the method has a parameter with @RequestMethodErrors is done immediately when the decorator is invoked. + // If the InvalidTestHandler class were declared directly in the describe block like TestHandler, the resulting error + // would prevent the tests from executing at all. + const declareInvalidHandler = (): any => { + class InvalidTestHandler { + // invalid = using @HandleModelErrors without also using @RequestModelErrors + @HandleModelErrors() + public testMethodInvalid( + @RequestModel(TestModel) body: TestModel, + ): any { + return { body } + } + } + return InvalidTestHandler + } + + it('adds the HttpRequestHandler scope', () => { + const meta = getInjectableMetadata(TestHandler.prototype.testMethodValid) + + expect(meta.scopeFn).to.equal(createHttpRequestHandlerScope) + }) + + it('throws an error when used without @RequestModelErrors on one of the method parameters', () => { + expect(declareInvalidHandler).to.throw(AppError) + }) + + it('replaces the placeholder RequestModelErrors provider with the real implementation', () => { + const meta = getInjectableMetadata(TestHandler.prototype.testMethodValid) + const paramMeta = meta.params.find(paramMeta => paramMeta.token === RequestModelErrors) as RequestModelErrorsMetadata + const errorsProvider = paramMeta.methodProviders.find(provider => provider.provide === RequestModelErrors) + const errors = {} + const collector = { compile: stub().returns(errors) } + + // the placeholder provider would throw when invoked + const result = getProviderValue(errorsProvider, collector) + + expect(collector.compile).to.have.been.calledOnce + expect(result).to.equal(errors) + }) + +}) diff --git a/packages/dandi/http-model/src/handle-model-errors-decorator.ts b/packages/dandi/http-model/src/handle-model-errors-decorator.ts new file mode 100644 index 00000000..4c1191ee --- /dev/null +++ b/packages/dandi/http-model/src/handle-model-errors-decorator.ts @@ -0,0 +1,34 @@ +import { AppError, MethodTarget } from '@dandi/common' +import { Scope } from '@dandi/core' +import { getInjectableMetadata } from '@dandi/core/internal/util' +import { createHttpRequestHandlerScope } from '@dandi/http' + +import { RequestModelErrorsMetadata, RequestModelErrors } from './request-model-errors-decorator' + +/** + * Method decorator for request handlers that adds the {@link HttpRequestHandlerScope}, and enables usage of the + * {@link RequestModelErrors} decorator. + * + * See {@link RequestModelErrors} for more information about why this decorator is needed. + */ +export function HandleModelErrors(): MethodDecorator { + const scopeDecorator = Scope(createHttpRequestHandlerScope) + return function handleModelErrorsDecorator(target: MethodTarget, propertyKey: string): void { + scopeDecorator(target, propertyKey, undefined) + const methodMeta = getInjectableMetadata(target[propertyKey]) + const paramMeta = methodMeta.params.find(paramMeta => paramMeta.token === RequestModelErrors) as RequestModelErrorsMetadata + + if (!paramMeta) { + throw new AppError( + `${target.constructor.name}.${propertyKey} is decorated with @HandleModelErrors, ` + + 'but does not have a parameter decorated with @RequestModelErrors', + ) + } + + paramMeta.methodProviders.splice( + paramMeta.methodProviders.findIndex(provider => provider.provide === RequestModelErrors), + 1, + paramMeta.createRequestModelErrorsProvider(), + ) + } +} diff --git a/packages/dandi/http-model/src/http-model.module.ts b/packages/dandi/http-model/src/http-model.module.ts new file mode 100644 index 00000000..94184bf1 --- /dev/null +++ b/packages/dandi/http-model/src/http-model.module.ts @@ -0,0 +1,16 @@ +import { ModuleBuilder, Registerable } from '@dandi/core' + +import { localToken } from './local-token' +import { RequestModelErrorsCollector } from './request-model-errors-collector' +import { RequestParamModelBuilderOptionsProvider } from './request-param-decorator' + +export class HttpModelModuleBuilder extends ModuleBuilder { + constructor(...entries: Registerable[]) { + super(HttpModelModuleBuilder, localToken.PKG, ...entries) + } +} + +export const HttpModelModule = new HttpModelModuleBuilder( + RequestModelErrorsCollector, + RequestParamModelBuilderOptionsProvider, +) diff --git a/packages/dandi/http-model/src/http-request-model.ts b/packages/dandi/http-model/src/http-request-model.ts new file mode 100644 index 00000000..811017e0 --- /dev/null +++ b/packages/dandi/http-model/src/http-request-model.ts @@ -0,0 +1,9 @@ +import { InjectionToken } from '@dandi/core' +import { HttpRequestHandlerScope } from '@dandi/http' + +import { localToken } from './local-token' + +export const HttpRequestModel: InjectionToken = localToken.opinionated('HttpRequestBody', { + multi: false, + restrictScope: HttpRequestHandlerScope, +}) diff --git a/packages/dandi/http-model/src/local-token.ts b/packages/dandi/http-model/src/local-token.ts index 886e7b98..ab1c53ef 100644 --- a/packages/dandi/http-model/src/local-token.ts +++ b/packages/dandi/http-model/src/local-token.ts @@ -1,14 +1,3 @@ -import { InjectionOptions, InjectionToken, OpinionatedToken, SymbolToken } from '@dandi/core' +import { localTokenFactory } from '@dandi/core' -export const PKG = '@dandi/http-model' - -export function localSymbolTokenFor(target: string): InjectionToken { - return SymbolToken.forLocal(PKG, target) -} - -export function localSymbolToken(target: string): InjectionToken { - return SymbolToken.local(PKG, target) -} -export function localOpinionatedToken(target: string, options: InjectionOptions): InjectionToken { - return OpinionatedToken.local(PKG, target, options) -} +export const localToken = localTokenFactory('@dandi/http-model') diff --git a/packages/dandi/http-model/src/path-param.decorator.ts b/packages/dandi/http-model/src/path-param.decorator.ts index dcc0b4c7..a3686056 100644 --- a/packages/dandi/http-model/src/path-param.decorator.ts +++ b/packages/dandi/http-model/src/path-param.decorator.ts @@ -1,7 +1,7 @@ import { HttpRequestPathParamMap } from '@dandi/http' import { ConvertedType } from '@dandi/model-builder' -import { makeRequestParamDecorator } from './request-param.decorator' +import { makeRequestParamDecorator } from './request-param-decorator' export function PathParam(type?: ConvertedType, name?: string): any { return makeRequestParamDecorator(HttpRequestPathParamMap, type || String, name, false) diff --git a/packages/dandi/http-model/src/query-param.decorator.ts b/packages/dandi/http-model/src/query-param.decorator.ts index 68b7f1a8..c3fa5173 100644 --- a/packages/dandi/http-model/src/query-param.decorator.ts +++ b/packages/dandi/http-model/src/query-param.decorator.ts @@ -1,7 +1,7 @@ import { HttpRequestQueryParamMap } from '@dandi/http' import { ConvertedType } from '@dandi/model-builder' -import { makeRequestParamDecorator } from './request-param.decorator' +import { makeRequestParamDecorator } from './request-param-decorator' export function QueryParam(type?: ConvertedType, name?: string, required = false): ParameterDecorator { return makeRequestParamDecorator(HttpRequestQueryParamMap, type || String, name, !required) diff --git a/packages/dandi/http-model/src/request-body.decorator.spec.ts b/packages/dandi/http-model/src/request-body.decorator.spec.ts deleted file mode 100644 index 04cf5af6..00000000 --- a/packages/dandi/http-model/src/request-body.decorator.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { MethodTarget } from '@dandi/common' -import { FactoryProvider } from '@dandi/core' -import { getInjectableParamMetadata } from '@dandi/core/internal/util' -import { HttpRequestBody } from '@dandi/http' -import { ModelBindingError, RequestBody, requestBodyProvider } from '@dandi/http-model' -import { ModelBuilder } from '@dandi/model-builder' - -import { expect } from 'chai' -import { SinonStubbedInstance, stub } from 'sinon' - -describe('@RequestBody', () => { - it('sets the HttpRequestBody token for the decorated parameter', () => { - class TestModel {} - - class TestController { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public method(@RequestBody(TestModel) body: any): void {} - } - - const meta = getInjectableParamMetadata(TestController.prototype as MethodTarget, 'method', 0) - - expect(meta).to.exist - expect(meta.token).to.equal(HttpRequestBody) - }) - - it('adds a request body provider for the decorated parameter', () => { - class TestModel {} - - class TestController { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public method(@RequestBody(TestModel) body: any): void {} - } - - const meta: RequestBody = getInjectableParamMetadata( - TestController.prototype as MethodTarget, - 'method', - 0, - ) - - expect(meta.providers).to.exist - expect(meta.providers[0].provide).to.equal(HttpRequestBody) - }) -}) - -describe('requestBodyProvider', () => { - class Foo {} - - let provider: FactoryProvider - let validator: SinonStubbedInstance - - beforeEach(() => { - provider = requestBodyProvider(Foo) as FactoryProvider - validator = { - constructMember: stub(), - constructModel: stub(), - } - }) - afterEach(() => { - provider = undefined - validator = undefined - }) - - it('returns undefined if the request has no body', () => { - const req: any = {} - - const result = provider.useFactory(req, validator) - - expect(result).to.be.undefined - }) - - it('validates the body if it exists', () => { - const req: any = { body: {} } - validator.constructModel.returns(req.body) - - expect(provider.useFactory(req, validator)).to.equal(req.body) - }) - - it('throws a ModelBindingError if model validation fails', () => { - const req: any = { body: {} } - validator.constructModel.throws(new Error('Your llama is lloose!')) - - expect(() => provider.useFactory(req, validator)).to.throw(ModelBindingError) - }) -}) diff --git a/packages/dandi/http-model/src/request-body.decorator.ts b/packages/dandi/http-model/src/request-body.decorator.ts deleted file mode 100644 index 7be71309..00000000 --- a/packages/dandi/http-model/src/request-body.decorator.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Constructor, MethodTarget } from '@dandi/common' -import { Provider } from '@dandi/core' -import { getInjectableParamMetadata, ParamMetadata } from '@dandi/core/internal/util' -import { HttpRequestBody, HttpRequestBodySource } from '@dandi/http' -import { ModelBuilder, ModelBuilderOptions } from '@dandi/model-builder' - -import { ModelBindingError } from './errors' -import { RequestParamModelBuilderOptions, RequestParamModelBuilderOptionsProvider } from './request-param.decorator' - -export interface RequestBody extends ParamMetadata { - model: Constructor -} - -export function requestBodyProvider(model: Constructor): Provider { - return { - provide: HttpRequestBody, - useFactory: (source: object, builder: ModelBuilder, options: ModelBuilderOptions) => { - if (!source) { - return undefined - } - if (!model) { - return source - } - try { - return builder.constructModel(model, source, options) - } catch (err) { - throw new ModelBindingError(err) - } - }, - deps: [HttpRequestBodySource, ModelBuilder, RequestParamModelBuilderOptions], - providers: [RequestParamModelBuilderOptionsProvider], - } -} - -export function requestBodyDecorator( - requestBody: RequestBody, - target: MethodTarget, - propertyName: string, - paramIndex: number, -): void { - const meta = getInjectableParamMetadata>(target, propertyName, paramIndex) - meta.token = HttpRequestBody - meta.providers = [requestBodyProvider(requestBody.model)] -} - -export function RequestBody(model?: Constructor): ParameterDecorator { - return requestBodyDecorator.bind(null, { model }) -} diff --git a/packages/dandi/mvc/src/request-decorator.int-spec.ts b/packages/dandi/http-model/src/request-decorators.int-spec.ts similarity index 53% rename from packages/dandi/mvc/src/request-decorator.int-spec.ts rename to packages/dandi/http-model/src/request-decorators.int-spec.ts index 9cf0b632..26436481 100644 --- a/packages/dandi/mvc/src/request-decorator.int-spec.ts +++ b/packages/dandi/http-model/src/request-decorators.int-spec.ts @@ -1,5 +1,5 @@ import { AppError, Url } from '@dandi/common' -import { testHarness, TestInjector } from '@dandi/core/testing' +import { spy, stub, testHarness, TestInjector } from '@dandi/core/testing' import { createHttpRequestScope, DandiHttpRequestHeadersAccessor, @@ -7,16 +7,24 @@ import { HttpRequest, HttpRequestPathParamMap, HttpRequestQueryParamMap, - HttpRequestRawBodyProvider, HttpRequestScope, + HttpRequestBody, } from '@dandi/http' -import { MissingParamError, PathParam, QueryParam, RequestBody } from '@dandi/http-model' -import { BodyParserInfo, HttpBodyParser, HttpRequestBodySourceProvider } from '@dandi/http-pipeline' -import { PassThroughBodyParser } from '@dandi/http-pipeline/testing' +import { + HandleModelErrors, + HttpModelModule, + MissingParamError, + PathParam, + QueryParam, + RequestModel, + RequestModelErrors, + RequestModelErrorsCollector, +} from '@dandi/http-model' import { Required, UrlProperty } from '@dandi/model' import { ModelBuilderModule } from '@dandi/model-builder' import { expect } from 'chai' +import { SinonSpy } from 'sinon' describe('Request Decorators', () => { class TestModel { @@ -25,8 +33,9 @@ describe('Request Decorators', () => { public url: Url } class TestController { - public testBody(@RequestBody(TestModel) body: TestModel): TestModel { - return body + + public testModel(@RequestModel(TestModel) model: TestModel): TestModel { + return model } public testPathParam(@PathParam(String) id: string): string { @@ -36,61 +45,83 @@ describe('Request Decorators', () => { public testQueryParam(@QueryParam(String) search?: string): string { return search } + + @HandleModelErrors() + public testModelErrors( + @RequestModelErrors() errors: RequestModelErrors, + @RequestModel(TestModel) body: TestModel, + ): any { + testModelErrors(errors, body) + return { errors, body } + } } - const harness = testHarness(ModelBuilderModule, + const harness = testHarness( + HttpModule, + HttpModelModule, + ModelBuilderModule, + DandiHttpRequestHeadersAccessor, { provide: HttpRequest, - useFactory: () => ({ - body: { - url: 'http://localhost', - }, - }), + useFactory: () => req, }, - HttpRequestRawBodyProvider, - HttpRequestBodySourceProvider, { - provide: HttpBodyParser, - useClass: PassThroughBodyParser, + provide: HttpRequestBody, + useFactory: () => req.body, }, - DandiHttpRequestHeadersAccessor, { provide: HttpRequestPathParamMap, - useFactory: () => ({}), + useFactory: () => pathMap, }, { provide: HttpRequestQueryParamMap, - useFactory: () => ({}), + useFactory: () => queryMap, }, { - provide: BodyParserInfo, - useValue: [], + provide: RequestModelErrorsCollector, + useFactory: () => errorsCollector, }, ) + let req: HttpRequest + let pathMap: any + let queryMap: any let controller: TestController let scope: HttpRequestScope let requestInjector: TestInjector + let errorsCollector: RequestModelErrorsCollector + let testModelErrors: SinonSpy beforeEach(() => { + req = { + body: { + url: 'http://localhost', + }, + get: stub() as any, + } as HttpRequest + pathMap = {} + queryMap = {} controller = new TestController() - scope = createHttpRequestScope({} as any) + scope = createHttpRequestScope({} as any, 'test') requestInjector = harness.createChild(scope) + errorsCollector = new RequestModelErrorsCollector() + testModelErrors = spy() }) afterEach(() => { + req = undefined + pathMap = undefined + queryMap = undefined controller = undefined scope = undefined requestInjector = undefined + errorsCollector = undefined + testModelErrors = undefined }) - describe('@RequestBody', () => { - - beforeEach(() => { - harness.register(HttpModule) - }) + describe('@RequestModel', () => { - it('constructs and validates the body', async () => { - const result: TestModel = await requestInjector.invoke(controller, 'testBody') + it('constructs and validates the model', async () => { + const result: TestModel = await requestInjector.invoke(controller, 'testModel') expect(result).to.be.instanceof(TestModel) expect(result.url).to.be.instanceof(Url) }) @@ -126,4 +157,21 @@ describe('Request Decorators', () => { }) }) + + describe('@RequestModelErrors', () => { + + it('injects undefined if there are no errors', async () => { + await requestInjector.invoke(controller, 'testModelErrors') + + expect(testModelErrors).to.have.been.calledOnceWith(undefined) + }) + + it('injects errors if there are errors', async () => { + delete req.body.url + await requestInjector.invoke(controller, 'testModelErrors') + + expect(testModelErrors).to.have.been.calledOnceWith({ body: { url: { required: true } } }) + }) + + }) }) diff --git a/packages/dandi/http-model/src/request-header.decorator.spec.ts b/packages/dandi/http-model/src/request-header.decorator.spec.ts index c2d25ff8..0bc873b4 100644 --- a/packages/dandi/http-model/src/request-header.decorator.spec.ts +++ b/packages/dandi/http-model/src/request-header.decorator.spec.ts @@ -2,14 +2,13 @@ import { OpinionatedToken } from '@dandi/core' import { getInjectableParamMetadata, methodTarget, ParamMetadata } from '@dandi/core/internal/util' import { testHarness } from '@dandi/core/testing' import { - createHttpRequestScope, HttpHeader, - HttpRequest, HttpRequestHeadersAccessor, HttpRequestHeadersHashAccessor, MimeType, requestHeaderToken, } from '@dandi/http' +import { createTestHttpRequestScope } from '@dandi/http/testing' import { expect } from 'chai' @@ -47,7 +46,7 @@ describe('@RequestHeader', () => { [HttpHeader.contentType]: MimeType.applicationJson, }), }) - const injector = harness.createChild(createHttpRequestScope({} as HttpRequest)) + const injector = harness.createChild(createTestHttpRequestScope()) expect(await injector.inject(requestHeaderToken(HttpHeader.contentType))).to.deep.equal({ contentType: MimeType.applicationJson }) diff --git a/packages/dandi/http-model/src/request-model-decorator.spec.ts b/packages/dandi/http-model/src/request-model-decorator.spec.ts new file mode 100644 index 00000000..ab324529 --- /dev/null +++ b/packages/dandi/http-model/src/request-model-decorator.spec.ts @@ -0,0 +1,147 @@ +import { MethodTarget } from '@dandi/common' +import { Injector, Provider } from '@dandi/core' +import { getInjectableParamMetadata } from '@dandi/core/internal/util' +import { getProviderValue, spy, stub } from '@dandi/core/testing' +import { + HttpRequestModel, + HttpRequestModelModelErrorsProvider, + HttpRequestModelProvider, + ModelBindingError, + RequestModel, + requestModelBuilderResultProvider, + RequestModelErrorsCollector, +} from '@dandi/http-model' +import { ModelBuilder, ModelBuilderNoThrowOnErrorOptions, ModelBuilderResult } from '@dandi/model-builder' + +import { expect } from 'chai' +import { SinonStubbedInstance } from 'sinon' + +describe('@RequestBody', () => { + it('sets the HttpRequestModel token for the decorated parameter', () => { + class TestModel {} + + class TestController { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public method(@RequestModel(TestModel) body: any): void {} + } + + const meta = getInjectableParamMetadata(TestController.prototype as MethodTarget, 'method', 0) + + expect(meta).to.exist + expect(meta.token).to.equal(HttpRequestModel) + }) + + it('adds a request body provider for the decorated parameter', () => { + class TestModel {} + + class TestController { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public method(@RequestModel(TestModel) body: any): void {} + } + + const meta: RequestModel = getInjectableParamMetadata( + TestController.prototype as MethodTarget, + 'method', + 0, + ) + + expect(meta.methodProviders, 'No meta methodProviders specified').to.exist + expect(meta.methodProviders.some(provider => provider.provide === HttpRequestModel)).to.exist + }) +}) + +describe('HttpRequestModelModelErrorsProvider', () => { + it('returns the errors property from a ModelBuilder object', () => { + const builderResult = { errors: {} } + expect(getProviderValue(HttpRequestModelModelErrorsProvider, builderResult)).to.equal(builderResult.errors) + }) +}) + +describe('HttpRequestModelProvider', () => { + let injector: SinonStubbedInstance + const getProviderResult = (builderResult: Partial>): any => { + return getProviderValue(HttpRequestModelProvider, injector, builderResult) + } + + beforeEach(() => { + injector = { + canResolve: stub(), + } as SinonStubbedInstance + }) + + it('returns undefined if there is no builderResult', () => { + expect(getProviderResult(undefined)).to.equal(undefined) + expect(injector.canResolve).not.to.have.been.called + }) + + it('returns the builderValue property without calling canResolve if there are no errors', () => { + const builderResult = { builderValue: {} } + expect(getProviderResult(builderResult)).to.equal(builderResult.builderValue) + expect(injector.canResolve).not.to.have.been.called + }) + + it('returns the builderValue property if there are errors, but a RequestModelErrors provider is present', () => { + const builderResult = { builderValue: {}, errors: {} } + injector.canResolve.returns(true) + expect(getProviderResult(builderResult)).to.equal(builderResult.builderValue) + }) + + it('throws a ModelBindingError if there are errors and not RequestModelErrors provider', () => { + const builderResult = { builderValue: {}, errors: {} } + expect(() => getProviderResult(builderResult)).to.throw(ModelBindingError) + }) +}) + +describe('requestBodyBuilderResultProvider', () => { + + class TestModel {} + + let provider: Provider> + let source: any + let builder: SinonStubbedInstance + let options: ModelBuilderNoThrowOnErrorOptions + let collector: RequestModelErrorsCollector + let builderResult: ModelBuilderResult + + const getProviderResult = (): any => getProviderValue(provider, source, builder, options, collector) + + beforeEach(() => { + provider = requestModelBuilderResultProvider(TestModel) + source = {} + builder = { + constructMember: stub(), + constructModel: stub().callsFake(() => builderResult) as any, + } + options = { throwOnError: false } + collector = new RequestModelErrorsCollector() + spy(collector, 'addBodyErrors') + builderResult = { source, builderValue: new TestModel(), errors: undefined } + }) + + afterEach(() => { + provider = undefined + source = undefined + builder = undefined + options = undefined + collector = undefined + builderResult = undefined + }) + + it('attempts to build the model', () => { + getProviderResult() + + expect(builder.constructModel).to.have.been.calledOnceWithExactly(TestModel, source, options) + }) + + it('adds any model errors to the collector', () => { + builderResult.errors = {} + getProviderResult() + + expect(collector.addBodyErrors).to.have.been.calledOnceWithExactly(builderResult.errors) + }) + + it('returns the result from constructModel', () => { + expect(getProviderResult()).to.equal(builderResult) + }) + +}) diff --git a/packages/dandi/http-model/src/request-model-decorator.ts b/packages/dandi/http-model/src/request-model-decorator.ts new file mode 100644 index 00000000..7af0062e --- /dev/null +++ b/packages/dandi/http-model/src/request-model-decorator.ts @@ -0,0 +1,114 @@ +import { Constructor, MethodTarget } from '@dandi/common' +import { InjectionToken, Injector, Provider, Scope } from '@dandi/core' +import { getInjectableParamMetadata, ParamMetadata } from '@dandi/core/internal/util' +import { createHttpRequestHandlerScope, HttpRequestBody, HttpRequestHandlerScope } from '@dandi/http' +import { ModelBuilder, ModelBuilderResult, ModelErrors, ModelBuilderNoThrowOnErrorOptions } from '@dandi/model-builder' + +import { ModelBindingError } from './errors' +import { HttpRequestModel } from './http-request-model' +import { localToken } from './local-token' +import { RequestModelErrorsCollector } from './request-model-errors-collector' +import { RequestModelErrors } from './request-model-errors-decorator' +import { RequestParamModelBuilderOptions } from './request-param-decorator' + +export interface RequestModel extends ParamMetadata { + model: Constructor +} + +export const HttpRequestModelBuilderResult: InjectionToken> = + localToken.opinionated('HttpRequestModelBuilderResult', { + multi: false, + restrictScope: HttpRequestHandlerScope, + }) + +export const HttpRequestModelErrors: InjectionToken = + localToken.opinionated('HttpRequestModelErrors', { + multi: false, + restrictScope: HttpRequestHandlerScope, + }) + +function httpRequestModelErrorsFactory(builderResult: ModelBuilderResult): ModelErrors { + return builderResult.errors +} + +export const HttpRequestModelModelErrorsProvider: Provider = { + provide: HttpRequestModelErrors, + useFactory: httpRequestModelErrorsFactory, + deps: [HttpRequestModelBuilderResult], +} + +function httpRequestModelFactory(injector: Injector, result: ModelBuilderResult): any { + + /** + * Using the {@link HandleModelErrors} decorator adds the {@link RequestModelErrors} to the injection context. + * If it is present, then we can be sure that the method looking for {@link HttpRequestModel} is also handling its own + * model errors, and an error should not be thrown from here. + */ + if (result?.errors && !injector.canResolve(RequestModelErrors)) { + throw new ModelBindingError(result.errors) + } + return result?.builderValue +} + +export const HttpRequestModelProvider: Provider = { + provide: HttpRequestModel, + useFactory: httpRequestModelFactory, + deps: [Injector, HttpRequestModelBuilderResult], +} + +/** + * @internal + */ +export function requestModelBuilderResultProvider(model: Constructor): Provider> { + return { + provide: HttpRequestModelBuilderResult, + useFactory: ( + source: object, + builder: ModelBuilder, + options: ModelBuilderNoThrowOnErrorOptions, + errorCollector: RequestModelErrorsCollector, + ) => { + const result: ModelBuilderResult = builder.constructModel(model, source, options) + if (result.errors) { + errorCollector.addBodyErrors(result.errors) + } + return result + }, + deps: [ + HttpRequestBody, + ModelBuilder, + RequestParamModelBuilderOptions, + RequestModelErrorsCollector, + ], + } +} + +/** + * @internal + */ +export function requestModelProviders(method: Function, model: Constructor): Provider[] { + return [ + requestModelBuilderResultProvider(model), + HttpRequestModelModelErrorsProvider, + HttpRequestModelProvider, + ] +} + +/** + * @internal + */ +export function requestModelDecorator( + requestModel: RequestModel, + target: MethodTarget, + propertyName: string, + paramIndex: number, +): void { + Scope(createHttpRequestHandlerScope)(target, propertyName, undefined) + const paramMeta = getInjectableParamMetadata>(target, propertyName, paramIndex) + paramMeta.token = HttpRequestModel + paramMeta.methodProviders = requestModelProviders(target[propertyName], requestModel.model) +} + +export function RequestModel(model?: Constructor): ParameterDecorator { + return requestModelDecorator.bind(null, { model }) +} diff --git a/packages/dandi/http-model/src/request-model-errors-collector.ts b/packages/dandi/http-model/src/request-model-errors-collector.ts new file mode 100644 index 00000000..4f1c3371 --- /dev/null +++ b/packages/dandi/http-model/src/request-model-errors-collector.ts @@ -0,0 +1,53 @@ +import { Injectable, RestrictScope } from '@dandi/core' +import { HttpRequestScope } from '@dandi/http' +import { ModelErrors } from '@dandi/model-builder' + +import { RequestModelErrors } from './request-model-errors-decorator' + +/** + * @internal + */ +@Injectable(RestrictScope(HttpRequestScope)) +export class RequestModelErrorsCollector { + + private readonly params = new Map() + private body: ModelErrors + + public get hasParamErrors(): boolean { + return !!this.params.size + } + + public get hasBodyErrors(): boolean { + return !!this.body + } + + public get hasErrors(): boolean { + return this.hasBodyErrors || this.hasParamErrors + } + + public addParamsErrors(paramName: string, modelErrors: ModelErrors): void { + this.params.set(paramName, modelErrors) + } + + public addBodyErrors(modelErrors: ModelErrors): void { + this.body = modelErrors + } + + public compile(): RequestModelErrors { + if (!this.hasErrors) { + return undefined + } + const result: RequestModelErrors = {} + if (this.hasBodyErrors) { + result.body = this.body + } + if (this.hasParamErrors) { + result.params = [...this.params.entries()].reduce((params, [paramName, errors]) => { + params[paramName] = errors + return params + }, {}) + } + return result + } + +} diff --git a/packages/dandi/http-model/src/request-model-errors-decorator.spec.ts b/packages/dandi/http-model/src/request-model-errors-decorator.spec.ts new file mode 100644 index 00000000..3860a67d --- /dev/null +++ b/packages/dandi/http-model/src/request-model-errors-decorator.spec.ts @@ -0,0 +1,138 @@ +import { AppError } from '@dandi/common' +import { FactoryProvider } from '@dandi/core' +import { getInjectableMetadata, InjectableMetadata } from '@dandi/core/internal/util' +import { createStubInstance, getProviderValue } from '@dandi/core/testing' +import { + HandleModelErrors, + QueryParam, + RequestModel, + RequestModelErrors, + RequestModelErrorsMetadata, +} from '@dandi/http-model' +import { Property } from '@dandi/model' + +import { expect } from 'chai' +import { SinonStubbedInstance } from 'sinon' + +import { RequestModelErrorsCollector } from './request-model-errors-collector' + +describe('@RequestModelErrors', () => { + + class TestModel { + @Property(String) + public foo: string + } + + class TestHandler { + @HandleModelErrors() + public testMethodValid( + @QueryParam(String) something: string, + @RequestModelErrors() errors: RequestModelErrors, + @RequestModel(TestModel) body: TestModel, + ): any { + return { something, errors, body } + } + + // invalid = using @RequestModelErrors without also using @HandleModelErrors + public testMethodInvalid( + @RequestModelErrors() errors: RequestModelErrors, + @RequestModel(TestModel) body: TestModel, + ): any { + return { errors, body } + } + } + + let collector: SinonStubbedInstance + + let validMeta: InjectableMetadata + let invalidMeta: InjectableMetadata + + let validErrorsParamMeta: RequestModelErrorsMetadata + let invalidErrorsParamMeta: RequestModelErrorsMetadata + + beforeEach(() => { + collector = createStubInstance(RequestModelErrorsCollector) + + validMeta = getInjectableMetadata(TestHandler.prototype.testMethodValid) + invalidMeta = getInjectableMetadata(TestHandler.prototype.testMethodInvalid) + + validErrorsParamMeta = validMeta.params + .find(param => param.token === RequestModelErrors) as RequestModelErrorsMetadata + invalidErrorsParamMeta = invalidMeta.params + .find(param => param.token === RequestModelErrors) as RequestModelErrorsMetadata + }) + afterEach(() => { + collector = undefined + + validMeta = undefined + invalidMeta = undefined + + validErrorsParamMeta = undefined + invalidErrorsParamMeta = undefined + }) + + describe('sanity check', () => { + it('TestHandler.testValidMethod has parameters before and after the RequestModelErrors param', () => { + const errorsParamIndex = validMeta.params.indexOf(validErrorsParamMeta) + + const beforeMsg = 'TestHandler.testValidMethod has no parameters before the RequestModelErrors param' + expect(errorsParamIndex, beforeMsg).to.be.greaterThan(0) + + const afterMsg = 'TestHandler.testValidMethod has no parameters after the RequestModelErrors param' + expect(errorsParamIndex, afterMsg).to.be.lessThan(validMeta.params.length - 1) + }) + }) + + it('sets RequestModelErrors as the injection token for the parameter', () => { + expect(validErrorsParamMeta).to.exist + }) + + it('adds a factory method to create a RequestModelErrors provider', () => { + expect(validErrorsParamMeta.createRequestModelErrorsProvider).to.exist + expect(validErrorsParamMeta.createRequestModelErrorsProvider).to.be.a('function') + }) + + it('adds a placeholder RequestModelErrors provider as a methodProviders', () => { + expect(validErrorsParamMeta.methodProviders).to.exist + expect(invalidErrorsParamMeta.methodProviders).to.exist + }) + + it('throws an error when invoking the placeholder RequestModelErrors provider', () => { + // this happens when using @RequestModelErrors on a param without also using @HandleModelErrors on the method + const errorsProvider = + invalidErrorsParamMeta.methodProviders.find(provider => provider.provide === RequestModelErrors) + expect(errorsProvider).to.exist + expect(() => getProviderValue(errorsProvider, collector)).to.throw(AppError) + }) + + describe('requestModelErrorsProvider', () => { + + let errorsProvider: FactoryProvider + + beforeEach(() => { + errorsProvider = validErrorsParamMeta.methodProviders + .find(provider => provider.provide === RequestModelErrors) as FactoryProvider + }) + afterEach(() => { + errorsProvider = undefined + }) + + it('includes the injectable tokens of its sibling parameters as provider dependencies', () => { + // ensures that any other providers that contribute to RequestModelErrors have been executed first + expect(errorsProvider.deps).to.exist + expect(errorsProvider.deps).to.include(RequestModelErrorsCollector) + expect(errorsProvider.deps).not.to.include(RequestModelErrors) + const siblingParams = validMeta.params.filter(param => param.name !== 'errors') + expect(errorsProvider.deps).to.include.members(siblingParams.map(siblingParams => siblingParams.token)) + }) + + it('returns the result of RequestModelErrorsCollector.compile()', () => { + const compileResult = {} + collector.compile.returns(compileResult) + + expect(getProviderValue(errorsProvider, collector)).to.equal(compileResult) + }) + + }) + +}) diff --git a/packages/dandi/http-model/src/request-model-errors-decorator.ts b/packages/dandi/http-model/src/request-model-errors-decorator.ts new file mode 100644 index 00000000..f5c41d79 --- /dev/null +++ b/packages/dandi/http-model/src/request-model-errors-decorator.ts @@ -0,0 +1,98 @@ +import { AppError, MethodTarget } from '@dandi/common' +import { OpinionatedToken, Provider } from '@dandi/core' +import { getInjectableMetadata, getInjectableParamMetadata, ParamMetadata } from '@dandi/core/internal/util' +import { HttpRequestHandlerScope } from '@dandi/http' +import { ModelErrors } from '@dandi/model-builder' + +import { localToken } from './local-token' +import { RequestModelErrorsCollector } from './request-model-errors-collector' + +export interface RequestModelErrors { + params?: { [paramName: string]: ModelErrors } + body?: ModelErrors +} + +/** + * @internal + */ +export interface RequestModelErrorsMetadata extends ParamMetadata { + createRequestModelErrorsProvider(): Provider +} + +function requestModelErrorsFactory(collector: RequestModelErrorsCollector): RequestModelErrors { + return collector.compile() +} + +export interface RequestModelErrorsDecorator extends OpinionatedToken { + (): ParameterDecorator +} + +const token: OpinionatedToken = + localToken.opinionated('RequestModelErrors', { + multi: false, + restrictScope: HttpRequestHandlerScope, + }) + +const RequestModelErrorsDecoratorToken: RequestModelErrorsDecorator = Object.assign( + // squash the decorator function and token together so that the exported RequestModelErrors can be used as both the + // decorator and the injection token + function RequestModelErrors(): ParameterDecorator { + return function requestModelErrorsDecorator( + target: MethodTarget, + propertyName: string, + paramIndex: number, + ): void { + const paramMeta = getInjectableParamMetadata(target, propertyName, paramIndex) + paramMeta.token = RequestModelErrorsDecoratorToken + + /** + * This logic is put into a method to ensure that it can be executed after all other params have created their + * own param metadata - see {@link RequestModelErrors} for more info. + */ + paramMeta.createRequestModelErrorsProvider = () => requestModelErrorsProvider(target[propertyName]) + // this is replaced by the @HandleModelErrors() decorator + paramMeta.methodProviders = [{ + provide: RequestModelErrorsDecoratorToken, + useFactory: function requestModelErrorsFactory(): never { + throw new AppError( + '@RequestModelErrors() was used without also adding @HandleModelErrors() to its method', + ) + }, + }] + } + }, + token, +) + +function requestModelErrorsProvider(method): Provider { + const methodMeta = getInjectableMetadata(method) + // use the method's param injection targets (expect for RequestModelErrors) as dependencies to ensure all + // ModelBuilder dependencies have executed and added any errors to the collector before compiling the final errors + // object from the collector + const deps = methodMeta.params + .filter(paramMeta => paramMeta.token !== RequestModelErrorsDecoratorToken) + .map(paramMeta => paramMeta.token) + return { + provide: RequestModelErrorsDecoratorToken, + useFactory: requestModelErrorsFactory, + deps: [RequestModelErrorsCollector, ...deps], + } +} + +/** + * A {@link ParameterDecorator} that injects a {@link RequestModelErrors} containing a summary of all errors encountered + * when attempting to convert and/or validate body, path, query, or header request values. When using + * {@link RequestModelErrors} on a parameter, its method must also be decorated with {@link HandleModelErrors}. + * + * The reason for this has to do with how decorators are processed in JavaScript. Parameter decorators are invoked + * starting with the last parameter, and method decorators are invoked after any parameter decorators. Since + * {@link RequestModelErrors} uses metadata defined by other parameter decorators, there needs to be a way to ensure + * that the other parameters had already defined their metadata by the time {@link requestModelErrorsProvider} was + * invoked to create its provider. + * + * Using the {@link HandleModelErrors} method decorator to trigger the creations of the {@link RequestModelErrors} + * provider enables this functionality without adding any restrictions on parameter order, and has the added benefit + * of creating extra clarity in the user's code - request handler methods that manually handle their own model errors + * are called out with the extra decorator, where other, undecorated methods would throw when encountering these errors. + */ +export const RequestModelErrors: RequestModelErrorsDecorator = RequestModelErrorsDecoratorToken diff --git a/packages/dandi/http-model/src/request-param.decorator.spec.ts b/packages/dandi/http-model/src/request-param-decorator.spec.ts similarity index 77% rename from packages/dandi/http-model/src/request-param.decorator.spec.ts rename to packages/dandi/http-model/src/request-param-decorator.spec.ts index 35384d23..b5374966 100644 --- a/packages/dandi/http-model/src/request-param.decorator.spec.ts +++ b/packages/dandi/http-model/src/request-param-decorator.spec.ts @@ -36,22 +36,20 @@ describe('@RequestParam', () => { const pathMeta = getInjectableParamMetadata(methodTarget(TestController), 'method', 0) const queryMeta = getInjectableParamMetadata(methodTarget(TestController), 'method', 1) - expect(pathMeta.providers).to.exist - expect(pathMeta.providers).not.to.be.empty - expect(pathMeta.providers[0].provide).to.equal(pathMeta.token) - expect((pathMeta.providers[0] as FactoryProvider).deps).to.include.members([ + expect(pathMeta.methodProviders).to.exist + expect(pathMeta.methodProviders).not.to.be.empty + expect(pathMeta.methodProviders[0].provide).to.equal(pathMeta.token) + expect((pathMeta.methodProviders[0] as FactoryProvider).deps).to.include.members([ ModelBuilder, HttpRequestPathParamMap, ]) - expect(queryMeta.providers).to.exist - expect(queryMeta.providers).not.to.be.empty - expect(queryMeta.providers[0].provide).to.equal(queryMeta.token) - expect((queryMeta.providers[0] as FactoryProvider).deps).to.include.members([ + expect(queryMeta.methodProviders).to.exist + expect(queryMeta.methodProviders).not.to.be.empty + expect(queryMeta.methodProviders[0].provide).to.equal(queryMeta.token) + expect((queryMeta.methodProviders[0] as FactoryProvider).deps).to.include.members([ ModelBuilder, HttpRequestQueryParamMap, ]) }) - - describe('validatorFactory', () => {}) }) diff --git a/packages/dandi/http-model/src/request-param.decorator.ts b/packages/dandi/http-model/src/request-param-decorator.ts similarity index 82% rename from packages/dandi/http-model/src/request-param.decorator.ts rename to packages/dandi/http-model/src/request-param-decorator.ts index 5cecb258..de2695e5 100644 --- a/packages/dandi/http-model/src/request-param.decorator.ts +++ b/packages/dandi/http-model/src/request-param-decorator.ts @@ -1,12 +1,12 @@ import { MethodTarget, isConstructor } from '@dandi/common' -import { InjectionToken, Provider, SyncFactoryProvider } from '@dandi/core' +import { InjectionToken, Provider, Scope, SyncFactoryProvider } from '@dandi/core' import { getInjectableParamMetadata, ParamMetadata } from '@dandi/core/internal/util' -import { ParamMap } from '@dandi/http' +import { createHttpRequestHandlerScope, ParamMap } from '@dandi/http' import { MemberMetadata, getMemberMetadata } from '@dandi/model' import { ConvertedType, MetadataModelValidator, ModelBuilder, ModelBuilderOptions } from '@dandi/model-builder' import { ConditionDecorators } from './condition' -import { localSymbolTokenFor, localOpinionatedToken } from './local-token' +import { localToken } from './local-token' import { requestParamValidatorFactory } from './request-param-validator' export interface RequestParamDecorator extends ParameterDecorator, ConditionDecorators { @@ -21,10 +21,10 @@ export function requestParamToken( paramName: string, requestParamName: string, ): InjectionToken { - return localSymbolTokenFor(`${mapToken}:${paramName}:${requestParamName}`) + return localToken.symbol(`${mapToken}:${paramName}:${requestParamName}`) } -export const RequestParamModelBuilderOptions: InjectionToken = localOpinionatedToken( +export const RequestParamModelBuilderOptions: InjectionToken = localToken.opinionated( 'RequestParamModelBuilderOptions', { multi: false, @@ -35,6 +35,7 @@ export const RequestParamModelBuilderOptionsProvider: SyncFactoryProvider ({ validators: [new MetadataModelValidator()], + throwOnError: false, }), } @@ -50,7 +51,6 @@ export function requestParamProvider( provide: token, useFactory: requestParamValidatorFactory.bind(undefined, type, paramName, paramMeta, memberMetadata), deps: [mapToken, ModelBuilder, RequestParamModelBuilderOptions], - providers: [RequestParamModelBuilderOptionsProvider], } } @@ -61,10 +61,11 @@ export function makeRequestParamDecorator( optional: boolean, ): RequestParamDecorator { const apply: ParameterDecorator & RequestParamDecorator = function( - target: MethodTarget, + target: MethodTarget, memberName: string, paramIndex: number, ) { + Scope(createHttpRequestHandlerScope)(target, memberName, undefined) const meta = getInjectableParamMetadata(target, memberName, paramIndex) const memberMetadata = getMemberMetadata(target.constructor, memberName, paramIndex) const token = requestParamToken(mapToken, memberName, name || meta.name) @@ -73,7 +74,9 @@ export function makeRequestParamDecorator( } meta.token = token meta.optional = optional - meta.providers = [requestParamProvider(mapToken, token, type, name || meta.name, meta, memberMetadata)] + meta.methodProviders = [ + requestParamProvider(mapToken, token, type, name || meta.name, meta, memberMetadata), + ] return { meta, diff --git a/packages/dandi/http-model/src/request-param-validator.spec.ts b/packages/dandi/http-model/src/request-param-validator.spec.ts index 5762066f..d5167836 100644 --- a/packages/dandi/http-model/src/request-param-validator.spec.ts +++ b/packages/dandi/http-model/src/request-param-validator.spec.ts @@ -1,12 +1,11 @@ import { ParamMetadata } from '@dandi/core/internal/util' -import { testHarnessSingle } from '@dandi/core/testing' -import { HttpRequestPathParamMap, HttpRequestScope } from '@dandi/http' -import { PathParam, RequestParamModelBuilderOptionsProvider } from '@dandi/http-model' +import { createStubInstance } from '@dandi/core/testing' +import { InvalidParamError, MissingParamError } from '@dandi/http-model' import { MemberMetadata } from '@dandi/model' -import { MetadataModelBuilder, PrimitiveTypeConverter, TypeConverter } from '@dandi/model-builder' +import { MetadataModelBuilder, MetadataModelValidator, ModelBuilderOptions } from '@dandi/model-builder' import { expect } from 'chai' -import { SinonStubbedInstance, createStubInstance, stub } from 'sinon' +import { SinonStubbedInstance } from 'sinon' import { requestParamValidatorFactory } from './request-param-validator' @@ -15,6 +14,19 @@ describe('requestParamValidatorFactory', () => { let paramMeta: ParamMetadata let builder: SinonStubbedInstance let memberMetadata: MemberMetadata + let modelBuilderOptions: ModelBuilderOptions + + function invokeFactory(): any { + return requestParamValidatorFactory( + String, + 'foo', + paramMeta, + memberMetadata, + paramMap, + builder, + modelBuilderOptions, + ) + } beforeEach(() => { paramMap = { foo: 'bar' } @@ -23,57 +35,76 @@ describe('requestParamValidatorFactory', () => { memberMetadata = { type: String, } + modelBuilderOptions = { + validators: [new MetadataModelValidator()], + throwOnError: false, + } }) afterEach(() => { paramMap = undefined + paramMeta = undefined builder = undefined + memberMetadata = undefined + modelBuilderOptions = undefined }) it('calls validators with the value from the param map specified by the key', () => { - requestParamValidatorFactory( - String, - 'foo', - paramMeta, - memberMetadata, - paramMap, - builder, - RequestParamModelBuilderOptionsProvider.useFactory(), - ) + invokeFactory() expect(builder.constructMember).to.have.been.calledOnce.calledWith(memberMetadata, 'foo', 'bar') }) - it('works', async () => { - const s = stub() - const convert = stub() - class TestController { - public testMethod(@PathParam(String) foo: string): void { - s(foo) - } - } - const controller = new TestController() - - const harness = await testHarnessSingle( - MetadataModelBuilder, - PrimitiveTypeConverter, - { - provide: TypeConverter, - useValue: { - type: String, - convert, - }, - }, - { - provide: HttpRequestPathParamMap, - useValue: { - foo: 'bar', - }, - }, - ) - const requestInjector = harness.createChild(HttpRequestScope) + it('throws an error if the input value is undefined and the parameter is not optional', () => { + paramMap.foo = undefined + + expect(invokeFactory).to.throw(MissingParamError) + }) - await requestInjector.invoke(controller, 'testMethod') + it('throws an error if the input value is null and the parameter is not optional', () => { + paramMap.foo = null - expect(convert).to.have.been.calledWith('bar', { type: String }) + expect(invokeFactory).to.throw(MissingParamError) }) + + it('throws an error if the input value is an empty string and the parameter is not optional', () => { + paramMap.foo = '' + + expect(invokeFactory).to.throw(MissingParamError) + }) + + it('returns undefined if the input value is undefined and the parameter is optional', () => { + paramMap.foo = undefined + paramMeta.optional = true + + expect(invokeFactory()).to.be.undefined + }) + + it('returns undefined if the input value is null and the parameter is optional', () => { + paramMap.foo = null + paramMeta.optional = true + + expect(invokeFactory()).to.be.undefined + }) + + it('returns undefined if the input value is an empty string and the parameter is optional', () => { + paramMap.foo = '' + paramMeta.optional = true + + expect(invokeFactory()).to.be.undefined + }) + + it('returns the result of builder.constructMember if it does not throw', () => { + builder.constructMember.returns({ builderValue: paramMap.foo, source: paramMap.foo }) + + const result = invokeFactory() + + expect(result).to.equal('bar') + }) + + it('throws an InvalidParamError if builder.constructMember throws', () => { + builder.constructMember.throws(new Error('Your llama is lloose!')) + + expect(invokeFactory).to.throw(InvalidParamError) + }) + }) diff --git a/packages/dandi/http-model/src/request-param-validator.ts b/packages/dandi/http-model/src/request-param-validator.ts index 7d45e2d5..e179695e 100644 --- a/packages/dandi/http-model/src/request-param-validator.ts +++ b/packages/dandi/http-model/src/request-param-validator.ts @@ -5,6 +5,9 @@ import { MemberBuilderOptions, ModelBuilder } from '@dandi/model-builder' import { InvalidParamError, MissingParamError } from './errors' +/** + * @internal + */ export function requestParamValidatorFactory( type: any, paramName: string, @@ -15,14 +18,14 @@ export function requestParamValidatorFactory( options: MemberBuilderOptions, ): any { const value = paramMap[paramName] - if (typeof value === 'undefined') { + if (typeof value === 'undefined' || value === null || value === '') { if (paramMeta.optional) { return undefined } throw new MissingParamError(paramName) } try { - return builder.constructMember(memberMetadata, paramName, value, options) + return builder.constructMember(memberMetadata, paramName, value, options)?.builderValue } catch (err) { throw new InvalidParamError(paramName, err) } diff --git a/packages/dandi/http-pipeline/src/body-parsing/form-multipart-body-parser.spec.ts b/packages/dandi/http-pipeline/src/body-parsing/form-multipart-body-parser.spec.ts index 0a6b70da..a76ec147 100644 --- a/packages/dandi/http-pipeline/src/body-parsing/form-multipart-body-parser.spec.ts +++ b/packages/dandi/http-pipeline/src/body-parsing/form-multipart-body-parser.spec.ts @@ -1,11 +1,9 @@ import { testHarness, TestInjector, stub } from '@dandi/core/testing' import { ContentDisposition, - createHttpRequestScope, HttpHeader, - HttpHeaders, HttpRequest, - HttpRequestHeader, + HttpRequestHeader, HttpRequestHeaders, HttpRequestHeadersAccessor, HttpRequestHeadersHashAccessor, MimeType, @@ -20,6 +18,7 @@ import { NativeJsonBodyParser, PlainTextBodyParser, } from '@dandi/http-pipeline' +import { createTestHttpRequestScope } from '@dandi/http/testing' import { expect } from 'chai' @@ -68,7 +67,7 @@ describe('FormMultipartBodyParser', () => { }, ) - let headersSource: HttpHeaders + let headersSource: HttpRequestHeaders let headers: HttpRequestHeadersAccessor let req: HttpRequest let requestInjector: TestInjector @@ -83,7 +82,7 @@ describe('FormMultipartBodyParser', () => { req = { get: (name: HttpRequestHeader) => headers.get(name), } as HttpRequest - requestInjector = harness.createChild(createHttpRequestScope(req)) + requestInjector = harness.createChild(createTestHttpRequestScope()) parser = await requestInjector.inject(FormMultipartBodyParser) }) afterEach(() => { diff --git a/packages/dandi/http-pipeline/src/cors/cors-origin-whitelist-provider.spec.ts b/packages/dandi/http-pipeline/src/cors/cors-origin-whitelist-provider.spec.ts index 296fdd62..a16948eb 100644 --- a/packages/dandi/http-pipeline/src/cors/cors-origin-whitelist-provider.spec.ts +++ b/packages/dandi/http-pipeline/src/cors/cors-origin-whitelist-provider.spec.ts @@ -1,6 +1,5 @@ import { testHarness, TestInjector } from '@dandi/core/testing' import { - createHttpRequestScope, HttpHeader, HttpHeaderWildcard, HttpRequest, @@ -12,6 +11,7 @@ import { CorsOriginWhitelist, CorsOriginWhitelistProvider, } from '@dandi/http-pipeline' +import { createTestHttpRequestScope } from '@dandi/http/testing' import { expect } from 'chai' @@ -38,7 +38,7 @@ describe('CorsOriginWhitelistProvider', () => { headers = HttpRequestHeadersHashAccessor.fromRaw({ [HttpHeader.origin]: origin, }) - injector = harness.createChild(createHttpRequestScope({} as HttpRequest)) + injector = harness.createChild(createTestHttpRequestScope()) }) afterEach(() => { origin = undefined diff --git a/packages/dandi/http-pipeline/src/cors/cors-preparer.spec.ts b/packages/dandi/http-pipeline/src/cors/cors-preparer.spec.ts index ce26e183..d786cca5 100644 --- a/packages/dandi/http-pipeline/src/cors/cors-preparer.spec.ts +++ b/packages/dandi/http-pipeline/src/cors/cors-preparer.spec.ts @@ -1,11 +1,12 @@ import { testHarness, stub, TestInjector } from '@dandi/core/testing' -import { createHttpRequestScope, HttpHeader, HttpRequest } from '@dandi/http' +import { HttpHeader, HttpRequest } from '@dandi/http' import { CorsAllowRequest, CorsHeaderValues, CorsPreparer, corsRequestAllowed, } from '@dandi/http-pipeline' +import { createTestHttpRequestScope } from '@dandi/http/testing' import { expect } from 'chai' import { SinonStubbedInstance } from 'sinon' @@ -27,7 +28,7 @@ describe('CorsPreparer', () => { req = { get: stub(), } as SinonStubbedInstance - injector = harness.createChild(createHttpRequestScope(req)) + injector = harness.createChild(createTestHttpRequestScope()) preparer = await injector.inject(CorsPreparer) }) afterEach(() => { diff --git a/packages/dandi/http-pipeline/src/cors/cors-transformer.spec.ts b/packages/dandi/http-pipeline/src/cors/cors-transformer.spec.ts index 4a464b19..b48a7476 100644 --- a/packages/dandi/http-pipeline/src/cors/cors-transformer.spec.ts +++ b/packages/dandi/http-pipeline/src/cors/cors-transformer.spec.ts @@ -1,6 +1,7 @@ import { stub, testHarness, TestInjector } from '@dandi/core/testing' -import { createHttpRequestScope, HttpHeader, HttpMethod, HttpRequest, MimeType } from '@dandi/http' +import { HttpHeader, HttpMethod, HttpRequest, MimeType } from '@dandi/http' import { CorsHeaderValues, CorsTransformer, HttpPipelineResult } from '@dandi/http-pipeline' +import { createTestHttpRequestScope } from '@dandi/http/testing' import { expect } from 'chai' import { SinonStubbedInstance } from 'sinon' @@ -32,7 +33,7 @@ describe('CorsTransformer', () => { req = { get: stub(), } as SinonStubbedInstance - injector = harness.createChild(createHttpRequestScope(req)) + injector = harness.createChild(createTestHttpRequestScope()) transformer = await injector.inject(CorsTransformer) result = { headers: { diff --git a/packages/dandi/http-pipeline/src/http-pipeline.spec.ts b/packages/dandi/http-pipeline/src/http-pipeline.spec.ts index 3c854948..440a354f 100644 --- a/packages/dandi/http-pipeline/src/http-pipeline.spec.ts +++ b/packages/dandi/http-pipeline/src/http-pipeline.spec.ts @@ -11,7 +11,7 @@ import { MimeType, parseMimeTypes, } from '@dandi/http' -import { MissingParamError, PathParam } from '@dandi/http-model' +import { HttpModelModule, MissingParamError, PathParam } from '@dandi/http-model' import { CorsAllowRequest, DefaultHttpPipelineErrorHandler, @@ -39,6 +39,7 @@ import { stub, createStubInstance, SinonStubbedInstance } from 'sinon' describe('HttpPipeline', () => { const harness = stubHarness(HttpPipeline, + HttpModelModule, ModelBuilderModule, HttpRequestAcceptTypesProvider, { diff --git a/packages/dandi/http-pipeline/src/http-pipeline.ts b/packages/dandi/http-pipeline/src/http-pipeline.ts index ef295f78..9cdb7733 100644 --- a/packages/dandi/http-pipeline/src/http-pipeline.ts +++ b/packages/dandi/http-pipeline/src/http-pipeline.ts @@ -9,7 +9,13 @@ import { Optional, Provider, } from '@dandi/core' -import { HttpRequest, HttpRequestAcceptTypes, HttpStatusCode, MimeType } from '@dandi/http' +import { + HttpRequest, + HttpRequestAcceptTypes, + HttpRequestScope, + HttpStatusCode, + MimeType, +} from '@dandi/http' import { CorsAllowRequest } from './cors/cors-allow-request' import { HttpPipelineConfig } from './http-pipeline-config' @@ -31,6 +37,7 @@ export class HttpPipeline { @Inject(HttpPipelineConfig) private config: HttpPipelineConfig, ) {} + @HttpRequestScope() public async handleRequest( @Inject(Injector) injector: Injector, @Inject(HttpRequestHandler) handler: any, diff --git a/packages/dandi/http/index.ts b/packages/dandi/http/index.ts index 000d7b6a..c36fefd5 100644 --- a/packages/dandi/http/index.ts +++ b/packages/dandi/http/index.ts @@ -6,6 +6,7 @@ export * from './src/http-method' export * from './src/http-request' export * from './src/http-request-accept-types' export * from './src/http-request-body-source' +export * from './src/http-request-handler-scope' export * from './src/http-request-header-provider' export * from './src/http-request-header-util' export * from './src/http-request-headers-accessor' diff --git a/packages/dandi/http/src/http-request-handler-scope.ts b/packages/dandi/http/src/http-request-handler-scope.ts new file mode 100644 index 00000000..98bd9910 --- /dev/null +++ b/packages/dandi/http/src/http-request-handler-scope.ts @@ -0,0 +1,22 @@ +import { Uuid } from '@dandi/common' +import { CustomInjectionScope, InvokeInjectionScope } from '@dandi/core' + +const HTTP_REQUEST_HANDLER_SCOPE = '@dandi/http#HttpRequestHandler' + +export interface HttpRequestHandlerScope extends CustomInjectionScope { + description: '@dandi/http#HttpRequestHandler' +} + +export interface HttpRequestHandlerScopeInstance extends HttpRequestHandlerScope, InvokeInjectionScope { + instanceId: any +} + +export const HttpRequestHandlerScope: HttpRequestHandlerScope = { + description: HTTP_REQUEST_HANDLER_SCOPE, + type: Symbol.for(HTTP_REQUEST_HANDLER_SCOPE), +} + +export function createHttpRequestHandlerScope(instance: any, methodName: any): HttpRequestHandlerScopeInstance { + return Object.assign({ instanceId: Uuid.create(), instance, methodName }, HttpRequestHandlerScope) +} + diff --git a/packages/dandi/http/src/http-request-scope.ts b/packages/dandi/http/src/http-request-scope.ts index daec2860..9f5997e8 100644 --- a/packages/dandi/http/src/http-request-scope.ts +++ b/packages/dandi/http/src/http-request-scope.ts @@ -1,22 +1,45 @@ -import { CustomInjectionScope } from '@dandi/core' - -import { HttpRequest } from './http-request' +import { CUSTOM_INSPECTOR, Uuid } from '@dandi/common' +import { CustomInjectionScope, InvokeInjectionScope, Scope } from '@dandi/core' const HTTP_REQUEST_SCOPE = '@dandi/http#HttpRequest' -export interface HttpRequestScope extends CustomInjectionScope { +export type HttpRequestScopeDecorator = () => MethodDecorator + +export interface HttpRequestScopeDescription { description: '@dandi/http#HttpRequest' + type: symbol } -export interface HttpRequestScopeInstance extends HttpRequestScope { +export type HttpRequestScope = CustomInjectionScope & HttpRequestScopeDecorator & HttpRequestScopeDescription + +export interface HttpRequestScopeInstance extends HttpRequestScope, InvokeInjectionScope { instanceId: any } -export const HttpRequestScope: HttpRequestScope = { +const DESCRIPTION: HttpRequestScopeDescription = { description: HTTP_REQUEST_SCOPE, type: Symbol.for(HTTP_REQUEST_SCOPE), } -export function createHttpRequestScope(req: HttpRequest): HttpRequestScopeInstance { - return Object.assign({ instanceId: req }, HttpRequestScope) +export const HttpRequestScope: HttpRequestScope = Object.defineProperties( + Object.assign( + + /** + * must be an arrow fn so isConstructor returns false when being checked by {@link scopesAreCompatible} + */ + (): MethodDecorator => Scope(createHttpRequestScope), + DESCRIPTION, + ), + { + [CUSTOM_INSPECTOR]: { + value: () => DESCRIPTION.description, + }, + toString: { + value: () => DESCRIPTION.description, + }, + }, +) + +export function createHttpRequestScope(instance: any, methodName: any): HttpRequestScopeInstance { + return Object.assign({ instanceId: Uuid.create(), instance, methodName }, HttpRequestScope) } diff --git a/packages/dandi/http/src/http-request-token.ts b/packages/dandi/http/src/http-request-token.ts index ca387f91..fdf47e3d 100644 --- a/packages/dandi/http/src/http-request-token.ts +++ b/packages/dandi/http/src/http-request-token.ts @@ -6,6 +6,10 @@ import { ParamMap } from './param-map' const options = { restrictScope: HttpRequestScope } +/** + * Represents the object parsed from the raw request body data. + */ export const HttpRequestBody: InjectionToken = localOpinionatedToken('HttpRequestBody', options) + export const HttpRequestPathParamMap: InjectionToken = localOpinionatedToken('HttpRequestPathParamMap', options) export const HttpRequestQueryParamMap: InjectionToken = localOpinionatedToken('HttpRequestQueryParamMap', options) diff --git a/packages/dandi/http/testing/index.ts b/packages/dandi/http/testing/index.ts index ae7d3c00..cb72ca1b 100644 --- a/packages/dandi/http/testing/index.ts +++ b/packages/dandi/http/testing/index.ts @@ -1 +1,2 @@ +export * from './src/http-request-scope' export * from './src/http-response.fixture' diff --git a/packages/dandi/http/testing/src/http-request-scope.ts b/packages/dandi/http/testing/src/http-request-scope.ts new file mode 100644 index 00000000..efbaacd4 --- /dev/null +++ b/packages/dandi/http/testing/src/http-request-scope.ts @@ -0,0 +1,5 @@ +import { createHttpRequestScope, HttpRequestScopeInstance } from '@dandi/http' + +export function createTestHttpRequestScope(): HttpRequestScopeInstance { + return createHttpRequestScope('test', 'test') +} diff --git a/packages/dandi/model-builder/index.ts b/packages/dandi/model-builder/index.ts index c6d9df5d..7728ed02 100644 --- a/packages/dandi/model-builder/index.ts +++ b/packages/dandi/model-builder/index.ts @@ -4,11 +4,15 @@ export * from './src/date-time-type-converter' export * from './src/key-transformer' export * from './src/metadata-model-builder' export * from './src/metadata-model-validator' -export * from './src/metadata-validation-error' export * from './src/model-builder' export * from './src/model-builder.module' +export * from './src/model-builder-error' +export * from './src/model-error' +export * from './src/model-error-key' +export * from './src/model-errors' export * from './src/model-validator' export * from './src/model-validation-error' +export * from './src/model-value-conversion-error' export * from './src/nested-key-transformer' export * from './src/one-of-conversion-error' export * from './src/primitive-type-converter' diff --git a/packages/dandi/model-builder/src/metadata-model-builder.int-spec.ts b/packages/dandi/model-builder/src/metadata-model-builder.int-spec.ts index 80ad89ae..b3c84118 100644 --- a/packages/dandi/model-builder/src/metadata-model-builder.int-spec.ts +++ b/packages/dandi/model-builder/src/metadata-model-builder.int-spec.ts @@ -2,6 +2,7 @@ import { Url, Uuid } from '@dandi/common' import { testHarness } from '@dandi/core/testing' import { MemberMetadata, Property, UrlProperty } from '@dandi/model' import { ModelBuilder, ModelBuilderModule } from '@dandi/model-builder' + import { expect } from 'chai' describe('MetadataModelBuilder', () => { diff --git a/packages/dandi/model-builder/src/metadata-model-builder.spec.ts b/packages/dandi/model-builder/src/metadata-model-builder.spec.ts index 1e998a22..0ba28871 100644 --- a/packages/dandi/model-builder/src/metadata-model-builder.spec.ts +++ b/packages/dandi/model-builder/src/metadata-model-builder.spec.ts @@ -1,29 +1,32 @@ import { Uuid } from '@dandi/common' +import { createStubInstance, spy, stub } from '@dandi/core/testing' import { Json, MapOf, MemberMetadata, OneOf, Property, SourceAccessor } from '@dandi/model' import { DataTransformer, + MemberBuilderResult, MetadataModelBuilder, - ModelValidationError, - OneOfConversionError, + ModelBuilderError, + ModelError, PrimitiveTypeConverter, + TypeConversionError, } from '@dandi/model-builder' import { expect } from 'chai' import { camel } from 'change-case' -import { SinonSpy, SinonStubbedInstance, createStubInstance, spy, stub } from 'sinon' +import { SinonSpy, SinonStubbedInstance } from 'sinon' describe('MetadataModelBuilder', () => { - let primitiveTypeValidator: SinonStubbedInstance + let primitiveTypeConverter: SinonStubbedInstance let builder: MetadataModelBuilder beforeEach(() => { - primitiveTypeValidator = createStubInstance(PrimitiveTypeConverter) - primitiveTypeValidator.convert.returnsArg(0) - builder = new MetadataModelBuilder(primitiveTypeValidator as any) + primitiveTypeConverter = createStubInstance(PrimitiveTypeConverter) + primitiveTypeConverter.convert.returnsArg(0) + builder = new MetadataModelBuilder(primitiveTypeConverter as any) }) afterEach(() => { builder = undefined - primitiveTypeValidator = undefined + primitiveTypeConverter = undefined }) describe('constructModel', () => { @@ -174,9 +177,9 @@ describe('MetadataModelBuilder', () => { constructMember.restore() stub(builder as any, 'constructMemberInternal') .onFirstCall() - .returnsArg(2) + .callsFake((metadata, key, source) => [[], source]) .onSecondCall() - .returns(123) + .returns([[], 123]) const result = builder.constructModel(TestModel, value) @@ -184,32 +187,45 @@ describe('MetadataModelBuilder', () => { expect(result.prop2).to.equal(123) }) - it('catches member validation errors and throws a ModelValidationError', () => { - const value = { prop1: 'foo', prop2: '123' } + it('converts uncaught member conversion errors to ModelError', () => { + class TestModel { + @Property(String) + public prop1: string + + @Property(Number) + public prop2: number + + @Property(String) + public prop3: string + } + const value = { prop1: 'foo', prop2: '123', prop3: 'abc' } constructMember.restore() - const memberError = new Error('Your llama is lloose!') + const memberError = new ModelError('prop2', 'llama', 'Your llama is lloose!') + const otherError = new Error('You other llama is allso lloose') stub(builder as any, 'constructMemberInternal') .onFirstCall() - .returnsArg(2) + .callsFake((meta, key, source) => [[], source]) .onSecondCall() .throws(memberError) - - expect(() => builder.constructModel(TestModel, value)) - .to.throw(ModelValidationError) - .contains({ - message: 'Error validating prop2: Your llama is lloose!', - innerError: memberError, - }) + .onThirdCall() + .throws(otherError) + + const expectErrors = expect(() => builder.constructModel(TestModel, value)) + .to.throw(ModelBuilderError) + .that.has.property('errors') + .that.is.an('array') + expectErrors.includes(memberError) + expectErrors.has.nested.property('[1].innerError', otherError) }) it('uses the data transformers if specified', () => { const source = { 'prop1.value': 'foo', 'prop2.value': 'bar' } const value = { prop1: { value: 'foo' }, prop2: { value: 'bar' } } - stub(builder as any, 'constructModelInternal') + stub(builder as any, 'constructModelInternal').returns([[], undefined]) const transformer: DataTransformer = { transform: stub().returns(value), } - const options = { dataTransformers: [transformer] } + const options = { dataTransformers: [transformer], throwOnError: true } builder.constructModel(TestModel, source, options) @@ -233,7 +249,7 @@ describe('MetadataModelBuilder', () => { // eslint-disable-next-line camelcase,@typescript-eslint/camelcase const source = { foo_bar: 'yeah', hey_man: 'okay' } - const options = { keyTransform: camel } + const options = { keyTransform: camel, throwOnError: true } builder.constructModel(KeyTransformerTest, source, options) @@ -259,7 +275,7 @@ describe('MetadataModelBuilder', () => { // eslint-disable-next-line camelcase,@typescript-eslint/camelcase const source = { blob: { foo_bar: 'yeah' }, hey_man: 'okay' } - const options = { keyTransform: camel } + const options = { keyTransform: camel, throwOnError: true } builder.constructModel(KeyTransformerTest, source, options) @@ -288,7 +304,8 @@ describe('MetadataModelBuilder', () => { // eslint-disable-next-line camelcase,@typescript-eslint/camelcase const source = { map: { foo_bar: 'yeah' }, hey_man: 'okay' } - const options = { keyTransform: camel } + const options = { keyTransform: camel, throwOnError: true } + const keyOptions = { throwOnError: true } builder.constructModel(KeyTransformerTest, source, options) @@ -296,10 +313,22 @@ describe('MetadataModelBuilder', () => { .to.have.callCount(4) // eslint-disable-next-line camelcase,@typescript-eslint/camelcase .calledWithExactly({ type: Map, keyType: String, valueType: String }, 'map', { foo_bar: 'yeah' }, options) - .calledWithExactly({ type: String }, "map.(key for 'foo_bar')", 'foo_bar', {}) - .calledWithExactly({ type: String }, 'map.foo_bar', 'yeah', options) + .calledWithExactly({ type: String }, 'map.foo_bar.key', 'foo_bar', keyOptions) + .calledWithExactly({ type: String }, 'map.foo_bar.value', 'yeah', options) .calledWithExactly({ type: String }, 'heyMan', 'okay', options) }) + + it('returns a ModelBuilderResult object if throwOnError is explicitly set to false', () => { + const value = { prop1: 'foo', prop2: '123' } + + const result = builder.constructModel(TestModel, value, { throwOnError: false }) + + expect(result).to.deep.equal({ + builderValue: value, + source: value, + errors: undefined, + }) + }) }) describe('constructMember', () => { @@ -311,10 +340,10 @@ describe('MetadataModelBuilder', () => { expect(builder.constructMember({}, 'prop', undefined)).to.be.undefined }) - it('uses the primitive validator to convert primitive types', () => { + it('uses the primitive converter to convert primitive types', () => { builder.constructMember({ type: String }, 'prop', 'foo') - expect(primitiveTypeValidator.convert).to.have.been.calledOnce.calledWithExactly('foo', { type: String }) + expect(primitiveTypeConverter.convert).to.have.been.calledOnce.calledWithExactly('foo', { type: String }) }) it('converts complex types with constructModelInternal', () => { @@ -337,10 +366,52 @@ describe('MetadataModelBuilder', () => { TestModel, { prop1: 'foo', prop2: 'bar' }, 'prop', - {}, + { throwOnError: true }, ) }) + it('returns a MemberBuilderResult when throwOnError is set to false', () => { + expect(builder.constructMember({ type: String }, 'prop', 'foo', { throwOnError: false })) + .to.deep.equal({ + builderValue: 'foo', + source: 'foo', + errors: undefined, + }) + }) + + it('runs optional validators if there are no errors', () => { + const validator = { + validateMember: stub().returns([]), + } + builder.constructMember({ type: String }, 'prop', 'foo', { + validators: [validator], + }) + expect(validator.validateMember).to.have.been.calledOnce + }) + + it('does not run validators if there are errors', () => { + primitiveTypeConverter.convert.throws(new Error('Your llama is lloose!')) + const validator = { + validateMember: stub().returns([]), + } + builder.constructMember({ type: String }, 'prop', 'foo', { + validators: [validator], + throwOnError: false, + }) + expect(validator.validateMember).not.to.have.been.called + }) + + it('catches uncaught type conversion errors', () => { + const err = new ModelError('prop', 'llamas') + stub(builder as any, 'constructMemberByType').throws(err) + + expect(() => builder.constructMember({ type: String }, 'prop', 'foo')) + .to.throw(ModelBuilderError) + .that.has.property('errors') + .that.is.an('array') + .that.includes(err) + }) + describe('arrays', () => { it('converts each of the array members using the array valueType', () => { const meta: MemberMetadata = { @@ -349,7 +420,7 @@ describe('MetadataModelBuilder', () => { } builder.constructMember(meta, 'obj', ['foo', 'bar']) - expect(primitiveTypeValidator.convert) + expect(primitiveTypeConverter.convert) .to.have.been.calledTwice.calledWithExactly('foo', { type: String }) .calledWithExactly('bar', { type: String }) }) @@ -360,7 +431,9 @@ describe('MetadataModelBuilder', () => { valueType: String, } - expect(() => builder.constructMember(meta, 'obj', '1, 2')).to.throw(ModelValidationError) + expect(() => builder.constructMember(meta, 'obj', '1, 2')) + .to.throw(ModelBuilderError) + .to.have.nested.property('modelErrors.obj.array') }) }) @@ -372,7 +445,7 @@ describe('MetadataModelBuilder', () => { } builder.constructMember(meta, 'obj', ['foo', 'bar']) - expect(primitiveTypeValidator.convert) + expect(primitiveTypeConverter.convert) .to.have.been.calledTwice.calledWithExactly('foo', { type: String }) .calledWithExactly('bar', { type: String }) }) @@ -383,7 +456,9 @@ describe('MetadataModelBuilder', () => { valueType: String, } - expect(() => builder.constructMember(meta, 'obj', '1, 2')).to.throw(ModelValidationError) + expect(() => builder.constructMember(meta, 'obj', '1, 2')) + .to.throw(ModelBuilderError) + .to.have.nested.property('modelErrors.obj.set') }) it('returns a set', () => { @@ -413,7 +488,7 @@ describe('MetadataModelBuilder', () => { [key2]: '2', } builder.constructMember(meta, 'obj', input) - expect(primitiveTypeValidator.convert) + expect(primitiveTypeConverter.convert) .to.have.been.callCount(4) .calledWithExactly(key1, { type: Uuid }) .calledWithExactly('1', { type: Number }) @@ -428,7 +503,9 @@ describe('MetadataModelBuilder', () => { valueType: Number, } - expect(() => builder.constructMember(meta, 'obj', '1, 2')).to.throw(ModelValidationError) + expect(() => builder.constructMember(meta, 'obj', '1, 2')) + .to.throw(ModelBuilderError) + .to.have.nested.property('modelErrors.obj.map') }) it('returns a map', () => { @@ -448,6 +525,28 @@ describe('MetadataModelBuilder', () => { expect(result).to.be.instanceOf(Map) expect(result.size).to.equal(2) }) + + it('does not set entries when a key cannot be converted', () => { + const meta: MemberMetadata = { + type: Map, + keyType: Uuid, + valueType: Number, + } + + const key1 = Uuid.create().toString() + const key2 = 'nope' + const input = { + [key1]: '1', + [key2]: '2', + } + primitiveTypeConverter.convert.withArgs('nope').throws(new TypeConversionError(key2, Uuid)) + + const result: MemberBuilderResult = builder.constructMember(meta, 'obj', input, { throwOnError: false }) + expect(result.errors).to.exist + expect(result.errors).to.deep.include({ 'obj.nope.key': { type: true }}) + expect(result.builderValue).to.have.key(key1) + expect(result.builderValue).not.to.have.key(key2) + }) }) describe('oneOf', () => { @@ -459,7 +558,7 @@ describe('MetadataModelBuilder', () => { spy(builder as any, 'constructMemberInternal') - primitiveTypeValidator.convert + primitiveTypeConverter.convert .onFirstCall() .throws(new Error('Not a number')) .onSecondCall() @@ -477,13 +576,15 @@ describe('MetadataModelBuilder', () => { spy(builder as any, 'constructMemberInternal') - primitiveTypeValidator.convert + primitiveTypeConverter.convert .onFirstCall() .throws(new Error('Not a number')) .onSecondCall() .throws(new Error('Not a boolean')) - expect(() => builder.constructMember(meta, 'prop', 'foo')).to.throw(OneOfConversionError) + expect(() => builder.constructMember(meta, 'prop', 'foo')) + .to.throw(ModelBuilderError) + .to.have.nested.property('modelErrors.prop.oneOf') }) }) }) diff --git a/packages/dandi/model-builder/src/metadata-model-builder.ts b/packages/dandi/model-builder/src/metadata-model-builder.ts index 835ee187..0c344fa8 100644 --- a/packages/dandi/model-builder/src/metadata-model-builder.ts +++ b/packages/dandi/model-builder/src/metadata-model-builder.ts @@ -2,8 +2,20 @@ import { Constructor, isPrimitiveType } from '@dandi/common' import { Inject, Injectable } from '@dandi/core' import { MemberMetadata, OneOf, getAllKeys, getModelMetadata } from '@dandi/model' -import { MemberBuilderOptions, ModelBuilder, ModelBuilderOptions } from './model-builder' -import { ModelValidationError } from './model-validation-error' +import { + MemberBuilderNoThrowOnErrorOptions, + MemberBuilderOptions, + MemberBuilderResult, + ModelBuilder, + ModelBuilderNoThrowOnErrorOptions, + ModelBuilderOptions, + ModelBuilderResult, +} from './model-builder' +import { ModelBuilderError } from './model-builder-error' +import { ModelError } from './model-error' +import { ModelErrorKey } from './model-error-key' +import { ModelErrors } from './model-errors' +import { ModelValueConversionError } from './model-value-conversion-error' import { OneOfConversionAttempt, OneOfConversionError } from './one-of-conversion-error' import { PrimitiveTypeConverter } from './primitive-type-converter' import { TypeConversionError } from './type-converter' @@ -12,21 +24,38 @@ import { TypeConversionError } from './type-converter' export class MetadataModelBuilder implements ModelBuilder { constructor(@Inject(PrimitiveTypeConverter) private primitive: PrimitiveTypeConverter) {} - public constructModel(type: Constructor, source: any, options?: ModelBuilderOptions): any { - if (options && options.dataTransformers) { + public constructModel(type: Constructor, source: any, options?: ModelBuilderOptions): TModel + public constructModel(type: Constructor, source: any, options: ModelBuilderNoThrowOnErrorOptions): ModelBuilderResult + public constructModel(type: Constructor, source: any, options?: ModelBuilderOptions): TModel | ModelBuilderResult { + options = Object.assign({ + throwOnError: true, + }, options) + if (options.dataTransformers) { options.dataTransformers.forEach((dt) => (source = dt.transform(source))) } - return this.constructModelInternal(type, source, null, options || {}) + const [errors, modelValue] = this.constructModelInternal(type, source, null, options || {}) + const modelErrors = ModelErrors.create(type, errors) + if (options.throwOnError) { + if (errors.length) { + throw new ModelBuilderError(type, errors, modelErrors) + } + return modelValue + } + return { + builderValue: modelValue, + source, + errors: modelErrors, + } } - private constructModelInternal( - type: Constructor, + private constructModelInternal( + type: Constructor, source: any, parentKey: string, options: MemberBuilderOptions, - ): any { + ): [ModelError[], TModel] { if (!type) { - return source + return [[], source] } const modelMetadata = getModelMetadata(type) @@ -41,17 +70,24 @@ export class MetadataModelBuilder implements ModelBuilder { }, {}) } + const modelErrors: ModelError[] = [] typeKeys.forEach((key) => { const memberMetadata = modelMetadata[key] const objValue = this.getSourceValue(source, key, memberMetadata) try { - result[key] = this.constructMemberInternal(memberMetadata, this.getKey(parentKey, key), objValue, options) + const [memberErrors, memberValue] = this.constructMemberInternal(memberMetadata, this.getKey(parentKey, key), objValue, options) + result[key] = memberValue + modelErrors.push(...memberErrors) } catch (err) { - throw new ModelValidationError(key, err) + if (err instanceof ModelError) { + modelErrors.push(err) + } else { + modelErrors.push(new ModelError(key, ModelErrorKey.unknown, undefined, err)) + } } }) - return result + return [modelErrors, result] } private getSourceValue(source: any, key: string, memberMetadata: MemberMetadata): any { @@ -71,31 +107,55 @@ export class MetadataModelBuilder implements ModelBuilder { }, source) } - public constructMember(metadata: MemberMetadata, key: string, value: any, options?: MemberBuilderOptions): any { - return this.constructMemberInternal(metadata, key, value, options || {}) + public constructMember(metadata: MemberMetadata, key: string, source: any, options?: MemberBuilderOptions): any + public constructMember(metadata: MemberMetadata, key: string, source: any, options: MemberBuilderNoThrowOnErrorOptions): MemberBuilderResult + public constructMember(metadata: MemberMetadata, key: string, source: any, options?: MemberBuilderOptions): any | MemberBuilderResult { + options = Object.assign({ + throwOnError: true, + }, options) + const [errors, memberValue] = this.constructMemberInternal(metadata, key, source, options) + const modelErrors = ModelErrors.create(metadata.type, errors) + if (options.throwOnError) { + if (errors.length) { + throw new ModelBuilderError(metadata.type, errors, modelErrors) + } + return memberValue + } + return { + builderValue: memberValue, + source, + errors: modelErrors, + } } private constructMemberInternal( metadata: MemberMetadata, key: string, - value: any, + source: any, options: MemberBuilderOptions, - ): any { - let result = value - if (value !== null && value !== undefined) { - result = this.constructMemberByType(metadata, key, value, options) + ): [ModelError[], any] { + let result = source + let modelErrors: ModelError[] = [] + + if (source !== null && source !== undefined && source !== '') { + try { + [modelErrors, result] = this.constructMemberByType(metadata, key, source, options) + } catch (err) { + modelErrors.push(err) + result = undefined + } } - if (options.validators) { + if (options.validators && !modelErrors.length) { options.validators.forEach((validator) => { - validator.validateMember(metadata, key, result) + modelErrors.push(...validator.validateMember(metadata, key, result)) }) } - return result + return [modelErrors, result] } - private constructMemberByType(metadata: MemberMetadata, key: string, value: any, options: MemberBuilderOptions): any { + private constructMemberByType(metadata: MemberMetadata, key: string, value: any, options: MemberBuilderOptions): [ModelError[], any] { if ((metadata.type as any) === Array) { return this.constructArrayMember(metadata, key, value, options) } @@ -113,7 +173,7 @@ export class MetadataModelBuilder implements ModelBuilder { } if (isPrimitiveType(metadata.type)) { - return this.convertPrimitive(metadata, value) + return this.convertPrimitive(metadata, key, value) } return this.constructModelInternal( metadata.type, @@ -123,27 +183,31 @@ export class MetadataModelBuilder implements ModelBuilder { ) } - private convertPrimitive(metadata: MemberMetadata, value: any): any { - return this.primitive.convert(value, metadata) + private convertPrimitive(metadata: MemberMetadata, key: string, value: any): [ModelError[], any] { + try { + return [[], this.primitive.convert(value, metadata)] + } catch (err) { + return [[new ModelError(key, ModelErrorKey.type, undefined, err)], undefined] + } } - private getKey(parentKey: string, key: string): string { + private getKey(parentKey: string, key: string, debugModifier?: string): string { const isNumericKey = !isNaN(parseInt(key, 10)) const keyStr = isNumericKey ? `[${key}]` : key - return `${parentKey || ''}${parentKey && !isNumericKey ? '.' : ''}${keyStr}` + return `${parentKey || ''}${parentKey && !isNumericKey ? '.' : ''}${keyStr}${debugModifier ? `.${debugModifier}` : ''}` } - private constructOneOf(metadata: MemberMetadata, key: string, value: any[], options: MemberBuilderOptions): any { + private constructOneOf(metadata: MemberMetadata, key: string, value: any[], options: MemberBuilderOptions): [ModelError[], any] { const attempts: OneOfConversionAttempt[] = [] for (const type of metadata.oneOf) { - try { - const oneOfMeta: MemberMetadata = { type } - return this.constructMemberInternal(oneOfMeta, key, value, options) - } catch (error) { - attempts.push({ type, error }) + const oneOfMeta: MemberMetadata = { type } + const [attemptErrors, attemptResult] = this.constructMemberInternal(oneOfMeta, key, value, options) + if (!attemptErrors.length) { + return [attemptErrors, attemptResult] } + attempts.push({ type, errors: attemptErrors }) } - throw new OneOfConversionError(attempts) + return [[new OneOfConversionError(key, ModelErrorKey.oneOf, attempts)], undefined] } private constructArrayMember( @@ -151,15 +215,18 @@ export class MetadataModelBuilder implements ModelBuilder { key: string, value: any[], options: MemberBuilderOptions, - ): any[] { + ): [ModelError[], any[]] { if (!Array.isArray(value)) { - throw new ModelValidationError(key, new TypeConversionError(value, Array)) + return [[new ModelValueConversionError(key, ModelErrorKey.array, new TypeConversionError(value, Array))], undefined] } - return value.map((entry, index) => { + return value.reduce(([errors, result], entry, index) => { const entryMeta: MemberMetadata = { type: metadata.valueType } - return this.constructMemberInternal(entryMeta, this.getKey(key, index.toString()), entry, options) - }) + const [entryErrors, entryValue] = this.constructMemberInternal(entryMeta, this.getKey(key, index.toString()), entry, options) + errors.push(...entryErrors) + result.push(entryValue) + return [errors, result] + }, [[], []]) } private constructSetMember( @@ -167,16 +234,18 @@ export class MetadataModelBuilder implements ModelBuilder { key: string, value: any[], options: MemberBuilderOptions, - ): Set { + ): [ModelError[], Set] { if (!Array.isArray(value)) { - throw new ModelValidationError(key, new TypeConversionError(value, Set)) + return [[new ModelValueConversionError(key, ModelErrorKey.set, new TypeConversionError(value, Set))], undefined] } - return value.reduce((result, entry, index) => { + return value.reduce(([errors, result], entry, index) => { const entryMeta: MemberMetadata = { type: metadata.valueType } - result.add(this.constructMemberInternal(entryMeta, this.getKey(key, index.toString()), entry, options)) - return result - }, new Set()) + const [entryErrors, entryValue] = this.constructMemberInternal(entryMeta, this.getKey(key, index.toString()), entry, options) + errors.push(...entryErrors) + result.add(entryValue) + return [errors, result] + }, [[], new Set()]) } private constructMapMember( @@ -184,22 +253,28 @@ export class MetadataModelBuilder implements ModelBuilder { key: string, value: any, options: MemberBuilderOptions, - ): Map { + ): [ModelError[], Map] { if (typeof value !== 'object') { - throw new ModelValidationError(key, new TypeConversionError(value, Map)) + return [[new ModelValueConversionError(key, ModelErrorKey.map, new TypeConversionError(value, Map))], undefined] } - return Object.keys(value).reduce((result, mapKey) => { + return Object.keys(value).reduce(([errors, result], mapKey) => { const keyMeta: MemberMetadata = { type: metadata.keyType } // note: the real options are not passed for the key because map keys must not be transformed - const convertedKey = this.constructMemberInternal(keyMeta, this.getKey(key, `(key for '${mapKey}')`), mapKey, {}) + const keyOptions = Object.assign({}, options) + delete keyOptions.keyTransform + const [keyErrors, convertedKey] = this.constructMemberInternal(keyMeta, this.getKey(key, mapKey, 'key'), mapKey, keyOptions) + errors.push(...keyErrors) const valueMeta: MemberMetadata = { type: metadata.valueType } - const convertedValue = this.constructMemberInternal(valueMeta, this.getKey(key, mapKey), value[mapKey], options) + const [entryErrors, convertedValue] = this.constructMemberInternal(valueMeta, this.getKey(key, mapKey, 'value'), value[mapKey], options) + errors.push(...entryErrors) - result.set(convertedKey, convertedValue) + if (!keyErrors.length) { + result.set(convertedKey, convertedValue) + } - return result - }, new Map()) + return [errors, result] + }, [[], new Map()]) } } diff --git a/packages/dandi/model-builder/src/metadata-model-validator.spec.ts b/packages/dandi/model-builder/src/metadata-model-validator.spec.ts index a5a0cd97..ffbfb5b8 100644 --- a/packages/dandi/model-builder/src/metadata-model-validator.spec.ts +++ b/packages/dandi/model-builder/src/metadata-model-validator.spec.ts @@ -1,4 +1,4 @@ -import { MetadataModelValidator, MetadataValidationError, RequiredPropertyError } from '@dandi/model-builder' +import { MetadataModelValidator, ModelValidationError, RequiredPropertyError } from '@dandi/model-builder' import { expect } from 'chai' describe('MetadataModelValidator', () => { @@ -12,125 +12,142 @@ describe('MetadataModelValidator', () => { }) describe('validate', () => { - it('throws a RequiredPropertyError for properties marked with @Required() if the value is null', () => { - expect(() => validator.validateMember({ required: true }, 'prop', null)) - .to.throw(RequiredPropertyError) - .contains({ message: "The 'prop' property is required" }) + it('returns a RequiredPropertyError for properties marked with @Required() if the value is an empty string', () => { + const [error] = validator.validateMember({ required: true }, 'prop', '') + expect(error) + .to.be.instanceof(RequiredPropertyError) + .contains({ errorKey: 'required', memberKey: 'prop' }) + }) + it('returns a RequiredPropertyError for properties marked with @Required() if the value is null', () => { + const [error] = validator.validateMember({ required: true }, 'prop', null) + expect(error) + .to.be.instanceof(RequiredPropertyError) + .contains({ errorKey: 'required', memberKey: 'prop' }) }) it('throws a RequiredPropertyError for properties marked with @Required() if the value is undefined', () => { - expect(() => validator.validateMember({ required: true }, 'prop', undefined)) - .to.throw(RequiredPropertyError) - .contains({ message: "The 'prop' property is required" }) + const [error] = validator.validateMember({ required: true }, 'prop', undefined) + expect(error) + .to.be.instanceof(RequiredPropertyError) + .contains({ errorKey: 'required', memberKey: 'prop' }) }) it('validates patterns', () => { - expect(validator.validateMember({ type: String, pattern: /foo/ }, 'prop', 'foo')).to.equal('foo') + expect(validator.validateMember({ type: String, pattern: /foo/ }, 'prop', 'foo')).to.be.empty }) - it('throws a MetadataValidationError if the value does not match a pattern', () => { - expect(() => validator.validateMember({ type: String, pattern: /bar/ }, 'prop', 'foo')) - .to.throw(MetadataValidationError) - .contains({ message: 'pattern' }) + it('throws a ModelValidationError if the value does not match a pattern', () => { + const [error] = validator.validateMember({ type: String, pattern: /bar/ }, 'prop', 'foo') + expect(error) + .to.be.instanceof(ModelValidationError) + .contains({ errorKey: 'pattern' }) }) it('validates a minimum string length', () => { - expect(validator.validateMember({ type: String, minLength: 3 }, 'prop', 'foo')).to.equal('foo') + expect(validator.validateMember({ type: String, minLength: 3 }, 'prop', 'foo')).to.be.empty }) - it('throws a MetadataValidationError if the value is shorter than the minimum length', () => { - expect(() => validator.validateMember({ type: String, minLength: 4 }, 'prop', 'foo')) - .to.throw(MetadataValidationError) - .contains({ message: 'minLength' }) + it('throws a ModelValidationError if the value is shorter than the minimum length', () => { + const [error] = validator.validateMember({ type: String, minLength: 4 }, 'prop', 'foo') + expect(error) + .to.be.instanceof(ModelValidationError) + .contains({ errorKey: 'minLength' }) }) it('validates a maximum string length', () => { - expect(validator.validateMember({ type: String, maxLength: 4 }, 'prop', 'foo')).to.equal('foo') + expect(validator.validateMember({ type: String, maxLength: 4 }, 'prop', 'foo')).to.be.empty }) - it('throws a MetadataValidationError if the value is longer than the maximum length', () => { - expect(() => validator.validateMember({ type: String, maxLength: 2 }, 'prop', 'foo')) - .to.throw(MetadataValidationError) - .contains({ message: 'maxLength' }) + it('throws a ModelValidationError if the value is longer than the maximum length', () => { + const [error] = validator.validateMember({ type: String, maxLength: 2 }, 'prop', 'foo') + expect(error) + .to.be.instanceof(ModelValidationError) + .contains({ errorKey: 'maxLength' }) }) it('validates a minimum array length', () => { expect( validator.validateMember({ type: Array, valueType: String, minLength: 3 }, 'prop', [1, 2, 3]), - ).to.deep.equal([1, 2, 3]) + ).to.be.empty }) - it('throws a MetadataValidationError if the array is smaller than the minimum length', () => { - expect(() => validator.validateMember({ type: Array, valueType: String, minLength: 4 }, 'prop', [1, 2, 3])) - .to.throw(MetadataValidationError) - .contains({ message: 'minLength' }) + it('throws a ModelValidationError if the array is smaller than the minimum length', () => { + const [error] = validator.validateMember({ type: Array, valueType: String, minLength: 4 }, 'prop', [1, 2, 3]) + expect(error) + .to.be.instanceof(ModelValidationError) + .contains({ errorKey: 'minLength' }) }) it('validates a maximum array length', () => { expect( validator.validateMember({ type: Array, valueType: String, maxLength: 4 }, 'prop', [1, 2, 3]), - ).to.deep.equal([1, 2, 3]) + ).to.be.empty }) - it('throws a MetadataValidationError if the array is larger than the maximum length', () => { - expect(() => validator.validateMember({ type: Array, valueType: String, maxLength: 2 }, 'prop', [1, 2, 3])) - .to.throw(MetadataValidationError) - .contains({ message: 'maxLength' }) + it('throws a ModelValidationError if the array is larger than the maximum length', () => { + const [error] = validator.validateMember({ type: Array, valueType: String, maxLength: 2 }, 'prop', [1, 2, 3]) + expect(error) + .to.be.instanceof(ModelValidationError) + .contains({ errorKey: 'maxLength' }) }) it('throws if minLength is defined, but the value does not have a length property', () => { - expect(() => - validator.validateMember({ type: Object, minLength: 4 }, 'prop', { + const [error] = validator.validateMember({ type: Object, minLength: 4 }, 'prop', { foo: 'bar ', - }), - ) - .to.throw(MetadataValidationError) + }) + expect(error) + .to.be.instanceof(ModelValidationError) .contains({ - message: 'minLength or maxLength value does not have a length property', + errorKey: 'minLength', + message: 'value does not have a length property', }) }) it('throws if maxLength is defined, but the value does not have a length property', () => { - expect(() => - validator.validateMember({ type: Object, maxLength: 4 }, 'prop', { + const [error] = validator.validateMember({ type: Object, maxLength: 4 }, 'prop', { foo: 'bar ', - }), - ) - .to.throw(MetadataValidationError) + }) + expect(error) + .to.be.instanceof(ModelValidationError) .contains({ - message: 'minLength or maxLength value does not have a length property', + errorKey: 'maxLength', + message: 'value does not have a length property', }) }) it('throws if minValue is defined, but the value is not numeric', () => { - expect(() => validator.validateMember({ type: Number, minValue: 4 }, 'prop', 'foo')) - .to.throw(MetadataValidationError) - .contains({ message: 'minValue or maxValue value is not numeric' }) + const [error] = validator.validateMember({ type: Number, minValue: 4 }, 'prop', 'foo') + expect(error) + .to.be.instanceof(ModelValidationError) + .contains({ errorKey: 'minValue', message: 'value is not numeric' }) }) it('throws if maxValue is defined, but the value is not numeric', () => { - expect(() => validator.validateMember({ type: Number, maxValue: 4 }, 'prop', 'foo')) - .to.throw(MetadataValidationError) - .contains({ message: 'minValue or maxValue value is not numeric' }) + const [error] = validator.validateMember({ type: Number, maxValue: 4 }, 'prop', 'foo') + expect(error) + .to.be.instanceof(ModelValidationError) + .contains({ errorKey: 'maxValue', message: 'value is not numeric' }) }) it('validates a minimum value', () => { - expect(validator.validateMember({ type: Number, minValue: 4 }, 'prop', 5)).to.equal(5) + expect(validator.validateMember({ type: Number, minValue: 4 }, 'prop', 5)).to.be.empty }) - it('throws a MetadataValidationError if the value is less than the minimum value', () => { - expect(() => validator.validateMember({ type: Number, minValue: 2 }, 'prop', 1)) - .to.throw(MetadataValidationError) - .contains({ message: 'minValue' }) + it('throws a ModelValidationError if the value is less than the minimum value', () => { + const [error] = validator.validateMember({ type: Number, minValue: 2 }, 'prop', 1) + expect(error) + .to.be.instanceof(ModelValidationError) + .contains({ errorKey: 'minValue' }) }) it('validates a maximum value', () => { - expect(validator.validateMember({ type: Number, minValue: 4 }, 'prop', 5)).to.equal(5) + expect(validator.validateMember({ type: Number, minValue: 4 }, 'prop', 5)).to.be.empty }) - it('throws a MetadataValidationError if the value is greater than the maximum value', () => { - expect(() => validator.validateMember({ type: Number, maxValue: 4 }, 'prop', 5)) - .to.throw(MetadataValidationError) - .contains({ message: 'maxValue' }) + it('throws a ModelValidationError if the value is greater than the maximum value', () => { + const [error] = validator.validateMember({ type: Number, maxValue: 4 }, 'prop', 5) + expect(error) + .to.be.instanceof(ModelValidationError) + .contains({ errorKey: 'maxValue' }) }) }) }) diff --git a/packages/dandi/model-builder/src/metadata-model-validator.ts b/packages/dandi/model-builder/src/metadata-model-validator.ts index 3c9d979f..9d96f02a 100644 --- a/packages/dandi/model-builder/src/metadata-model-validator.ts +++ b/packages/dandi/model-builder/src/metadata-model-validator.ts @@ -1,42 +1,100 @@ import { Injectable } from '@dandi/core' import { MemberMetadata } from '@dandi/model' -import { MetadataValidationError } from './metadata-validation-error' +import { ModelError } from './model-error' +import { ModelErrorKey } from './model-error-key' +import { ModelValidationError } from './model-validation-error' import { ModelValidator } from './model-validator' import { RequiredPropertyError } from './required-property-error' @Injectable(ModelValidator) export class MetadataModelValidator implements ModelValidator { - public validateMember(metadata: MemberMetadata, key: string, value: any): void { - if (value === null || value === undefined) { + + public validateMember(metadata: MemberMetadata, key: string, value: any): ModelError[] { + + if (value === null || value === undefined || value === '') { if (metadata.required) { - throw new RequiredPropertyError(key) + return [new RequiredPropertyError(key)] } - return + return [] } + const errors: ModelError[] = [] + if (metadata.pattern && !metadata.pattern.test(value.toString())) { - throw new MetadataValidationError('pattern') - } - if ((!isNaN(metadata.minLength) || !isNaN(metadata.maxLength)) && value.length === undefined) { - throw new MetadataValidationError('minLength or maxLength', 'value does not have a length property') - } - if (!isNaN(metadata.minLength) && value.length < metadata.minLength) { - throw new MetadataValidationError('minLength') - } - if (!isNaN(metadata.maxLength) && value.length > metadata.maxLength) { - throw new MetadataValidationError('maxLength') - } - if ((!isNaN(metadata.minValue) || !isNaN(metadata.maxValue)) && isNaN(value as any)) { - throw new MetadataValidationError('minValue or maxValue', 'value is not numeric') + errors.push(new ModelValidationError(key, 'pattern', metadata.pattern)) } - if (!isNaN(metadata.minValue) && (value as any) < metadata.minValue) { - throw new MetadataValidationError('minValue') + if (value.length === undefined) { + + if (!isNaN(metadata.minLength)) { + errors.push(new ModelValidationError(key, ModelErrorKey.minLength, metadata.minLength, 'value does not have a length property')) + } + if (!isNaN(metadata.maxLength)) { + errors.push(new ModelValidationError(key, ModelErrorKey.maxLength, metadata.minLength, 'value does not have a length property')) + } + + } else { + + if (!isNaN(metadata.minLength) && value.length < metadata.minLength) { + errors.push(new ModelValidationError( + key, + ModelErrorKey.minLength, + metadata.minLength, + `value must have a length of at least ${metadata.minLength}`, + + )) + } + if (!isNaN(metadata.maxLength) && value.length > metadata.maxLength) { + errors.push(new ModelValidationError( + key, + ModelErrorKey.maxLength, + metadata.maxLength, + `value must have a length of at most ${metadata.maxLength}`, + )) + } + } - if (!isNaN(metadata.maxValue) && (value as any) > metadata.maxValue) { - throw new MetadataValidationError('maxValue') + + if (isNaN(value)) { + + if (!isNaN(metadata.minValue)) { + errors.push(new ModelValidationError( + key, + ModelErrorKey.minValue, + metadata.minValue, + 'value is not numeric', + )) + } + if (!isNaN(metadata.maxValue)) { + errors.push(new ModelValidationError( + key, + ModelErrorKey.maxValue, + metadata.maxValue, + 'value is not numeric', + )) + } + + } else { + + if (!isNaN(metadata.minValue) && (value as any) < metadata.minValue) { + errors.push(new ModelValidationError( + key, + ModelErrorKey.minValue, + metadata.minValue, + `value must be at least ${metadata.minValue}`, + )) + } + if (!isNaN(metadata.maxValue) && (value as any) > metadata.maxValue) { + errors.push(new ModelValidationError( + key, + ModelErrorKey.maxValue, + metadata.maxValue, + `value must be at most ${metadata.maxValue}`, + )) + } + } - return value + return errors } } diff --git a/packages/dandi/model-builder/src/metadata-validation-error.ts b/packages/dandi/model-builder/src/metadata-validation-error.ts deleted file mode 100644 index a01cb883..00000000 --- a/packages/dandi/model-builder/src/metadata-validation-error.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { AppError } from '@dandi/common' - -export class MetadataValidationError extends AppError { - constructor(public readonly metadataKey, message?: string) { - super(`${metadataKey}${message ? ' ' : ''}${message || ''}`) - } -} diff --git a/packages/dandi/model-builder/src/model-builder-error.ts b/packages/dandi/model-builder/src/model-builder-error.ts new file mode 100644 index 00000000..9f5b00df --- /dev/null +++ b/packages/dandi/model-builder/src/model-builder-error.ts @@ -0,0 +1,21 @@ +import { AppError, Constructor } from '@dandi/common' + +import { ModelError } from './model-error' +import { ModelErrors } from './model-errors' + +export class ModelErrorsError extends AppError { + constructor(public readonly modelErrors: ModelErrors) { + super(modelErrors.toString()) + } +} + +export class ModelBuilderError extends ModelErrorsError { + + constructor( + public readonly type: Constructor, + public readonly errors: ModelError[], + modelErrors?: ModelErrors, + ) { + super(modelErrors || ModelErrors.create(type, errors)) + } +} diff --git a/packages/dandi/model-builder/src/model-builder.ts b/packages/dandi/model-builder/src/model-builder.ts index cabe1cab..abf1214b 100644 --- a/packages/dandi/model-builder/src/model-builder.ts +++ b/packages/dandi/model-builder/src/model-builder.ts @@ -5,17 +5,27 @@ import { MemberMetadata } from '@dandi/model' import { DataTransformer } from './data-transformer' import { KeyTransformFn } from './key-transformer' import { localToken } from './local-token' +import { ModelErrors } from './model-errors' import { ModelValidator } from './model-validator' export interface MemberBuilderOptions { validators?: ModelValidator[] keyTransform?: KeyTransformFn + throwOnError?: boolean +} + +export interface MemberBuilderNoThrowOnErrorOptions extends MemberBuilderOptions { + throwOnError: false } export interface ModelBuilderOptions extends MemberBuilderOptions { dataTransformers?: DataTransformer[] } +export interface ModelBuilderNoThrowOnErrorOptions extends ModelBuilderOptions { + throwOnError: false +} + export const ModelBuilderOptions = { provider(token: InjectionToken, options: ModelBuilderOptions): Provider { return { @@ -25,9 +35,21 @@ export const ModelBuilderOptions = { }, } +export interface MemberBuilderResult { + builderValue?: any + source: any + errors?: ModelErrors +} + +export interface ModelBuilderResult extends MemberBuilderResult { + builderValue?: TModel +} + export interface ModelBuilder { - constructModel(type: Constructor, obj: any, options?: ModelBuilderOptions): T + constructModel(type: Constructor, obj: any, options?: ModelBuilderOptions): TModel + constructModel(type: Constructor, obj: any, options: ModelBuilderNoThrowOnErrorOptions): ModelBuilderResult constructMember(metadata: MemberMetadata, key: string, value: any, options?: ModelBuilderOptions): any + constructMember(metadata: MemberMetadata, key: string, value: any, options: MemberBuilderNoThrowOnErrorOptions): MemberBuilderResult } export const ModelBuilder: InjectionToken = localToken.opinionated('ModelBuilder', { diff --git a/packages/dandi/model-builder/src/model-error-key.ts b/packages/dandi/model-builder/src/model-error-key.ts new file mode 100644 index 00000000..6bdb0f30 --- /dev/null +++ b/packages/dandi/model-builder/src/model-error-key.ts @@ -0,0 +1,14 @@ +export enum ModelErrorKey { + array = 'array', + map = 'map', + minLength = 'minLength', + minValue = 'minValue', + maxLength = 'maxLength', + maxValue = 'maxValue', + oneOf = 'oneOf', + pattern = 'pattern', + required = 'required', + set = 'set', + type = 'type', + unknown = 'unknown', +} diff --git a/packages/dandi/model-builder/src/model-error.ts b/packages/dandi/model-builder/src/model-error.ts new file mode 100644 index 00000000..65a7645c --- /dev/null +++ b/packages/dandi/model-builder/src/model-error.ts @@ -0,0 +1,19 @@ +import { AppError } from '@dandi/common' + +export interface MemberErrorData { + errorKey: string + errorData?: any + message?: string +} + +export class ModelError extends AppError implements MemberErrorData { + constructor( + public readonly memberKey: string, + public readonly errorKey: string, + message?: string, + innerError?: Error, + public readonly errorData?: any, + ) { + super(message, innerError) + } +} diff --git a/packages/dandi/model-builder/src/model-errors.spec.ts b/packages/dandi/model-builder/src/model-errors.spec.ts new file mode 100644 index 00000000..6470e256 --- /dev/null +++ b/packages/dandi/model-builder/src/model-errors.spec.ts @@ -0,0 +1,233 @@ +import { ModelErrors, ModelError, ModelErrorKey } from '@dandi/model-builder' + +import { expect } from 'chai' + +describe('ModelErrors', () => { + + it('returns undefined when there are no errors', () => { + expect(ModelErrors.create(Object, [])).to.be.undefined + }) + + it('sets top level keys for each memberKey', () => { + const errors = [ + new ModelError('foo', ModelErrorKey.required), + new ModelError('bar', ModelErrorKey.required), + ] + + const modelErrors = ModelErrors.create(Object, errors) + + expect(modelErrors).to.have.keys('foo', 'bar') + }) + + it('sets second level keys for each errorKey', () => { + const errors = [ + new ModelError('foo', ModelErrorKey.required), + new ModelError('bar', ModelErrorKey.required), + ] + + const modelErrors = ModelErrors.create(Object, errors) + + expect(modelErrors).to.have.nested.property('foo.required') + expect(modelErrors).to.have.nested.property('bar.required') + }) + + it('collects multiple errors for the same member key', () => { + const errors = [ + new ModelError('foo', ModelErrorKey.required), + new ModelError('foo', ModelErrorKey.type), + ] + + const modelErrors = ModelErrors.create(Object, errors) + + expect(modelErrors).to.have.nested.property('foo.required') + expect(modelErrors).to.have.nested.property('foo.type') + }) + + it('sets a "true" value for each member/error if the errors do not have more specific information', () => { + + const errors = [ + new ModelError('foo', ModelErrorKey.required), + new ModelError('bar', ModelErrorKey.required), + ] + + expect(ModelErrors.create(Object, errors)).to.deep.equal({ + foo: { required: true }, + bar: { required: true}, + }) + }) + + it('sets the message as the value for a member error key if it is the only available information', () => { + const errors = [ + new ModelError('foo', ModelErrorKey.required, 'hi'), + ] + + expect(ModelErrors.create(Object, errors)).to.deep.equal({ + foo: { required: 'hi' }, + }) + }) + + it('creates an ModelErrorEntry if the error specifies errorData with no message', () => { + const errors = [ + new ModelError('foo', ModelErrorKey.required, undefined, undefined, 'hi'), + ] + + expect(ModelErrors.create(Object, errors)).to.deep.equal({ + foo: { required: { errorData: 'hi' } }, + }) + }) + + it('collapses MemberModelErrorInfo when the existing entry is `true`', () => { + const errors = [ + new ModelError('foo', ModelErrorKey.required), + new ModelError('foo', ModelErrorKey.required, 'hi'), + ] + + expect(ModelErrors.create(Object, errors)).to.deep.equal({ + foo: { required: 'hi' }, + }) + }) + + it('collapses MemberModelErrorInfo when the existing entry is a value and the next entry is `true`', () => { + const errors = [ + new ModelError('foo', ModelErrorKey.required, 'hi'), + new ModelError('foo', ModelErrorKey.required), + ] + + expect(ModelErrors.create(Object, errors)).to.deep.equal({ + foo: { required: 'hi' }, + }) + }) + + it('creates an array when the existing entry is a message', () => { + const errors = [ + new ModelError('foo', ModelErrorKey.required, 'hi'), + new ModelError('foo', ModelErrorKey.required, 'hello'), + ] + + expect(ModelErrors.create(Object, errors)).to.deep.equal({ + foo: { required: ['hi', 'hello'] }, + }) + }) + + it('creates an array when the existing entry is an object', () => { + const errors = [ + new ModelError('foo', ModelErrorKey.required, 'hi', undefined, 'whups'), + new ModelError('foo', ModelErrorKey.required, 'hello'), + ] + + expect(ModelErrors.create(Object, errors)).to.deep.equal({ + foo: { required: [{ errorData: 'whups', message: 'hi' }, 'hello'] }, + }) + }) + + it('adds to the existing array entry when it is an array', () => { + const errors = [ + new ModelError('foo', ModelErrorKey.required, 'hi', undefined, 'whups'), + new ModelError('foo', ModelErrorKey.required, 'hello'), + new ModelError('foo', ModelErrorKey.required, 'lloose llama!'), + ] + + expect(ModelErrors.create(Object, errors)).to.deep.equal({ + foo: { required: [{ errorData: 'whups', message: 'hi' }, 'hello', 'lloose llama!'] }, + }) + }) + + describe('generateModelErrorsMessage', () => { + it('generates a single line message when there is a single error', () => { + const message = ModelErrors.create(Object, [new ModelError('foo', ModelErrorKey.required)]).toString() + + expect(message).to.equal('Error converting source to model Object: foo (required)') + }) + + it('generates a multi line message with the member and error keys on the first when there are multiple messages on a single error', () => { + const message = ModelErrors.create(Object, [ + new ModelError('foo', ModelErrorKey.required, 'you need this'), + new ModelError('foo', ModelErrorKey.required, 'for reals'), + ]).toString() + + const expectedMessage = +`Errors converting source to model Object: foo (required) + you need this + for reals` + + expect(message).to.equal(expectedMessage) + }) + + it('generates a multi line message with members on separate lines when there are errors on multiple members', () => { + const message = ModelErrors.create(Object, [ + new ModelError('foo', ModelErrorKey.required), + new ModelError('bar', ModelErrorKey.required), + ]).toString() + + const expectedMessage = +`Errors converting source to model Object: + foo (required) + bar (required)` + + expect(message).to.equal(expectedMessage) + }) + + it('generates a multi line message with members and their error keys on separate lines when there are multiple errors on multiple members', () => { + const message = ModelErrors.create(Object, [ + new ModelError('foo', ModelErrorKey.required), + new ModelError('foo', ModelErrorKey.type), + new ModelError('bar', ModelErrorKey.required), + new ModelError('bar', ModelErrorKey.type), + ]).toString() + + const expectedMessage = +`Errors converting source to model Object: + foo (required, type) + bar (required, type)` + + expect(message).to.equal(expectedMessage) + }) + + it('generates a multi line message with member keys, their error keys, and their error messages on separate lines', () => { + const message = ModelErrors.create(Object, [ + new ModelError('foo', ModelErrorKey.required, 'you need this'), + new ModelError('foo', ModelErrorKey.type, 'not the right thing'), + new ModelError('bar', ModelErrorKey.required, 'you need this'), + new ModelError('bar', ModelErrorKey.type, 'not the right thing'), + ]).toString() + + const expectedMessage = +`Errors converting source to model Object: + foo + (required) you need this + (type) not the right thing + bar + (required) you need this + (type) not the right thing` + + expect(message).to.equal(expectedMessage) + }) + + it('generates a multi line message with member keys, their error keys, and multiple error messages on separate lines', () => { + const message = ModelErrors.create(Object, [ + new ModelError('foo', ModelErrorKey.required, 'you need this'), + new ModelError('foo', ModelErrorKey.type, 'not the right thing'), + new ModelError('foo', ModelErrorKey.type, 'do not want'), + new ModelError('bar', ModelErrorKey.required, 'you need this'), + new ModelError('bar', ModelErrorKey.type, 'not the right thing'), + new ModelError('bar', ModelErrorKey.type, 'nope no thanks'), + ]).toString() + + const expectedMessage = +`Errors converting source to model Object: + foo + (required) you need this + (type) + not the right thing + do not want + bar + (required) you need this + (type) + not the right thing + nope no thanks` + + expect(message).to.equal(expectedMessage) + }) + }) + +}) diff --git a/packages/dandi/model-builder/src/model-errors.ts b/packages/dandi/model-builder/src/model-errors.ts new file mode 100644 index 00000000..f860ca09 --- /dev/null +++ b/packages/dandi/model-builder/src/model-errors.ts @@ -0,0 +1,181 @@ +import { AppError, Constructor, CUSTOM_INSPECTOR } from '@dandi/common' + +import { ModelError } from './model-error' +import { ModelErrorKey } from './model-error-key' + +export interface ModelErrorEntry { + errorData: any + message?: string +} + +export type MemberModelErrorInfo = string | ModelErrorEntry + +export type MemberModelErrorsEntry = true | MemberModelErrorInfo | MemberModelErrorInfo[] + +export type MemberModelErrors = { [TErrorKey in ModelErrorKey]?: MemberModelErrorsEntry } + +export type ModelErrors = { + [memberPath: string]: MemberModelErrors +} + +export interface ModelErrorsStatic { + create(targetType: Constructor, errors: ModelError[]): ModelErrors +} + +function getErrorEntry(error: ModelError): true | MemberModelErrorInfo { + if (!error.message && !error.errorData) { + return true + } + if (error.message && error.errorData) { + return { + errorData: error.errorData, + message: error.message, + } + } + return error.message || { errorData: error.errorData } +} + +function updateEntry( + existing: MemberModelErrorsEntry, + next: true | MemberModelErrorInfo, +): MemberModelErrorsEntry { + if (next === true) { + return existing || next + } + if (!existing || existing === true) { + return next + } + if (Array.isArray(existing)) { + existing.push(next) + return existing + } + return [existing, next] +} + +function getMessagesFromEntry(entry: MemberModelErrorsEntry): string[] { + if (Array.isArray(entry)) { + return entry + .map(item => typeof item === 'string' ? item : item.message) + .filter(msg => !!msg) + } + + if (typeof entry === 'string') { + return [entry] + } + + if (typeof entry === 'object' && entry.message) { + return [entry.message] + } + + return [] +} + +function formatErrorKey(errorKey: string): string { + return `(${errorKey})` +} + +interface MemberModelMessageInfo { + errorKey: string + messages?: string[] +} + +function getMemberModelMessages(memberErrors: MemberModelErrors): MemberModelMessageInfo[] { + return [...Object.entries(memberErrors)].map(([errorKey, errorEntry]) => { + const messages = getMessagesFromEntry(errorEntry) + return { errorKey, messages } + }) +} + +function formatErrorMessageInfo(info: MemberModelMessageInfo, level: number = 0): string { + if (!info.messages.length) { + return formatErrorKey(info.errorKey) + } + if (info.messages.length === 1) { + return `${formatErrorKey(info.errorKey)} ${info.messages[0]}` + } + return [ + formatErrorKey(info.errorKey), + ...info.messages.map(msg => AppError.indentLine(msg, level + 1)), + ].join('\n') +} + +function formatMemberMessageInfo(memberKey: string, messages: MemberModelMessageInfo[], level = 0): string { + if (messages.length === 1) { + return AppError.indentLine(`${memberKey} ${formatErrorMessageInfo(messages[0], level)}`, level) + } + if (messages.every(info => !info.messages.length)) { + return AppError.indentLine(`${memberKey} ${formatErrorKey(messages.map(msg => msg.errorKey).join(', '))}`, level) + } + return [ + AppError.indentLine(memberKey, level), + ...messages.map(info => AppError.indentLine(formatErrorMessageInfo(info, level + 1), level + 1)), + ].join('\n') +} + +function generateModelErrorsMessage(targetType: Constructor, modelErrors: ModelErrors): string { + const memberMessages = [...Object.entries(modelErrors)].reduce((result, [memberKey, memberErrors]) => { + const memberModelMessages = getMemberModelMessages(memberErrors) + result.push([memberKey, memberModelMessages]) + return result + }, [] as [string, MemberModelMessageInfo[]][]) + + const [firstMessageMemberKey, firstErroredMemberMessages] = memberMessages[0] + const [firstErroredMemberErrorMessageInfo] = firstErroredMemberMessages + const hasMultipleErrors = memberMessages.length > 1 || + firstErroredMemberMessages.length > 1 || + firstErroredMemberErrorMessageInfo.messages.length > 1 + const preamble = `Error${hasMultipleErrors ? 's' : ''} converting source to model ${targetType.name}:` + + if (!hasMultipleErrors) { + return `${preamble} ${firstMessageMemberKey} ${formatErrorMessageInfo(firstErroredMemberErrorMessageInfo)}` + } + if (memberMessages.length === 1) { + return `${preamble} ${formatMemberMessageInfo(firstMessageMemberKey, firstErroredMemberMessages)}` + } + return [ + preamble, + ...memberMessages.map(([memberKey, errorMessages]) => { + return formatMemberMessageInfo(memberKey, errorMessages, 1) + }), + ].join('\n') +} + +export const ModelErrors: ModelErrorsStatic = { + create(targetType: Constructor, errors: ModelError[]): ModelErrors { + if (!errors.length) { + return undefined + } + + const modelErrors = errors.reduce((result, error) => { + const key = error.memberKey + if (!result[key]) { + result[key] = {} + } + + const existing = result[key][error.errorKey] + const entry = getErrorEntry(error) + result[key][error.errorKey] = updateEntry(existing, entry) + + return result + }, {}) + + const toString = (): string => { + return generateModelErrorsMessage(targetType, modelErrors) + } + + return Object.defineProperties(modelErrors, { + [CUSTOM_INSPECTOR]: { + value: toString, + configurable: false, + }, + [Symbol.toStringTag]: { + value: toString, + configurable: false, + }, + toString: { + value: toString, + configurable: false, + }, + }) + }, +} diff --git a/packages/dandi/model-builder/src/model-validation-error.ts b/packages/dandi/model-builder/src/model-validation-error.ts index 811869b7..86b23e44 100644 --- a/packages/dandi/model-builder/src/model-validation-error.ts +++ b/packages/dandi/model-builder/src/model-validation-error.ts @@ -1,7 +1,12 @@ -import { AppError } from '@dandi/common' +import { ModelError } from './model-error' -export class ModelValidationError extends AppError { - constructor(public readonly propertyName: string, innerError: Error) { - super(`Error validating ${propertyName}: ${innerError.message}`, innerError) +export class ModelValidationError extends ModelError { + constructor( + memberKey: string, + errorKey: string, + public readonly metaValue?: any, + message?: string, + ) { + super(memberKey, errorKey, message) } } diff --git a/packages/dandi/model-builder/src/model-validator.ts b/packages/dandi/model-builder/src/model-validator.ts index 38820104..80513624 100644 --- a/packages/dandi/model-builder/src/model-validator.ts +++ b/packages/dandi/model-builder/src/model-validator.ts @@ -2,9 +2,10 @@ import { InjectionToken } from '@dandi/core' import { MemberMetadata } from '@dandi/model' import { localToken } from './local-token' +import { ModelError } from './model-error' export interface ModelValidator { - validateMember(metadata: MemberMetadata, key: string, value: any): void + validateMember(metadata: MemberMetadata, key: string, value: any): ModelError[] } export const ModelValidator: InjectionToken = localToken.opinionated('ModelValidator', { diff --git a/packages/dandi/model-builder/src/model-value-conversion-error.ts b/packages/dandi/model-builder/src/model-value-conversion-error.ts new file mode 100644 index 00000000..bdc67c63 --- /dev/null +++ b/packages/dandi/model-builder/src/model-value-conversion-error.ts @@ -0,0 +1,11 @@ +import { ModelError } from './model-error' + +export class ModelValueConversionError extends ModelError { + constructor( + memberKey: string, + errorKey: string, + innerError?: Error, + ) { + super(memberKey, errorKey, `Could not convert source value to ${errorKey}`, innerError) + } +} diff --git a/packages/dandi/model-builder/src/one-of-conversion-error.ts b/packages/dandi/model-builder/src/one-of-conversion-error.ts index f8a43d56..9e8baf39 100644 --- a/packages/dandi/model-builder/src/one-of-conversion-error.ts +++ b/packages/dandi/model-builder/src/one-of-conversion-error.ts @@ -1,12 +1,18 @@ -import { AppError, Constructor } from '@dandi/common' +import { Constructor } from '@dandi/common' + +import { ModelError } from './model-error' export interface OneOfConversionAttempt { type: Constructor - error: Error + errors: Error[] } -export class OneOfConversionError extends AppError { - constructor(public readonly attempts: OneOfConversionAttempt[]) { - super(`Could not convert to any of the specified types`) +export class OneOfConversionError extends ModelError { + constructor( + memberKey: string, + errorKey: string, + public readonly attempts: OneOfConversionAttempt[], + ) { + super(memberKey, errorKey, 'Could not convert to any of the specified types', undefined, attempts) } } diff --git a/packages/dandi/model-builder/src/required-property-error.ts b/packages/dandi/model-builder/src/required-property-error.ts index 8699f8d3..d91d8db8 100644 --- a/packages/dandi/model-builder/src/required-property-error.ts +++ b/packages/dandi/model-builder/src/required-property-error.ts @@ -1,7 +1,8 @@ -import { AppError } from '@dandi/common' +import { ModelErrorKey } from './model-error-key' +import { ModelValidationError } from './model-validation-error' -export class RequiredPropertyError extends AppError { - constructor(public readonly propertyName: string | number) { - super(`The '${propertyName}' property is required`) +export class RequiredPropertyError extends ModelValidationError { + constructor(memberKey: string) { + super(memberKey, ModelErrorKey.required) } } diff --git a/packages/dandi/mvc-view/src/mvc-view-renderer.spec.ts b/packages/dandi/mvc-view/src/mvc-view-renderer.spec.ts index c49619e6..5893c9c0 100644 --- a/packages/dandi/mvc-view/src/mvc-view-renderer.spec.ts +++ b/packages/dandi/mvc-view/src/mvc-view-renderer.spec.ts @@ -1,6 +1,5 @@ import { testHarness, TestInjector } from '@dandi/core/testing' import { - createHttpRequestScope, HttpHeader, HttpModule, HttpRequest, @@ -15,6 +14,7 @@ import { HttpPipelineResult, } from '@dandi/http-pipeline' import { TestApplicationJsonRenderer } from '@dandi/http-pipeline/testing' +import { createTestHttpRequestScope } from '@dandi/http/testing' import { Route } from '@dandi/mvc' import { makeViewResult, MvcViewRenderer, ViewEngineConfig, ViewResultFactory } from '@dandi/mvc-view' @@ -70,7 +70,7 @@ describe('MvcViewRenderer', () => { .withArgs(HttpHeader.accept) .returns(MimeType.textHtml), } as SinonStubbedInstance - requestInjector = harness.createChild(createHttpRequestScope(req)) + requestInjector = harness.createChild(createTestHttpRequestScope()) pipelineRenderer = await requestInjector.inject(HttpPipelineRenderer) }) afterEach(() => { diff --git a/packages/dandi/mvc-view/src/view-result-factory.spec.ts b/packages/dandi/mvc-view/src/view-result-factory.spec.ts index 8a7f3a9d..d38d7289 100644 --- a/packages/dandi/mvc-view/src/view-result-factory.spec.ts +++ b/packages/dandi/mvc-view/src/view-result-factory.spec.ts @@ -2,7 +2,6 @@ import { resolve } from 'path' import { createStubInstance, stub, testHarness, TestInjector } from '@dandi/core/testing' import { - createHttpRequestScope, HttpHeader, HttpRequestHeadersAccessor, HttpRequestHeadersHashAccessor, @@ -10,6 +9,7 @@ import { MimeType, parseMimeTypes, } from '@dandi/http' +import { createTestHttpRequestScope } from '@dandi/http/testing' import { Route } from '@dandi/mvc' import { HttpRequestHeaderComparers, @@ -93,7 +93,7 @@ describe('ViewResultFactory', () => { } beforeEach(async () => { - injector = harness.createChild(createHttpRequestScope({} as any)) + injector = harness.createChild(createTestHttpRequestScope()) errorConfig = {} headers = createStubInstance(HttpRequestHeadersHashAccessor) }) diff --git a/packages/dandi/mvc/src/controller-metadata.ts b/packages/dandi/mvc/src/controller-metadata.ts index ab1ea787..3c6d0b19 100644 --- a/packages/dandi/mvc/src/controller-metadata.ts +++ b/packages/dandi/mvc/src/controller-metadata.ts @@ -20,6 +20,6 @@ export interface ControllerMetadata extends MvcMetadata, AuthorizationMetadata { routeMap?: RouteMap } -export function getControllerMetadata(target: Constructor): ControllerMetadata { +export function getControllerMetadata(target: Constructor): ControllerMetadata { return getMetadata(META_KEY, () => ({ routeMap: new RouteMap() }), target) } diff --git a/packages/dandi/mvc/src/dandi-route-executor.ts b/packages/dandi/mvc/src/dandi-route-executor.ts index eae7ad5b..2b8cbc9c 100644 --- a/packages/dandi/mvc/src/dandi-route-executor.ts +++ b/packages/dandi/mvc/src/dandi-route-executor.ts @@ -1,7 +1,6 @@ import { AppError, Disposable, Uuid } from '@dandi/common' -import { Inject, Injectable, Logger, Injector, Optional } from '@dandi/core' +import { Inject, Injectable, Logger, Injector, Optional, InjectionScope } from '@dandi/core' import { - createHttpRequestScope, ForbiddenError, HttpHeader, HttpRequest, @@ -60,7 +59,13 @@ export class DandiRouteExecutor implements RouteExecutor { ) performance.mark('DandiRouteExecutor.execRoute', 'beforeHandleRequest') - await Disposable.useAsync(this.injector.createChild(createHttpRequestScope(req), requestProviders), async requestInjector => { + const preRequestScope: InjectionScope = { + type: 'DandiRouteExecutor.execRoute', + description: '', + instanceId: req, + } + await Disposable.useAsync(this.injector.createChild(preRequestScope, requestProviders), async requestInjector => { + // TODO: move auth checks into HttpPipeline so the preRequestScope is no longer needed await requestInjector.invoke(this as DandiRouteExecutor, 'checkAuthorizationConditions') await requestInjector.invoke(this.pipeline, 'handleRequest') }) diff --git a/packages/dandi/mvc/src/dandi-route-initializer.spec.ts b/packages/dandi/mvc/src/dandi-route-initializer.spec.ts index d10dbc9c..12ec5dc4 100644 --- a/packages/dandi/mvc/src/dandi-route-initializer.spec.ts +++ b/packages/dandi/mvc/src/dandi-route-initializer.spec.ts @@ -2,7 +2,6 @@ import { Uuid } from '@dandi/common' import { Injector, Registerable } from '@dandi/core' import { stubValueProvider, testHarness, TestInjector, TestInjectorBase } from '@dandi/core/testing' import { - createHttpRequestScope, HttpHeader, HttpMethod, HttpModule, @@ -12,7 +11,7 @@ import { HttpRequestQueryParamMap, HttpResponse, } from '@dandi/http' -import { RequestBody } from '@dandi/http-model' +import { RequestModel } from '@dandi/http-model' import { CorsAllowCredentials, CorsAllowHeaders, @@ -21,10 +20,10 @@ import { CorsExposeHeaders, CorsHeaderValues, CorsMaxAge, - CorsOriginWhitelist, + CorsOriginWhitelist, HttpPipelineModule, HttpRequestInfo, } from '@dandi/http-pipeline' -import { httpResponseFixture } from '@dandi/http/testing' +import { httpResponseFixture, createTestHttpRequestScope } from '@dandi/http/testing' import { AuthorizationAuthProviderFactory, AuthProviderFactory, @@ -41,6 +40,8 @@ import { createStubInstance, SinonStubbedInstance, stub } from 'sinon' describe('DandiRouteInitializer', () => { const harness = testHarness(DandiRouteInitializer, + HttpModule, + HttpPipelineModule, { provide: AuthProviderFactory, useFactory: () => createStubInstance(AuthorizationAuthProviderFactory), @@ -56,11 +57,11 @@ describe('DandiRouteInitializer', () => { @Controller('/dandi-route-initializer-test') class TestController { // eslint-disable-next-line @typescript-eslint/no-unused-vars - public method(@RequestBody(TestModel) body: TestModel): void {} + public method(@RequestModel(TestModel) body: TestModel): void {} } function createRequestInjector(providers: Registerable[]): TestInjector { - return new TestInjectorBase(injector.createChild(createHttpRequestScope(req), providers)) + return new TestInjectorBase(injector.createChild(createTestHttpRequestScope(), providers)) } let injector: Injector @@ -172,11 +173,6 @@ describe('DandiRouteInitializer', () => { }) }) - it('adds request body providers if the method has a parameter that requests HttpRequestBody', async () => { - const providers = initializer.initRouteRequest(route, req, requestInfo, res) - expect(providers.find(p => p.provide === HttpRequestBody)).to.exist - }) - it('registers any authProviders', async () => { class Foo {} const value = {} @@ -306,13 +302,12 @@ describe('DandiRouteInitializer', () => { routes = undefined }) - it('returns the array of HttpMethods that are allowed given the request', async () => { + it.only('returns the array of HttpMethods that are allowed given the request', async () => { - harness.register(CorsHeaderValues, HttpModule) req.get.withArgs(HttpHeader.origin).returns('some-origin.com') req.get.withArgs(HttpHeader.host).returns('another-origin.com') const providers = initializer.initRouteRequest(routes[0], req, requestInfo, res) - const injector = harness.createChild(createHttpRequestScope(req), providers) + const injector = harness.createChild(createTestHttpRequestScope(), providers) const allowedMethods = await injector.inject(CorsAllowMethods) expect(allowedMethods).to.deep.equal([HttpMethod.get, HttpMethod.post]) diff --git a/packages/dandi/mvc/src/dandi-route-initializer.ts b/packages/dandi/mvc/src/dandi-route-initializer.ts index ea1d3945..7d06c411 100644 --- a/packages/dandi/mvc/src/dandi-route-initializer.ts +++ b/packages/dandi/mvc/src/dandi-route-initializer.ts @@ -207,7 +207,7 @@ export class DandiRouteInitializer implements RouteInitializer { private async determineAllowedMethods(siblingRoutes: Route[], req: HttpRequest): Promise { const allowedMethods = await Promise.all(siblingRoutes.map(async siblingRoute => { const siblingRequest = Object.assign({ - get: req.get.bind(req), + get: (key: string) => req.get(key), }, req) const providers = this.generateCorsProviders(siblingRoute).concat([ { @@ -224,7 +224,7 @@ export class DandiRouteInitializer implements RouteInitializer { deps: [CorsHeaderValues, HttpRequestHeadersAccessor], }, ]) - const childInjector = this.injector.createChild(createHttpRequestScope(siblingRequest), providers) + const childInjector = this.injector.app.createChild(createHttpRequestScope(this, 'determineAllowedMethod'), providers) if ((await childInjector.inject(CorsAllowRequest))?.singleValue) { return siblingRoute.httpMethod } diff --git a/yarn.lock b/yarn.lock index 6cb655fb..e31e2303 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10,17 +10,17 @@ "@babel/highlight" "^7.8.3" "@babel/core@^7.7.5": - version "7.8.4" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.8.4.tgz#d496799e5c12195b3602d0fddd77294e3e38e80e" - integrity sha512-0LiLrB2PwrVI+a2/IEskBopDYSd8BCb3rOvH7D5tzoWd696TBEduBvuLVm4Nx6rltrLZqvI3MCalB2K2aVzQjA== + version "7.8.7" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.8.7.tgz#b69017d221ccdeb203145ae9da269d72cf102f3b" + integrity sha512-rBlqF3Yko9cynC5CCFy6+K/w2N+Sq/ff2BPy+Krp7rHlABIr5epbA7OxVeKoMHB39LZOp1UY5SuLjy6uWi35yA== dependencies: "@babel/code-frame" "^7.8.3" - "@babel/generator" "^7.8.4" + "@babel/generator" "^7.8.7" "@babel/helpers" "^7.8.4" - "@babel/parser" "^7.8.4" - "@babel/template" "^7.8.3" - "@babel/traverse" "^7.8.4" - "@babel/types" "^7.8.3" + "@babel/parser" "^7.8.7" + "@babel/template" "^7.8.6" + "@babel/traverse" "^7.8.6" + "@babel/types" "^7.8.7" convert-source-map "^1.7.0" debug "^4.1.0" gensync "^1.0.0-beta.1" @@ -30,12 +30,12 @@ semver "^5.4.1" source-map "^0.5.0" -"@babel/generator@^7.8.4": - version "7.8.4" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.8.4.tgz#35bbc74486956fe4251829f9f6c48330e8d0985e" - integrity sha512-PwhclGdRpNAf3IxZb0YVuITPZmmrXz9zf6fH8lT4XbrmfQKr6ryBzhv593P5C6poJRciFCL/eHGW2NuGrgEyxA== +"@babel/generator@^7.8.6", "@babel/generator@^7.8.7": + version "7.8.8" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.8.8.tgz#cdcd58caab730834cee9eeadb729e833b625da3e" + integrity sha512-HKyUVu69cZoclptr8t8U5b6sx6zoWjh8jiUhnuj3MpZuKT2dJ8zPTuiy31luq32swhI0SpwItCIlU8XW7BZeJg== dependencies: - "@babel/types" "^7.8.3" + "@babel/types" "^7.8.7" jsesc "^2.5.1" lodash "^4.17.13" source-map "^0.5.0" @@ -81,39 +81,39 @@ esutils "^2.0.2" js-tokens "^4.0.0" -"@babel/parser@^7.7.5", "@babel/parser@^7.8.3", "@babel/parser@^7.8.4": - version "7.8.4" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.8.4.tgz#d1dbe64691d60358a974295fa53da074dd2ce8e8" - integrity sha512-0fKu/QqildpXmPVaRBoXOlyBb3MC+J0A66x97qEfLOMkn3u6nfY5esWogQwi/K0BjASYy4DbnsEWnpNL6qT5Mw== +"@babel/parser@^7.7.5", "@babel/parser@^7.8.6", "@babel/parser@^7.8.7": + version "7.8.8" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.8.8.tgz#4c3b7ce36db37e0629be1f0d50a571d2f86f6cd4" + integrity sha512-mO5GWzBPsPf6865iIbzNE0AvkKF3NE+2S3eRUpE+FE07BOAkXh6G+GW/Pj01hhXjve1WScbaIO4UlY1JKeqCcA== -"@babel/template@^7.7.4", "@babel/template@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.3.tgz#e02ad04fe262a657809327f578056ca15fd4d1b8" - integrity sha512-04m87AcQgAFdvuoyiQ2kgELr2tV8B4fP/xJAVUL3Yb3bkNdMedD3d0rlSQr3PegP0cms3eHjl1F7PWlvWbU8FQ== +"@babel/template@^7.7.4", "@babel/template@^7.8.3", "@babel/template@^7.8.6": + version "7.8.6" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b" + integrity sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg== dependencies: "@babel/code-frame" "^7.8.3" - "@babel/parser" "^7.8.3" - "@babel/types" "^7.8.3" + "@babel/parser" "^7.8.6" + "@babel/types" "^7.8.6" -"@babel/traverse@^7.7.4", "@babel/traverse@^7.8.4": - version "7.8.4" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.8.4.tgz#f0845822365f9d5b0e312ed3959d3f827f869e3c" - integrity sha512-NGLJPZwnVEyBPLI+bl9y9aSnxMhsKz42so7ApAv9D+b4vAFPpY013FTS9LdKxcABoIYFU52HcYga1pPlx454mg== +"@babel/traverse@^7.7.4", "@babel/traverse@^7.8.4", "@babel/traverse@^7.8.6": + version "7.8.6" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.8.6.tgz#acfe0c64e1cd991b3e32eae813a6eb564954b5ff" + integrity sha512-2B8l0db/DPi8iinITKuo7cbPznLCEk0kCxDoB9/N6gGNg/gxOXiR/IcymAFPiBwk5w6TtQ27w4wpElgp9btR9A== dependencies: "@babel/code-frame" "^7.8.3" - "@babel/generator" "^7.8.4" + "@babel/generator" "^7.8.6" "@babel/helper-function-name" "^7.8.3" "@babel/helper-split-export-declaration" "^7.8.3" - "@babel/parser" "^7.8.4" - "@babel/types" "^7.8.3" + "@babel/parser" "^7.8.6" + "@babel/types" "^7.8.6" debug "^4.1.0" globals "^11.1.0" lodash "^4.17.13" -"@babel/types@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.8.3.tgz#5a383dffa5416db1b73dedffd311ffd0788fb31c" - integrity sha512-jBD+G8+LWpMBBWvVcdr4QysjUE4mU/syrhN17o1u3gx0/WzJB1kwiVZAXRtWbsIPOwW8pF/YJV5+nmetPzepXg== +"@babel/types@^7.8.3", "@babel/types@^7.8.6", "@babel/types@^7.8.7": + version "7.8.7" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.8.7.tgz#1fc9729e1acbb2337d5b6977a63979b4819f5d1d" + integrity sha512-k2TreEHxFA4CjGkL+GYjRyx35W0Mr7DP5+9q6WMkyKXB+904bYmG40syjMFV0oLlhhFCwWl0vA0DyzTDkwAiJw== dependencies: esutils "^2.0.2" lodash "^4.17.13" @@ -135,24 +135,31 @@ integrity sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw== "@sinonjs/commons@^1", "@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0": - version "1.7.0" - resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.7.0.tgz#f90ffc52a2e519f018b13b6c4da03cbff36ebed6" - integrity sha512-qbk9AP+cZUsKdW1GJsBpxPKFmCJ0T8swwzVje3qFd+AkQb74Q/tiuzrdfFg8AD2g5HH/XbE/I8Uc1KYHVYWfhg== + version "1.7.1" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.7.1.tgz#da5fd19a5f71177a53778073978873964f49acf1" + integrity sha512-Debi3Baff1Qu1Unc3mjJ96MgpbwTn43S1+9yJ0llWygPwDNu2aaWBD6yc9y/Z8XDRNhx7U+u2UDg2OGQXkclUQ== dependencies: type-detect "4.0.8" -"@sinonjs/formatio@^4.0.1": - version "4.0.1" - resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-4.0.1.tgz#50ac1da0c3eaea117ca258b06f4f88a471668bdb" - integrity sha512-asIdlLFrla/WZybhm0C8eEzaDNNrzymiTqHMeJl6zPW2881l3uuVRpm0QlRQEjqYWv6CcKMGYME3LbrLJsORBw== +"@sinonjs/fake-timers@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-6.0.0.tgz#b64b0faadfdd01a6dcf0c4dcdb78438d86fa7dbf" + integrity sha512-atR1J/jRXvQAb47gfzSK8zavXy7BcpnYq21ALon0U99etu99vsir0trzIO3wpeLtW+LLVY6X7EkfVTbjGSH8Ww== + dependencies: + "@sinonjs/commons" "^1.7.0" + +"@sinonjs/formatio@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-5.0.1.tgz#f13e713cb3313b1ab965901b01b0828ea6b77089" + integrity sha512-KaiQ5pBf1MpS09MuA0kp6KBQt2JUOQycqVG1NZXvzeaXe5LGFqAKueIS0bw4w0P9r7KuBSVdUk5QjXsUdu2CxQ== dependencies: "@sinonjs/commons" "^1" - "@sinonjs/samsam" "^4.2.0" + "@sinonjs/samsam" "^5.0.2" -"@sinonjs/samsam@^4.2.0", "@sinonjs/samsam@^4.2.2": - version "4.2.2" - resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-4.2.2.tgz#0f6cb40e467865306d8a20a97543a94005204e23" - integrity sha512-z9o4LZUzSD9Hl22zV38aXNykgFeVj8acqfFabCY6FY83n/6s/XwNJyYYldz6/9lBJanpno9h+oL6HTISkviweA== +"@sinonjs/samsam@^5.0.2", "@sinonjs/samsam@^5.0.3": + version "5.0.3" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-5.0.3.tgz#86f21bdb3d52480faf0892a480c9906aa5a52938" + integrity sha512-QucHkc2uMJ0pFGjJUDP3F9dq5dx8QIaqISl9QgwLOh6P9yv877uONPGXh/OH/0zmM3tW1JjuJltAZV2l7zU+uQ== dependencies: "@sinonjs/commons" "^1.6.0" lodash.get "^4.4.2" @@ -171,9 +178,9 @@ "@types/chai" "*" "@types/chai@*", "@types/chai@^4.2.7": - version "4.2.8" - resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.8.tgz#c8d645506db0d15f4aafd4dfa873f443ad87ea59" - integrity sha512-U1bQiWbln41Yo6EeHMr+34aUhvrMVyrhn9lYfPSpLTCrZlGxU4Rtn1bocX+0p2Fc/Jkd2FanCEXdw0WNfHHM0w== + version "4.2.11" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.11.tgz#d3614d6c5f500142358e6ed24e1bf16657536c50" + integrity sha512-t7uW6eFafjO+qJ3BIV2gGUyZs27egcNRkUdalkud+Qa3+kg//f129iuOFivHDXQ+vnU3fDXuwgv0cqMCbcE8sw== "@types/color-name@^1.1.1": version "1.1.1" @@ -207,15 +214,15 @@ resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-1.21.0.tgz#db792d29f535d49522cb6d94dd9da053efc950a1" integrity sha512-Zhrf65tpjOlVIYrUhX9eu1VzRo8iixQDLFPbfqFxPpG4pBTNNPZ2BFhYE0IAsDfW9GWg+RcrUqiLwrGJH4rq4w== -"@types/mocha@^5.2.7": - version "5.2.7" - resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-5.2.7.tgz#315d570ccb56c53452ff8638738df60726d5b6ea" - integrity sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ== +"@types/mocha@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-7.0.2.tgz#b17f16cf933597e10d6d78eae3251e692ce8b0ce" + integrity sha512-ZvO2tAcjmMi8V/5Z3JsyofMe3hasRcaw88cto5etSVMwVQfeivGAlEYmaQgceUSVYFofVjT+ioHsATjdWcFt1w== "@types/node@^12.12.24": - version "12.12.26" - resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.26.tgz#213e153babac0ed169d44a6d919501e68f59dea9" - integrity sha512-UmUm94/QZvU5xLcUlNR8hA7Ac+fGpO1EG/a8bcWVz0P0LqtxFmun9Y2bbtuckwGboWJIT70DoWq1r3hb56n3DA== + version "12.12.30" + resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.30.tgz#3501e6f09b954de9c404671cefdbcc5d9d7c45f6" + integrity sha512-sz9MF/zk6qVr3pAnM0BSQvYIBK44tS75QC5N+VbWSE4DjCV/pJ+UzCW/F+vVnl7TkOPcuwQureKNtSSwjBTaMg== "@types/sinon-chai@^3.2.3": version "3.2.3" @@ -226,44 +233,44 @@ "@types/sinon" "*" "@types/sinon@*": - version "7.5.1" - resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-7.5.1.tgz#d27b81af0d1cfe1f9b24eebe7a24f74ae40f5b7c" - integrity sha512-EZQUP3hSZQyTQRfiLqelC9NMWd1kqLcmQE0dMiklxBkgi84T+cHOhnKpgk4NnOWpGX863yE6+IaGnOXUNFqDnQ== + version "7.5.2" + resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-7.5.2.tgz#5e2f1d120f07b9cda07e5dedd4f3bf8888fccdb9" + integrity sha512-T+m89VdXj/eidZyejvmoP9jivXgBDdkOSBVQjU9kF349NEx10QdPNGxHeZUaj1IlJ32/ewdyXJjnJxyxJroYwg== "@typescript-eslint/eslint-plugin@^2.19.2": - version "2.19.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.19.2.tgz#e279aaae5d5c1f2547b4cff99204e1250bc7a058" - integrity sha512-HX2qOq2GOV04HNrmKnTpSIpHjfl7iwdXe3u/Nvt+/cpmdvzYvY0NHSiTkYN257jHnq4OM/yo+OsFgati+7LqJA== + version "2.23.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.23.0.tgz#aa7133bfb7b685379d9eafe4ae9e08b9037e129d" + integrity sha512-8iA4FvRsz8qTjR0L/nK9RcRUN3QtIHQiOm69FzV7WS3SE+7P7DyGGwh3k4UNR2JBbk+Ej2Io+jLAaqKibNhmtw== dependencies: - "@typescript-eslint/experimental-utils" "2.19.2" + "@typescript-eslint/experimental-utils" "2.23.0" eslint-utils "^1.4.3" functional-red-black-tree "^1.0.1" regexpp "^3.0.0" tsutils "^3.17.1" -"@typescript-eslint/experimental-utils@2.19.2": - version "2.19.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-2.19.2.tgz#4611d44cf0f0cb460c26aa7676fc0a787281e233" - integrity sha512-B88QuwT1wMJR750YvTJBNjMZwmiPpbmKYLm1yI7PCc3x0NariqPwqaPsoJRwU9DmUi0cd9dkhz1IqEnwfD+P1A== +"@typescript-eslint/experimental-utils@2.23.0": + version "2.23.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-2.23.0.tgz#5d2261c8038ec1698ca4435a8da479c661dc9242" + integrity sha512-OswxY59RcXH3NNPmq+4Kis2CYZPurRU6mG5xPcn24CjFyfdVli5mySwZz/g/xDbJXgDsYqNGq7enV0IziWGXVQ== dependencies: "@types/json-schema" "^7.0.3" - "@typescript-eslint/typescript-estree" "2.19.2" + "@typescript-eslint/typescript-estree" "2.23.0" eslint-scope "^5.0.0" "@typescript-eslint/parser@^2.19.2": - version "2.19.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-2.19.2.tgz#21f42c0694846367e7d6a907feb08ab2f89c0879" - integrity sha512-8uwnYGKqX9wWHGPGdLB9sk9+12sjcdqEEYKGgbS8A0IvYX59h01o8os5qXUHMq2na8vpDRaV0suTLM7S8wraTA== + version "2.23.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-2.23.0.tgz#f3d4e2928ff647fe77fc2fcef1a3534fee6a3212" + integrity sha512-k61pn/Nepk43qa1oLMiyqApC6x5eP5ddPz6VUYXCAuXxbmRLqkPYzkFRKl42ltxzB2luvejlVncrEpflgQoSUg== dependencies: "@types/eslint-visitor-keys" "^1.0.0" - "@typescript-eslint/experimental-utils" "2.19.2" - "@typescript-eslint/typescript-estree" "2.19.2" + "@typescript-eslint/experimental-utils" "2.23.0" + "@typescript-eslint/typescript-estree" "2.23.0" eslint-visitor-keys "^1.1.0" -"@typescript-eslint/typescript-estree@2.19.2": - version "2.19.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-2.19.2.tgz#67485b00172f400474d243c6c0be27581a579350" - integrity sha512-Xu/qa0MDk6upQWqE4Qy2X16Xg8Vi32tQS2PR0AvnT/ZYS4YGDvtn2MStOh5y8Zy2mg4NuL06KUHlvCh95j9C6Q== +"@typescript-eslint/typescript-estree@2.23.0": + version "2.23.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-2.23.0.tgz#d355960fab96bd550855488dcc34b9a4acac8d36" + integrity sha512-pmf7IlmvXdlEXvE/JWNNJpEvwBV59wtJqA8MLAxMKLXNKVRC3HZBXR/SlZLPWTCcwOSg9IM7GeRSV3SIerGVqw== dependencies: debug "^4.1.1" eslint-visitor-keys "^1.1.0" @@ -273,15 +280,15 @@ semver "^6.3.0" tsutils "^3.17.1" -acorn-jsx@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.1.0.tgz#294adb71b57398b0680015f0a38c563ee1db5384" - integrity sha512-tMUqwBWfLFbJbizRmEcWSLw6HnFzfdJs2sOJEOwwtVPMoH/0Ay+E703oZz78VSXZiiDcZrQ5XKjPIUQixhmgVw== +acorn-jsx@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.2.0.tgz#4c66069173d6fdd68ed85239fc256226182b2ebe" + integrity sha512-HiUX/+K2YpkpJ+SzBffkM/AQ2YE03S0U1kjTLVpoJdhZMOWy8qvXVN9JdLqv2QsaQ6MPYQIuNmwD8zOiYUofLQ== -acorn@^7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.0.tgz#949d36f2c292535da602283586c2477c57eb2d6c" - integrity sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ== +acorn@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.1.tgz#e35668de0b402f359de515c5482a1ab9f89a69bf" + integrity sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg== aggregate-error@^3.0.0: version "3.0.1" @@ -292,9 +299,9 @@ aggregate-error@^3.0.0: indent-string "^4.0.0" ajv@^6.10.0, ajv@^6.10.2, ajv@^6.5.5: - version "6.11.0" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.11.0.tgz#c3607cbc8ae392d8a5a536f25b21f8e5f3f87fe9" - integrity sha512-nCprB/0syFYy9fVYU1ox1l2KN8S9I+tziH8D4zdZuLT3N6RMlGSGt5FSTpAiHB/Whv8Qs1cWHma1aMKZyaHRKA== + version "6.12.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.0.tgz#06d60b96d87b8454a5adaba86e7854da629db4b7" + integrity sha512-D6gFiFA0RRLyUbvijN74DWAjXSFxWKaWP7mldxkVhyhAV3+SWA9HEJPHQ2c9soIeTFJqcSdFDGFgdqs1iUU2Hw== dependencies: fast-deep-equal "^3.1.1" fast-json-stable-stringify "^2.0.0" @@ -307,11 +314,11 @@ ansi-colors@3.2.3: integrity sha512-LEHHyuhlPY3TmuUYMh2oz89lTShfvgbmzaBcxve9t/9Wuy7Dwf4yoAKcND7KFT1HAQfqZ12qtc+DUrBMeKF9nw== ansi-escapes@^4.2.1: - version "4.3.0" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.0.tgz#a4ce2b33d6b214b7950d8595c212f12ac9cc569d" - integrity sha512-EiYhwo0v255HUL6eDyuLrXEkTi7WwVCLAw+SeOQ7M7qdun1z1pum4DEm/nuqIVbPvi9RPPc9k9LbyBv6H0DwVg== + version "4.3.1" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.1.tgz#a5c47cc43181f1f38ffd7076837700d395522a61" + integrity sha512-JWF7ocqNrp8u9oqpgV+wH5ftbt+cfvv+PTjOvKLT3AdYly/LmORARfEVT1iyjwN+4MqE5UmVKoAdIBqeoCHgLA== dependencies: - type-fest "^0.8.1" + type-fest "^0.11.0" ansi-regex@^3.0.0: version "3.0.0" @@ -335,7 +342,7 @@ ansi-styles@^3.2.0, ansi-styles@^3.2.1: dependencies: color-convert "^1.9.0" -ansi-styles@^4.0.0: +ansi-styles@^4.0.0, ansi-styles@^4.1.0: version "4.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.2.1.tgz#90ae75c424d008d2624c5bf29ead3177ebfcf359" integrity sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA== @@ -515,7 +522,7 @@ chai@^4.2.0: pathval "^1.1.0" type-detect "^4.0.5" -chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.2: +chalk@^2.0.0, chalk@^2.1.0, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -524,6 +531,14 @@ chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + chardet@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" @@ -863,11 +878,12 @@ eslint-plugin-import@^2.18.2: resolve "^1.12.0" eslint-plugin-mocha@^6.2.2: - version "6.2.2" - resolved "https://registry.yarnpkg.com/eslint-plugin-mocha/-/eslint-plugin-mocha-6.2.2.tgz#6ef4b78bd12d744beb08a06e8209de330985100d" - integrity sha512-oNhPzfkT6Q6CJ0HMVJ2KLxEWG97VWGTmuHOoRcDLE0U88ugUyFNV9wrT2XIt5cGtqc5W9k38m4xTN34L09KhBA== + version "6.3.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-mocha/-/eslint-plugin-mocha-6.3.0.tgz#72bfd06a5c4323e17e30ef41cd726030e8cdb8fd" + integrity sha512-Cd2roo8caAyG21oKaaNTj7cqeYRWW1I2B5SfpKRp0Ip1gkfwoR1Ow0IGlPWnNjzywdF4n+kHL8/9vM6zCJUxdg== dependencies: - ramda "^0.26.1" + eslint-utils "^2.0.0" + ramda "^0.27.0" eslint-scope@^5.0.0: version "5.0.0" @@ -884,6 +900,13 @@ eslint-utils@^1.4.3: dependencies: eslint-visitor-keys "^1.1.0" +eslint-utils@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.0.0.tgz#7be1cc70f27a72a76cd14aa698bcabed6890e1cd" + integrity sha512-0HCPuJv+7Wv1bACm8y5/ECVfYdfsAm9xmVb7saeFlxjPYALefjhbYoCkBjPdPzGH8wWyTpAez82Fh3VKYEZ8OA== + dependencies: + eslint-visitor-keys "^1.1.0" + eslint-visitor-keys@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2" @@ -933,12 +956,12 @@ eslint@^6.7.2: v8-compile-cache "^2.0.3" espree@^6.1.2: - version "6.1.2" - resolved "https://registry.yarnpkg.com/espree/-/espree-6.1.2.tgz#6c272650932b4f91c3714e5e7b5f5e2ecf47262d" - integrity sha512-2iUPuuPP+yW1PZaMSDM9eyVf8D5P0Hi8h83YtZ5bPc/zHYjII5khoixIUTMO794NOY8F/ThF1Bo8ncZILarUTA== + version "6.2.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-6.2.1.tgz#77fc72e1fd744a2052c20f38a5b575832e82734a" + integrity sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw== dependencies: - acorn "^7.1.0" - acorn-jsx "^5.1.0" + acorn "^7.1.1" + acorn-jsx "^5.2.0" eslint-visitor-keys "^1.1.0" esprima@^4.0.0: @@ -947,9 +970,9 @@ esprima@^4.0.0: integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== esquery@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.0.1.tgz#406c51658b1f5991a5f9b62b1dc25b00e3e5c708" - integrity sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA== + version "1.1.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.1.0.tgz#c5c0b66f383e7656404f86b31334d72524eddb48" + integrity sha512-MxYW9xKmROWF672KqjO75sszsA8Mxhw06YFeS5VHlB98KDHbOSurm3ArsjO60Eaf3QmGMCP1yn+0JQkNLo/97Q== dependencies: estraverse "^4.0.0" @@ -1010,9 +1033,9 @@ fast-levenshtein@~2.0.6: integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= figures@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/figures/-/figures-3.1.0.tgz#4b198dd07d8d71530642864af2d45dd9e459c4ec" - integrity sha512-ravh8VRXqHuMvZt/d8GblBeqDMkdJMBdv/2KntFH+ra5MXkO7nxNKpzQ3n6QD/2da1kH0aWmNISdvhM7gl2gVg== + version "3.2.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" + integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg== dependencies: escape-string-regexp "^1.0.5" @@ -1031,12 +1054,12 @@ fill-range@^7.0.1: to-regex-range "^5.0.1" find-cache-dir@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.2.0.tgz#e7fe44c1abc1299f516146e563108fd1006c1874" - integrity sha512-1JKclkYYsf1q9WIJKLZa9S9muC+08RIjzAlLrK4QcYLJMS6mk9yombQ9qf+zJ7H9LS800k0s44L4sDq9VYzqyg== + version "3.3.1" + resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.1.tgz#89b33fad4a4670daa94f855f7fbe31d6d84fe880" + integrity sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ== dependencies: commondir "^1.0.1" - make-dir "^3.0.0" + make-dir "^3.0.2" pkg-dir "^4.1.0" find-up@3.0.0, find-up@^3.0.0: @@ -1188,9 +1211,9 @@ globals@^11.1.0: integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== globals@^12.1.0: - version "12.3.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-12.3.0.tgz#1e564ee5c4dded2ab098b0f88f24702a3c56be13" - integrity sha512-wAfjdLgFsPZsklLJvOBUBmzYE8/CwhEqSBEMRXA3qxIiNtyqvjYurAtIfDh6chlEPUfmTY3MnZh5Hfh4q0UlIw== + version "12.4.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-12.4.0.tgz#a18813576a41b00a24a97e7f815918c2e19925f8" + integrity sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg== dependencies: type-fest "^0.8.1" @@ -1209,7 +1232,7 @@ har-schema@^2.0.0: resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= -har-validator@~5.1.0: +har-validator@~5.1.3: version "5.1.3" resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080" integrity sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g== @@ -1240,9 +1263,9 @@ has@^1.0.3: function-bind "^1.1.1" hasha@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/hasha/-/hasha-5.1.0.tgz#dd05ccdfcfe7dab626247ce2a58efe461922f4ca" - integrity sha512-OFPDWmzPN1l7atOV1TgBVmNtBxaIysToK6Ve9DK+vT6pYuklw/nPNT+HJbZi0KDcI6vWB+9tgvZ5YD7fA3CXcA== + version "5.2.0" + resolved "https://registry.yarnpkg.com/hasha/-/hasha-5.2.0.tgz#33094d1f69c40a4a6ac7be53d5fe3ff95a269e0c" + integrity sha512-2W+jKdQbAdSIrggA8Q35Br8qKadTrqCTC8+XZvBWepKDK6m9XkX6Iz1a2yh2KP01kzAR/dpuMeUnocoLYDcskw== dependencies: is-stream "^2.0.0" type-fest "^0.8.0" @@ -1253,9 +1276,9 @@ he@1.2.0: integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== hosted-git-info@^2.1.4: - version "2.8.5" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.5.tgz#759cfcf2c4d156ade59b0b2dfabddc42a6b9c70c" - integrity sha512-kssjab8CvdXfcXMXVcvsXum4Hwdq9XGtRD3TteMEvEbq0LXyiNQr6AprqKqfeaDXze7SxWvRxdpwE6ku7ikLkg== + version "2.8.8" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" + integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg== html-escaper@^2.0.0: version "2.0.0" @@ -1315,22 +1338,22 @@ inherits@2: integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== inquirer@^7.0.0: - version "7.0.4" - resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.0.4.tgz#99af5bde47153abca23f5c7fc30db247f39da703" - integrity sha512-Bu5Td5+j11sCkqfqmUTiwv+tWisMtP0L7Q8WrqA2C/BbBhy1YTdFrvjjlrKq8oagA/tLQBski2Gcx/Sqyi2qSQ== + version "7.1.0" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.1.0.tgz#1298a01859883e17c7264b82870ae1034f92dd29" + integrity sha512-5fJMWEmikSYu0nv/flMc475MhGbB7TSPd/2IpFV4I4rMklboCH2rQjYY5kKiYGHqUF9gvaambupcJFFG9dvReg== dependencies: ansi-escapes "^4.2.1" - chalk "^2.4.2" + chalk "^3.0.0" cli-cursor "^3.1.0" cli-width "^2.0.0" external-editor "^3.0.3" figures "^3.0.0" lodash "^4.17.15" mute-stream "0.0.8" - run-async "^2.2.0" + run-async "^2.4.0" rxjs "^6.5.3" string-width "^4.1.0" - strip-ansi "^5.1.0" + strip-ansi "^6.0.0" through "^2.3.6" is-arrayish@^0.2.1: @@ -1578,9 +1601,9 @@ jsprim@^1.2.2: verror "1.10.0" just-extend@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.0.2.tgz#f3f47f7dfca0f989c55410a7ebc8854b07108afc" - integrity sha512-FrLwOgm+iXrPV+5zDU6Jqu4gCRXbWEQg2O3SKONsWE4w7AXFRkryS53bpWdaL9cNol+AmR3AEYz6kn+o0fCPnw== + version "4.1.0" + resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.1.0.tgz#7278a4027d889601640ee0ce0e5a00b992467da4" + integrity sha512-ApcjaOdVTJ7y4r08xI5wIqpvwS48Q0PBG4DJROcEkH1f8MdAiNFyFxz3xoL0LWAVwjrwPYZdVHHxhRHcx/uGLA== lcov-parse@^1.0.0: version "1.0.0" @@ -1648,19 +1671,12 @@ log-driver@^1.2.7: resolved "https://registry.yarnpkg.com/log-driver/-/log-driver-1.2.7.tgz#63b95021f0702fedfa2c9bb0a24e7797d71871d8" integrity sha512-U7KCmLdqsGHBLeWqYlFA0V0Sl6P08EE1ZrmA9cxjUE0WVqT9qnyVDPz1kzpFEP0jdJuFnasWIfSd7fsaNXkpbg== -log-symbols@2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a" - integrity sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg== - dependencies: - chalk "^2.0.1" - -lolex@^5.0.1, lolex@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/lolex/-/lolex-5.1.2.tgz#953694d098ce7c07bc5ed6d0e42bc6c0c6d5a367" - integrity sha512-h4hmjAvHTmd+25JSwrtTIuwbKdwg5NzZVRMLn9saij4SZaepCrTCxPr35H/3bjwfMJtN+t3CX8672UIkglz28A== +log-symbols@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-3.0.0.tgz#f3a08516a5dea893336a7dee14d18a1cfdab77c4" + integrity sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ== dependencies: - "@sinonjs/commons" "^1.7.0" + chalk "^2.4.2" longjohn@^0.2.12: version "0.2.12" @@ -1674,17 +1690,17 @@ luxon@^1.21.3: resolved "https://registry.yarnpkg.com/luxon/-/luxon-1.22.0.tgz#639525c7c69e594953c7b142794466b8ea85b868" integrity sha512-3sLvlfbFo+AxVEY3IqxymbumtnlgBwjDExxK60W3d+trrUzErNAz/PfvPT+mva+vEUrdIodeCOs7fB6zHtRSrw== -make-dir@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.0.0.tgz#1b5f39f6b9270ed33f9f054c5c0f84304989f801" - integrity sha512-grNJDhb8b1Jm1qeqW5R/O63wUo4UXo2v2HMic6YT9i/HBlF93S8jkMgH7yugvY9ABDShH4VZMn8I+U8+fCNegw== +make-dir@^3.0.0, make-dir@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.0.2.tgz#04a1acbf22221e1d6ef43559f43e05a90dbb4392" + integrity sha512-rYKABKutXa6vXTXhoV18cBE7PaewPXHe/Bdq4v+ZLMhxbWApkFFplT0LcbMW+6BbjnQXzZ/sAvSE/JdguApG5w== dependencies: semver "^6.0.0" make-error@^1.1.1: - version "1.3.5" - resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.5.tgz#efe4e81f6db28cadd605c70f29c831b58ef776c8" - integrity sha512-c3sIjNUow0+8swNwVpqoH4YCShKNFkMaw6oH1mNS2haDZQqkeZFlHS3dhoeEbKKmJB4vXpJucU6oH75aDYeE9g== + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== mime-db@1.43.0: version "1.43.0" @@ -1716,9 +1732,9 @@ minimist@0.0.8: integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= minimist@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" - integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ= + version "1.2.5" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== mkdirp@0.5.1, mkdirp@^0.5.1: version "0.5.1" @@ -1728,9 +1744,9 @@ mkdirp@0.5.1, mkdirp@^0.5.1: minimist "0.0.8" mocha@^7.0.0: - version "7.0.1" - resolved "https://registry.yarnpkg.com/mocha/-/mocha-7.0.1.tgz#276186d35a4852f6249808c6dd4a1376cbf6c6ce" - integrity sha512-9eWmWTdHLXh72rGrdZjNbG3aa1/3NRPpul1z0D979QpEnFdCG0Q5tv834N+94QEN2cysfV72YocQ3fn87s70fg== + version "7.1.0" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-7.1.0.tgz#c784f579ad0904d29229ad6cb1e2514e4db7d249" + integrity sha512-MymHK8UkU0K15Q/zX7uflZgVoRWiTjy0fXE/QjKts6mowUvGxOdPhZ2qj3b0iZdUrNZlW9LAIMFHB4IW+2b3EQ== dependencies: ansi-colors "3.2.3" browser-stdout "1.3.1" @@ -1743,7 +1759,7 @@ mocha@^7.0.0: growl "1.10.5" he "1.2.0" js-yaml "3.13.1" - log-symbols "2.2.0" + log-symbols "3.0.0" minimatch "3.0.4" mkdirp "0.5.1" ms "2.1.1" @@ -1787,16 +1803,15 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== -nise@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/nise/-/nise-3.0.1.tgz#0659982af515e5aac15592226246243e8da0013d" - integrity sha512-fYcH9y0drBGSoi88kvhpbZEsenX58Yr+wOJ4/Mi1K4cy+iGP/a73gNoyNhu5E9QxPdgTlVChfIaAlnyOy/gHUA== +nise@^4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/nise/-/nise-4.0.3.tgz#9f79ff02fa002ed5ffbc538ad58518fa011dc913" + integrity sha512-EGlhjm7/4KvmmE6B/UFsKh7eHykRl9VH+au8dduHLCyWUO/hr7+N+WtTvDUwc9zHuM1IaIJs/0lQ6Ag1jDkQSg== dependencies: "@sinonjs/commons" "^1.7.0" - "@sinonjs/formatio" "^4.0.1" + "@sinonjs/fake-timers" "^6.0.0" "@sinonjs/text-encoding" "^0.7.1" just-extend "^4.0.2" - lolex "^5.0.1" path-to-regexp "^1.7.0" node-environment-flags@1.0.6: @@ -2108,17 +2123,12 @@ progress@^2.0.0: resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== -psl@^1.1.24: +psl@^1.1.28: version "1.7.0" resolved "https://registry.yarnpkg.com/psl/-/psl-1.7.0.tgz#f1c4c47a8ef97167dea5d6bbf4816d736e884a3c" integrity sha512-5NsSEDv8zY70ScRnOTn7bK7eanl2MvFrOrS/R6x+dBt5g1ghnj9Zv90kO8GwT8gxcu2ANyFprnFYB85IogIJOQ== -punycode@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" - integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= - -punycode@^2.1.0: +punycode@^2.1.0, punycode@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== @@ -2128,10 +2138,10 @@ qs@~6.5.2: resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== -ramda@^0.26.1: - version "0.26.1" - resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" - integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ== +ramda@^0.27.0: + version "0.27.0" + resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.27.0.tgz#915dc29865c0800bf3f69b8fd6c279898b59de43" + integrity sha512-pVzZdDpWwWqEVVLshWUHjNwuVP7SfcmPraYuqocJp1yo2U1R7P+5QAfDhdItkuoGqIBnBYrtPp7rEPqDn9HlZA== read-pkg-up@^2.0.0: version "2.0.0" @@ -2175,9 +2185,9 @@ release-zalgo@^1.0.0: es6-error "^4.0.1" request@^2.88.0: - version "2.88.0" - resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" - integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg== + version "2.88.2" + resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" + integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== dependencies: aws-sign2 "~0.7.0" aws4 "^1.8.0" @@ -2186,7 +2196,7 @@ request@^2.88.0: extend "~3.0.2" forever-agent "~0.6.1" form-data "~2.3.2" - har-validator "~5.1.0" + har-validator "~5.1.3" http-signature "~1.2.0" is-typedarray "~1.0.0" isstream "~0.1.2" @@ -2196,7 +2206,7 @@ request@^2.88.0: performance-now "^2.1.0" qs "~6.5.2" safe-buffer "^5.1.2" - tough-cookie "~2.4.3" + tough-cookie "~2.5.0" tunnel-agent "^0.6.0" uuid "^3.3.2" @@ -2221,9 +2231,9 @@ resolve-from@^5.0.0: integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.3.2: - version "1.15.0" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.15.0.tgz#1b7ca96073ebb52e741ffd799f6b39ea462c67f5" - integrity sha512-+hTmAldEGE80U2wJJDC1lebb5jWqvTYAfm3YZ1ckk1gBr0MnCqUKlwK1e+anaFljIl+F5tR5IoZcm4ZDA1zMQw== + version "1.15.1" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.15.1.tgz#27bdcdeffeaf2d6244b95bb0f9f4b4653451f3e8" + integrity sha512-84oo6ZTtoTUpjgNEr5SJyzQhzL72gaRodsSfyxC/AXRvwu0Yse9H8eF9IpGo7b8YetZhlI6v7ZQ6bKBFV/6S7w== dependencies: path-parse "^1.0.6" @@ -2243,16 +2253,16 @@ rimraf@2.6.3: glob "^7.1.3" rimraf@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.1.tgz#48d3d4cb46c80d388ab26cd61b1b466ae9ae225a" - integrity sha512-IQ4ikL8SjBiEDZfk+DFVwqRK8md24RWMEJkdSlgNLkyyAImcjf8SWvU1qFMDOb4igBClbTQ/ugPqXcRwdFTxZw== + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== dependencies: glob "^7.1.3" -run-async@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0" - integrity sha1-A3GrSuC91yDUFm19/aZP96RFpsA= +run-async@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.0.tgz#e59054a5b86876cfae07f431d18cbaddc594f1e8" + integrity sha512-xJTbh/d7Lm7SBhc1tNvTpeCHaEzoyxPrqNlvSdMfBTYwaY++UJFyXUOxAtsRUXjlqOfj8luNaR9vjCh4KeV+pg== dependencies: is-promise "^2.1.0" @@ -2323,21 +2333,21 @@ signal-exit@^3.0.2: integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= sinon-chai@^3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-3.4.0.tgz#06fb88dee80decc565106a3061d380007f21e18d" - integrity sha512-BpVxsjEkGi6XPbDXrgWUe7Cb1ZzIfxKUbu/MmH5RoUnS7AXpKo3aIYIyQUg0FMvlUL05aPt7VZuAdaeQhEnWxg== + version "3.5.0" + resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-3.5.0.tgz#c9a78304b0e15befe57ef68e8a85a00553f5c60e" + integrity sha512-IifbusYiQBpUxxFJkR3wTU68xzBN0+bxCScEaKMjBvAQERg6FnTTc1F17rseLb1tjmkJ23730AXpFI0c47FgAg== -sinon@^8.0.2: - version "8.1.1" - resolved "https://registry.yarnpkg.com/sinon/-/sinon-8.1.1.tgz#21fffd5ad0a2d072a8aa7f8a3cf7ed2ced497497" - integrity sha512-E+tWr3acRdoe1nXbHMu86SSqA1WGM7Yw3jZRLvlCMnXwTHP8lgFFVn5BnKnF26uc5SfZ3D7pA9sN7S3Y2jG4Ew== +sinon@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-9.0.1.tgz#dbb18f7d8f5835bcf91578089c0a97b2fffdd73b" + integrity sha512-iTTyiQo5T94jrOx7X7QLBZyucUJ2WvL9J13+96HMfm2CGoJYbIPqRfl6wgNcqmzk0DI28jeGx5bUTXizkrqBmg== dependencies: "@sinonjs/commons" "^1.7.0" - "@sinonjs/formatio" "^4.0.1" - "@sinonjs/samsam" "^4.2.2" + "@sinonjs/fake-timers" "^6.0.0" + "@sinonjs/formatio" "^5.0.1" + "@sinonjs/samsam" "^5.0.3" diff "^4.0.2" - lolex "^5.1.2" - nise "^3.0.1" + nise "^4.0.1" supports-color "^7.1.0" slice-ansi@^2.1.0: @@ -2577,13 +2587,13 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" -tough-cookie@~2.4.3: - version "2.4.3" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" - integrity sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ== +tough-cookie@~2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" + integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== dependencies: - psl "^1.1.24" - punycode "^1.4.1" + psl "^1.1.28" + punycode "^2.1.1" ts-custom-error-shim@^1.0.2: version "1.0.2" @@ -2612,9 +2622,9 @@ tsconfig-paths@^3.9.0: strip-bom "^3.0.0" tslib@^1.8.1, tslib@^1.9.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" - integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== + version "1.11.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35" + integrity sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA== tsutils@^3.17.1: version "3.17.1" @@ -2647,6 +2657,11 @@ type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5, type-detect@^4.0.8: resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== +type-fest@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.11.0.tgz#97abf0872310fed88a5c466b25681576145e33f1" + integrity sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ== + type-fest@^0.8.0, type-fest@^0.8.1: version "0.8.1" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" @@ -2660,9 +2675,9 @@ typedarray-to-buffer@^3.1.5: is-typedarray "^1.0.0" typescript@^3.7.4: - version "3.7.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.5.tgz#0692e21f65fd4108b9330238aac11dd2e177a1ae" - integrity sha512-/P5lkRXkWHNAbcJIiHPfRoKqyd7bsyCma1hZNUGfn20qm64T6ZBlrzprymeu918H+mB/0rIg2gGK/BXkhhYgBw== + version "3.8.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.3.tgz#409eb8544ea0335711205869ec458ab109ee1061" + integrity sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w== uri-js@^4.2.2: version "4.2.2" @@ -2676,6 +2691,11 @@ uuid@^3.3.2, uuid@^3.3.3: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== +uuid@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-7.0.2.tgz#7ff5c203467e91f5e0d85cfcbaaf7d2ebbca9be6" + integrity sha512-vy9V/+pKG+5ZTYKf+VcphF5Oc6EFiu3W8Nv3P3zIh0EqVI80ZxOzuPfe9EHjkFNvf8+xuTHVeei4Drydlx4zjw== + v8-compile-cache@^2.0.3: version "2.1.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e" @@ -2753,9 +2773,9 @@ wrappy@1: integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= write-file-atomic@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.1.tgz#558328352e673b5bb192cf86500d60b230667d4b" - integrity sha512-JPStrIyyVJ6oCSz/691fAjFtefZ6q+fP6tm+OS4Qw6o+TGQxNp1ziY2PgS+X/m0V8OWhZiO/m4xSj+Pr4RrZvw== + version "3.0.3" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8" + integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q== dependencies: imurmurhash "^0.1.4" is-typedarray "^1.0.0" @@ -2774,7 +2794,7 @@ y18n@^4.0.0: resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== -yargs-parser@13.1.1, yargs-parser@^13.1.1: +yargs-parser@13.1.1: version "13.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.1.tgz#d26058532aa06d365fe091f6a1fc06b2f7e5eca0" integrity sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ== @@ -2782,10 +2802,18 @@ yargs-parser@13.1.1, yargs-parser@^13.1.1: camelcase "^5.0.0" decamelize "^1.2.0" -yargs-parser@^16.1.0: - version "16.1.0" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-16.1.0.tgz#73747d53ae187e7b8dbe333f95714c76ea00ecf1" - integrity sha512-H/V41UNZQPkUMIT5h5hiwg4QKIY1RPvoBV4XcjUbRM8Bk2oKqqyZ0DIEbTFZB0XjbtSPG8SAa/0DxCQmiRgzKg== +yargs-parser@^13.1.1, yargs-parser@^13.1.2: + version "13.1.2" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" + integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs-parser@^18.1.0: + version "18.1.0" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.0.tgz#1b0ab1118ebd41f68bb30e729f4c83df36ae84c3" + integrity sha512-o/Jr6JBOv6Yx3pL+5naWSoIA2jJ+ZkMYQG/ie9qFbukBe4uzmBatlXFOiu/tNKRWEtyf+n5w7jc/O16ufqOTdQ== dependencies: camelcase "^5.0.0" decamelize "^1.2.0" @@ -2799,7 +2827,7 @@ yargs-unparser@1.6.0: lodash "^4.17.15" yargs "^13.3.0" -yargs@13.3.0, yargs@^13.3.0: +yargs@13.3.0: version "13.3.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.0.tgz#4c657a55e07e5f2cf947f8a366567c04a0dedc83" integrity sha512-2eehun/8ALW8TLoIl7MVaRUrg+yCnenu8B4kBlRxj3GJGDKU1Og7sMXPNm1BYyM1DOJmTZ4YeN/Nwxv+8XJsUA== @@ -2815,10 +2843,26 @@ yargs@13.3.0, yargs@^13.3.0: y18n "^4.0.0" yargs-parser "^13.1.1" +yargs@^13.3.0: + version "13.3.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" + integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== + dependencies: + cliui "^5.0.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^13.1.2" + yargs@^15.0.2: - version "15.1.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.1.0.tgz#e111381f5830e863a89550bd4b136bb6a5f37219" - integrity sha512-T39FNN1b6hCW4SOIk1XyTOWxtXdcen0t+XYrysQmChzSipvhBO8Bj0nK1ozAasdk24dNWuMZvr4k24nz+8HHLg== + version "15.3.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.3.0.tgz#403af6edc75b3ae04bf66c94202228ba119f0976" + integrity sha512-g/QCnmjgOl1YJjGsnUg2SatC7NUYEiLXJqxNOQU9qSpjzGtGXda9b+OKccr1kLTy8BN9yqEyqfq5lxlwdc13TA== dependencies: cliui "^6.0.0" decamelize "^1.2.0" @@ -2830,7 +2874,7 @@ yargs@^15.0.2: string-width "^4.2.0" which-module "^2.0.0" y18n "^4.0.0" - yargs-parser "^16.1.0" + yargs-parser "^18.1.0" yn@3.1.1: version "3.1.1"