Skip to content

detailed/extended error messages #238

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export interface ValidatorOptions {
property: string; // Object's property that haven't pass validation.
value: any; // Value that haven't pass a validation.
constraints?: { // Constraints that failed validation with error messages.
[type: string]: string;
[type: string]: string | ExtendedMessage;
};
children?: ValidationError[]; // Contains all nested validation errors of the property
}
Expand Down Expand Up @@ -152,6 +152,37 @@ In our case, when we validated a Post object, we have such array of ValidationEr
]
```

If you want to build your own error messages (e.g. in an internationalized app), the ExtendedMessage comes into play:
you get back all constraint parameters:
```typescript
validator.validate(data, { validationError: { extendedMessage: true } });
```
The parameter 'detailedMessage' will cause all constraints to have an ExtendedError instead of the plain string message.

So an input of this type:
```typescript
export class Post {
@MinLength(10)
title: string = "Hello"
}
```
will yield e.g.:
```typescript
[{
target: /* post object */,
property: "title",
value: "Hello",
constraints: {
minLength: {
type: "minLength",
args: [10],
message: "title must be longer than or equal to 10 characters"
}
}
}]
```
Together with [passing context to decorators](#passing-context-to-decorators), the error messages are fully customizable.

If you don't want a `target` to be exposed in validation errors, there is a special option when you use validator:

```typescript
Expand Down
12 changes: 12 additions & 0 deletions src/validation/ExtendedMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Contains necessary information to present good error messages to the user
*/
export class ExtendedMessage {
constructor(
public readonly type: string,
public readonly message: string,
public readonly args: any[]
) {
// nop
}
}
4 changes: 3 additions & 1 deletion src/validation/ValidationError.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/**
* Validation error description.
*/
import {ExtendedMessage} from "./ExtendedMessage";

export class ValidationError {

/**
Expand All @@ -26,7 +28,7 @@ export class ValidationError {
* Constraints that failed validation with error messages.
*/
constraints: {
[type: string]: string
[type: string]: string | ExtendedMessage
};

/**
Expand Down
47 changes: 28 additions & 19 deletions src/validation/ValidationExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {ValidationTypes} from "./ValidationTypes";
import {ConstraintMetadata} from "../metadata/ConstraintMetadata";
import {ValidationArguments} from "./ValidationArguments";
import {ValidationUtils} from "./ValidationUtils";
import {ExtendedMessage} from "./ExtendedMessage";

/**
* Executes validation over given object.
Expand All @@ -21,6 +22,8 @@ export class ValidationExecutor {
awaitingPromises: Promise<any>[] = [];
ignoreAsyncValidations: boolean = false;

private readonly validatorOptions: ValidatorOptions;

// -------------------------------------------------------------------------
// Private Properties
// -------------------------------------------------------------------------
Expand All @@ -32,7 +35,8 @@ export class ValidationExecutor {
// -------------------------------------------------------------------------

constructor(private validator: Validator,
private validatorOptions?: ValidatorOptions) {
validatorOptions?: ValidatorOptions) {
this.validatorOptions = validatorOptions || {};
}

// -------------------------------------------------------------------------
Expand All @@ -50,15 +54,14 @@ export class ValidationExecutor {
console.warn(`No metadata found. There is more than once class-validator version installed probably. You need to flatten your dependencies.`);
}

const groups = this.validatorOptions ? this.validatorOptions.groups : undefined;
const groups = this.validatorOptions.groups;
const targetMetadatas = this.metadataStorage.getTargetValidationMetadatas(object.constructor, targetSchema, groups);
const groupedMetadatas = this.metadataStorage.groupByPropertyName(targetMetadatas);

if (this.validatorOptions && this.validatorOptions.forbidUnknownValues && !targetMetadatas.length) {
if (this.validatorOptions.forbidUnknownValues && !targetMetadatas.length) {
const validationError = new ValidationError();

if (!this.validatorOptions ||
!this.validatorOptions.validationError ||
if (!this.validatorOptions.validationError ||
this.validatorOptions.validationError.target === undefined ||
this.validatorOptions.validationError.target === true)
validationError.target = object;
Expand All @@ -73,7 +76,7 @@ export class ValidationExecutor {
return;
}

if (this.validatorOptions && this.validatorOptions.whitelist)
if (this.validatorOptions.whitelist)
this.whitelist(object, groupedMetadatas, validationErrors);

// General validation
Expand All @@ -97,7 +100,7 @@ export class ValidationExecutor {
// handle IS_DEFINED validation type the special way - it should work no matter skipMissingProperties is set or not
this.defaultValidations(object, value, definedMetadatas, validationError.constraints);

if ((value === null || value === undefined) && this.validatorOptions && this.validatorOptions.skipMissingProperties === true) {
if ((value === null || value === undefined) && this.validatorOptions.skipMissingProperties === true) {
return;
}

Expand All @@ -122,7 +125,7 @@ export class ValidationExecutor {

if (notAllowedProperties.length > 0) {

if (this.validatorOptions && this.validatorOptions.forbidNonWhitelisted) {
if (this.validatorOptions.forbidNonWhitelisted) {

// throw errors
notAllowedProperties.forEach(property => {
Expand Down Expand Up @@ -166,14 +169,12 @@ export class ValidationExecutor {
private generateValidationError(object: Object, value: any, propertyName: string) {
const validationError = new ValidationError();

if (!this.validatorOptions ||
!this.validatorOptions.validationError ||
if (!this.validatorOptions.validationError ||
this.validatorOptions.validationError.target === undefined ||
this.validatorOptions.validationError.target === true)
validationError.target = object;

if (!this.validatorOptions ||
!this.validatorOptions.validationError ||
if (!this.validatorOptions.validationError ||
this.validatorOptions.validationError.value === undefined ||
this.validatorOptions.validationError.value === true)
validationError.value = value;
Expand All @@ -193,10 +194,19 @@ export class ValidationExecutor {
.reduce((resultA, resultB) => resultA && resultB, true);
}

private buildExtendedMessage(metadata: ValidationMetadata, message: string, customConstraintMetadata?: ConstraintMetadata): string | ExtendedMessage {
if (this.validatorOptions.validationError && this.validatorOptions.validationError.extendedMessage) {
const type = this.getConstraintType(metadata, customConstraintMetadata);
return new ExtendedMessage(type, message, metadata.constraints || []);
} else {
return message;
}
}

private defaultValidations(object: Object,
value: any,
metadatas: ValidationMetadata[],
errorMap: { [key: string]: string }) {
errorMap: { [key: string]: string | ExtendedMessage }) {
return metadatas
.filter(metadata => {
if (metadata.each) {
Expand All @@ -210,14 +220,14 @@ export class ValidationExecutor {
})
.forEach(metadata => {
const [key, message] = this.createValidationError(object, value, metadata);
errorMap[key] = message;
errorMap[key] = this.buildExtendedMessage(metadata, message);
});
}

private customValidations(object: Object,
value: any,
metadatas: ValidationMetadata[],
errorMap: { [key: string]: string }) {
errorMap: { [key: string]: string | ExtendedMessage }) {

metadatas.forEach(metadata => {
getFromContainer(MetadataStorage)
Expand All @@ -238,14 +248,14 @@ export class ValidationExecutor {
const promise = validatedValue.then(isValid => {
if (!isValid) {
const [type, message] = this.createValidationError(object, value, metadata, customConstraintMetadata);
errorMap[type] = message;
errorMap[type] = this.buildExtendedMessage(metadata, message, customConstraintMetadata);
}
});
this.awaitingPromises.push(promise);
} else {
if (!validatedValue) {
const [type, message] = this.createValidationError(object, value, metadata, customConstraintMetadata);
errorMap[type] = message;
errorMap[type] = this.buildExtendedMessage(metadata, message, customConstraintMetadata);
}
}
});
Expand Down Expand Up @@ -324,8 +334,7 @@ export class ValidationExecutor {
};

let message = metadata.message;
if (!metadata.message &&
(!this.validatorOptions || (this.validatorOptions && !this.validatorOptions.dismissDefaultMessages))) {
if (!metadata.message && !this.validatorOptions.dismissDefaultMessages) {
if (customValidatorMetadata && customValidatorMetadata.instance.defaultMessage instanceof Function) {
message = customValidatorMetadata.instance.defaultMessage(validationArguments);
}
Expand Down
5 changes: 5 additions & 0 deletions src/validation/ValidatorOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,16 @@ export interface ValidatorOptions {
*/
value?: boolean;

/**
* use ExtendedMessage instead of sring message in ValidationError
*/
extendedMessage?: boolean;
};

/**
* Settings true will cause fail validation of unknown objects.
*/
forbidUnknownValues?: boolean;


}
144 changes: 144 additions & 0 deletions test/functional/ExtendedMessage.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import "es6-shim";
import {
ValidationArguments,
Validator,
ValidatorConstraint,
ValidatorConstraintInterface, ValidatorOptions
} from "../../src";
import {MinLength} from "../../src";

import {should, use} from "chai";

import * as chaiAsPromised from "chai-as-promised";
import {Validate} from "../../src";

should();
use(chaiAsPromised);

const validator = new Validator();
const DETAILS_ENABLED: ValidatorOptions = {validationError: {extendedMessage: true}};

class StandardValidation {
@MinLength(10)
public readonly text: string = "invalid";
}

@ValidatorConstraint({name: "customText", async: false})
export class CustomTextLength implements ValidatorConstraintInterface {

validate(text: string, args: ValidationArguments) {
return text.length > (args.constraints || [10])[0];
}

defaultMessage(args: ValidationArguments) { // here you can provide default error message if validation failed
return "Text ($value) is too short or too long: " + (args.constraints || [10])[0];
}

}

describe("ExtendedMessage", () => {

describe("disabled", () => {
it("validates with simple message with default options", () => {
// given
const fixture = new StandardValidation();
// when
const errors = validator.validateSync(fixture);
// then
errors[0].constraints.should.be.eql({minLength: "text must be longer than or equal to 10 characters"});
});

it("validates with simple message with option disabled", () => {
// given
const fixture = new StandardValidation();
// when
const errors = validator.validateSync(fixture, {validationError: {extendedMessage: false}});
// then
errors[0].constraints.should.be.eql({minLength: "text must be longer than or equal to 10 characters"});
});

});

describe("with standard validations", () => {
it("validates with extended message", () => {
// given
const fixture = new StandardValidation();
// when
const errors = validator.validateSync(fixture, DETAILS_ENABLED);
// then
errors[0].constraints.should.be.eql({
minLength: {
type: "minLength",
args: [10],
message: "text must be longer than or equal to 10 characters"
}
});
});
});

describe("with custom validator", () => {
it("validates with extended message (no arguments)", () => {
// given
class CustomValidation {
@Validate(CustomTextLength)
public readonly text: string = "invalid";
}
const fixture = new CustomValidation();

// when
const errors = validator.validateSync(fixture, DETAILS_ENABLED);

// then
errors[0].constraints.should.be.eql({
customText: {
type: "customText",
args: [],
message: "Text (invalid) is too short or too long: 10"
}
});
});

it("validates with extended message (with arguments)", () => {
// given
class CustomValidation {
@Validate(CustomTextLength, [20])
public readonly text: string = "invalid";
}
const fixture = new CustomValidation();

// when
const errors = validator.validateSync(fixture, DETAILS_ENABLED);

// then
errors[0].constraints.should.be.eql({
customText: {
type: "customText",
args: [20],
message: "Text (invalid) is too short or too long: 20"
}
});
});

it("validates with extended message (handles 'undefined' argument)", () => {
// given
class CustomValidation {
@Validate(CustomTextLength, [undefined])
public readonly text: string = "invalid";
}
const fixture = new CustomValidation();

// when
const errors = validator.validateSync(fixture, DETAILS_ENABLED);

// then
errors[0].constraints.should.be.eql({
customText: {
type: "customText",
args: [undefined],
message: "Text (invalid) is too short or too long: undefined"
}
});
});
});

});