From 195db9474c07ce6d2f12231d01ab96c0ce1935e0 Mon Sep 17 00:00:00 2001 From: Travis Date: Fri, 1 May 2020 11:39:08 +0200 Subject: [PATCH] feat(ajv): Add UseSchema decorator. Refactoring Ajv error message and prepare v6 migration --- .nycrc | 2 +- docs/.vuepress/config.js | 12 +- docs/docs/filters.md | 5 +- docs/docs/model.md | 38 +- docs/docs/pipes.md | 231 ++++++++++++ docs/docs/services.md | 4 +- docs/docs/snippets/model/jsonschema.ts | 40 +- .../pipes/async-transformer-pipe-usage.ts | 15 + .../async-transformer-pipe-with-options.ts | 22 ++ .../snippets/pipes/async-transformer-pipe.ts | 20 + docs/docs/snippets/pipes/body-params.ts | 15 + .../pipes/controller-model-validation.ts | 11 + .../pipes/pipes-decorator-with-options.ts | 14 + docs/docs/snippets/pipes/pipes-decorator.ts | 10 + .../snippets/pipes/transformer-pipe-usage.ts | 16 + docs/docs/snippets/pipes/transformer-pipe.ts | 13 + docs/docs/snippets/pipes/use-params.ts | 20 + .../pipes/validation-pipe-identity.ts | 8 + .../pipes/validation-pipe-with-ajv.ts | 18 + .../validation/class-transformer-pipe.ts | 10 + .../validation/class-validator-pipe.ts | 27 ++ .../snippets/validation/joi-pipe-decorator.ts | 7 + .../snippets/validation/joi-pipe-usage.ts | 13 + docs/docs/snippets/validation/joi-pipe.ts | 20 + .../snippets/validation/validator-pipe.ts | 20 + docs/docs/validation.md | 112 ++++++ docs/tutorials/ajv.md | 10 +- docs/tutorials/custom-validator.md | 52 +-- .../snippets/passport/FacebookProtocol.ts | 2 +- .../src/controllers/scopes/ScopeCtrl.ts | 41 ++ packages/ajv/readme.md | 4 +- packages/ajv/src/decorators/useSchema.spec.ts | 0 packages/ajv/src/decorators/useSchema.ts | 12 + packages/ajv/src/errors/AjvValidationError.ts | 10 +- packages/ajv/src/index.ts | 7 +- packages/ajv/src/interfaces/IAjvSettings.ts | 20 +- .../ajv/src/pipes/AjvErrorFormatterPipe.ts | 66 ++++ .../ajv/src/pipes/AjvValidationPipe.spec.ts | 351 ++++++++++++++++++ packages/ajv/src/pipes/AjvValidationPipe.ts | 79 ++++ packages/ajv/src/services/Ajv.spec.ts | 41 ++ packages/ajv/src/services/Ajv.ts | 22 ++ packages/ajv/src/services/AjvService.spec.ts | 78 ---- packages/ajv/src/services/AjvService.ts | 110 ------ packages/ajv/test/ajv.spec.ts | 175 --------- .../components/PrimitiveConverter.ts | 4 +- .../ConverterDeserializationError.spec.ts | 13 - .../errors/ConverterDeserializationError.ts | 23 -- .../ConverterSerializationError.spec.ts | 13 - .../errors/ConverterSerializationError.ts | 23 -- .../errors/RequiredPropertyError.spec.ts | 2 +- .../errors/RequiredPropertyError.ts | 23 +- .../errors/UnknownPropertyError.spec.ts | 2 +- .../converters/errors/UnknownPropertyError.ts | 24 +- packages/common/src/converters/index.ts | 5 - .../services/ConverterService.spec.ts | 16 - .../converters/services/ConverterService.ts | 167 ++++----- .../src/jsonschema/class/JsonSchema.spec.ts | 42 +-- .../common/src/jsonschema/class/JsonSchema.ts | 43 +-- packages/common/src/jsonschema/index.ts | 2 + .../registries/JsonSchemesRegistry.ts | 9 +- .../jsonschema/services/JsonSchemesService.ts | 14 +- .../src/jsonschema/utils/getJsonSchema.ts | 17 + .../src/jsonschema/utils/getJsonType.spec.ts | 46 +++ .../src/jsonschema/utils/getJsonType.ts | 32 ++ .../common/src/jsonschema/utils/getSchema.ts | 6 - packages/common/src/mvc/decorators/index.ts | 1 + .../mvc/decorators/params/pathParams.spec.ts | 13 +- .../src/mvc/decorators/params/pathParams.ts | 33 +- .../mvc/decorators/params/queryParams.spec.ts | 14 +- .../src/mvc/decorators/params/queryParams.ts | 33 +- .../common/src/mvc/decorators/required.ts | 8 +- .../mvc/errors/ParseExpressionError.spec.ts | 26 -- .../src/mvc/errors/ParseExpressionError.ts | 32 -- .../src/mvc/errors/RequiredParamError.spec.ts | 28 -- .../src/mvc/errors/RequiredParamError.ts | 35 -- .../errors/RequiredValidationError.spec.ts | 31 ++ .../src/mvc/errors/RequiredValidationError.ts | 30 ++ .../src/mvc/errors/UnknowFilterError.ts | 22 -- .../src/mvc/errors/ValidationError.spec.ts | 19 + .../common/src/mvc/errors/ValidationError.ts | 12 + packages/common/src/mvc/index.ts | 6 +- .../common/src/mvc/models/ParamMetadata.ts | 4 +- .../src/mvc/pipes/ParseExpressionPipe.ts | 1 - .../common/src/mvc/pipes/RequiredPipe.spec.ts | 82 ---- packages/common/src/mvc/pipes/RequiredPipe.ts | 15 - .../src/mvc/pipes/ValidationPipe.spec.ts | 96 ++--- .../common/src/mvc/pipes/ValidationPipe.ts | 37 +- .../src/mvc/services/ValidationService.ts | 3 + .../errors/ParamValidationError.spec.ts | 107 ++++++ .../platform/errors/ParamValidationError.ts | 24 ++ .../errors/UnknowFilterError.spec.ts | 6 +- .../src/platform/errors/UnknownFilterError.ts | 11 + packages/common/src/platform/index.ts | 4 + .../platform/services/PlatformHandler.spec.ts | 11 +- .../src/platform/services/PlatformHandler.ts | 31 +- packages/core/src/class/EntityDescription.ts | 4 +- packages/core/src/class/Store.ts | 2 +- packages/core/src/utils/ObjectUtils.ts | 2 +- packages/core/src/utils/catchError.ts | 7 + packages/core/src/utils/index.ts | 1 + packages/di/src/decorators/overrideService.ts | 4 +- packages/di/src/services/InjectorService.ts | 6 +- packages/exceptions/src/core/Exception.ts | 2 +- .../src/controllers/calendars/CalendarCtrl.ts | 2 +- .../src/controllers/stream/StreamCtrl.ts | 12 + packages/integration/test/auth.spec.ts | 4 +- .../integration/test/helpers/FakeServer.ts | 3 - packages/integration/test/query.spec.ts | 3 +- packages/integration/test/response.spec.ts | 2 +- packages/integration/test/rest.spec.ts | 14 +- packages/swagger/src/utils/index.ts | 4 +- packages/testing/src/TestContext.ts | 3 +- 112 files changed, 1991 insertions(+), 1188 deletions(-) create mode 100644 docs/docs/pipes.md create mode 100644 docs/docs/snippets/pipes/async-transformer-pipe-usage.ts create mode 100644 docs/docs/snippets/pipes/async-transformer-pipe-with-options.ts create mode 100644 docs/docs/snippets/pipes/async-transformer-pipe.ts create mode 100644 docs/docs/snippets/pipes/body-params.ts create mode 100644 docs/docs/snippets/pipes/controller-model-validation.ts create mode 100644 docs/docs/snippets/pipes/pipes-decorator-with-options.ts create mode 100644 docs/docs/snippets/pipes/pipes-decorator.ts create mode 100644 docs/docs/snippets/pipes/transformer-pipe-usage.ts create mode 100644 docs/docs/snippets/pipes/transformer-pipe.ts create mode 100644 docs/docs/snippets/pipes/use-params.ts create mode 100644 docs/docs/snippets/pipes/validation-pipe-identity.ts create mode 100644 docs/docs/snippets/pipes/validation-pipe-with-ajv.ts create mode 100644 docs/docs/snippets/validation/class-transformer-pipe.ts create mode 100644 docs/docs/snippets/validation/class-validator-pipe.ts create mode 100644 docs/docs/snippets/validation/joi-pipe-decorator.ts create mode 100644 docs/docs/snippets/validation/joi-pipe-usage.ts create mode 100644 docs/docs/snippets/validation/joi-pipe.ts create mode 100644 docs/docs/snippets/validation/validator-pipe.ts create mode 100644 docs/docs/validation.md create mode 100644 examples/getting-started/src/controllers/scopes/ScopeCtrl.ts create mode 100644 packages/ajv/src/decorators/useSchema.spec.ts create mode 100644 packages/ajv/src/decorators/useSchema.ts create mode 100644 packages/ajv/src/pipes/AjvErrorFormatterPipe.ts create mode 100644 packages/ajv/src/pipes/AjvValidationPipe.spec.ts create mode 100644 packages/ajv/src/pipes/AjvValidationPipe.ts create mode 100644 packages/ajv/src/services/Ajv.spec.ts create mode 100644 packages/ajv/src/services/Ajv.ts delete mode 100644 packages/ajv/src/services/AjvService.spec.ts delete mode 100644 packages/ajv/src/services/AjvService.ts delete mode 100644 packages/ajv/test/ajv.spec.ts delete mode 100644 packages/common/src/converters/errors/ConverterDeserializationError.spec.ts delete mode 100644 packages/common/src/converters/errors/ConverterDeserializationError.ts delete mode 100644 packages/common/src/converters/errors/ConverterSerializationError.spec.ts delete mode 100644 packages/common/src/converters/errors/ConverterSerializationError.ts create mode 100644 packages/common/src/jsonschema/utils/getJsonSchema.ts create mode 100644 packages/common/src/jsonschema/utils/getJsonType.spec.ts create mode 100644 packages/common/src/jsonschema/utils/getJsonType.ts delete mode 100644 packages/common/src/jsonschema/utils/getSchema.ts delete mode 100644 packages/common/src/mvc/errors/ParseExpressionError.spec.ts delete mode 100644 packages/common/src/mvc/errors/ParseExpressionError.ts delete mode 100644 packages/common/src/mvc/errors/RequiredParamError.spec.ts delete mode 100644 packages/common/src/mvc/errors/RequiredParamError.ts create mode 100644 packages/common/src/mvc/errors/RequiredValidationError.spec.ts create mode 100644 packages/common/src/mvc/errors/RequiredValidationError.ts delete mode 100644 packages/common/src/mvc/errors/UnknowFilterError.ts create mode 100644 packages/common/src/mvc/errors/ValidationError.spec.ts create mode 100644 packages/common/src/mvc/errors/ValidationError.ts delete mode 100644 packages/common/src/mvc/pipes/RequiredPipe.spec.ts delete mode 100644 packages/common/src/mvc/pipes/RequiredPipe.ts create mode 100644 packages/common/src/platform/errors/ParamValidationError.spec.ts create mode 100644 packages/common/src/platform/errors/ParamValidationError.ts rename packages/common/src/{mvc => platform}/errors/UnknowFilterError.spec.ts (56%) create mode 100644 packages/common/src/platform/errors/UnknownFilterError.ts create mode 100644 packages/core/src/utils/catchError.ts create mode 100644 packages/integration/src/controllers/stream/StreamCtrl.ts diff --git a/.nycrc b/.nycrc index 815e77a0951..d36d0139310 100644 --- a/.nycrc +++ b/.nycrc @@ -24,5 +24,5 @@ "lines": 99.84, "statements": 99.85, "functions": 99.68, - "branches": 88.75 + "branches": 88.72 } diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index ed37f27b834..c273b8166b9 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -79,17 +79,18 @@ module.exports = { {link: "/docs/model.html", text: "Models"}, {link: "/docs/converters.html", text: "Converters"}, {link: "/docs/middlewares.html", text: "Middlewares"}, - {link: "/docs/filters.html", text: "Filters"}, + {link: "/docs/pipes.html", text: "Pipes"}, {link: "/docs/interceptors.html", text: "Interceptors"}, {link: "/docs/authentication.html", text: "Authentication"}, {link: "/docs/hooks.html", text: "Hooks"}, - {link: "/docs/exceptions.html", text: "Exceptions"}, + {link: "/docs/exceptions.html", text: "Exceptions"} ] }, { text: "Advanced", items: [ {link: "/docs/request-data-persistence.html", text: "Request data persistence"}, + {link: "/docs/validation.html", text: "Validation"}, {link: "/docs/injection-scopes.html", text: "Injection scopes"}, {link: "/docs/custom-providers.html", text: "Custom providers"}, {link: "/docs/custom-endpoint-decorators.html", text: "Custom endpoint decorators"}, @@ -151,7 +152,7 @@ module.exports = { "model", "converters", "middlewares", - "filters", + "pipes", "interceptors", "authentication", "hooks", @@ -163,6 +164,7 @@ module.exports = { collapsable: false, children: [ "request-data-persistence", + "validation", "injection-scopes", "custom-providers", "custom-endpoint-decorators", @@ -184,7 +186,6 @@ module.exports = { "seq", "swagger", "ajv", - "custom-validator", "multer", "serve-static-files", "templating", @@ -205,7 +206,6 @@ module.exports = { "/tutorials/socket-io", "/tutorials/swagger", "/tutorials/ajv", - "/tutorials/custom-validator", "/tutorials/multer", "/tutorials/serve-static-files", "/tutorials/templating", @@ -219,7 +219,7 @@ module.exports = { "/docs/model", "/docs/converters", "/docs/middlewares", - "/docs/filters", + "/docs/pipes", "/docs/interceptors", "/docs/authentication", "/docs/hooks", diff --git a/docs/docs/filters.md b/docs/docs/filters.md index ad7d328312c..3fee65e7c3a 100644 --- a/docs/docs/filters.md +++ b/docs/docs/filters.md @@ -22,12 +22,13 @@ And finally you can use your custom filter on your controller/middleware: <<< @/docs/docs/snippets/filters/filter-usage.ts ### UseFilter Options + @@UseFilter@@ allows you to register your custom decorator with few options as following: - `paramType` (ParamTypes): Parameter type like BODY, QUERY, PARAMS, etc..., - `required` (boolean, optional): Throw an error if the value is undefined, - `expression` (string, optional): An expression to parse, -- `useConverter` (boolean): Enable converterService to deserialize value, -- `useValidation` (boolean): Enable ValidationService, +- `useConverter` (boolean): Enable json mapper to deserialize value, +- `useValidation` (boolean): Enable validation, - `useType` (boolean): Set explicitly the class/model used by the parameters. diff --git a/docs/docs/model.md b/docs/docs/model.md index 01246cae6b1..f4fdcf353c6 100644 --- a/docs/docs/model.md +++ b/docs/docs/model.md @@ -12,6 +12,19 @@ The classes models can be used in the following cases: To create a model, Ts.ED provides decorators which will store and generate a standard [JsonSchema](http://json-schema.org/) model. +::: warning +Validation is only available when you import `@tsed/ajv` package in your server. + +```typescript +import "@tsed/ajv"; + +@ServerSettings() +class Server extends ServerLoader {} +``` + +Without this package, decorators like @@Email@@ won't have any effect. +::: + ## Example The example below uses decorators to describe a property of the class and store metadata @@ -50,11 +63,32 @@ use @@PropertyType@@ decorator as following: ## Use JsonSchema In some cases, it may be useful to retrieve the JSON Schema from a Model to use with another library. - -Here is an example of use with the AJV library: +This is possible by using @@getJsonSchema@@. Here a small example: <<< @/docs/docs/snippets/model/jsonschema.ts +Result: + +```json +{ + "type": "object", + "properties": { + "firstName": { + "type": "string", + "minLength": 3 + }, + "lastName": { + "type": "string", + "minLength": 3 + } + }, + "required": [ + "firstName", + "lastName" + ] +} +``` + ## Decorators diff --git a/docs/docs/pipes.md b/docs/docs/pipes.md new file mode 100644 index 00000000000..63527b533aa --- /dev/null +++ b/docs/docs/pipes.md @@ -0,0 +1,231 @@ +# Pipes + +A pipe is a class annotated with the @@Injectable@@ decorator. +Pipes should implement the @@IPipe@@ interface. + +Pipes have two typical use cases: + +- **transformation**: transform input data to the desired output +- **validation**: evaluate input data and if valid, simply pass it through unchanged; otherwise, throw an exception when the data is incorrect + +Pipes are called when an Incoming request is handled by the controller route handler and operate on the `Request` object. +Pipe receive the argument where the pipe is placed. This means, each parameters can invoke a list of pipes, and it can be different for each parameters. +Any transformation or validation operation takes place at that time, after which the route handler is invoked with any (potentially) transformed arguments. + +::: tip +Pipes run inside an exception zones. This means that when a Pipe throws an exception, it will be handled by the @@GlobalExceptionHandler@@. Given the above, +any method controller are called when an exception is thrown inside a Pipe. +::: + +## Built-in pipes + +Ts.ED comes with the following pipes: + +- @@ParseExpressionPipe@@, +- @@ValidationPipe@@, +- @@DeserializerPipe@@ + +Theses pipes are exported to allow pipes overriding. Theses decorators are commonly used by @@BodyParams@@, @@QueryParams@@, etc... +In this case, the pipes are added by using @@UseParam@@ on a parameters. + +For example, the @@BodyParams@@ use on a parameters call the @@UseParams@@ with some options, and @@UseParams@@ call also different decorators +to add Pipes: + + + + +<<< @/docs/docs/snippets/pipes/body-params.ts + + + + +<<< @/docs/docs/snippets/pipes/use-params.ts + + + + +The **main idea** is, you are able to combine any pipes to reach the expected behavior ! + +Now let's build a validation pipe from scratch to understand the pipe mechanism. + +Initially, we'll have it simply take an input value and immediately return the same value, behaving like an identity function. + +<<< @/docs/docs/snippets/pipes/validation-pipe-identity.ts + +::: tip +`IPipe` is a generic interface in which `T` indicates the type of the input value, and `R` indicates the return type of the `transform()` method. +::: + +Every pipe has to provide the transform() method. This method has two parameters: + +- `value` +- `metadata` + +The `value` is the currently processed argument (before it is received by the route handling method), while metadata is its `metadata`. +The metadata object has these properties (see also @@ParamMetadata@@): + +```typescript +class ParamMetadata { + target: Type; + propertyKey: string | symbol; + index: number; + required: boolean; + paramType: string | ParamTypes; + expression: string; + useType: Type; + pipes: Type[]; + store: Store; +} +``` + +These properties describe the current processed argument. + +Property | Description +---|--- +`target` | The class of the parameter +`propertyKey` | The method of the parameter +`index` | The position of the parameter in the method signature +`required` | Indicates whether the parameter is required +`paramType` | @@ParamTypes@@ represents the starting object used by the first pipe, +`expression` | Expression used to get the property from the object injected with paramType, +`type` | Class used to deserialize the plain object +`collectionType` | Collection type used to deserialize a collection of plain object +`store` | @@Store@@ contain extra options collected by the decorators used on the parameter. + +::: warning +TypeScript interfaces disappear during transpilation. Thus, if a method parameter's `type` is declared as an interface instead of a class, the type value will be `Object`. +::: + +## Validation use case + +The goal of validation use case is to ensure that the input parameter is valid before use in a method. + +Officially, Ts.ED use @@JsonSchema@@ two ways to declare a JsonSchema: + +- With [model](/docs/models.html) decorators, +- With @@UseSchema@@ decorator. + +We'll takes the model declaration to explain the Validation pipe use case. Let's focus in on the a `PersonModel`: + +```typescript +import {MinLength, Required} from "@tsed/common"; + +class PersonModel { + @MinLength(3) + @Required() + firstName: string; + + @MinLength(3) + @Required() + lastName: string; +} +``` + +`PersonModel` will generate the following JsonSchema: + +```json +{ + "type": "object", + "properties": { + "firstName": { + "type": "string", + "minLength": 3 + }, + "lastName": { + "type": "string", + "minLength": 3 + } + }, + "required": [ + "firstName", + "lastName" + ] +} +``` + +We want to ensure that any incoming request to the create method contains a valid body. +So we have to validate the two members of the `PersonModel` object, used as type parameter: + +<<< @/docs/docs/snippets/pipes/controller-model-validation.ts + +By using a pipe, we'll are able to handle the parameter, get his schema and use a validation library (here is AJV) +and throw exception when the payload is not valid. + +<<< @/docs/docs/snippets/pipes/validation-pipe-with-ajv.ts + +The validation pipe is a very specific use case because, Ts.ED use it automatically when a parameter is handled + by the **routing request**. The previous pipe example, to works, need to be registered with the @@OverrideProvider@@ decorator instead of @@Injectable@@. + +See more details on the [validation page](/docs/validation.html). + +## Transformation use case + +Validation isn't the sole use case for **Pipes**. +At the beginning of this chapter, we mentioned that a pipe can also **transform** the input data to the desired output. +This is possible because the value returned from the transform function completely overrides the previous value of the argument. + +When is this useful? Consider that sometimes the data passed from the client needs to undergo some change - *for example converting plain object javascript to class* - before it can be properly handled by the route handler method. +Furthermore, some required data fields may be missing, and we would like to apply default values. + +Transformer pipes can perform these functions by interposing a processing function between the client request and the request handler. + +<<< @/docs/docs/snippets/pipes/transformer-pipe.ts + +We can simply tie this pipe to the selected param as shown below: + +<<< @/docs/docs/snippets/pipes/transformer-pipe-usage.ts + +::: tip +On the previous example, we use @@RawPathParams@@ to get the raw value, without transformation or validation from existing Ts.ED Pipe. +::: + +## Async transformation use case + +Pipe transformation support also `async` and promise as returned value. +This is useful, when you have to get data from **database** based on an input data like an ID. + +Given this `PersonModel`: + +```typescript +import {MinLength, Required} from "@tsed/common"; +import {Property} from "./property"; + +class PersonModel { + @Property() + id: string; + + @MinLength(3) + @Required() + firstName: string; + + @MinLength(3) + @Required() + lastName: string; +} +``` + +We can implement the following pipe to get Person data from database: + +<<< @/docs/docs/snippets/pipes/async-transformer-pipe.ts + +Then, we can use this pipe on a parameter with @@UsePipe@@: + +<<< @/docs/docs/snippets/pipes/async-transformer-pipe-usage.ts + +::: tip +The previous example use two pipes decorators which are dependent to each other. We can summarize it by declaring a custom decorator: + +<<< @/docs/docs/snippets/pipes/pipes-decorator.ts +::: + +## Get options from decorator + +Sometime it might be useful to forward option from decorator used on parameters to the registered Pipe. + +Let's focus on our previous decorator example, by adding extra parameter options: + +<<< @/docs/docs/snippets/pipes/pipes-decorator-with-options.ts + +Now, we can retrieve the options by using the `metadata.store`: + +<<< @/docs/docs/snippets/pipes/async-transformer-pipe-with-options.ts diff --git a/docs/docs/services.md b/docs/docs/services.md index f65ad76e876..6993559f382 100644 --- a/docs/docs/services.md +++ b/docs/docs/services.md @@ -80,8 +80,8 @@ class MyController { ## Override a Service -The decorator [@OverrideService](/api/di/decorators/OverrideService.md) gives you the ability to -override some internal Ts.ED service like the [ParseService](/api/common/filters/services/ParseService.md). +The decorator @@OverrideProvider@@ gives you the ability to +override some internal Ts.ED service like the @@ParseService@@. Example usage: ```typescript diff --git a/docs/docs/snippets/model/jsonschema.ts b/docs/docs/snippets/model/jsonschema.ts index 352d1df2f57..dcb935f11cb 100644 --- a/docs/docs/snippets/model/jsonschema.ts +++ b/docs/docs/snippets/model/jsonschema.ts @@ -1,33 +1,15 @@ -import {JsonSchemesService, OverrideService, ValidationService} from "@tsed/common"; -import * as Ajv from "ajv"; -import {ErrorObject} from "ajv"; -import {BadRequest} from "@tsed/exceptions"; +import {getJsonSchema, MinLength, Required} from "@tsed/common"; -@OverrideService(ValidationService) -export class AjvService extends ValidationService { - constructor(private jsonSchemaService: JsonSchemesService) { - super(); - } +class PersonModel { + @MinLength(3) + @Required() + firstName: string; - public validate(obj: any, targetType: any, baseType?: any): void { - const schema = this.jsonSchemaService.getSchemaDefinition(targetType); - - if (schema) { - const ajv = new Ajv(); - const valid = ajv.validate(schema, obj); - - if (!valid) { - throw(this.buildErrors(ajv.errors!)); - } - } - } - - private buildErrors(errors: ErrorObject[]) { + @MinLength(3) + @Required() + lastName: string; +} - const message = errors.map(error => { - return `{{name}}${error.dataPath} ${error.message} (${error.keyword})`; - }).join("\n"); +const schema = getJsonSchema(PersonModel); - return new BadRequest(message); - } -} +console.log(schema); diff --git a/docs/docs/snippets/pipes/async-transformer-pipe-usage.ts b/docs/docs/snippets/pipes/async-transformer-pipe-usage.ts new file mode 100644 index 00000000000..637bc4e6409 --- /dev/null +++ b/docs/docs/snippets/pipes/async-transformer-pipe-usage.ts @@ -0,0 +1,15 @@ +import {Controller, Put, RawPathParams, UsePipe} from "@tsed/common"; +import {PersonModel} from "../models/PersonModel"; +import {PersonPipe} from "../services/PersonPipe"; + +@Controller("/persons") +export class PersonsController { + @Put("/:id") + async update(@RawPathParams("id") + @UsePipe(PersonPipe) person: PersonModel) { + + // do something + + return person; + } +} diff --git a/docs/docs/snippets/pipes/async-transformer-pipe-with-options.ts b/docs/docs/snippets/pipes/async-transformer-pipe-with-options.ts new file mode 100644 index 00000000000..8f13587ab3a --- /dev/null +++ b/docs/docs/snippets/pipes/async-transformer-pipe-with-options.ts @@ -0,0 +1,22 @@ +import {Inject, Injectable, IPipe, ParamMetadata} from "@tsed/common"; +import {NotFound} from "@tsed/exceptions"; +import {IUsePersonParamOptions} from "../decorators/UsePersonParam"; +import {PersonModel} from "../models/PersonModel"; +import {PersonsService} from "../models/PersonsService"; + +@Injectable() +export class PersonPipe implements IPipe> { + @Inject() + personsService: PersonsService; + + async transform(id: string, metadata: ParamMetadata): Promise { + const person = await this.personsService.findOne(id); + const options = metadata.store.get(PersonPipe); + + if (!person && options.optional) { + throw new NotFound("Person not found"); + } + + return person; + } +} diff --git a/docs/docs/snippets/pipes/async-transformer-pipe.ts b/docs/docs/snippets/pipes/async-transformer-pipe.ts new file mode 100644 index 00000000000..068ea2cb004 --- /dev/null +++ b/docs/docs/snippets/pipes/async-transformer-pipe.ts @@ -0,0 +1,20 @@ +import {Inject, Injectable, IPipe, ParamMetadata} from "@tsed/common"; +import {NotFound} from "@tsed/exceptions"; +import {PersonModel} from "../models/PersonModel"; +import {PersonsService} from "../models/PersonsService"; + +@Injectable() +export class PersonPipe implements IPipe> { + @Inject() + personsService: PersonsService; + + async transform(id: string, metadata: ParamMetadata): Promise { + const person = await this.personsService.findOne(id); + + if (!person) { + throw new NotFound("Person not found"); + } + + return person; + } +} diff --git a/docs/docs/snippets/pipes/body-params.ts b/docs/docs/snippets/pipes/body-params.ts new file mode 100644 index 00000000000..783d4d01453 --- /dev/null +++ b/docs/docs/snippets/pipes/body-params.ts @@ -0,0 +1,15 @@ +import {ParamTypes, UseParam} from "@tsed/common"; +import {mapParamsOptions} from "@tsed/common/src/mvc/decorators/utils/mapParamsOptions"; + +export function BodyParams(...args: any[]): ParameterDecorator { + const {expression, useType, useConverter = true, useValidation = true} = mapParamsOptions(args); + + return UseParam( + ParamTypes.BODY, // The first parameters is the starting object (eg: `request.body`) + { + expression, + useType, + useConverter, + useValidation + }); +} diff --git a/docs/docs/snippets/pipes/controller-model-validation.ts b/docs/docs/snippets/pipes/controller-model-validation.ts new file mode 100644 index 00000000000..bce860a73c5 --- /dev/null +++ b/docs/docs/snippets/pipes/controller-model-validation.ts @@ -0,0 +1,11 @@ +import {Controller, Post} from "@tsed/common"; +import {BodyParams} from "./body-params"; +import {PersonModel} from "../models/PersonModel" + +@Controller("/persons") +export class PersonsController { + @Post("/") + save(@BodyParams() person: PersonModel) { + return person; + } +} diff --git a/docs/docs/snippets/pipes/pipes-decorator-with-options.ts b/docs/docs/snippets/pipes/pipes-decorator-with-options.ts new file mode 100644 index 00000000000..a849e136140 --- /dev/null +++ b/docs/docs/snippets/pipes/pipes-decorator-with-options.ts @@ -0,0 +1,14 @@ +import {RawPathParams, UsePipe} from "@tsed/common"; +import {applyDecorators} from "@tsed/core"; +import {PersonPipe} from "../services/PersonPipe"; + +export interface IUsePersonParamOptions { + optional?: boolean; +} + +export function UsePersonParam(options: IUsePersonParamOptions = {}): ParameterDecorator { + return applyDecorators( + RawPathParams("id"), + UsePipe(PersonPipe, options) // UsePipe accept second parameter to store your options + ); +} diff --git a/docs/docs/snippets/pipes/pipes-decorator.ts b/docs/docs/snippets/pipes/pipes-decorator.ts new file mode 100644 index 00000000000..165423446fd --- /dev/null +++ b/docs/docs/snippets/pipes/pipes-decorator.ts @@ -0,0 +1,10 @@ +import {RawPathParams, UsePipe} from "@tsed/common"; +import {applyDecorators} from "@tsed/core"; +import {PersonPipe} from "../services/PersonPipe"; + +export function UsePersonParam(): ParameterDecorator { + return applyDecorators( + RawPathParams("id"), + UsePipe(PersonPipe) + ); +} diff --git a/docs/docs/snippets/pipes/transformer-pipe-usage.ts b/docs/docs/snippets/pipes/transformer-pipe-usage.ts new file mode 100644 index 00000000000..17be2cfa90b --- /dev/null +++ b/docs/docs/snippets/pipes/transformer-pipe-usage.ts @@ -0,0 +1,16 @@ +import {Controller, Get, Inject, RawPathParams, UsePipe} from "@tsed/common"; +import {ParseIntPipe} from "../pipes/ParseIntPipe"; +import {PersonsService} from "../services/PersonsService"; + +@Controller("/persons") +export class PersonsController { + @Inject() + private personsService: PersonsService; + + @Get(":id") + async findOne(@RawPathParams("id") + @UsePipe(ParseIntPipe) id: number) { + + return this.personsService.findOne(id); + } +} diff --git a/docs/docs/snippets/pipes/transformer-pipe.ts b/docs/docs/snippets/pipes/transformer-pipe.ts new file mode 100644 index 00000000000..f13b0b0d77d --- /dev/null +++ b/docs/docs/snippets/pipes/transformer-pipe.ts @@ -0,0 +1,13 @@ +import {Injectable, IPipe, ParamMetadata, ValidationError} from "@tsed/common"; + +@Injectable() +export class ParseIntPipe implements IPipe { + transform(value: string, metadata: ParamMetadata): number { + const val = parseInt(value, 10); + if (isNaN(val)) { + throw new ValidationError("Value must an integer or a parsable integer"); + } + + return val; + } +} diff --git a/docs/docs/snippets/pipes/use-params.ts b/docs/docs/snippets/pipes/use-params.ts new file mode 100644 index 00000000000..be733c58fbf --- /dev/null +++ b/docs/docs/snippets/pipes/use-params.ts @@ -0,0 +1,20 @@ +import { + IParamOptions, + ParamTypes, + UseDeserialization, + UseParamExpression, + UseParamType, + UseValidation +} from "@tsed/common"; +import {UseType} from "@tsed/common"; +import {applyDecorators} from "@tsed/core"; + +export function UseParam(paramType: ParamTypes | string, options: IParamOptions = {}): ParameterDecorator { + return applyDecorators( + UseParamType(paramType), + options.useType && UseType(options.useType), + options.expression && UseParamExpression(options.expression), + options.useValidation && UseValidation(), + options.useConverter && UseDeserialization() + ) as ParameterDecorator; +} diff --git a/docs/docs/snippets/pipes/validation-pipe-identity.ts b/docs/docs/snippets/pipes/validation-pipe-identity.ts new file mode 100644 index 00000000000..818819ef15f --- /dev/null +++ b/docs/docs/snippets/pipes/validation-pipe-identity.ts @@ -0,0 +1,8 @@ +import {Injectable, IPipe, ParamMetadata} from "@tsed/common"; + +@Injectable() +export class ValidationPipe implements IPipe { + transform(value: any, metadata: ParamMetadata) { + return value; + } +} diff --git a/docs/docs/snippets/pipes/validation-pipe-with-ajv.ts b/docs/docs/snippets/pipes/validation-pipe-with-ajv.ts new file mode 100644 index 00000000000..c8986744a52 --- /dev/null +++ b/docs/docs/snippets/pipes/validation-pipe-with-ajv.ts @@ -0,0 +1,18 @@ +import {getJsonSchema, IPipe, ParamMetadata, ValidationError} from "@tsed/common"; +import {Injectable} from "@tsed/di"; +import * as Ajv from "ajv"; + +@Injectable() +export class AjvValidationPipe implements IPipe { + ajv = new Ajv(); + + transform(value: any, metadata: ParamMetadata): any { + const schema = getJsonSchema(metadata.type); + + if (!this.ajv.validate(schema, value)) { + throw new ValidationError("Oops something is wrong", this.ajv.errors!); + } + + return value; + } +} diff --git a/docs/docs/snippets/validation/class-transformer-pipe.ts b/docs/docs/snippets/validation/class-transformer-pipe.ts new file mode 100644 index 00000000000..2fa1e27784b --- /dev/null +++ b/docs/docs/snippets/validation/class-transformer-pipe.ts @@ -0,0 +1,10 @@ +import {DeserializerPipe, IPipe, ParamMetadata} from "@tsed/common"; +import {OverrideProvider} from "@tsed/di"; +import {plainToClass} from "class-transformer"; + +@OverrideProvider(DeserializerPipe) +export class ClassTransformerPipe implements IPipe { + transform(value: any, metadata: ParamMetadata) { + return plainToClass(metadata.type, value); + } +} diff --git a/docs/docs/snippets/validation/class-validator-pipe.ts b/docs/docs/snippets/validation/class-validator-pipe.ts new file mode 100644 index 00000000000..fd78f1589e9 --- /dev/null +++ b/docs/docs/snippets/validation/class-validator-pipe.ts @@ -0,0 +1,27 @@ +import {IPipe, OverrideProvider, ParamMetadata, ValidationError, ValidationPipe} from "@tsed/common"; +import {plainToClass} from "class-transformer"; +import {validate} from "class-validator"; + +@OverrideProvider(ValidationPipe) +export class ClassValidationPipe extends ValidationPipe implements IPipe { + async transform(value: any, metadata: ParamMetadata) { + if (!this.shouldValidate(metadata)) { // there is no type and collectionType + return value; + } + + const object = plainToClass(metadata.type, value); + const errors = await validate(object); + + if (errors.length > 0) { + throw new ValidationError("Oops something is wrong", errors); + } + + return value; + } + + protected shouldValidate(metadata: ParamMetadata): boolean { + const types: Function[] = [String, Boolean, Number, Array, Object]; + + return !super.shouldValidate(metadata) || !types.includes(metadata.type); + } +} diff --git a/docs/docs/snippets/validation/joi-pipe-decorator.ts b/docs/docs/snippets/validation/joi-pipe-decorator.ts new file mode 100644 index 00000000000..f53d72756a9 --- /dev/null +++ b/docs/docs/snippets/validation/joi-pipe-decorator.ts @@ -0,0 +1,7 @@ +import {ObjectSchema} from "@hapi/joi"; +import {StoreSet} from "@tsed/core"; +import {JoiValidationPipe} from "../pipes/JoiValidationPipe"; + +export function UseJoiSchema(schema: ObjectSchema) { + return StoreSet(JoiValidationPipe, schema); +} diff --git a/docs/docs/snippets/validation/joi-pipe-usage.ts b/docs/docs/snippets/validation/joi-pipe-usage.ts new file mode 100644 index 00000000000..775041ff1fb --- /dev/null +++ b/docs/docs/snippets/validation/joi-pipe-usage.ts @@ -0,0 +1,13 @@ +import {BodyParams, Controller, Get, Inject, UsePipe} from "@tsed/common"; +import {UseJoiSchema} from "../decorators/UseJoiSchema"; +import {PersonModel, joiPersonModel} from "../models/PersonModel"; + +@Controller("/persons") +export class PersonsController { + @Get(":id") + async findOne(@BodyParams("id") + @UseJoiSchema(joiPersonModel) person: PersonModel) { + + return person; + } +} diff --git a/docs/docs/snippets/validation/joi-pipe.ts b/docs/docs/snippets/validation/joi-pipe.ts new file mode 100644 index 00000000000..755de795d63 --- /dev/null +++ b/docs/docs/snippets/validation/joi-pipe.ts @@ -0,0 +1,20 @@ +import {ObjectSchema} from "@hapi/joi"; +import {Injectable, IPipe, ParamMetadata, ValidationError} from "@tsed/common"; + +@Injectable() +export class JoiValidationPipe implements IPipe { + transform(value: any, metadata: ParamMetadata) { + const schema = metadata.store.get(JoiValidationPipe); + + if (schema) { + const {error} = schema.validate(value); + + if (error) { + throw new ValidationError("Oops something is wrong", [error]); + } + } + + return value; + } +} + diff --git a/docs/docs/snippets/validation/validator-pipe.ts b/docs/docs/snippets/validation/validator-pipe.ts new file mode 100644 index 00000000000..d8e5b63adba --- /dev/null +++ b/docs/docs/snippets/validation/validator-pipe.ts @@ -0,0 +1,20 @@ +import {getJsonSchema, IPipe, OverrideProvider, ParamMetadata, ValidationError, ValidationPipe} from "@tsed/common"; +import {validate} from "./validate"; + +@OverrideProvider(ValidationPipe) +export class CustomValidationPipe extends ValidationPipe implements IPipe { + public transform(obj: any, metadata: ParamMetadata): void { + // JSON service contain tool to build the Schema definition of a model. + const schema = getJsonSchema(metadata.type); + + if (schema) { + const valid = validate(schema, obj); + + if (!valid) { + throw new ValidationError("My message", [ + /// list of errors + ]); + } + } + } +} diff --git a/docs/docs/validation.md b/docs/docs/validation.md new file mode 100644 index 00000000000..3dfa6ddb132 --- /dev/null +++ b/docs/docs/validation.md @@ -0,0 +1,112 @@ +# Validation + +Ts.ED provide by default a [AJV](/tutorials/ajv.md) package `@tsed/ajv` to perform a validation on a [Model](/docs/models.html). + +This package must be installed to run automatic validation on input data. Any model used on parameter and annotated with one of JsonSchema decorator will be +validated with AJV. + +``` +npm install --save @tsed/ajv +``` + +But, you can choose another library as model validator. + +## Custom Validation + +Ts.ED allows you to change the default @@ValidationPipe@@ by your own library. The principle is simple. +Create a CustomValidationPipe and use @@OverrideProvider@@ to change the default @@ValidationPipe@@. + +<<< @/docs/docs/snippets/validation/validator-pipe.ts + +::: warning +Don't forgot to import the new `CustomValidatorPipe` in your `server.ts` ! +::: + +### Use Joi + +There are several approaches available for object validation. One common approach is to use schema-based validation. +The [Joi](https://github.com/hapijs/joi) library allows you to create schemas in a pretty straightforward way, with a readable API. + +Let's look at a pipe that makes use of Joi-based schemas. + +Start by installing the required package: + +``` +npm install --save @hapi/joi +npm install --save-dev @types/hapi__joi +``` + +In the code sample below, we create a simple class that takes a schema as a constructor argument. +We then apply the `schema.validate()` method, which validates our incoming argument against the provided schema. + +In the next section, you'll see how we supply the appropriate schema for a given controller method using the @@UsePipe@@ decorator. + +<<< @/docs/docs/snippets/validation/joi-pipe.ts + +Now, we have to create a custom decorator to store the Joi schema along with a parameter: + +<<< @/docs/docs/snippets/validation/joi-pipe-decorator.ts + +And finally, we are able to add Joi schema with our new decorator: + +<<< @/docs/docs/snippets/validation/joi-pipe-usage.ts + +### Use Class validator + +Let's look at an alternate implementation of our validation technique. + +Ts.ED works also with the [class-validator](https://github.com/typestack/class-validator) library. +This library allows you to use **decorator-based** validation (like Ts.ED with his [JsonSchema](/docs/models) decorators). +Decorator-based validation combined with Ts.ED [Pipe](/docs/pipes.html) capabilities since we have access to the medata.type of the processed parameter. + +Before we start, we need to install the required packages: + +``` +npm i --save class-validator class-transformer +``` + +Once these are installed, we can add a few decorators to the `PersonModel`: + +```typescript +import { IsString, IsInt } from "class-validator"; + +export class CreateCatDto { + @IsString() + firstName: string; + + @IsInt() + age: number; +} +``` + +::: tip +Read more about the class-validator decorators [here](https://github.com/typestack/class-validator#usage). +::: + +Now we can create a [ClassValidationPipe] class: + +<<< @/docs/docs/snippets/validation/class-validator-pipe.ts + +::: warning Notice +Above, we have used the [class-transformer](https://github.com/typestack/class-transformer) library. +It's made by the same author as the **class-validator** library, and as a result, they play very well together. +::: + +Note that we get the type from @@ParamMetadata@@ and give it to plainToObject function. The method `shouldValidate` +bypass the validation process for the basic types and when the `metadata.type` or `metadata.collectionType` are not available. + +Next, we use the **class-transformer** function `plainToClass()` to transform our plain JavaScript argument object into a typed object +so that we can apply validation. The incoming body, when deserialized from the network request, does not have any type information. +Class-validator needs to use the validation decorators we defined for our **PersonModel** earlier, +so we need to perform this transformation. + +Finally, we return the value when we haven't errors or throws a `ValidationError`. + +::: tip +If you use **class-validator**, it also be logical to use [class-transformer](https://github.com/typestack/class-transformer) as Deserializer. +So we recommend to override also the @@DeserializerPipe@@. + +<<< @/docs/docs/snippets/validation/class-transformer-pipe.ts +::: + +We just have to import the pipe on our `server.ts` and use model as type on a parameter. diff --git a/docs/tutorials/ajv.md b/docs/tutorials/ajv.md index 5782360700f..5170e402394 100644 --- a/docs/tutorials/ajv.md +++ b/docs/tutorials/ajv.md @@ -47,7 +47,7 @@ export class Server extends ServerLoader { The AJV module allows a few settings to be added through the ServerSettings (all are optional): * *options*, are AJV specific options passed directly to the AJV constructor, -* *errorFormat*, can be used to alter the output produced by the AjvService. +* *errorFormatter*, can be used to alter the output produced by the `@tsed/ajv` package. The error message could be changed like: @@ -58,13 +58,11 @@ import "@tsed/ajv"; // import ajv ts.ed module @ServerSettings({ rootDir: __dirname, ajv: { - errorFormat: (error) => `At ${error.modelName}${error.dataPath}, value '${error.data}' ${error.message}`, - options: {verbose: true} + errorFormatter: (error) => `At ${error.modelName}${error.dataPath}, value '${error.data}' ${error.message}`, + verbose: true }, }) -export class Server extends ServerLoader { - -} +export class Server extends ServerLoader {} ``` ## Decorators diff --git a/docs/tutorials/custom-validator.md b/docs/tutorials/custom-validator.md index a287aab99f2..6e03d823d72 100644 --- a/docs/tutorials/custom-validator.md +++ b/docs/tutorials/custom-validator.md @@ -1,53 +1,3 @@ # Custom validator -Ts.ED provide by default a [AJV](/tutorials/ajv.md) package to perform a validation on a Model. But, you can choose another library as model validator. - -To do that, you need to create a custom validation service that will inherit from the [ValidationService](/api/common/filters/services/ValidationService.md) - and override this service with the [OverrideService](/api/di/decorators/OverrideService.md) decorator. - -### Create your service - -In your project, create a new file named `CustomValidationService.ts` and create a class based on this example: - -```typescript -import {BadRequest} from "@tsed/exceptions"; -import {OverrideService, JsonSchemesService, ValidationService} from "@tsed/common"; - -@OverrideService(ValidationService) -export class CustomValidationService extends ValidationService { - constructor(private jsonSchemaService: JsonSchemesService) { - super(); - } - - public validate(obj: any, targetType: any, baseType?: any): void { - // JSON service contain tool to build the Schema definition of a model. - const schema = this.jsonSchemaService.getSchemaDefinition(targetType); - - if (schema) { - const valid = myLibraryValidation.validate(schema, obj); - - if (!valid) { - throw(new BadRequest(`{{name}} is wrong`)); - } - } - } -} -``` - -### Import your service - -Edit your `server.ts` and import manually your `CustomValidationService`: - -```typescript -import {ServerLoader, ServerSettings} from "@tsed/common"; -import "./services/override/CustomValidationService"; - -@ServerSettings({ - // ... -}) -export class Server extends ServerLoader { - -} -``` - -Now your custom validation service will be used when a model must be validated. +Content moved on: [validation page](/docs/validation.html) diff --git a/docs/tutorials/snippets/passport/FacebookProtocol.ts b/docs/tutorials/snippets/passport/FacebookProtocol.ts index d252d602964..af6b78027f5 100644 --- a/docs/tutorials/snippets/passport/FacebookProtocol.ts +++ b/docs/tutorials/snippets/passport/FacebookProtocol.ts @@ -4,7 +4,7 @@ import {Strategy, StrategyOptions} from "passport-facebook"; import {AuthService} from "../services/auth/AuthService"; @Protocol({ - name: "discord", + name: "facebook", useStrategy: Strategy, settings: { clientID: "FACEBOOK_APP_ID", diff --git a/examples/getting-started/src/controllers/scopes/ScopeCtrl.ts b/examples/getting-started/src/controllers/scopes/ScopeCtrl.ts new file mode 100644 index 00000000000..77f7e93b3d5 --- /dev/null +++ b/examples/getting-started/src/controllers/scopes/ScopeCtrl.ts @@ -0,0 +1,41 @@ +import {Controller, Get, PathParams, Post, Res} from "@tsed/common"; + +@Controller("/scopes") +export class ScopeCtrl { + @Get("/scenario1/:scope/:scopeId") + async testScenario1(@PathParams("scope") scope: string) { + // Here scope will be {0: 'a', 1: 'b', 2: 'c'} instead of 'abc' in version 5.47.0 + console.log(scope); + + return scope; + } + + @Get("/scenario2/:scope/:scopeId") + async testScenario2(@PathParams("scope") scope: any) { + // This way it works in version 5.47.0 + console.log(scope); + + return scope; + } + + @Post("/scenario3/:scope/:scopeId") + async testScenario3( + @PathParams("scope") scope: string + ): Promise { + console.log(scope); + + // Here the function will return {0: 'a', 1: 'b', 2: 'c'} instead of ['a','b','c'] in version 5.44.13 + return ["a", "b", "c"]; + } + + @Post("/scenario4/:scope/:scopeId") + async testScenario4( + @PathParams("scope") scope: string, + @Res() response: any + ): Promise { + console.log(scope); + + // This way it works in version 5.44.13 + return response.json(["a", "b", "c"]); + } +} diff --git a/packages/ajv/readme.md b/packages/ajv/readme.md index 2028c631222..a66fb9a2e11 100644 --- a/packages/ajv/readme.md +++ b/packages/ajv/readme.md @@ -70,7 +70,7 @@ export class CalendarModel { The AJV module allows a few settings to be added through the ServerSettings (all are optional): * *options*, are AJV specific options passed directly to the AJV constructor, -* *errorFormat*, can be used to alter the output produced by the AjvService. +* *errorFormatter*, can be used to alter the output produced by the @tsed/ajv package. The error message could be changed like: @@ -81,7 +81,7 @@ import "@tsed/ajv"; // import ajv ts.ed module @ServerSettings({ rootDir: __dirname, ajv: { - errorFormat: (error) => `At ${error.modelName}${error.dataPath}, value '${error.data}' ${error.message}`, + errorFormatter: (error) => `At ${error.modelName}${error.dataPath}, value '${error.data}' ${error.message}`, options: {verbose: true} }, }) diff --git a/packages/ajv/src/decorators/useSchema.spec.ts b/packages/ajv/src/decorators/useSchema.spec.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/ajv/src/decorators/useSchema.ts b/packages/ajv/src/decorators/useSchema.ts new file mode 100644 index 00000000000..ceaf98940a9 --- /dev/null +++ b/packages/ajv/src/decorators/useSchema.ts @@ -0,0 +1,12 @@ +import {StoreMerge} from "@tsed/core"; +import {JSONSchema6} from "json-schema"; +import {AjvValidationPipe} from "../pipes/AjvValidationPipe"; + +/** + * Use raw JsonSchema to validate parameter. + * @param schema + * @constructor + */ +export function UseSchema(schema: JSONSchema6) { + return StoreMerge(AjvValidationPipe, {schema}); +} diff --git a/packages/ajv/src/errors/AjvValidationError.ts b/packages/ajv/src/errors/AjvValidationError.ts index 200daf39dfe..9ab050c004b 100644 --- a/packages/ajv/src/errors/AjvValidationError.ts +++ b/packages/ajv/src/errors/AjvValidationError.ts @@ -1,11 +1,5 @@ -import {IResponseError} from "@tsed/common"; +import {ValidationError} from "@tsed/common"; -export class AjvValidationError extends Error implements IResponseError { +export class AjvValidationError extends ValidationError { public name: string = "AJV_VALIDATION_ERROR"; - public errors: any[]; - - constructor(message: string, errors: any[]) { - super(message); - this.errors = errors; - } } diff --git a/packages/ajv/src/index.ts b/packages/ajv/src/index.ts index 971cfe5e595..74cab91697e 100644 --- a/packages/ajv/src/index.ts +++ b/packages/ajv/src/index.ts @@ -1,10 +1,15 @@ export * from "./errors/AjvValidationError"; -export * from "./services/AjvService"; +export * from "./decorators/useSchema"; +export * from "./pipes/AjvValidationPipe"; +export * from "./pipes/AjvErrorFormatterPipe"; +export * from "./services/Ajv"; + import {IAjvSettings} from "./interfaces/IAjvSettings"; declare global { namespace TsED { interface Configuration { + // @ts-ignore ajv?: IAjvSettings; } } diff --git a/packages/ajv/src/interfaces/IAjvSettings.ts b/packages/ajv/src/interfaces/IAjvSettings.ts index 5ef6ed8f45e..99c7c883457 100644 --- a/packages/ajv/src/interfaces/IAjvSettings.ts +++ b/packages/ajv/src/interfaces/IAjvSettings.ts @@ -1,4 +1,4 @@ -import {ErrorObject} from "ajv"; +import {ErrorObject, Options} from "ajv"; /** * @@ -15,14 +15,14 @@ export type ErrorFormatter = (error: AjvErrorObject) => string; /** * */ -export interface IAjvSettings { +export interface IAjvSettings extends Options { + /** + * @deprecated Use errorFormatter instead + */ errorFormat?: ErrorFormatter; - options?: IAjvOptions; -} - -/** - * - */ -export interface IAjvOptions { - verbose?: boolean; + errorFormatter?: ErrorFormatter; + /** + * @deprecated set options directly on ajv root object. + */ + options?: Options; } diff --git a/packages/ajv/src/pipes/AjvErrorFormatterPipe.ts b/packages/ajv/src/pipes/AjvErrorFormatterPipe.ts new file mode 100644 index 00000000000..aea55749a50 --- /dev/null +++ b/packages/ajv/src/pipes/AjvErrorFormatterPipe.ts @@ -0,0 +1,66 @@ +import {Constant, PropertyRegistry} from "@tsed/common"; +import {getValue, nameOf, setValue, Type} from "@tsed/core"; +import {Injectable} from "@tsed/di"; +import {ErrorObject} from "ajv"; +import {AjvValidationError} from "../errors/AjvValidationError"; +import {AjvErrorObject, ErrorFormatter} from "../interfaces/IAjvSettings"; + +function defaultFormatter(error: AjvErrorObject, index: string | number) { + const value = JSON.stringify(error.data === undefined ? "undefined" : error.data); + + return ( + [ + !error.modelName && "Value", + index !== undefined && !error.modelName && ".", + index !== undefined && !isNaN(+index) && `[${index}]`, + index !== undefined && isNaN(+index) && `${index}`, + index !== undefined && error.modelName && isNaN(+index) && ".", + `${error.modelName || ""}`, + error.dataPath, + ` ${error.message}. Given value: ${value}` + ] + // @ts-ignore + .filter(Boolean) + .join("") + .trim() + ); +} + +@Injectable() +export class AjvErrorFormatterPipe { + @Constant("ajv.errorFormatter", defaultFormatter) + errorFormatter: ErrorFormatter; + + transform(errors: ErrorObject[], options: any) { + const {type, index} = options; + + const message = errors + .map((error: AjvErrorObject) => { + if (type) { + error.modelName = nameOf(type); + error.message = this.mapClassError(error, type); + } + + return this.errorFormatter.call(this, error, index); + }) + .join("\n"); + + return new AjvValidationError(message, errors); + } + + private mapClassError(error: AjvErrorObject, targetType: Type) { + const propertyKey = getValue("params.missingProperty", error); + + if (propertyKey) { + const prop = PropertyRegistry.get(targetType, propertyKey); + + if (prop) { + setValue("params.missingProperty", error, prop.name || propertyKey); + + return error.message!.replace(`'${propertyKey}'`, `'${prop.name || propertyKey}'`); + } + } + + return error.message; + } +} diff --git a/packages/ajv/src/pipes/AjvValidationPipe.spec.ts b/packages/ajv/src/pipes/AjvValidationPipe.spec.ts new file mode 100644 index 00000000000..0bd5828ef6c --- /dev/null +++ b/packages/ajv/src/pipes/AjvValidationPipe.spec.ts @@ -0,0 +1,351 @@ +import {UseSchema} from "@tsed/ajv"; +import { + BodyParams, + ParamRegistry, + ParamTypes, + ParamValidationError, + Property, + QueryParams, + Required, + UseParam, + ValidationError +} from "@tsed/common"; +import {TestContext} from "@tsed/testing"; +import {expect} from "chai"; +import {AjvValidationPipe} from "./AjvValidationPipe"; + +async function validate(value: any, metadata: any) { + const pipe: AjvValidationPipe = await TestContext.invoke(AjvValidationPipe); + + try { + return pipe.transform(value, metadata); + } catch (er) { + if (er instanceof ValidationError) { + return ParamValidationError.from(metadata, er); + } + + throw er; + } +} + +describe("AjvValidationPipe", () => { + beforeEach(() => + TestContext.create({ + ajv: { + verbose: true + } + }) + ); + afterEach(() => TestContext.reset()); + + describe("With raw json schema", () => { + it("should validate object", async () => { + class Ctrl { + get(@BodyParams() @UseSchema({type: "object"}) value: any) {} + } + + const value = {}; + const result = await validate(value, ParamRegistry.get(Ctrl, "get", 0)); + + expect(result).to.deep.equal(value); + }); + it("should throw an error", async () => { + class Ctrl { + get(@BodyParams() @UseSchema({type: "object"}) value: any) {} + } + + const value: any[] = []; + + const error = await validate(value, ParamRegistry.get(Ctrl, "get", 0)); + + expect(error?.message).to.deep.equal("Bad request on parameter \"request.body\".\nValue should be object. Given value: []"); + expect(error?.origin?.errors).to.deep.equal([ + { + data: [], + dataPath: "", + keyword: "type", + message: "should be object", + params: { + type: "object" + }, + parentSchema: { + type: "object" + }, + schema: "object", + schemaPath: "#/type" + } + ]); + }); + }); + describe("With String", () => { + it("should validate value", async () => { + class Ctrl { + get(@BodyParams() value: string) {} + } + + const metadata = ParamRegistry.get(Ctrl, "get", 0); + expect(await validate("test", metadata)).to.deep.equal("test"); + }); + it("should validate value (array)", async () => { + class Ctrl { + get(@BodyParams({useType: String}) value: string[]) {} + } + + const metadata = ParamRegistry.get(Ctrl, "get", 0); + expect(await validate(["test"], metadata)).to.deep.equal(["test"]); + }); + }); + describe("With QueryParam with boolean", () => { + it("should validate value", async () => { + class Ctrl { + get(@QueryParams("test") value: boolean) {} + } + + const metadata = ParamRegistry.get(Ctrl, "get", 0); + + expect(await validate("true", metadata)).to.deep.equal("true"); + expect(await validate("null", metadata)).to.deep.equal("null"); + expect(await validate(undefined, metadata)).to.deep.equal(undefined); + }); + }); + describe("With model", () => { + it("should validate object", async () => { + class Model { + @Property() + @Required() + id: string; + } + + class Ctrl { + get(@UseParam(ParamTypes.BODY) value: Model) {} + } + + const value = { + id: "hello" + }; + const result = await validate(value, ParamRegistry.get(Ctrl, "get", 0)); + + expect(result).to.deep.equal(value); + }); + it("should throw an error", async () => { + class Model { + @Property() + @Required() + id: string; + } + + class Ctrl { + get(@UseParam(ParamTypes.BODY) value: Model) {} + } + + const value: any = {}; + + const error = await validate(value, ParamRegistry.get(Ctrl, "get", 0)); + + expect(error?.message).to.deep.equal( + "Bad request on parameter \"request.body\".\nModel should have required property 'id'. Given value: {}" + ); + expect(error?.origin.errors).to.deep.equal([ + { + data: {}, + dataPath: "", + keyword: "required", + message: "should have required property 'id'", + modelName: "Model", + params: { + missingProperty: "id" + }, + parentSchema: { + definitions: {}, + properties: { + id: { + type: "string" + } + }, + required: ["id"], + type: "object" + }, + schema: { + id: { + type: "string" + } + }, + schemaPath: "#/required" + } + ]); + }); + it("should throw an error (deep property)", async () => { + class UserModel { + @Required() + id: string; + } + + class Model { + @Required() + id: string; + + @Required() + user: UserModel; + } + + class Ctrl { + get(@UseParam(ParamTypes.BODY) value: Model) {} + } + + const value: any = { + id: "id", + user: {} + }; + + const error = await validate(value, ParamRegistry.get(Ctrl, "get", 0)); + + expect(error?.message).to.deep.equal( + "Bad request on parameter \"request.body\".\nModel.user should have required property 'id'. Given value: {}" + ); + }); + }); + describe("With array of model", () => { + it("should validate object", async () => { + class Model { + @Property() + @Required() + id: string; + } + + class Ctrl { + get(@UseParam(ParamTypes.BODY, {useType: Model}) value: Model[]) {} + } + + const value = [ + { + id: "hello" + } + ]; + const result = await validate(value, ParamRegistry.get(Ctrl, "get", 0)); + + expect(result).to.deep.equal(value); + }); + it("should throw an error", async () => { + class Model { + @Property() + @Required() + id: string; + } + + class Ctrl { + get(@UseParam(ParamTypes.BODY, {useType: Model}) value: Model[]) {} + } + + const value: any = [{}]; + + const error = await validate(value, ParamRegistry.get(Ctrl, "get", 0)); + + expect(error?.message).to.deep.equal( + "Bad request on parameter \"request.body\".\n[0]Model should have required property 'id'. Given value: {}" + ); + }); + it("should throw an error (deep property)", async () => { + class UserModel { + @Required() + id: string; + } + + class Model { + @Required() + id: string; + + @Required() + user: UserModel; + } + + class Ctrl { + get(@UseParam(ParamTypes.BODY, {useType: Model}) value: Model[]) {} + } + + const value: any = [ + { + id: "id", + user: {} + } + ]; + + const error = await validate(value, ParamRegistry.get(Ctrl, "get", 0)); + + expect(error?.message).to.deep.equal( + "Bad request on parameter \"request.body\".\n[0]Model.user should have required property 'id'. Given value: {}" + ); + }); + }); + describe("With Map of model", () => { + it("should validate object", async () => { + class Model { + @Property() + @Required() + id: string; + } + + class Ctrl { + get(@UseParam(ParamTypes.BODY, {useType: Model}) value: Map) {} + } + + const value = { + key1: { + id: "hello" + } + }; + const result = await validate(value, ParamRegistry.get(Ctrl, "get", 0)); + + expect(result).to.deep.equal(value); + }); + it("should throw an error", async () => { + class Model { + @Property() + @Required() + id: string; + } + + class Ctrl { + get(@UseParam(ParamTypes.BODY, {useType: Model}) value: Map) {} + } + + const value: any = {key1: {}}; + + const error = await validate(value, ParamRegistry.get(Ctrl, "get", 0)); + + expect(error?.message).to.deep.equal( + "Bad request on parameter \"request.body\".\nkey1.Model should have required property 'id'. Given value: {}" + ); + }); + it("should throw an error (deep property)", async () => { + class UserModel { + @Required() + id: string; + } + + class Model { + @Required() + id: string; + + @Required() + user: UserModel; + } + + class Ctrl { + get(@UseParam(ParamTypes.BODY, {useType: Model}) value: Map) {} + } + + const value: any = { + key1: { + id: "id", + user: {} + } + }; + + const error = await validate(value, ParamRegistry.get(Ctrl, "get", 0)); + + expect(error?.message).to.deep.equal( + "Bad request on parameter \"request.body\".\nkey1.Model.user should have required property 'id'. Given value: {}" + ); + }); + }); +}); diff --git a/packages/ajv/src/pipes/AjvValidationPipe.ts b/packages/ajv/src/pipes/AjvValidationPipe.ts new file mode 100644 index 00000000000..5e76e89e403 --- /dev/null +++ b/packages/ajv/src/pipes/AjvValidationPipe.ts @@ -0,0 +1,79 @@ +import {ConverterService, getJsonSchema, Inject, IPipe, OverrideProvider, ParamMetadata, ValidationPipe} from "@tsed/common"; +import {isEmpty} from "@tsed/core"; +import {Ajv} from "../services/Ajv"; +import {AjvErrorFormatterPipe} from "./AjvErrorFormatterPipe"; + +@OverrideProvider(ValidationPipe) +export class AjvValidationPipe extends ValidationPipe implements IPipe { + @Inject() + converterService: ConverterService; // FIXME remove mapping when the new schema lib will be released + + @Inject() + formatter: AjvErrorFormatterPipe; + + @Inject() + ajv: Ajv; + + transform(value: any, metadata: ParamMetadata): any { + const {schema} = metadata.store.get(AjvValidationPipe) || {}; + + if (schema) { + this.validate(schema, value); + } else if (metadata.isPrimitive) { + this.validateFromPrimitive(value, metadata); + } else if (this.shouldValidate(metadata)) { + this.validateFromModel(value, metadata); + } + + return value; + } + + protected validate(schema: any, value: any, options: any = {}) { + const valid = this.ajv.validate(schema, value); + + if (!valid) { + throw this.formatter.transform(this.ajv.errors!, options); + } + } + + protected validateFromPrimitive(value: any, metadata: ParamMetadata) { + value = this.converterService.deserialize(value, metadata.collectionType || metadata.type, metadata.type); + + if (isEmpty(value)) { + if (this.checkIsRequired(value, metadata)) { + return true; + } + } + + const schema = getJsonSchema(metadata.type); + + if (!metadata.isCollection) { + this.validate(schema, value, {}); + } else { + Object.entries(value).forEach(([key, item]) => { + this.validate(schema, item, {index: key}); + }); + } + + return value; + } + + private validateFromModel(value: any, metadata: ParamMetadata) { + const schema = getJsonSchema(metadata.type); + + const options = { + ignoreCallback: (obj: any, type: any) => type === Date, + checkRequiredValue: false + }; + + if (metadata.isCollection) { + Object.entries(value).forEach(([key, item]) => { + item = this.converterService.deserialize(item, metadata.type, undefined, options); // FIXME REMOVE THIS when @tsed/schema is out + this.validate(schema, item, {type: metadata.type, index: key}); + }); + } else { + value = this.converterService.deserialize(value, metadata.type, undefined, options); // FIXME REMOVE THIS when @tsed/schema is out + this.validate(schema, value, {type: metadata.type}); + } + } +} diff --git a/packages/ajv/src/services/Ajv.spec.ts b/packages/ajv/src/services/Ajv.spec.ts new file mode 100644 index 00000000000..0449c6753ef --- /dev/null +++ b/packages/ajv/src/services/Ajv.spec.ts @@ -0,0 +1,41 @@ +import {TestContext} from "@tsed/testing"; +import * as AjvKlass from "ajv"; +import {expect} from "chai"; +import {Ajv} from "./Ajv"; + +describe("Ajv", () => { + beforeEach(() => TestContext.create()); + afterEach(() => TestContext.reset()); + it("should create a new Ajv instance", async () => { + const ajv = await TestContext.invoke(Ajv); + + expect(ajv).to.instanceof(AjvKlass); + expect( + ajv.validate( + { + type: "object" + }, + {} + ) + ).to.equal(true); + + ajv.validate( + { + type: "object" + }, + [] + ); + + expect(ajv.errors).to.deep.equal([ + { + dataPath: "", + keyword: "type", + message: "should be object", + params: { + type: "object" + }, + schemaPath: "#/type" + } + ]); + }); +}); diff --git a/packages/ajv/src/services/Ajv.ts b/packages/ajv/src/services/Ajv.ts new file mode 100644 index 00000000000..e27bf3b498d --- /dev/null +++ b/packages/ajv/src/services/Ajv.ts @@ -0,0 +1,22 @@ +import {Configuration, ProviderScope, registerProvider} from "@tsed/di"; +import * as AjvKlass from "ajv"; +import {IAjvSettings} from "../interfaces/IAjvSettings"; + +// tslint:disable-next-line:variable-name +export const Ajv: any = AjvKlass; +export type Ajv = AjvKlass.Ajv; + +registerProvider({ + provide: Ajv, + deps: [Configuration], + scope: ProviderScope.SINGLETON, + useFactory(configuration: Configuration) { + const {errorFormat, errorFormatter, options = {}, ...props} = configuration.get("ajv") || {}; + + return new AjvKlass({ + verbose: false, + ...props, + ...options + }); + } +}); diff --git a/packages/ajv/src/services/AjvService.spec.ts b/packages/ajv/src/services/AjvService.spec.ts deleted file mode 100644 index ea6122d3fd5..00000000000 --- a/packages/ajv/src/services/AjvService.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -import {inject, TestContext} from "@tsed/testing"; -import * as Ajv from "ajv"; -import {expect} from "chai"; -import {JsonFoo, JsonFoo2, Nested, Stuff, Thingy} from "../../../../test/helper/classes"; -import {AjvService} from "../../src"; - -describe("AjvService", () => { - let ajvService: AjvService; - before( - inject([AjvService], (_ajvService_: AjvService) => { - ajvService = _ajvService_; - }) - ); - after(TestContext.reset); - - describe("when there an error", () => { - it("should throws errors (1)", () => { - const foo2 = new JsonFoo2(); - foo2.test = "te"; - try { - ajvService.validate(foo2, JsonFoo2); - } catch (er) { - expect(er.message).to.eq("At JsonFoo2.test should NOT be shorter than 3 characters"); - } - }); - - it("should throws errors (2)", () => { - const foo2 = new JsonFoo2(); - foo2.test = "te"; - - // @ts-ignore - ajvService.options.verbose = true; - // @ts-ignore - ajvService.ajv = new Ajv({verbose: true}); - try { - ajvService.validate(foo2, JsonFoo2); - } catch (er) { - expect(er.message).to.eq("At JsonFoo2.test, value \"te\" should NOT be shorter than 3 characters"); - } - }); - - it("should throws errors (3)", () => { - const obj = new Thingy(); - obj.stuff = new Stuff(); - obj.stuff.nested = new Nested(); - obj.stuff.nested!.count = "100" as any; - - // @ts-ignore - ajvService.options.verbose = true; - - // @ts-ignore - ajvService.ajv = new Ajv({verbose: true}); - try { - ajvService.validate(obj, Thingy); - } catch (er) { - expect(er.message).to.eq("At Thingy.stuff.nested.count, value \"100\" should be number"); - } - }); - }); - - describe("when there is a success", () => { - it("should not throws errors", () => { - const foo2 = new JsonFoo2(); - foo2.test = "test"; - foo2.foo = new JsonFoo(); - - return ajvService.validate(foo2, JsonFoo2); - }); - - it("should not throws errors (null)", () => { - return ajvService.validate(null, JsonFoo2); - }); - - it("should not throws errors (undefined)", () => { - return ajvService.validate(undefined, JsonFoo2); - }); - }); -}); diff --git a/packages/ajv/src/services/AjvService.ts b/packages/ajv/src/services/AjvService.ts deleted file mode 100644 index f06189625c6..00000000000 --- a/packages/ajv/src/services/AjvService.ts +++ /dev/null @@ -1,110 +0,0 @@ -import {ConverterService, JsonSchemesService, OverrideService, PropertyRegistry, ValidationService} from "@tsed/common"; -import {getValue, nameOf, setValue, Type} from "@tsed/core"; -import {Configuration} from "@tsed/di"; -import * as Ajv from "ajv"; -import {ErrorObject} from "ajv"; -import {AjvValidationError} from "../errors/AjvValidationError"; -import {AjvErrorObject, ErrorFormatter, IAjvOptions, IAjvSettings} from "../interfaces/IAjvSettings"; - -@OverrideService(ValidationService) -export class AjvService extends ValidationService { - private errorFormatter: ErrorFormatter; - private options: IAjvOptions; - private ajv: Ajv.Ajv; - - constructor( - private jsonSchemaService: JsonSchemesService, - @Configuration() private configuration: Configuration, - private converterService: ConverterService - ) { - super(); - - const ajvSettings: IAjvSettings = this.configuration.get("ajv") || {}; - - this.options = Object.assign( - { - verbose: false - }, - ajvSettings.options || {} - ); - - this.ajv = new Ajv(this.options); - - this.errorFormatter = ajvSettings.errorFormat ? ajvSettings.errorFormat : this.defaultFormatter; - } - - /** - * - * @param obj - * @param targetType - * @param baseType - * @returns {boolean} - */ - public validate(obj: any, targetType: any, baseType?: any): boolean { - const schema = this.jsonSchemaService.getSchemaDefinition(targetType) as any; - - if (schema && !(obj === null || obj === undefined)) { - const collection = baseType ? obj : [obj]; - const options = { - ignoreCallback: (obj: any, type: any) => type === Date, - checkRequiredValue: false - }; - - const test = (obj: any) => { - const valid = this.ajv.validate(schema, obj); - - if (!valid) { - throw this.buildErrors(this.ajv.errors!, targetType); - } - }; - - Object.keys(collection).forEach((key: any) => - test(this.converterService.deserialize(collection[key], targetType, undefined, options)) - ); - } - - return true; - } - - /** - * - * @param {ajv.ErrorObject[]} errors - * @param {Type} targetType - * @returns {BadRequest} - */ - private buildErrors(errors: ErrorObject[], targetType: Type) { - const message = errors - .map((error: AjvErrorObject) => { - error.modelName = nameOf(targetType); - const propertyKey = getValue("params.missingProperty", error); - - if (propertyKey) { - const prop = PropertyRegistry.get(targetType, propertyKey); - - if (prop) { - setValue("params.missingProperty", error, prop.name || propertyKey); - error.message = error.message!.replace(`'${propertyKey}'`, `'${prop.name || propertyKey}'`); - } - } - - return this.errorFormatter.call(this, error); - }) - .join("\n"); - - return new AjvValidationError(message, errors); - } - - /** - * - * @param error - * @returns {string} - */ - private defaultFormatter(error: AjvErrorObject) { - let value = ""; - if (this.options.verbose) { - value = `, value "${error.data}"`; - } - - return `At ${error.modelName}${error.dataPath}${value} ${error.message}`; - } -} diff --git a/packages/ajv/test/ajv.spec.ts b/packages/ajv/test/ajv.spec.ts deleted file mode 100644 index 93efc97dfc0..00000000000 --- a/packages/ajv/test/ajv.spec.ts +++ /dev/null @@ -1,175 +0,0 @@ -import {ConverterService, Format, JsonSchemesService, ParseExpressionError, Required} from "@tsed/common"; -import {nameOf} from "@tsed/core"; -import {Configuration} from "@tsed/di/src/decorators/configuration"; -import {TestContext} from "@tsed/testing"; -import {expect} from "chai"; -import {AjvService} from "../src"; - -let ajvService: AjvService; - -const runValidation = (obj: any, targetType: any, collectionType?: any): Chai.Assertion => { - try { - const result = ajvService.validate(obj, targetType, collectionType); - - return expect(result); - } catch (err) { - if (err.name === "AJV_VALIDATION_ERROR") { - const message = "" + new ParseExpressionError(nameOf(targetType), undefined, err); - - return expect(message.split("\n")[1]); - } - - return expect("" + err); - } -}; - -describe("AJV", () => { - let jsonSchemesService: JsonSchemesService; - beforeEach(TestContext.inject([Configuration], async (configuration: Configuration) => { - ajvService = await TestContext.invoke(AjvService, [ - { - token: ConverterService, - use: { - deserialize(obj: any) { - return obj; - } - } - } - ]); - jsonSchemesService = TestContext.injector.get(JsonSchemesService)!; - })); - - after(TestContext.reset); - - describe("Date validation", () => { - const errorMsg = "At TestDate.dateStart should match format \"date-time\""; - - class TestDate { - @Format("date-time") - dateStart: Date; - } - - it("should have expected json schema", () => { - expect(jsonSchemesService.getSchemaDefinition(TestDate)).to.deep.eq({ - definitions: {}, - properties: { - dateStart: { - format: "date-time", - type: "string" - } - }, - type: "object" - }); - }); - it("should validate data (1)", () => { - runValidation({}, TestDate).to.be.true; - }); - - it("should not validate data (2)", () => { - runValidation({dateStart: "1987-07-12 01:00:00"}, TestDate).to.be.eq(errorMsg); - }); - it("should validate data (3)", () => { - runValidation({dateStart: new Date().toISOString()}, TestDate).to.be.true; - }); - it("should not validate data (4)", () => { - runValidation({dateStart: new Date()}, TestDate).to.be.eq("At TestDate.dateStart should be string"); - }); - it("should not validate data (5)", () => { - runValidation({dateStart: "test"}, TestDate).to.be.eq(errorMsg); - }); - it("should validate data (6)", () => { - runValidation({other: "test"}, TestDate).to.be.true; - }); - }); - - describe("Array of", () => { - const errorMsg = "At TestDate.dateStart should match format \"date-time\""; - - class TestDate { - @Format("date-time") - dateStart: Date; - } - - it("should have expected json schema", () => { - expect(jsonSchemesService.getSchemaDefinition(TestDate)).to.deep.eq({ - definitions: {}, - properties: { - dateStart: { - format: "date-time", - type: "string" - } - }, - type: "object" - }); - }); - it("should validate data (1)", () => { - runValidation([{}], TestDate, Array).to.be.true; - }); - it("should not validate data (2)", () => { - runValidation([{dateStart: "1987-07-12 01:00:00"}], TestDate, Array).to.be.eq(errorMsg); - }); - it("should validate data (3)", () => { - runValidation([{dateStart: new Date().toISOString()}], TestDate, Array).to.be.true; - }); - }); - - describe("Set of", () => { - const errorMsg = "At TestDate.dateStart should match format \"date-time\""; - - class TestDate { - @Format("date-time") - dateStart: Date; - } - - it("should have expected json schema", () => { - expect(jsonSchemesService.getSchemaDefinition(TestDate)).to.deep.eq({ - definitions: {}, - properties: { - dateStart: { - format: "date-time", - type: "string" - } - }, - type: "object" - }); - }); - it("should validate data (1)", () => { - runValidation({test: {}}, TestDate, Set).to.be.true; - }); - - it("should not validate data (2)", () => { - runValidation({test: {dateStart: "1987-07-12 01:00:00"}}, TestDate, Set).to.be.eq(errorMsg); - }); - - it("should validate data (3)", () => { - runValidation({test: {dateStart: new Date().toISOString()}}, TestDate, Set).to.be.true; - }); - }); - - describe("Required validation", () => { - class TestRequired { - @Required() - dateStart: Date; - } - - it("should have expected json schema", () => { - expect(jsonSchemesService.getSchemaDefinition(TestRequired)).to.deep.eq({ - definitions: {}, - properties: { - dateStart: { - type: "string" - } - }, - required: ["dateStart"], - type: "object" - }); - }); - it("should not validate data (1)", () => { - runValidation({}, TestRequired).to.be.eq("At TestRequired should have required property 'dateStart'"); - }); - - it("should validate data (2)", () => { - runValidation({dateStart: ""}, TestRequired).to.be.true; - }); - }); -}); diff --git a/packages/common/src/converters/components/PrimitiveConverter.ts b/packages/common/src/converters/components/PrimitiveConverter.ts index ab6c381811b..543baf2ec41 100644 --- a/packages/common/src/converters/components/PrimitiveConverter.ts +++ b/packages/common/src/converters/components/PrimitiveConverter.ts @@ -1,4 +1,4 @@ -import {BadRequest} from "@tsed/exceptions"; +import {ValidationError} from "../../mvc/errors/ValidationError"; import {Converter} from "../decorators/converter"; import {IConverter} from "../interfaces/index"; @@ -20,7 +20,7 @@ export class PrimitiveConverter implements IConverter { const n = +data; if (isNaN(n)) { - throw new BadRequest("Cast error. Expression value is not a number."); + throw new ValidationError("Cast error. Expression value is not a number.", []); } return n; diff --git a/packages/common/src/converters/errors/ConverterDeserializationError.spec.ts b/packages/common/src/converters/errors/ConverterDeserializationError.spec.ts deleted file mode 100644 index 732bb1ed98e..00000000000 --- a/packages/common/src/converters/errors/ConverterDeserializationError.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {expect} from "chai"; -import {ConverterDeserializationError} from "./ConverterDeserializationError"; - -describe("ConverterDeserializationError", () => { - it("should return error", () => { - const genericError = new Error("test"); - const errorInstance = new ConverterDeserializationError(class Test {}, {}, genericError); - - expect(errorInstance.message).to.equal("Conversion failed for class \"Test\" with object => {}.\ntest"); - expect(errorInstance.name).to.equal("CONVERTER_DESERIALIZATION_ERROR"); - expect(errorInstance.stack).to.equal(genericError.stack); - }); -}); diff --git a/packages/common/src/converters/errors/ConverterDeserializationError.ts b/packages/common/src/converters/errors/ConverterDeserializationError.ts deleted file mode 100644 index 7d0f08321c9..00000000000 --- a/packages/common/src/converters/errors/ConverterDeserializationError.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {nameOf, Type} from "@tsed/core"; -import {InternalServerError} from "@tsed/exceptions"; - -/** - * @private - */ -export class ConverterDeserializationError extends InternalServerError { - name: string = "CONVERTER_DESERIALIZATION_ERROR"; - stack: any; - - constructor(target: Type, obj: any, err: Error) { - super(ConverterDeserializationError.buildMessage(target, obj, err)); - this.stack = err.stack; - } - - /** - * - * @returns {string} - */ - static buildMessage(target: Type, obj: any, err: Error) { - return `Conversion failed for class "${nameOf(target)}" with object => ${JSON.stringify(obj)}.\n${err.message}`.trim(); - } -} diff --git a/packages/common/src/converters/errors/ConverterSerializationError.spec.ts b/packages/common/src/converters/errors/ConverterSerializationError.spec.ts deleted file mode 100644 index 106a4277498..00000000000 --- a/packages/common/src/converters/errors/ConverterSerializationError.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {expect} from "chai"; -import {ConverterSerializationError} from "./ConverterSerializationError"; - -describe("ConverterSerializationError", () => { - it("should return error", () => { - const genericError = new Error("test"); - const errorInstance = new ConverterSerializationError(class Test {}, genericError); - - expect(errorInstance.name).to.equal("CONVERTER_SERIALIZATION_ERROR"); - expect(errorInstance.message).to.equal("Conversion failed for \"Test\". test"); - expect(errorInstance.stack).to.equal(genericError.stack); - }); -}); diff --git a/packages/common/src/converters/errors/ConverterSerializationError.ts b/packages/common/src/converters/errors/ConverterSerializationError.ts deleted file mode 100644 index e5f83357bad..00000000000 --- a/packages/common/src/converters/errors/ConverterSerializationError.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {InternalServerError} from "@tsed/exceptions"; -import {Type, nameOf} from "@tsed/core"; - -/** - * @private - */ -export class ConverterSerializationError extends InternalServerError { - name: string = "CONVERTER_SERIALIZATION_ERROR"; - stack: any; - - constructor(target: Type, err: Error) { - super(ConverterSerializationError.buildMessage(target, err)); - this.stack = err.stack; - } - - /** - * - * @returns {string} - */ - static buildMessage(target: Type, err: Error) { - return `Conversion failed for "${nameOf(target)}". ${err.message}`.trim(); - } -} diff --git a/packages/common/src/converters/errors/RequiredPropertyError.spec.ts b/packages/common/src/converters/errors/RequiredPropertyError.spec.ts index 39cdf3c3247..0e6ffa8b62a 100644 --- a/packages/common/src/converters/errors/RequiredPropertyError.spec.ts +++ b/packages/common/src/converters/errors/RequiredPropertyError.spec.ts @@ -6,6 +6,6 @@ describe("RequiredPropertyError", () => { const errorInstance = new RequiredPropertyError(class Test {}, "propertyKey", "value"); expect(errorInstance.message).to.equal("Property propertyKey on class Test is required. Given value: value"); - expect(errorInstance.name).to.equal("BAD_REQUEST"); + expect(errorInstance.name).to.equal("VALIDATION_ERROR"); }); }); diff --git a/packages/common/src/converters/errors/RequiredPropertyError.ts b/packages/common/src/converters/errors/RequiredPropertyError.ts index 646ef6b0bfe..07589f8e86e 100644 --- a/packages/common/src/converters/errors/RequiredPropertyError.ts +++ b/packages/common/src/converters/errors/RequiredPropertyError.ts @@ -1,16 +1,14 @@ import {nameOf, Type} from "@tsed/core"; -import {BadRequest} from "@tsed/exceptions"; +import {ValidationError} from "../../mvc/errors/ValidationError"; /** - * @private + * @deprecated Ajv or validation library must perform this validation */ -export class RequiredPropertyError extends BadRequest { +export class RequiredPropertyError extends ValidationError { errors: any[]; constructor(target: Type, propertyName: string | symbol, value: any) { - super(RequiredPropertyError.buildMessage(target, propertyName, value)); - - this.errors = [ + super(`Property ${propertyName as string} on class ${nameOf(target)} is required. Given value: ${value}`, [ { dataPath: "", keyword: "required", @@ -21,17 +19,6 @@ export class RequiredPropertyError extends BadRequest { }, schemaPath: "#/required" } - ]; - } - - /** - * - * @returns {string} - * @param target - * @param propertyName - * @param value - */ - static buildMessage(target: Type, propertyName: string | symbol, value: any) { - return `Property ${propertyName as string} on class ${nameOf(target)} is required. Given value: ${value}`; + ]); } } diff --git a/packages/common/src/converters/errors/UnknownPropertyError.spec.ts b/packages/common/src/converters/errors/UnknownPropertyError.spec.ts index 8959c50ea25..1364d3d7b9d 100644 --- a/packages/common/src/converters/errors/UnknownPropertyError.spec.ts +++ b/packages/common/src/converters/errors/UnknownPropertyError.spec.ts @@ -8,6 +8,6 @@ describe("UnknownPropertyError", () => { const errorInstance = new UnknownPropertyError(Test, "propertyKey"); expect(errorInstance.message).to.equal("Property propertyKey on class Test is not allowed."); - expect(errorInstance.name).to.equal("BAD_REQUEST"); + expect(errorInstance.name).to.equal("UNKNOWN_PROPERTY_ERROR"); }); }); diff --git a/packages/common/src/converters/errors/UnknownPropertyError.ts b/packages/common/src/converters/errors/UnknownPropertyError.ts index f8e899514e9..63abdfe5df4 100644 --- a/packages/common/src/converters/errors/UnknownPropertyError.ts +++ b/packages/common/src/converters/errors/UnknownPropertyError.ts @@ -1,16 +1,14 @@ import {nameOf, Type} from "@tsed/core"; -import {BadRequest} from "@tsed/exceptions"; +import {ValidationError} from "../../mvc/errors/ValidationError"; /** - * @private + * */ -export class UnknownPropertyError extends BadRequest { - errors: any[]; +export class UnknownPropertyError extends ValidationError { + public name: string = "UNKNOWN_PROPERTY_ERROR"; constructor(target: Type, propertyName: string | symbol) { - super(UnknownPropertyError.buildMessage(target, propertyName)); - - this.errors = [ + super(`Property ${String(propertyName)} on class ${nameOf(target)} is not allowed.`, [ { dataPath: "", keyword: "unknown", @@ -21,16 +19,6 @@ export class UnknownPropertyError extends BadRequest { }, schemaPath: "#/unknown" } - ]; - } - - /** - * - * @returns {string} - * @param target - * @param propertyName - */ - static buildMessage(target: Type, propertyName: string | symbol) { - return `Property ${String(propertyName)} on class ${nameOf(target)} is not allowed.`; + ]); } } diff --git a/packages/common/src/converters/index.ts b/packages/common/src/converters/index.ts index 1b988b6e0ba..d70f77283c0 100644 --- a/packages/common/src/converters/index.ts +++ b/packages/common/src/converters/index.ts @@ -1,8 +1,3 @@ -/** - * @module common/converters - * @preferred - */ -/** */ export * from "./interfaces"; // decorators diff --git a/packages/common/src/converters/services/ConverterService.spec.ts b/packages/common/src/converters/services/ConverterService.spec.ts index e1b85b18aac..d2a99616eae 100644 --- a/packages/common/src/converters/services/ConverterService.spec.ts +++ b/packages/common/src/converters/services/ConverterService.spec.ts @@ -1,4 +1,3 @@ -import {Store} from "@tsed/core"; import {InjectorService} from "@tsed/di"; import {Configuration} from "@tsed/di/src/decorators/configuration"; import {TestContext} from "@tsed/testing"; @@ -582,21 +581,6 @@ describe("ConverterService", () => { expect(converterService.serialize(new JsonFoo3())).to.be.an("object"); }); }); - - describe("serialization error", () => { - let converterService: ConverterService; - beforeEach( - TestContext.inject([ConverterService], (_converterService_: ConverterService) => { - converterService = _converterService_; - }) - ); - - it("should emit a BadRequest when attribute is required", () => - assert.throws(() => { - const foo4: any = new JsonFoo4(); - converterService.serialize(foo4); - }, "Property foo on class JsonFoo4 is required.")); - }); describe("@PropertySerialize", () => { it("should use function to Deserialize property", async () => { const converterService = await TestContext.invoke(ConverterService, []); diff --git a/packages/common/src/converters/services/ConverterService.ts b/packages/common/src/converters/services/ConverterService.ts index 7bf689ba522..193a93c13ea 100644 --- a/packages/common/src/converters/services/ConverterService.ts +++ b/packages/common/src/converters/services/ConverterService.ts @@ -1,14 +1,11 @@ import {getClass, isArrayOrArrayClass, isEmpty, isPrimitiveOrPrimitiveClass, Metadata, Type} from "@tsed/core"; import {Configuration, Injectable, InjectorService} from "@tsed/di"; -import {BadRequest} from "@tsed/exceptions"; import {IConverterSettings} from "../../config/interfaces/IConverterSettings"; import {PropertyMetadata} from "../../jsonschema/class/PropertyMetadata"; import {PropertyRegistry} from "../../jsonschema/registries/PropertyRegistry"; -import {getJsonSchema} from "../../jsonschema/utils/getSchema"; +import {getJsonSchema} from "../../jsonschema/utils/getJsonSchema"; import {ArrayConverter, DateConverter, MapConverter, PrimitiveConverter, SetConverter, SymbolConverter} from "../components"; import {CONVERTER} from "../constants/index"; -import {ConverterDeserializationError} from "../errors/ConverterDeserializationError"; -import {ConverterSerializationError} from "../errors/ConverterSerializationError"; import {RequiredPropertyError} from "../errors/RequiredPropertyError"; import {UnknownPropertyError} from "../errors/UnknownPropertyError"; import {IConverter, IConverterOptions, IDeserializer, ISerializer} from "../interfaces/index"; @@ -63,36 +60,31 @@ export class ConverterService { * @param options */ serialize(obj: any, options: IConverterOptions = {}): any { - try { - if (isEmpty(obj)) { - return obj; - } + if (isEmpty(obj)) { + return obj; + } - const converter = this.getConverter(obj); - const serializer: ISerializer = (o: any, opt?: any) => this.serialize(o, Object.assign({}, options, opt)); + const converter = this.getConverter(obj); + const serializer: ISerializer = (o: any, opt?: any) => this.serialize(o, Object.assign({}, options, opt)); - if (converter && converter.serialize) { - // serialize from a custom JsonConverter - return converter.serialize(obj, serializer); - } + if (converter && converter.serialize) { + // serialize from a custom JsonConverter + return converter.serialize(obj, serializer); + } - if (typeof obj.serialize === "function") { - // serialize from serialize method - return obj.serialize(options, this); - } + if (typeof obj.serialize === "function") { + // serialize from serialize method + return obj.serialize(options, this); + } - if (typeof obj.toJSON === "function" && !obj.toJSON.$ignore) { - // serialize from serialize method - return obj.toJSON(); - } + if (typeof obj.toJSON === "function" && !obj.toJSON.$ignore) { + // serialize from serialize method + return obj.toJSON(); + } - // Default converter - if (!isPrimitiveOrPrimitiveClass(obj)) { - return this.serializeClass(obj, options); - } - } catch (err) { - /* istanbul ignore next */ - throw err.name === "BAD_REQUEST" ? err : new ConverterSerializationError(getClass(obj), err); + // Default converter + if (!isPrimitiveOrPrimitiveClass(obj)) { + return this.serializeClass(obj, options); } /* istanbul ignore next */ @@ -132,11 +124,6 @@ export class ConverterService { } }); - // Required validation - if (checkRequiredValue) { - this.checkRequiredValue(obj, properties); - } - return plainObject; } @@ -157,59 +144,55 @@ export class ConverterService { deserialize(obj: any, targetType: any, baseType?: any, options: IConverterOptions = {}): any { const {ignoreCallback, checkRequiredValue = true} = options; - try { - if (ignoreCallback && ignoreCallback(obj, targetType, baseType)) { - return obj; - } - - if (targetType !== Boolean && (isEmpty(obj) || isEmpty(targetType) || targetType === Object)) { - return obj; - } - - const converter = this.getConverter(targetType); - const deserializer: IDeserializer = (o: any, targetType: any, baseType: any) => this.deserialize(o, targetType, baseType, options); + if (ignoreCallback && ignoreCallback(obj, targetType, baseType)) { + return obj; + } - if (converter) { - // deserialize from a custom JsonConverter - return converter!.deserialize!(obj, targetType, baseType, deserializer); - } + if (targetType !== Boolean && (isEmpty(obj) || isEmpty(targetType) || targetType === Object)) { + return obj; + } - /* istanbul ignore next */ - if (isArrayOrArrayClass(obj)) { - const converter = this.getConverter(Array); + const converter = this.getConverter(targetType); + const deserializer: IDeserializer = (o: any, targetType: any, baseType: any) => this.deserialize(o, targetType, baseType, options); - return converter!.deserialize!(obj, Array, baseType, deserializer); - } + if (converter) { + // deserialize from a custom JsonConverter + return converter!.deserialize!(obj, targetType, baseType, deserializer); + } - if ((targetType as any).prototype && typeof (targetType as any).prototype.deserialize === "function") { - // deserialize from method + /* istanbul ignore next */ + if (isArrayOrArrayClass(obj)) { + const converter = this.getConverter(Array); - const instance = new targetType(); - instance.deserialize(obj); + return converter!.deserialize!(obj, Array, baseType, deserializer); + } - return instance; - } + if ((targetType as any).prototype && typeof (targetType as any).prototype.deserialize === "function") { + // deserialize from method - // Default converter const instance = new targetType(); - const properties = PropertyRegistry.getProperties(targetType); + instance.deserialize(obj); - Object.keys(obj).forEach((propertyName: string) => { - const propertyMetadata = ConverterService.getPropertyMetadata(properties, propertyName); + return instance; + } - return this.convertProperty(obj, instance, propertyName, propertyMetadata, options); - }); + // Default converter + const instance = new targetType(); + const properties = PropertyRegistry.getProperties(targetType); - // Required validation - if (checkRequiredValue) { - this.checkRequiredValue(instance, properties); - } + Object.keys(obj).forEach((propertyName: string) => { + const propertyMetadata = ConverterService.getPropertyMetadata(properties, propertyName); - return instance; - } catch (err) { - /* istanbul ignore next */ - throw err.name === "BAD_REQUEST" ? err : new ConverterDeserializationError(targetType, obj, err); + return this.convertProperty(obj, instance, propertyName, propertyMetadata, options); + }); + + // Required validation + if (checkRequiredValue) { + // TODO v6 REMOVE REQUIRED check + this.checkRequiredValue(instance, properties); } + + return instance; } /** @@ -264,38 +247,22 @@ export class ConverterService { let propertyValue = obj[propertyMetadata!.name] || obj[propertyName]; const propertyKey = propertyMetadata!.propertyKey || propertyName; - try { - if (typeof instance[propertyKey] !== "function") { - if (typeof propertyMetadata!.onDeserialize === "function") { - propertyValue = propertyMetadata!.onDeserialize(propertyValue); - } - - instance[propertyKey] = this.deserialize( - propertyValue, - propertyMetadata!.isCollection ? propertyMetadata!.collectionType : propertyMetadata!.type, - propertyMetadata!.type, - options - ); + if (typeof instance[propertyKey] !== "function") { + if (typeof propertyMetadata!.onDeserialize === "function") { + propertyValue = propertyMetadata!.onDeserialize(propertyValue); } - } catch (err) { - /* istanbul ignore next */ - (() => { - const castedErrorMessage = `Error for ${propertyName} with value ${JSON.stringify(propertyValue)} \n ${err.message}`; - if (err instanceof BadRequest) { - throw new BadRequest(castedErrorMessage); - } - const castedError: any = new Error(castedErrorMessage); - castedError.status = err.status; - castedError.stack = err.stack; - castedError.origin = err; - throw castedError; - })(); + instance[propertyKey] = this.deserialize( + propertyValue, + propertyMetadata!.isCollection ? propertyMetadata!.collectionType : propertyMetadata!.type, + propertyMetadata!.type, + options + ); } } /** - * + * @deprecated * @param instance * @param {Map} properties */ diff --git a/packages/common/src/jsonschema/class/JsonSchema.spec.ts b/packages/common/src/jsonschema/class/JsonSchema.spec.ts index 7546a3ccd9a..8f4f2a32e4f 100644 --- a/packages/common/src/jsonschema/class/JsonSchema.spec.ts +++ b/packages/common/src/jsonschema/class/JsonSchema.spec.ts @@ -136,43 +136,6 @@ describe("JsonSchema", () => { }); }); - describe("getJsonType()", () => { - it("should return number", () => { - expect(JsonSchema.getJsonType(Number)).to.eq("number"); - }); - - it("should return string", () => { - expect(JsonSchema.getJsonType(String)).to.eq("string"); - }); - - it("should return boolean", () => { - expect(JsonSchema.getJsonType(Boolean)).to.eq("boolean"); - }); - - it("should return array", () => { - expect(JsonSchema.getJsonType(Array)).to.eq("array"); - }); - - it("should return string when date is given", () => { - expect(JsonSchema.getJsonType(Date)).to.eq("string"); - }); - - it("should return object", () => { - expect(JsonSchema.getJsonType({})).to.eq("object"); - }); - - it("should return object when class is given", () => { - expect(JsonSchema.getJsonType(class {})).to.eq("object"); - }); - - it("should return [string] when an array is given", () => { - expect(JsonSchema.getJsonType(["string"])).to.deep.eq(["string"]); - }); - it("should return string when an string is given", () => { - expect(JsonSchema.getJsonType("string")).to.deep.eq("string"); - }); - }); - describe("toJSON()", () => { it("should return object", () => { const schema = new JsonSchema(); @@ -181,4 +144,9 @@ describe("JsonSchema", () => { expect(schema.toJSON()).to.deep.eq({type: "object", description: "description"}); }); }); + describe("getJsonType", () => { + it("should return json type", () => { + expect(JsonSchema.getJsonType(String)).to.equal("string"); + }); + }); }); diff --git a/packages/common/src/jsonschema/class/JsonSchema.ts b/packages/common/src/jsonschema/class/JsonSchema.ts index 1926c36d953..2472007db84 100644 --- a/packages/common/src/jsonschema/class/JsonSchema.ts +++ b/packages/common/src/jsonschema/class/JsonSchema.ts @@ -1,21 +1,7 @@ -import { - deepExtends, - descriptorOf, - Enumerable, - isArrayOrArrayClass, - isDate, - isPrimitiveOrPrimitiveClass, - nameOf, - NotEnumerable, - primitiveOf -} from "@tsed/core"; +import {deepExtends, descriptorOf, Enumerable, isArrayOrArrayClass, nameOf, NotEnumerable} from "@tsed/core"; import {JSONSchema6, JSONSchema6Type, JSONSchema6TypeName} from "json-schema"; +import {getJsonType} from "../utils/getJsonType"; -/** - * - * @type {string[]} - */ -export const JSON_TYPES = ["string", "number", "integer", "boolean", "object", "array", "null", "any"]; /** * * @type {string[]} @@ -160,7 +146,7 @@ export class JsonSchema implements JSONSchema6 { set type(value: any | JSONSchema6TypeName | JSONSchema6TypeName[]) { if (value) { this._refName = nameOf(value); - this._type = JsonSchema.getJsonType(value); + this._type = getJsonType(value); } else { delete this._refName; delete this._type; @@ -208,30 +194,11 @@ export class JsonSchema implements JSONSchema6 { /** * * @param value + * @deprecated * @returns {JSONSchema6TypeName | JSONSchema6TypeName[]} */ static getJsonType(value: any): JSONSchema6TypeName | JSONSchema6TypeName[] { - if (isPrimitiveOrPrimitiveClass(value)) { - if (JSON_TYPES.indexOf(value as string) > -1) { - return value; - } - - return primitiveOf(value); - } - - if (isArrayOrArrayClass(value)) { - if (value !== Array) { - return value; - } - - return "array"; - } - - if (isDate(value)) { - return "string"; - } - - return "object"; + return getJsonType(value); } /** diff --git a/packages/common/src/jsonschema/index.ts b/packages/common/src/jsonschema/index.ts index 829905cb910..aa59970da6a 100644 --- a/packages/common/src/jsonschema/index.ts +++ b/packages/common/src/jsonschema/index.ts @@ -4,3 +4,5 @@ export * from "./class/PropertyMetadata"; export * from "./registries/PropertyRegistry"; export * from "./registries/JsonSchemesRegistry"; export * from "./services/JsonSchemesService"; +export * from "./utils/getJsonSchema"; +export * from "./utils/getJsonType"; diff --git a/packages/common/src/jsonschema/registries/JsonSchemesRegistry.ts b/packages/common/src/jsonschema/registries/JsonSchemesRegistry.ts index e0ec60c0413..eda15d8a81d 100644 --- a/packages/common/src/jsonschema/registries/JsonSchemesRegistry.ts +++ b/packages/common/src/jsonschema/registries/JsonSchemesRegistry.ts @@ -1,4 +1,5 @@ -import {ancestorsOf, deepExtends, isClass, Registry, Store, Type} from "@tsed/core"; +import {getJsonType} from "../utils/getJsonType"; +import {ancestorsOf, deepExtends, isClass, isPrimitiveOrPrimitiveClass, Registry, Store, Type} from "@tsed/core"; import {JSONSchema6} from "json-schema"; import {JsonSchema} from "../class/JsonSchema"; @@ -85,6 +86,12 @@ export class JsonSchemaRegistry extends Registry> { * @returns {JSONSchema6} */ getSchemaDefinition(target: Type): JSONSchema6 { + if (isPrimitiveOrPrimitiveClass(target)) { + return { + type: getJsonType(target) + }; + } + return ancestorsOf(target).reduce((acc: JSONSchema6, target: Type) => { deepExtends(acc, this.getSchema(target)); diff --git a/packages/common/src/jsonschema/services/JsonSchemesService.ts b/packages/common/src/jsonschema/services/JsonSchemesService.ts index 3994976c996..68c83b9b195 100644 --- a/packages/common/src/jsonschema/services/JsonSchemesService.ts +++ b/packages/common/src/jsonschema/services/JsonSchemesService.ts @@ -1,13 +1,15 @@ -import {JSONSchema6} from "json-schema"; import {ProxyRegistry, Type} from "@tsed/core"; import {Service} from "@tsed/di"; +import {JSONSchema6} from "json-schema"; import {JsonSchema} from "../class/JsonSchema"; import {JsonSchemesRegistry} from "../registries/JsonSchemesRegistry"; +import {getJsonSchema} from "../utils/getJsonSchema"; +/** + * @deprecated use getJsonSchema instead + */ @Service() export class JsonSchemesService extends ProxyRegistry { - private cache: Map, JSONSchema6> = new Map(); - constructor() { super(JsonSchemesRegistry); } @@ -18,10 +20,6 @@ export class JsonSchemesService extends ProxyRegistry { * @returns {JSONSchema4} */ getSchemaDefinition(target: Type): JSONSchema6 | undefined { - if (!this.cache.has(target)) { - this.cache.set(target, JsonSchemesRegistry.getSchemaDefinition(target)); - } - - return this.cache.get(target); + return getJsonSchema(target); } } diff --git a/packages/common/src/jsonschema/utils/getJsonSchema.ts b/packages/common/src/jsonschema/utils/getJsonSchema.ts new file mode 100644 index 00000000000..911356420f1 --- /dev/null +++ b/packages/common/src/jsonschema/utils/getJsonSchema.ts @@ -0,0 +1,17 @@ +import {Type} from "@tsed/core"; +import {JSONSchema6} from "json-schema"; +import {JsonSchemesRegistry} from "../registries/JsonSchemesRegistry"; + +const caches: Map, JSONSchema6> = new Map(); + +export function getJsonSchema(target: Type): JSONSchema6 { + if (!caches.has(target)) { + caches.set(target, JsonSchemesRegistry.getSchemaDefinition(target)); + } + + return caches.get(target)!; +} + +export function deleteSchema(target: Type) { + caches.delete(target); +} diff --git a/packages/common/src/jsonschema/utils/getJsonType.spec.ts b/packages/common/src/jsonschema/utils/getJsonType.spec.ts new file mode 100644 index 00000000000..390e8fcd334 --- /dev/null +++ b/packages/common/src/jsonschema/utils/getJsonType.spec.ts @@ -0,0 +1,46 @@ +import {deleteSchema, getJsonType} from "@tsed/common"; +import {expect} from "chai"; + +describe("getJsonType()", () => { + it("should return number", () => { + expect(getJsonType(Number)).to.eq("number"); + }); + + it("should return string", () => { + expect(getJsonType(String)).to.eq("string"); + }); + + it("should return boolean", () => { + expect(getJsonType(Boolean)).to.eq("boolean"); + }); + + it("should return array", () => { + expect(getJsonType(Array)).to.eq("array"); + }); + + it("should return string when date is given", () => { + expect(getJsonType(Date)).to.eq("string"); + }); + + it("should return object", () => { + expect(getJsonType({})).to.eq("object"); + }); + + it("should return object when class is given", () => { + expect(getJsonType(class {})).to.eq("object"); + }); + + it("should return [string] when an array is given", () => { + expect(getJsonType(["string"])).to.deep.eq(["string"]); + }); + it("should return string when an string is given", () => { + expect(getJsonType("string")).to.deep.eq("string"); + }); + + it("should delete a schema", () => { + class Test {} + + getJsonType(Test); + deleteSchema(Test); + }); +}); diff --git a/packages/common/src/jsonschema/utils/getJsonType.ts b/packages/common/src/jsonschema/utils/getJsonType.ts new file mode 100644 index 00000000000..e29f92016d6 --- /dev/null +++ b/packages/common/src/jsonschema/utils/getJsonType.ts @@ -0,0 +1,32 @@ +import {isArrayOrArrayClass, isDate, isPrimitiveOrPrimitiveClass, primitiveOf} from "@tsed/core"; +import {JSONSchema6TypeName} from "json-schema"; + +/** + * + * @type {string[]} + */ +export const JSON_TYPES = ["string", "number", "integer", "boolean", "object", "array", "null", "any"]; + +export function getJsonType(value: any): JSONSchema6TypeName | JSONSchema6TypeName[] { + if (isPrimitiveOrPrimitiveClass(value)) { + if (JSON_TYPES.indexOf(value as string) > -1) { + return value; + } + + return primitiveOf(value); + } + + if (isArrayOrArrayClass(value)) { + if (value !== Array) { + return value; + } + + return "array"; + } + + if (isDate(value)) { + return "string"; + } + + return "object"; +} diff --git a/packages/common/src/jsonschema/utils/getSchema.ts b/packages/common/src/jsonschema/utils/getSchema.ts deleted file mode 100644 index 602bdb8c207..00000000000 --- a/packages/common/src/jsonschema/utils/getSchema.ts +++ /dev/null @@ -1,6 +0,0 @@ -import {Type} from "@tsed/core"; -import {JsonSchemesRegistry} from "../registries/JsonSchemesRegistry"; - -export function getJsonSchema(target: Type) { - return JsonSchemesRegistry.getSchemaDefinition(target); -} diff --git a/packages/common/src/mvc/decorators/index.ts b/packages/common/src/mvc/decorators/index.ts index c128989b668..689585c7a3a 100644 --- a/packages/common/src/mvc/decorators/index.ts +++ b/packages/common/src/mvc/decorators/index.ts @@ -34,6 +34,7 @@ export * from "./class/caseSensitive"; export * from "./params/usePipe"; export * from "./params/useParam"; export * from "./params/useParamType"; +export * from "./params/useType"; export * from "./params/useValidation"; export * from "./params/useDeserialization"; export * from "./params/useParamExpression"; diff --git a/packages/common/src/mvc/decorators/params/pathParams.spec.ts b/packages/common/src/mvc/decorators/params/pathParams.spec.ts index 0e3798f3393..bd15e5e811b 100644 --- a/packages/common/src/mvc/decorators/params/pathParams.spec.ts +++ b/packages/common/src/mvc/decorators/params/pathParams.spec.ts @@ -1,7 +1,5 @@ -import * as Sinon from "sinon"; -import {ParamRegistry, ParamTypes, PathParams} from "../../../../src/mvc"; +import {ParamRegistry, ParamTypes, PathParams, RawPathParams} from "../../../../src/mvc"; -const sandbox = Sinon.createSandbox(); describe("@PathParams", () => { it("should call ParamFilter.useParam method with the correct parameters", () => { class Test {} @@ -15,4 +13,13 @@ describe("@PathParams", () => { param.paramType.should.eq(ParamTypes.PATH); param.type.should.eq(Test); }); + it("should call ParamFilter.useParam method with the correct parameters (raw)", () => { + class Ctrl { + test(@RawPathParams("expression") header: string) {} + } + + const param = ParamRegistry.get(Ctrl, "test", 0); + param.expression.should.eq("expression"); + param.paramType.should.eq(ParamTypes.PATH); + }); }); diff --git a/packages/common/src/mvc/decorators/params/pathParams.ts b/packages/common/src/mvc/decorators/params/pathParams.ts index fab39c27c61..a4284f351df 100644 --- a/packages/common/src/mvc/decorators/params/pathParams.ts +++ b/packages/common/src/mvc/decorators/params/pathParams.ts @@ -1,8 +1,8 @@ import {Type} from "@tsed/core"; import {IParamOptions} from "../../interfaces/IParamOptions"; import {ParamTypes} from "../../models/ParamTypes"; -import {UseParam} from "./useParam"; import {mapParamsOptions} from "../utils/mapParamsOptions"; +import {UseParam} from "./useParam"; /** * PathParams return the value from [request.params](http://expressjs.com/en/4x/api.html#req.params) object. @@ -50,3 +50,34 @@ export function PathParams(...args: any[]): ParameterDecorator { useValidation }); } + +/** + * RawPathParams return the raw value from [request.params](http://expressjs.com/en/4x/api.html#req.params) object. + * + * Any validation and transformation are performed on the value. Use [pipes](/docs/pipes.html) to validate and/or transform the value. + * + * #### Example + * + * ```typescript + * @Controller('/') + * class MyCtrl { + * @Get('/') + * get(@RawPathParams() params: string) { + * console.log('Entire params', params); + * } + * + * @Get('/') + * get(@RawPathParams('id') id: string) { + * console.log('ID', id); + * } + * } + * ``` + * > For more information on deserialization see [converters](/docs/converters.md) page. + * + * @param expression The path of the property to get. + * @decorator + * @returns {ParameterDecorator} + */ +export function RawPathParams(expression: string) { + return UseParam(ParamTypes.PATH, {expression}); +} diff --git a/packages/common/src/mvc/decorators/params/queryParams.spec.ts b/packages/common/src/mvc/decorators/params/queryParams.spec.ts index 4c95b899c20..ce00eafe599 100644 --- a/packages/common/src/mvc/decorators/params/queryParams.spec.ts +++ b/packages/common/src/mvc/decorators/params/queryParams.spec.ts @@ -1,7 +1,5 @@ -import * as Sinon from "sinon"; -import {ParamRegistry, ParamTypes, QueryParams} from "../../../../src/mvc"; +import {ParamRegistry, ParamTypes, QueryParams, RawQueryParams} from "../../../../src/mvc"; -const sandbox = Sinon.createSandbox(); describe("@QueryParams", () => { it("should call ParamFilter.useParam method with the correct parameters", () => { class Test {} @@ -15,4 +13,14 @@ describe("@QueryParams", () => { param.paramType.should.eq(ParamTypes.QUERY); param.type.should.eq(Test); }); + + it("should call ParamFilter.useParam method with the correct parameters (rawQueryParams)", () => { + class Ctrl { + test(@RawQueryParams("expression") header: string) {} + } + + const param = ParamRegistry.get(Ctrl, "test", 0); + param.expression.should.eq("expression"); + param.paramType.should.eq(ParamTypes.QUERY); + }); }); diff --git a/packages/common/src/mvc/decorators/params/queryParams.ts b/packages/common/src/mvc/decorators/params/queryParams.ts index 74f94d63835..e516ca20377 100644 --- a/packages/common/src/mvc/decorators/params/queryParams.ts +++ b/packages/common/src/mvc/decorators/params/queryParams.ts @@ -1,8 +1,8 @@ import {Type} from "@tsed/core"; import {IParamOptions} from "../../interfaces/IParamOptions"; import {ParamTypes} from "../../models/ParamTypes"; -import {UseParam} from "./useParam"; import {mapParamsOptions} from "../utils/mapParamsOptions"; +import {UseParam} from "./useParam"; /** * QueryParams return the value from [request.query](http://expressjs.com/en/4x/api.html#req.query) object. @@ -55,3 +55,34 @@ export function QueryParams(...args: any[]): ParameterDecorator { useValidation }); } + +/** + * RawQueryParams return the value from [request.query](http://expressjs.com/en/4x/api.html#req.query) object. + * + * Any validation and transformation are performed on the value. Use [pipes](/docs/pipes.html) to validate and/or transform the value. + * + * #### Example + * + * ```typescript + * @Controller('/') + * class MyCtrl { + * @Get('/') + * get(@RawPathParams() params: any) { + * console.log('Entire params', params); + * } + * + * @Get('/') + * get(@RawPathParams('id') id: string) { + * console.log('ID', id); + * } + * } + * ``` + * > For more information on deserialization see [converters](/docs/converters.md) page. + * + * @param expression The path of the property to get. + * @decorator + * @returns {ParameterDecorator} + */ +export function RawQueryParams(expression: string) { + return UseParam(ParamTypes.QUERY, {expression}); +} diff --git a/packages/common/src/mvc/decorators/required.ts b/packages/common/src/mvc/decorators/required.ts index 7374c1b6a5a..bd85e990f4c 100644 --- a/packages/common/src/mvc/decorators/required.ts +++ b/packages/common/src/mvc/decorators/required.ts @@ -1,6 +1,4 @@ -import {applyDecorators, DecoratorParameters, getDecoratorType, StoreMerge, UnsupportedDecoratorType} from "@tsed/core"; -import {ParamMetadata} from "../models/ParamMetadata"; -import {RequiredPipe} from "../pipes/RequiredPipe"; +import {applyDecorators, DecoratorParameters, StoreMerge, UnsupportedDecoratorType} from "@tsed/core"; import {Allow} from "./allow"; import {getStorableMetadata} from "./utils/getStorableMetadata"; @@ -60,10 +58,6 @@ export function Required(...allowedRequiredValues: any[]): any { if (allowedRequiredValues.length) { Allow(...allowedRequiredValues)(...decoratorArgs); } - - if (getDecoratorType(decoratorArgs) === "parameter") { - (metadata as ParamMetadata).pipes.push(RequiredPipe); - } }, StoreMerge("responses", { "400": { diff --git a/packages/common/src/mvc/errors/ParseExpressionError.spec.ts b/packages/common/src/mvc/errors/ParseExpressionError.spec.ts deleted file mode 100644 index f58b88ba948..00000000000 --- a/packages/common/src/mvc/errors/ParseExpressionError.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {expect} from "chai"; -import {ParseExpressionError} from "../../../src/mvc"; - -describe("ParseExpressionError", () => { - before(() => {}); - - it("should return error", () => { - const errorInstance = new ParseExpressionError("name", "expression", {message: "message"}); - expect(errorInstance.message).to.equal("Bad request on parameter \"request.name.expression\".\nmessage"); - expect(errorInstance.name).to.equal("BAD_REQUEST"); - expect(errorInstance.dataPath).to.equal("expression"); - expect(errorInstance.requestType).to.equal("name"); - expect(JSON.parse(JSON.stringify(errorInstance))).to.deep.equal({ - dataPath: "expression", - headers: {}, - errorMessage: "Bad request on parameter \"request.name.expression\".\nmessage", - name: "BAD_REQUEST", - requestType: "name", - status: 400, - type: "HTTP_EXCEPTION", - origin: { - message: "message" - } - }); - }); -}); diff --git a/packages/common/src/mvc/errors/ParseExpressionError.ts b/packages/common/src/mvc/errors/ParseExpressionError.ts deleted file mode 100644 index 9b87c35cb09..00000000000 --- a/packages/common/src/mvc/errors/ParseExpressionError.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {BadRequest} from "@tsed/exceptions"; - -/** - * @private - */ -export class ParseExpressionError extends BadRequest { - dataPath: string; - requestType: string; - errorMessage: string; - origin: Error; - - constructor(name: string, expression: string | RegExp | undefined, err: any = {}) { - super(ParseExpressionError.buildMessage(name, expression, err.message)); - this.errorMessage = this.message; - this.dataPath = String(expression) || ""; - this.requestType = name; - this.origin! = err.origin || err; - } - - /** - * - * @param name - * @param expression - * @param message - * @returns {string} - */ - static buildMessage(name: string, expression: string | RegExp | undefined, message?: string) { - name = name.toLowerCase().replace(/parse|params|filter/gi, ""); - - return `Bad request on parameter "request.${name}${expression ? "." + expression : ""}".\n${message}`.trim(); - } -} diff --git a/packages/common/src/mvc/errors/RequiredParamError.spec.ts b/packages/common/src/mvc/errors/RequiredParamError.spec.ts deleted file mode 100644 index d1ad7dc0cf1..00000000000 --- a/packages/common/src/mvc/errors/RequiredParamError.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import {expect} from "chai"; -import {RequiredParamError} from "../../../src/mvc"; - -describe("RequiredParamError", () => { - it("should have a message", () => { - const errorInstance = new RequiredParamError("name", "expression"); - expect(errorInstance.message).to.equal("Bad request, parameter \"request.name.expression\" is required."); - expect(errorInstance.name).to.equal("BAD_REQUEST"); - expect(JSON.parse(JSON.stringify(errorInstance))).to.deep.equal({ - name: "BAD_REQUEST", - status: 400, - headers: {}, - type: "HTTP_EXCEPTION", - errors: [ - { - dataPath: "", - keyword: "required", - message: "should have required param 'expression'", - modelName: "name", - params: { - missingProperty: "expression" - }, - schemaPath: "#/required" - } - ] - }); - }); -}); diff --git a/packages/common/src/mvc/errors/RequiredParamError.ts b/packages/common/src/mvc/errors/RequiredParamError.ts deleted file mode 100644 index a27533bbf30..00000000000 --- a/packages/common/src/mvc/errors/RequiredParamError.ts +++ /dev/null @@ -1,35 +0,0 @@ -import {BadRequest} from "@tsed/exceptions"; - -export class RequiredParamError extends BadRequest { - errors: any[]; - - constructor(name: string, expression: string | RegExp) { - super(RequiredParamError.buildMessage(name, "" + expression)); - const type = name.toLowerCase().replace(/parse|params|filter/gi, ""); - - this.errors = [ - { - dataPath: "", - keyword: "required", - message: `should have required param '${expression}'`, - modelName: type, - params: { - missingProperty: expression - }, - schemaPath: "#/required" - } - ]; - } - - /** - * - * @param name - * @param expression - * @returns {string} - */ - static buildMessage(name: string, expression: string) { - name = name.toLowerCase().replace(/parse|params|filter/gi, ""); - - return `Bad request, parameter "${name === "request" ? name : "request." + name}.${expression}" is required.`; - } -} diff --git a/packages/common/src/mvc/errors/RequiredValidationError.spec.ts b/packages/common/src/mvc/errors/RequiredValidationError.spec.ts new file mode 100644 index 00000000000..38a05ba74a9 --- /dev/null +++ b/packages/common/src/mvc/errors/RequiredValidationError.spec.ts @@ -0,0 +1,31 @@ +import {RequiredValidationError} from "@tsed/common"; +import {expect} from "chai"; + +describe("RequiredValidationError", () => { + it("should have a message", () => { + const error = RequiredValidationError.from({ + service: "name", + expression: "expression" + } as any); + expect(error.message).to.equal("It should have required parameter 'expression'"); + expect(error.name).to.equal("REQUIRED_VALIDATION_ERROR"); + expect(JSON.parse(JSON.stringify(error))).to.deep.equal({ + name: "REQUIRED_VALIDATION_ERROR", + status: 400, + headers: {}, + type: "HTTP_EXCEPTION", + errors: [ + { + dataPath: "", + keyword: "required", + message: "It should have required parameter 'expression'", + modelName: "name", + params: { + missingProperty: "expression" + }, + schemaPath: "#/required" + } + ] + }); + }); +}); diff --git a/packages/common/src/mvc/errors/RequiredValidationError.ts b/packages/common/src/mvc/errors/RequiredValidationError.ts new file mode 100644 index 00000000000..f9ca8258c48 --- /dev/null +++ b/packages/common/src/mvc/errors/RequiredValidationError.ts @@ -0,0 +1,30 @@ +import {ParamMetadata} from "../models/ParamMetadata"; +import {nameOf} from "@tsed/core"; +import {ValidationError} from "./ValidationError"; + +export class RequiredValidationError extends ValidationError { + public name: string = "REQUIRED_VALIDATION_ERROR"; + public errors: any[]; + + static from(metadata: ParamMetadata) { + const name = nameOf(metadata.service); + const expression = metadata.expression; + const type = name.toLowerCase().replace(/parse|params|filter/gi, ""); + const message = `It should have required parameter '${expression}'`; + + const errors = [ + { + dataPath: "", + keyword: "required", + message, + modelName: type, + params: { + missingProperty: expression + }, + schemaPath: "#/required" + } + ]; + + return new RequiredValidationError(message, errors); + } +} diff --git a/packages/common/src/mvc/errors/UnknowFilterError.ts b/packages/common/src/mvc/errors/UnknowFilterError.ts deleted file mode 100644 index 1da36437f78..00000000000 --- a/packages/common/src/mvc/errors/UnknowFilterError.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {nameOf, Type} from "@tsed/core"; -import {InternalServerError} from "@tsed/exceptions"; - -/** - * @private - */ -export class UnknowFilterError extends InternalServerError { - name: "UNKNOW_FILTER_ERROR"; - status: 500; - - constructor(target: Type) { - super(UnknowFilterError.buildMessage(target)); - } - - /** - * - * @returns {string} - */ - static buildMessage(target: Type) { - return `Filter ${nameOf(target)} not found.`; - } -} diff --git a/packages/common/src/mvc/errors/ValidationError.spec.ts b/packages/common/src/mvc/errors/ValidationError.spec.ts new file mode 100644 index 00000000000..296667ec10b --- /dev/null +++ b/packages/common/src/mvc/errors/ValidationError.spec.ts @@ -0,0 +1,19 @@ +import {ValidationError} from "@tsed/common"; +import {expect} from "chai"; + +describe("ValidationError", () => { + it("should return error", () => { + const error = new ValidationError("should have required property", [ + { + dataPath: "hello" + } + ]); + + expect(error.errors).to.deep.eq([ + { + dataPath: "hello" + } + ]); + expect(error.message).to.eq("should have required property"); + }); +}); diff --git a/packages/common/src/mvc/errors/ValidationError.ts b/packages/common/src/mvc/errors/ValidationError.ts new file mode 100644 index 00000000000..00c871702eb --- /dev/null +++ b/packages/common/src/mvc/errors/ValidationError.ts @@ -0,0 +1,12 @@ +import {BadRequest} from "@tsed/exceptions"; +import {IResponseError} from "../interfaces/IResponseError"; + +export class ValidationError extends BadRequest implements IResponseError { + public name: string = "VALIDATION_ERROR"; + public errors: any[]; + + constructor(message: string, errors: any[] = []) { + super(message); + this.errors = errors; + } +} diff --git a/packages/common/src/mvc/index.ts b/packages/common/src/mvc/index.ts index a2e6fe1888d..f7f1a090a3c 100644 --- a/packages/common/src/mvc/index.ts +++ b/packages/common/src/mvc/index.ts @@ -22,7 +22,6 @@ export * from "./middlewares/AcceptMimesMiddleware"; export * from "./middlewares/ResponseViewMiddleware"; // pipes -export * from "./pipes/RequiredPipe"; export * from "./pipes/ValidationPipe"; export * from "./pipes/ParseExpressionPipe"; export * from "./pipes/DeserializerPipe"; @@ -33,9 +32,8 @@ export * from "./services/ValidationService"; // errors export * from "./errors/TemplateRenderingError"; -export * from "./errors/ParseExpressionError"; -export * from "./errors/RequiredParamError"; -export * from "./errors/UnknowFilterError"; +export * from "./errors/RequiredValidationError"; +export * from "./errors/ValidationError"; // decorators export * from "./decorators"; diff --git a/packages/common/src/mvc/models/ParamMetadata.ts b/packages/common/src/mvc/models/ParamMetadata.ts index 08524cce058..72dc428aedd 100644 --- a/packages/common/src/mvc/models/ParamMetadata.ts +++ b/packages/common/src/mvc/models/ParamMetadata.ts @@ -17,8 +17,8 @@ export interface IParamConstructorOptions { pipes?: Type[]; } -export interface IPipe { - transform(value: any, context: ParamMetadata): any; +export interface IPipe { + transform(value: T, metadata: ParamMetadata): R; } export class ParamMetadata extends Storable implements IParamConstructorOptions { diff --git a/packages/common/src/mvc/pipes/ParseExpressionPipe.ts b/packages/common/src/mvc/pipes/ParseExpressionPipe.ts index 3b7cac5d487..c59eb39fbcd 100644 --- a/packages/common/src/mvc/pipes/ParseExpressionPipe.ts +++ b/packages/common/src/mvc/pipes/ParseExpressionPipe.ts @@ -19,7 +19,6 @@ export class ParseExpressionPipe implements IPipe { expression = String(param.expression).toLowerCase(); } - // return (value: any) => { value = getValue(expression, value); if ([ParamTypes.QUERY, ParamTypes.PATH].includes(paramType as ParamTypes) && value === "" && type !== String) { diff --git a/packages/common/src/mvc/pipes/RequiredPipe.spec.ts b/packages/common/src/mvc/pipes/RequiredPipe.spec.ts deleted file mode 100644 index e54b6c1b516..00000000000 --- a/packages/common/src/mvc/pipes/RequiredPipe.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import {ParamMetadata, ParamTypes, RequiredParamError} from "@tsed/common"; -import {TestContext} from "@tsed/testing"; -import {expect} from "chai"; -import * as Sinon from "sinon"; -import {RequiredPipe} from "./RequiredPipe"; - -const sandbox = Sinon.createSandbox(); -describe("RequiredPipe", () => { - beforeEach(TestContext.create); - beforeEach(TestContext.reset); - afterEach(() => { - sandbox.restore(); - }); - it( - "should return value (no required)", - TestContext.inject([RequiredPipe], (pipe: RequiredPipe) => { - // @ts-ignore - class Test {} - - const param = new ParamMetadata({ - index: 0, - target: Test, - propertyKey: "test", - paramType: ParamTypes.REQUEST - }); - // @ts-ignore - param._type = String; - param.collectionType = Array; - - // WHEN - expect(pipe.transform("value", param)).to.deep.eq("value"); - }) - ); - it( - "should return value (required)", - TestContext.inject([RequiredPipe], (pipe: RequiredPipe) => { - // @ts-ignore - class Test {} - - const param = new ParamMetadata({ - index: 0, - target: Test, - propertyKey: "test", - paramType: ParamTypes.REQUEST - }); - param.required = true; - // @ts-ignore - param._type = String; - param.collectionType = Array; - - // WHEN - expect(pipe.transform("value", param)).to.deep.eq("value"); - }) - ); - it( - "should do throw an error", - TestContext.inject([RequiredPipe], (pipe: RequiredPipe) => { - // @ts-ignore - class Test {} - - const param = new ParamMetadata({ - index: 0, - target: Test, - propertyKey: "test", - paramType: ParamTypes.REQUEST - }); - param.required = true; - // @ts-ignore - param._type = String; - param.collectionType = Array; - - // WHEN - let actualError: any; - try { - pipe.transform("", param); - } catch (error) { - actualError = error; - } - expect(actualError).to.be.instanceof(RequiredParamError); - }) - ); -}); diff --git a/packages/common/src/mvc/pipes/RequiredPipe.ts b/packages/common/src/mvc/pipes/RequiredPipe.ts deleted file mode 100644 index f9385cd7d1c..00000000000 --- a/packages/common/src/mvc/pipes/RequiredPipe.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {nameOf} from "@tsed/core"; -import {Injectable} from "@tsed/di"; -import {RequiredParamError} from "../errors/RequiredParamError"; -import {IPipe, ParamMetadata} from "../models/ParamMetadata"; - -@Injectable() -export class RequiredPipe implements IPipe { - transform(value: any, param: ParamMetadata) { - if (param.isRequired(value)) { - throw new RequiredParamError(nameOf(param.service), param.expression); - } - - return value; - } -} diff --git a/packages/common/src/mvc/pipes/ValidationPipe.spec.ts b/packages/common/src/mvc/pipes/ValidationPipe.spec.ts index 2f66269a3ec..f89f26d4f02 100644 --- a/packages/common/src/mvc/pipes/ValidationPipe.spec.ts +++ b/packages/common/src/mvc/pipes/ValidationPipe.spec.ts @@ -1,70 +1,50 @@ -import {ParamMetadata, ParamTypes, ParseExpressionError} from "@tsed/common"; -import {TestContext} from "@tsed/testing"; +import {ParamMetadata, ParamRegistry, ParamTypes} from "@tsed/common"; +import {catchError} from "@tsed/core"; import {expect} from "chai"; import * as Sinon from "sinon"; +import {QueryParams} from "../decorators/params/queryParams"; import {ValidationPipe} from "./ValidationPipe"; -const sandbox = Sinon.createSandbox(); describe("ValidationPipe", () => { - beforeEach(TestContext.create); - beforeEach(TestContext.reset); - afterEach(() => { - sandbox.restore(); - }); - it( - "should return value", - TestContext.inject([ValidationPipe], (pipe: ValidationPipe) => { - // @ts-ignore - sandbox.stub(pipe.validationService, "validate"); + it("should return value", async () => { + const validate = Sinon.stub(); + const validator = new ValidationPipe({ + validate + }); - class Test {} + class Test {} - const param = new ParamMetadata({ - index: 0, - target: Test, - propertyKey: "test", - paramType: ParamTypes.REQUEST - }); - // @ts-ignore - param._type = String; - param.collectionType = Array; + const param = new ParamMetadata({ + index: 0, + target: Test, + propertyKey: "test", + paramType: ParamTypes.REQUEST + }); + // @ts-ignore + param._type = String; + param.collectionType = Array; - // WHEN - expect(pipe.transform("value", param)).to.deep.eq("value"); - // @ts-ignore - pipe.validationService.validate.should.have.been.calledWithExactly("value", String, Array); - }) - ); - it( - "should throw an error", - TestContext.inject([ValidationPipe], (pipe: ValidationPipe) => { - const error = new Error("message"); - // @ts-ignore - sandbox.stub(pipe.validationService, "validate").callsFake(() => { - throw error; - }); + // WHEN + expect(validator.transform("value", param)).to.deep.eq("value"); + // @ts-ignore + validate.should.have.been.calledWithExactly("value", String, Array); + }); - class Test {} + it("should throw an error", async () => { + const error = new Error("message"); + const validator = new ValidationPipe({ + validate() { + throw error; + } + }); - const param = new ParamMetadata({ - index: 0, - target: Test, - propertyKey: "test", - paramType: ParamTypes.REQUEST - }); - // @ts-ignore - param._type = String; - param.collectionType = Array; + class Test { + test(@QueryParams("param", String) param: string[]) {} + } - // WHEN - let actualError: any; - try { - pipe.transform("value", param); - } catch (er) { - actualError = er; - } - // @ts-ignore - expect(actualError).to.instanceof(ParseExpressionError); - }) - ); + // WHEN + const actualError = catchError(() => validator.transform("value", ParamRegistry.get(Test, "test", 0))); + // @ts-ignore + expect(actualError.message).to.eq("message"); + }); }); diff --git a/packages/common/src/mvc/pipes/ValidationPipe.ts b/packages/common/src/mvc/pipes/ValidationPipe.ts index 3300d2062b2..6f88cce48f4 100644 --- a/packages/common/src/mvc/pipes/ValidationPipe.ts +++ b/packages/common/src/mvc/pipes/ValidationPipe.ts @@ -1,23 +1,22 @@ -import {nameOf} from "@tsed/core"; import {Injectable} from "@tsed/di"; -import {ParseExpressionError} from "../errors/ParseExpressionError"; +import {RequiredValidationError} from "../errors/RequiredValidationError"; import {IPipe, ParamMetadata} from "../models/ParamMetadata"; import {ValidationService} from "../services/ValidationService"; -@Injectable() +@Injectable({ + type: "validator" +}) export class ValidationPipe implements IPipe { - constructor(private validationService: ValidationService) {} - - transform(value: any, param: ParamMetadata) { - if (this.shouldValidate(param)) { - const {collectionType} = param; - const type = param.type || param.collectionType; - - try { - this.validationService.validate(value, type, collectionType); - } catch (err) { - throw new ParseExpressionError(nameOf(param.service), param.expression, err); - } + constructor(protected validationService: ValidationService) {} + + transform(value: any, metadata: ParamMetadata) { + this.checkIsRequired(value, metadata); + + if (this.shouldValidate(metadata)) { + const {collectionType} = metadata; + const type = metadata.type || metadata.collectionType; + + this.validationService.validate(value, type, collectionType); } return value; @@ -26,4 +25,12 @@ export class ValidationPipe implements IPipe { protected shouldValidate(param: ParamMetadata) { return !!(param.type || param.collectionType); } + + protected checkIsRequired(value: any, metadata: ParamMetadata) { + if (metadata.isRequired(value)) { + throw RequiredValidationError.from(metadata); + } + + return true; + } } diff --git a/packages/common/src/mvc/services/ValidationService.ts b/packages/common/src/mvc/services/ValidationService.ts index cda9fc10af8..8badef3a1c4 100644 --- a/packages/common/src/mvc/services/ValidationService.ts +++ b/packages/common/src/mvc/services/ValidationService.ts @@ -1,5 +1,8 @@ import {Service} from "@tsed/di"; +/** + * @deprecated Use ValidationPipe instead + */ @Service() export class ValidationService { public validate(obj: any, targetType: any, baseType?: any): boolean { diff --git a/packages/common/src/platform/errors/ParamValidationError.spec.ts b/packages/common/src/platform/errors/ParamValidationError.spec.ts new file mode 100644 index 00000000000..eb1eae86d50 --- /dev/null +++ b/packages/common/src/platform/errors/ParamValidationError.spec.ts @@ -0,0 +1,107 @@ +import {RequiredValidationError, ValidationError} from "@tsed/common"; +import {expect} from "chai"; +import {ParamValidationError} from "./ParamValidationError"; + +describe("ParseExpressionError", () => { + before(() => {}); + + it("should return error", () => { + const error = ParamValidationError.from( + { + service: "name", + expression: "expression" + } as any, + {message: "message"} + ); + expect(error.message).to.equal("Bad request on parameter \"request.name.expression\".\nmessage"); + expect(error.name).to.equal("PARAM_VALIDATION_ERROR"); + expect(error.dataPath).to.equal("expression"); + expect(error.requestType).to.equal("name"); + expect(JSON.parse(JSON.stringify(error))).to.deep.equal({ + dataPath: "expression", + headers: {}, + name: "PARAM_VALIDATION_ERROR", + requestType: "name", + status: 400, + type: "HTTP_EXCEPTION", + origin: { + message: "message" + } + }); + }); + it("should throw error from origin error (RequiredValidationError)", () => { + const metadata = { + service: "name", + expression: "expression" + } as any; + const origin = RequiredValidationError.from(metadata); + + const error = ParamValidationError.from(metadata, origin); + expect(error.message).to.equal("Bad request on parameter \"request.name.expression\".\nIt should have required parameter 'expression'"); + expect(error.dataPath).to.equal("expression"); + expect(error.requestType).to.equal("name"); + + expect(JSON.parse(JSON.stringify(error))).to.deep.equal({ + dataPath: "expression", + headers: {}, + name: "PARAM_VALIDATION_ERROR", + origin: { + errors: [ + { + dataPath: "", + keyword: "required", + message: "It should have required parameter 'expression'", + modelName: "name", + params: { + missingProperty: "expression" + }, + schemaPath: "#/required" + } + ], + headers: {}, + name: "REQUIRED_VALIDATION_ERROR", + status: 400, + type: "HTTP_EXCEPTION" + }, + requestType: "name", + status: 400, + type: "HTTP_EXCEPTION" + }); + }); + it("should throw error from origin error (ValidationError)", () => { + const metadata = { + service: "name", + expression: "expression" + } as any; + const origin = new ValidationError("It should have 1 item", [ + { + dataPath: "hello" + } + ]); + + const error = ParamValidationError.from(metadata, origin); + expect(error.message).to.equal("Bad request on parameter \"request.name.expression\".\nIt should have 1 item"); + expect(error.dataPath).to.equal("expression"); + expect(error.requestType).to.equal("name"); + + expect(JSON.parse(JSON.stringify(error))).to.deep.equal({ + dataPath: "expression", + headers: {}, + name: "PARAM_VALIDATION_ERROR", + origin: { + errors: [ + { + dataPath: "hello" + } + ], + headers: {}, + name: "VALIDATION_ERROR", + status: 400, + type: "HTTP_EXCEPTION" + }, + requestType: "name", + status: 400, + type: "HTTP_EXCEPTION" + }); + }); +}); diff --git a/packages/common/src/platform/errors/ParamValidationError.ts b/packages/common/src/platform/errors/ParamValidationError.ts new file mode 100644 index 00000000000..5e3f501afda --- /dev/null +++ b/packages/common/src/platform/errors/ParamValidationError.ts @@ -0,0 +1,24 @@ +import {nameOf} from "@tsed/core"; +import {BadRequest} from "@tsed/exceptions"; +import {ParamMetadata} from "../../mvc/models/ParamMetadata"; + +export class ParamValidationError extends BadRequest { + public name: string = "PARAM_VALIDATION_ERROR"; + public dataPath: string; + public requestType: string; + + static from(metadata: ParamMetadata, origin: any = {}) { + const name = nameOf(metadata.service) + .toLowerCase() + .replace(/parse|params|filter/gi, ""); + const expression = metadata.expression; + const message = `Bad request on parameter "request.${name}${expression ? "." + expression : ""}".\n${origin.message}`.trim(); + + const error = new ParamValidationError(message); + error.dataPath = String(metadata.expression) || ""; + error.requestType = nameOf(metadata.service); + error.origin = origin.origin || origin; + + return error; + } +} diff --git a/packages/common/src/mvc/errors/UnknowFilterError.spec.ts b/packages/common/src/platform/errors/UnknowFilterError.spec.ts similarity index 56% rename from packages/common/src/mvc/errors/UnknowFilterError.spec.ts rename to packages/common/src/platform/errors/UnknowFilterError.spec.ts index d69bde47e73..a7768aa0a85 100644 --- a/packages/common/src/mvc/errors/UnknowFilterError.spec.ts +++ b/packages/common/src/platform/errors/UnknowFilterError.spec.ts @@ -1,9 +1,9 @@ import {expect} from "chai"; -import {UnknowFilterError} from "../../../src/mvc"; +import {UnknownFilterError} from "./UnknownFilterError"; -describe("UnknowFilterError", () => { +describe("UnknownFilterError", () => { it("should have a message", () => { - const errorInstance = new UnknowFilterError(class Target {}); + const errorInstance = new UnknownFilterError(class Target {}); expect(errorInstance.message).to.equal("Filter Target not found."); expect(errorInstance.name).to.equal("INTERNAL_SERVER_ERROR"); }); diff --git a/packages/common/src/platform/errors/UnknownFilterError.ts b/packages/common/src/platform/errors/UnknownFilterError.ts new file mode 100644 index 00000000000..adc59891bce --- /dev/null +++ b/packages/common/src/platform/errors/UnknownFilterError.ts @@ -0,0 +1,11 @@ +import {nameOf, Type} from "@tsed/core"; +import {InternalServerError} from "@tsed/exceptions"; + +export class UnknownFilterError extends InternalServerError { + name: "UNKNOWN_FILTER_ERROR"; + status: 500; + + constructor(target: Type) { + super(`Filter ${nameOf(target)} not found.`); + } +} diff --git a/packages/common/src/platform/index.ts b/packages/common/src/platform/index.ts index f7a14ffc310..ed984da5e3d 100644 --- a/packages/common/src/platform/index.ts +++ b/packages/common/src/platform/index.ts @@ -24,6 +24,10 @@ export * from "./domain/RequestLogger"; export * from "./domain/RequestLogger"; export * from "./domain/ControllerProvider"; +// errors +export * from "./errors/ParamValidationError"; +export * from "./errors/UnknownFilterError"; + // providers export * from "./services/Platform"; export * from "./services/PlatformDriver"; diff --git a/packages/common/src/platform/services/PlatformHandler.spec.ts b/packages/common/src/platform/services/PlatformHandler.spec.ts index e12bef1f5fb..a80197ea659 100644 --- a/packages/common/src/platform/services/PlatformHandler.spec.ts +++ b/packages/common/src/platform/services/PlatformHandler.spec.ts @@ -21,8 +21,7 @@ import {PlatformHandler} from "./PlatformHandler"; function build(injector: InjectorService, type: string | ParamTypes | Type, {expression, required}: any = {}) { class Test { - test() { - } + test() {} } const param = new ParamMetadata({target: Test, propertyKey: "test", index: 0}); @@ -68,8 +67,7 @@ class Test { return error; } - useErr(err: any, req: any, res: any, next: any) { - } + useErr(err: any, req: any, res: any, next: any) {} } describe("PlatformHandler", () => { @@ -123,8 +121,7 @@ describe("PlatformHandler", () => { sandbox.stub(injector, "getProvider").returns(undefined); // WHEN - const handlerMetadata = platformHandler.createHandlerMetadata(() => { - }); + const handlerMetadata = platformHandler.createHandlerMetadata(() => {}); // THEN handlerMetadata.type.should.eq(HandlerType.FUNCTION); @@ -137,7 +134,7 @@ describe("PlatformHandler", () => { async (injector: InjectorService, platformHandler: PlatformHandler) => { // GIVEN sandbox.stub(Test.prototype, "get").callsFake(o => o); - sandbox.stub(injector, "invoke").callsFake(() => new Test()); + injector.invoke(Test); const request = new FakeRequest(); const response = new FakeRequest(); diff --git a/packages/common/src/platform/services/PlatformHandler.ts b/packages/common/src/platform/services/PlatformHandler.ts index c19bfbfe5b6..9db5daa5ab7 100644 --- a/packages/common/src/platform/services/PlatformHandler.ts +++ b/packages/common/src/platform/services/PlatformHandler.ts @@ -8,10 +8,12 @@ import { IHandlerConstructorOptions, IPipe, ParamMetadata, - ParamTypes, - UnknowFilterError + ParamTypes } from "../../mvc"; +import {ValidationError} from "../../mvc/errors/ValidationError"; import {HandlerContext} from "../domain/HandlerContext"; +import {ParamValidationError} from "../errors/ParamValidationError"; +import {UnknownFilterError} from "../errors/UnknownFilterError"; @Injectable({ scope: ProviderScope.SINGLETON @@ -143,7 +145,7 @@ export class PlatformHandler { const instance = this.injector.get(param.filter); if (!instance || !instance.transform) { - throw new UnknowFilterError(param.filter!); + throw new UnknownFilterError(param.filter!); } return instance.transform(expression, context.request, context.response); @@ -188,7 +190,7 @@ export class PlatformHandler { } = context; try { - context.args = parameters.map(param => this.mapParam(param, context)); + context.args = await Promise.all(parameters.map(param => this.mapParam(param, context))); await context.callHandler(); } catch (error) { @@ -210,13 +212,26 @@ export class PlatformHandler { /** * - * @param param + * @param metadata * @param context */ - private mapParam(param: ParamMetadata, context: HandlerContext) { + private async mapParam(metadata: ParamMetadata, context: HandlerContext) { const {injector} = context; - const value = this.getParam(param, context); + const value = this.getParam(metadata, context); + + // istanbul ignore next + const handleError = async (cb: Function) => { + try { + return await cb(); + } catch (er) { + throw er instanceof ValidationError ? ParamValidationError.from(metadata, er) : er; + } + }; + + return metadata.pipes.reduce(async (value, pipe) => { + value = await value; - return param.pipes.reduce((value, pipe) => injector.get(pipe)!.transform(value, param), value); + return handleError(() => injector.get(pipe)!.transform(value, metadata)); + }, value); } } diff --git a/packages/core/src/class/EntityDescription.ts b/packages/core/src/class/EntityDescription.ts index 8cc2965cd87..097d5e2ea48 100644 --- a/packages/core/src/class/EntityDescription.ts +++ b/packages/core/src/class/EntityDescription.ts @@ -141,7 +141,7 @@ export abstract class EntityDescription { * * @returns {boolean} */ - get isArray() { + get isArray(): boolean { return isArrayOrArrayClass(this.collectionType); } @@ -183,6 +183,6 @@ export abstract class EntityDescription { * @returns {boolean} */ isRequired(value: any): boolean { - return this.required && [undefined, null, ""].indexOf(value) > -1 && this.allowedRequiredValues.indexOf(value) === -1; + return this.required && [undefined, null, ""].includes(value) && !this.allowedRequiredValues.includes(value); } } diff --git a/packages/core/src/class/Store.ts b/packages/core/src/class/Store.ts index bc2e81dfc36..e15eaba5e0f 100644 --- a/packages/core/src/class/Store.ts +++ b/packages/core/src/class/Store.ts @@ -92,7 +92,7 @@ export class Store { * @param key Required. The key of the element to return from the Map object. * @returns {T} Returns the element associated with the specified key or undefined if the key can't be found in the Map object. */ - get(key: any): any { + get(key: any): T { return this._map.get(nameOf(key)); } diff --git a/packages/core/src/utils/ObjectUtils.ts b/packages/core/src/utils/ObjectUtils.ts index 1ad2bcf0080..b131fa3e0d5 100644 --- a/packages/core/src/utils/ObjectUtils.ts +++ b/packages/core/src/utils/ObjectUtils.ts @@ -132,7 +132,7 @@ export function isPrimitive(target: any): boolean { * @param target * @returns {Boolean} */ -export function isArray(target: any): target is any[] { +export function isArray(target: any): target is T[] { return Array.isArray(target); } diff --git a/packages/core/src/utils/catchError.ts b/packages/core/src/utils/catchError.ts new file mode 100644 index 00000000000..5d7b564d614 --- /dev/null +++ b/packages/core/src/utils/catchError.ts @@ -0,0 +1,7 @@ +export function catchError(cb: Function): T | undefined { + try { + cb(); + } catch (er) { + return er; + } +} diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 5c325b14c6f..805de416200 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -1,4 +1,5 @@ export * from "./applyBefore"; +export * from "./catchError"; export * from "./DecoratorUtils"; export * from "./deepExtends"; export * from "./deepClone"; diff --git a/packages/di/src/decorators/overrideService.ts b/packages/di/src/decorators/overrideService.ts index 9378fbe60c6..5e98d5e1710 100644 --- a/packages/di/src/decorators/overrideService.ts +++ b/packages/di/src/decorators/overrideService.ts @@ -1,10 +1,10 @@ import {OverrideProvider} from "./overrideProvider"; - +// tslint:disable: variable-name /** * Override a service which is already registered in ProviderRegistry. * @returns {Function} * @decorators * @param targetService + * @deprecated Use OverrideProvider */ -// tslint:disable-next-line: variable-name export const OverrideService = OverrideProvider; diff --git a/packages/di/src/services/InjectorService.ts b/packages/di/src/services/InjectorService.ts index 053db926fbe..8e5e656c6f7 100644 --- a/packages/di/src/services/InjectorService.ts +++ b/packages/di/src/services/InjectorService.ts @@ -375,9 +375,13 @@ export class InjectorService extends Container { locals: Map, options: Partial ) { + options = {...options}; Object.defineProperty(instance, propertyKey, { get: () => { - return this.invoke(useType, locals, options); + const instance = this.invoke(useType, locals, options); + options.rebuild = false; // invalid + + return instance; } }); } diff --git a/packages/exceptions/src/core/Exception.ts b/packages/exceptions/src/core/Exception.ts index 4e331dcc90b..6bdb176c4fb 100644 --- a/packages/exceptions/src/core/Exception.ts +++ b/packages/exceptions/src/core/Exception.ts @@ -17,7 +17,7 @@ export class Exception extends Error { * Stack calling */ public stack: string; - public origin: Error; + public origin: Error & any; /** * HTTP Code Status */ diff --git a/packages/integration/src/controllers/calendars/CalendarCtrl.ts b/packages/integration/src/controllers/calendars/CalendarCtrl.ts index 00362dac212..3d3ecb24ab1 100644 --- a/packages/integration/src/controllers/calendars/CalendarCtrl.ts +++ b/packages/integration/src/controllers/calendars/CalendarCtrl.ts @@ -42,7 +42,7 @@ interface ICalendar { * Add @ControllerProvider annotation to declare your provide as Router controller. The first param is the global path for your controller. * The others params is the children controllers. * - * In this case, EventCtrl is a depedency of CalendarCtrl. All routes of EventCtrl will be mounted on the `/calendars` path. + * In this case, EventCtrl is a dependency of CalendarCtrl. All routes of EventCtrl will be mounted on the `/calendars` path. */ @Controller("/calendars", EventCtrl) @Description("Controller description") diff --git a/packages/integration/src/controllers/stream/StreamCtrl.ts b/packages/integration/src/controllers/stream/StreamCtrl.ts new file mode 100644 index 00000000000..df1a63cce8d --- /dev/null +++ b/packages/integration/src/controllers/stream/StreamCtrl.ts @@ -0,0 +1,12 @@ +import {Controller, Get, Res} from "@tsed/common"; +import Axios from "axios"; + +@Controller("/stream") +export class StreamCtrl { + @Get("/") + async get() { + return Axios.get("https://cerevoice.s3.amazonaws.com/Heather800010cbc6611f5540bd0809a388dc95a615b.wav", { + responseType: "stream" + }); + } +} diff --git a/packages/integration/test/auth.spec.ts b/packages/integration/test/auth.spec.ts index 1d59ff280d6..4c08bc266df 100644 --- a/packages/integration/test/auth.spec.ts +++ b/packages/integration/test/auth.spec.ts @@ -45,7 +45,7 @@ describe("Auth scenario", () => { .set({authorization: "token-admin"}) .expect(400); - expect(response.error.text).to.contains("Bad request, parameter \"request.body.id\" is required."); + expect(response.error.text).to.equal("Bad request on parameter \"request.body.id\".
It should have required parameter 'id'"); }); it("should return a 204 response", async () => { @@ -88,7 +88,7 @@ describe("Auth scenario", () => { .set({authorization: "token-admin"}) .expect(400); - expect(response.error.text).to.contains("Bad request, parameter \"request.body.id\" is required."); + expect(response.error.text).to.contains("Bad request on parameter \"request.body.id\".
It should have required parameter 'id'"); }); it("should return a 204 response", async () => { diff --git a/packages/integration/test/helpers/FakeServer.ts b/packages/integration/test/helpers/FakeServer.ts index 72512607db4..3dc154ebc59 100644 --- a/packages/integration/test/helpers/FakeServer.ts +++ b/packages/integration/test/helpers/FakeServer.ts @@ -53,9 +53,6 @@ const rootDir = __dirname + "/../../src"; } }) export class FakeServer extends ServerLoader { - // tslint:disable-next-line: variable-name - static Server: FakeServer; - /** * This method let you configure the middleware required by your application to works. * @returns {Server} diff --git a/packages/integration/test/query.spec.ts b/packages/integration/test/query.spec.ts index bc9c29f7eb7..341d87ace90 100644 --- a/packages/integration/test/query.spec.ts +++ b/packages/integration/test/query.spec.ts @@ -43,7 +43,6 @@ describe("Query spec", () => { after(TestContext.reset); describe("Scenario1: Boolean value", () => { - it("should return true when query is true", async () => { const response = await request.get("/rest/test-scenario-1?test=true").expect(200); @@ -136,7 +135,7 @@ describe("Query spec", () => { }); it("should throw bad request", async () => { const response = await request.get(`${endpoint}?test=error`).expect(400); - + // FIXME REMOVE THIS when @tsed/schema is out response.text.should.be.deep.equal("Bad request on parameter \"request.query.test\".
Cast error. Expression value is not a number."); }); it("should return undefined when query is empty", async () => { diff --git a/packages/integration/test/response.spec.ts b/packages/integration/test/response.spec.ts index 08ffc4ba00e..851d6c5d2c6 100644 --- a/packages/integration/test/response.spec.ts +++ b/packages/integration/test/response.spec.ts @@ -263,7 +263,7 @@ describe("Response", () => { it("should throw a badRequest when path params isn't set as number", async () => { const response = await request.get("/rest/response/scenario9/kkk").expect(400); - response.text.should.be.equal("Cast error. Expression value is not a number."); + response.text.should.be.equal("Bad request on parameter \"request.path.id\".
Cast error. Expression value is not a number."); }); }); }); diff --git a/packages/integration/test/rest.spec.ts b/packages/integration/test/rest.spec.ts index 34c8de48b0c..80144dfa943 100644 --- a/packages/integration/test/rest.spec.ts +++ b/packages/integration/test/rest.spec.ts @@ -231,7 +231,7 @@ describe("Rest", () => { .put("/rest/calendars") .expect(400) .end((err: any, response: any) => { - expect(response.error.text).to.contains("Bad request, parameter \"request.body.name\" is required."); + expect(response.error.text).to.eq("Bad request on parameter \"request.body.name\".
It should have required parameter 'name'"); done(); }); }); @@ -318,7 +318,7 @@ describe("Rest", () => { } ]); - expect(response.text).to.eq("Bad request on parameter \"request.body\".
At UserCreation.email should match format \"email\""); + expect(response.text).to.eq("Bad request on parameter \"request.body\".
UserCreation.email should match format \"email\". Given value: \"undefined\""); }); it("should return an error when password is empty", async () => { @@ -334,7 +334,7 @@ describe("Rest", () => { ]); expect(response.text).to.eq( - "Bad request on parameter \"request.body\".
At UserCreation.password should NOT be shorter than 6 characters" + "Bad request on parameter \"request.body\".
UserCreation.password should NOT be shorter than 6 characters. Given value: \"undefined\"" ); expect(JSON.parse(response.headers.errors)).to.deep.eq([ @@ -437,13 +437,13 @@ describe("Rest", () => { .post("/rest/errors/required-param") .expect(400) .end((err: any, response: any) => { - expect(response.text).to.eq("Bad request, parameter \"request.body.name\" is required."); + expect(response.text).to.eq("Bad request on parameter \"request.body.name\".
It should have required parameter 'name'"); expect(JSON.parse(response.headers.errors)).to.deep.eq([ { dataPath: "", keyword: "required", - message: "should have required param 'name'", + message: "It should have required parameter 'name'", modelName: "body", params: { missingProperty: "name" @@ -461,7 +461,7 @@ describe("Rest", () => { .expect(400) .end((err: any, response: any) => { expect(response.text).to.eq( - "Bad request on parameter \"request.body\".
At CustomModel should have required property 'name'" + "Bad request on parameter \"request.body\".
CustomModel should have required property 'name'. Given value: \"undefined\"" ); expect(JSON.parse(response.headers.errors)).to.deep.eq([ @@ -509,7 +509,7 @@ describe("Rest", () => { .send({}) .expect(400) .end((err: any, response: any) => { - expect(response.text).to.eq("Bad request on parameter \"request.body\".
At CustomPropModel should have required property 'role_item'"); + expect(response.text).to.eq("Bad request on parameter \"request.body\".
CustomPropModel should have required property 'role_item'. Given value: \"undefined\""); done(); }); }); diff --git a/packages/swagger/src/utils/index.ts b/packages/swagger/src/utils/index.ts index 1bd2308dea5..22081df8828 100644 --- a/packages/swagger/src/utils/index.ts +++ b/packages/swagger/src/utils/index.ts @@ -1,4 +1,4 @@ -import {JsonSchema, PathParamsType} from "@tsed/common"; +import {getJsonType, PathParamsType} from "@tsed/common"; import {deepExtends} from "@tsed/core"; function getVariable(subpath: string) { @@ -82,7 +82,7 @@ export function parseSwaggerPath(base: string, path: PathParamsType = ""): {path * @returns {string | string[]} */ export function swaggerType(type: any): string { - return JsonSchema.getJsonType(type) as any; + return getJsonType(type) as any; } /** diff --git a/packages/testing/src/TestContext.ts b/packages/testing/src/TestContext.ts index 5b05fca438f..0606ee35648 100644 --- a/packages/testing/src/TestContext.ts +++ b/packages/testing/src/TestContext.ts @@ -1,5 +1,4 @@ import {PlatformTest, ServerLoader, TokenProvider} from "@tsed/common"; -import {Type} from "@tsed/core"; export interface IInvokeOptions { token?: TokenProvider; @@ -36,7 +35,7 @@ export class TestContext extends PlatformTest { }; } - static invoke(target: TokenProvider, providers: IInvokeOptions[]): T | Promise { + static invoke(target: TokenProvider, providers: IInvokeOptions[] = []): T | Promise { providers = providers.map(p => { return { token: p.token || p.provide,