Validation
Ts.ED provide by default an AJV package @tsed/ajv
to perform a validation on a Model. The CLI install @tsed/ajv
module by default. But if you start your project without Ts.ED CLI, you have to install it manually.
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 ajv @tsed/ajv
yarn add ajv @tsed/ajv
pnpm add ajv @tsed/ajv
bun add ajv @tsed/ajv
Then import @tsed/ajv
in your Server:
import {Configuration} from "@tsed/di";
import "@tsed/ajv"; // import ajv ts.ed module
@Configuration({
ajv: {
returnsCoercedValues: true // returns coerced value to the next pipe instead of returns original value (See #2355)
}
})
export class Server {}
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,
- errorFormatter can be used to alter the output produced by the
@tsed/ajv
package.
The error message could be changed like this:
import {Configuration} from "@tsed/diu";
import "@tsed/ajv"; // import ajv ts.ed module
@Configuration({
ajv: {
errorFormatter: (error) => `At ${error.modelName}${error.dataPath}, value '${error.data}' ${error.message}`,
verbose: true
}
})
export class Server {}
Decorators
Ts.ED gives some decorators to write your validation model:
You can find more information about the decorators in the Model documentation.
Usage examples
Model validation
A model can be used on a method controller along with BodyParams or other decorators, and will be validated by Ajv.
import {Required, MaxLength, MinLength, Minimum, Maximum, Format, Enum, Pattern, Email} from "@tsed/schema";
export class CalendarModel {
@MaxLength(20)
@MinLength(3)
@Required()
title: string;
@Minimum(0)
@Maximum(10)
rating: number;
@Email()
email: string;
@Format("date") // or date-time, etc...
createDate: Date;
@Pattern(/hello/)
customInput: string;
@Enum("value1", "value2")
customInput: "value1" | "value2";
}
Validation error
When a validation error occurs, AJV generates a list of errors with a full description like this:
[
{
"keyword": "minLength",
"dataPath": ".password",
"schemaPath": "#/properties/password/minLength",
"params": {"limit": 6},
"message": "should NOT be shorter than 6 characters",
"modelName": "User"
}
]
Custom validators
User-defined keywords
Ajv allows you to define custom keywords to validate a property.
You can find more details on the different ways to declare a custom validator on this page: https://ajv.js.org/docs/keywords.html
Ts.ED introduces the Keyword decorator to declare a new custom validator for Ajv. Combined with the CustomKey decorator to add keywords to a property of your class, you can use more complex scenarios than what basic JsonSchema allows.
For example, we can create a custom validator to support the range
validation over a number. To do that, we have to define the custom validator by using Keyword decorator:
import {Keyword, KeywordMethods} from "@tsed/ajv";
import {array, number} from "@tsed/schema";
@Keyword({
keyword: "range",
type: "number",
schemaType: "array",
implements: ["exclusiveRange"],
metaSchema: array().items([number(), number()]).minItems(2).additionalItems(false)
})
class RangeKeyword implements KeywordMethods {
compile([min, max]: number[], parentSchema: any) {
return parentSchema.exclusiveRange === true ? (data: any) => data > min && data < max : (data: any) => data >= min && data <= max;
}
}
Then we can declare a model using the standard decorators from @tsed/schema
:
import {CustomKey} from "@tsed/schema";
import {Range, ExclusiveRange} from "../decorators/Range"; // custom decorator
export class Product {
@CustomKey("range", [10, 100])
@CustomKey("exclusiveRange", true)
price: number;
// OR
@Range(10, 100)
@ExclusiveRange(true)
price2: number;
}
import {CustomKey} from "@tsed/schema";
export function Range(min: number, max: number) {
return CustomKey("range", [min, max]);
}
export function ExclusiveRange(bool: boolean) {
return CustomKey("exclusiveRange", bool);
}
Finally, we can create a unit test to verify if our example works properly:
import "@tsed/ajv";
import {PlatformTest} from "@tsed/platform-http/testing";
import {getJsonSchema} from "@tsed/schema";
import {Product} from "./Product";
import "../keywords/RangeKeyword";
describe("Product", () => {
beforeEach(PlatformTest.create);
afterEach(PlatformTest.reset);
it("should call custom keyword validation (compile)", () => {
const ajv = PlatformTest.get<Ajv>(Ajv);
const schema = getJsonSchema(Product, {customKeys: true});
const validate = ajv.compile(schema);
expect(schema).to.deep.equal({
properties: {
price: {
exclusiveRange: true,
range: [10, 100],
type: "number"
}
},
type: "object"
});
expect(validate({price: 10.01})).toEqual(true);
expect(validate({price: 99.99})).toEqual(true);
expect(validate({price: 10})).toEqual(false);
expect(validate({price: 100})).toEqual(false);
});
});
WARNING
If you planed to create keyword that transform the data, you have to set returnsCoercedValues
to true
in your configuration.
With "code" function
Starting from v7, Ajv uses CodeGen module for all pre-defined keywords - see codegen.md for details.
Example even
keyword:
import {Keyword, KeywordMethods} from "@tsed/ajv";
import {array, number} from "@tsed/schema";
import {_, KeywordCxt} from "ajv";
@Keyword({
keyword: "even",
type: "number",
schemaType: "boolean"
})
class EvenKeyword implements KeywordMethods {
code(cxt: KeywordCxt) {
const {data, schema} = cxt;
const op = schema ? _`!==` : _`===`;
cxt.fail(\_`${data} %2 ${op} 0`);
}
}
import ajv, {_, KeywordCxt} from "ajv";
ajv.addKeyword({
keyword: "even",
type: "number",
schemaType: "boolean",
// $data: true // to support [$data reference](./validation.html#data-reference), ...
code(cxt: KeywordCxt) {
const {data, schema} = cxt;
const op = schema ? _`!==` : _`===`;
cxt.fail(_`${data} %2 ${op} 0`); // ... the only code change needed is to use `cxt.fail$data` here
}
});
const schema = {even: true};
const validate = ajv.compile(schema);
console.log(validate(2)); // true
console.log(validate(3)); // false
Custom Formats
You can add and replace any format using Formats decorator. For example, the current format validator for uri
doesn't allow empty string. So, with this decorator you can create or override an existing ajv-formats validator.
import {Formats, FormatsMethods} from "@tsed/ajv";
const NOT_URI_FRAGMENT = /\/|:/;
const URI =
/^(?:[a-z][a-z0-9+\-.]*:)(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|[Vv][0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|(?:[a-z0-9\-._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)(?:\?(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i;
@Formats("uri", {type: "string"})
export class UriFormat implements FormatsMethods<string> {
validate(str: string): boolean {
// http://jmrware.com/articles/2009/uri_regexp/URI_regex.html + optional protocol + required "."
return str === "" ? true : NOT_URI_FRAGMENT.test(str) && URI.test(str);
}
}
Then, we can import this class to our server as follows:
import {Configuration} from "@tsed/di";
import "@tsed/ajv"; // import ajv ts.ed module
import "./formats/UriFormat.js"; // just import the class, then Ts.ED will mount automatically the new format
@Configuration({
ajv: {
// ajv options
}
})
export class Server {}
Now, this example will be valid:
import {Uri, getJsonSchema} from "@tsed/schema";
import {PlatformTest} from "@tsed/platform-http/testing";
import {AjvService} from "@tsed/ajv";
import "./UriFormat.js";
describe("UriFormat", () => {
beforeEach(() => PlatformTest.create());
afterEach(() => PlatformTest.reset());
it("should validate empty string when we load the our custom Formats for AJV", async () => {
class MyModel {
@Uri() // or @Format("uri")
uri: string;
}
const service = PlatformTest.get<AjvService>(AjvService);
const jsonSchema = getJsonSchema(MyModel);
expect(jsonSchema).to.deep.equal({
properties: {
uri: {
format: "uri",
type: "string"
}
},
type: "object"
});
const result = await service.validate({uri: ""}, {type: MyModel});
expect(result).to.deep.eq({uri: ""});
});
});
Override default validation pipe
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.
WARNING
Replace the default JsonSchema validation provided by Ts.ED isn't recommended. You lose the ability to generate the swagger documentation and the json-mapper feature.
import {ValidationError, ValidationPipe} from "@tsed/platform-params";
import {JsonParameterStore, PipeMethods} from "@tsed/schema";
import {OverrideProvider} from "@tsed/di";
import {getJsonSchema} from "@tsed/schema";
import {validate} from "./validate";
@OverrideProvider(ValidationPipe)
export class CustomValidationPipe extends ValidationPipe implements PipeMethods {
public transform(obj: any, metadata: JsonParameterStore): 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
]);
}
}
}
}
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 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 joi
yarn add joi
pnpm add joi
bun add 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.
import {ObjectSchema} from "joi";
import {Injectable} from "@tsed/di";
import {JsonParameterStore, PipeMethods} from "@tsed/schema";
import {ValidationError, ValidationPipe} from "@tsed/platform-params";
@OverrideProvider(ValidationPipe)
export class JoiValidationPipe implements PipeMethods {
transform(value: any, metadata: JsonParameterStore) {
const schema = metadata.store.get<ObjectSchema>(JoiValidationPipe);
if (schema) {
const {error} = schema.validate(value);
if (error) {
throw new ValidationError("Oops something is wrong", [error]);
}
}
return value;
}
}
Now, we have to create a custom decorator to store the Joi schema along with a parameter:
import {ObjectSchema} from "joi";
import {StoreSet} from "@tsed/core";
import {JoiValidationPipe} from "../pipes/JoiValidationPipe";
export function UseJoiSchema(schema: ObjectSchema) {
return StoreSet(JoiValidationPipe, schema);
}
And finally, we are able to add Joi schema with our new decorator:
import {BodyParams} from "@tsed/platform-params";
import {Get} from "@tsed/schema";
import {Controller} from "@tsed/di";
import {UseJoiSchema} from "../decorators/UseJoiSchema";
import {joiPersonModel, PersonModel} from "../models/PersonModel";
@Controller("/persons")
export class PersonsController {
@Get(":id")
async findOne(
@BodyParams("id")
@UseJoiSchema(joiPersonModel)
person: PersonModel
) {
return person;
}
}
Use Class validator
Let's look at an alternate implementation of our validation technique.
Ts.ED works also with the class-validator library. This library allows you to use decorator-based validation (like Ts.ED with his JsonSchema decorators). Decorator-based validation combined with Ts.ED Pipe 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
yarn add class-validator class-transformer
pnpm add class-validator class-transformer
bun add class-validator class-transformer
Once these are installed, we can add a few decorators to the PersonModel
:
import {IsString, IsInt} from "class-validator";
export class CreateCatDto {
@IsString()
firstName: string;
@IsInt()
age: number;
}
TIP
Read more about the class-validator decorators here.
Now we can create a [ClassValidationPipe] class:
import {ValidationError, ValidationPipe} from "@tsed/platform-params";
import {JsonParameterStore, PipeMethods} from "@tsed/schema";
import {OverrideProvider} from "@tsed/di";
import {plainToClass} from "class-transformer";
import {validate} from "class-validator";
@OverrideProvider(ValidationPipe)
export class ClassValidationPipe extends ValidationPipe implements PipeMethods<any> {
async transform(value: any, metadata: JsonParameterStore) {
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: JsonParameterStore): boolean {
const types: Function[] = [String, Boolean, Number, Array, Object];
return !(metadata.type || metadata.collectionType) || !types.includes(metadata.type);
}
}
Notice
Above, we have used the 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 as Deserializer. So we recommend to override also the DeserializerPipe.
import {DeserializerPipe} from "@tsed/platform-params";
import {JsonParameterStore, PipeMethods} from "@tsed/schema";
import {OverrideProvider} from "@tsed/di";
import {plainToClass} from "class-transformer";
@OverrideProvider(DeserializerPipe)
export class ClassTransformerPipe implements PipeMethods {
transform(value: any, metadata: JsonParameterStore) {
return plainToClass(metadata.type, value);
}
}
We just have to import the pipe on our server.ts
and use model as type on a parameter.