Skip to content

Commit

Permalink
feat(core): validator rework
Browse files Browse the repository at this point in the history
BREAKING CHANGE: Rich validation report was removed from the exception itself and rich object
containing abstract report was added in it's place. Register method doesn't throw validation
exception and returns it if one occurs. To utilize previous behavior use registerOrReject method.
  • Loading branch information
Matii96 committed Dec 20, 2022
1 parent 17bee86 commit acf2baf
Show file tree
Hide file tree
Showing 18 changed files with 228 additions and 180 deletions.
3 changes: 2 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ module.exports = {
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-unused-vars': 'error'
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/no-non-null-assertion': 'off',
},
overrides: [
{
Expand Down
62 changes: 1 addition & 61 deletions packages/core/lib/core.mocks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IsDefined, IsInt, IsString, ValidationError } from 'class-validator';
import { IsDefined, IsInt, IsString } from 'class-validator';
import { From, Nested } from './loader';

export class DbConfigMock {
Expand All @@ -20,63 +20,3 @@ export class TemplateMock {
@IsDefined()
db: DbConfigMock;
}

export const mockFailedValidation: ValidationError[] = [
{
target: new TemplateMock(),
property: 'port',
children: [] as any[],
constraints: { isInt: 'port must be an integer number' },
},
{
target: new TemplateMock(),
value: {},
property: 'db',
children: [
{
target: new DbConfigMock(),
property: 'url',
children: [] as any[],
constraints: { isString: 'url must be a string' },
},
{
target: new DbConfigMock(),
property: 'password',
children: [],
constraints: { isString: 'password must be a string' },
},
],
},
{
target: new TemplateMock(),
value: {},
property: 'db2',
children: [
{
target: new DbConfigMock(),
property: 'url',
children: [] as any[],
constraints: { isDefined: 'url must be a defined', isString: 'url must be a string' },
},
{
target: new DbConfigMock(),
property: 'subdb',
children: [
{
target: new DbConfigMock(),
property: 'url',
children: [] as any[],
constraints: { isString: 'url must be a string' },
},
{
target: new DbConfigMock(),
property: 'password',
children: [],
constraints: { isString: 'password must be a string' },
},
],
constraints: { isString: 'password must be a string' },
},
],
},
];
13 changes: 12 additions & 1 deletion packages/core/lib/manager/config.manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,21 @@ export class ConfigManager {
const groups = configs.map((config) => this.initSourceGroup(config));
const loadResults = await Promise.all(groups.map((group) => group.load(true)));
const configsValues = ([] as any[]).concat(...loadResults);
this._validator.validate(configsValues);

const validationResult = this._validator.validate(configsValues);
if (validationResult) {
return validationResult;
}
groups.forEach((group) => group.templates.forEach((template) => this._groups.set(template, group)));
}

async registerOrReject(...configs: ConfigManagerRegisterOptions[]) {
const validationResult = await this.register(...configs);
if (validationResult) {
throw validationResult;
}
}

private initSourceGroup(config: ConfigManagerRegisterOptions) {
let templates = (config as RegisterMultipleTemplatesOptions).templates;
if (!templates) templates = [(config as RegisterSingleTemplateOptions).template];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`ConfigValidator should structure validation errors 1`] = `
[
{
"errors": [
{
"currentValue": undefined,
"failedConstraints": [
{
"details": "port must be an integer number",
"name": "isInt",
},
],
"property": "port",
"source": "PORT",
},
{
"children": [
{
"currentValue": undefined,
"failedConstraints": [
{
"details": "url must be a string",
"name": "isString",
},
],
"property": "url",
"source": "DB_URL",
},
{
"currentValue": undefined,
"failedConstraints": [
{
"details": "password must be a string",
"name": "isString",
},
],
"property": "password",
"source": "DB_PASSWORD",
},
],
"failedConstraints": undefined,
"property": "db",
},
],
"template": [Function],
},
]
`;
62 changes: 62 additions & 0 deletions packages/core/lib/validator/config.validator.mocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { ValidationError } from 'class-validator';
import { DbConfigMock, TemplateMock } from '../core.mocks';

export const mockFailedValidation: ValidationError[] = [
{
target: new TemplateMock(),
property: 'port',
children: [] as any[],
constraints: { isInt: 'port must be an integer number' },
},
{
target: new TemplateMock(),
value: {},
property: 'db',
children: [
{
target: new DbConfigMock(),
property: 'url',
children: [] as any[],
constraints: { isString: 'url must be a string' },
},
{
target: new DbConfigMock(),
property: 'password',
children: [],
constraints: { isString: 'password must be a string' },
},
],
},
{
target: new TemplateMock(),
value: {},
property: 'db2',
children: [
{
target: new DbConfigMock(),
property: 'url',
children: [] as any[],
constraints: { isDefined: 'url must be a defined', isString: 'url must be a string' },
},
{
target: new DbConfigMock(),
property: 'subdb',
children: [
{
target: new DbConfigMock(),
property: 'url',
children: [] as any[],
constraints: { isString: 'url must be a string' },
},
{
target: new DbConfigMock(),
property: 'password',
children: [],
constraints: { isString: 'password must be a string' },
},
],
constraints: { isString: 'password must be a string' },
},
],
},
];
15 changes: 11 additions & 4 deletions packages/core/lib/validator/config.validator.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'reflect-metadata';
import { TemplateMock } from '../core.mocks';
import { ConfigValidationException } from './exception/config.validation.exception';
import { DbConfigMock, TemplateMock } from '../core.mocks';
import { ConfigValidationException } from './errors/config.validation.exception';
import { ConfigValidator } from './config.validator';

describe('ConfigValidator', () => {
Expand All @@ -10,8 +10,15 @@ describe('ConfigValidator', () => {
validator = new ConfigValidator();
});

it('should throw ConfigValidationException', () => {
it('should return ConfigValidationException', () => {
const config = new TemplateMock();
expect(() => validator.validate([config])).toThrow(ConfigValidationException);
expect(validator.validate([config])).toBeInstanceOf(ConfigValidationException);
});

it('should structure validation errors', () => {
const config = new TemplateMock();
config.db = new DbConfigMock();
const validationResult = validator.validate([config])!;
expect(validationResult.errors).toMatchSnapshot();
});
});
56 changes: 47 additions & 9 deletions packages/core/lib/validator/config.validator.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,57 @@
import { validateSync } from 'class-validator';
import { validateSync, ValidationError } from 'class-validator';
import { PROPERTIES_MAPPING_METADATA } from '../loader/constants';
import { PropertiesMapping } from '../loader/types';
import { Type } from '../utils/type.interface';
import { ConfigValidationException } from './exception/config.validation.exception';
import { ConfigValidationExceptionOptions } from './exception/config.validation.exception.options';
import { ConfigPropertyValidationError } from './errors/config.property.validation.error';
import { ConfigSubtemplateValidationError } from './errors/config.subtemplate.validation.error';
import { ConfigTemplateValidationError } from './errors/config.template.validation.error';
import { ConfigValidationException } from './errors/config.validation.exception';

export class ConfigValidator {
validate(configs: object[]) {
const failedValidations = configs
.map<ConfigValidationExceptionOptions>((config) => ({
template: config.constructor as Type,
errors: validateSync(config, { skipMissingProperties: false }),
}))
.map((config) => this.validateTemplate(config))
.filter(({ errors }) => errors.length > 0);

if (failedValidations.length > 0) {
throw new ConfigValidationException(failedValidations);
return new ConfigValidationException(failedValidations);
}
}

private validateTemplate(config: object): ConfigTemplateValidationError {
return {
template: config.constructor as Type,
errors: validateSync(config, { skipMissingProperties: false }).map((error) => this.toPropertyError(error)),
};
}

private toPropertyError(error: ValidationError): ConfigPropertyValidationError | ConfigSubtemplateValidationError {
if (error.children && error.children.length > 0) {
return {
property: error.property,
failedConstraints: this.toFailedConstraints(error.constraints),
children: error.children.map((child) => this.toPropertyError(child)),
} satisfies ConfigSubtemplateValidationError;
}

const propertiesMapping: PropertiesMapping = error.target
? Reflect.getMetadata(PROPERTIES_MAPPING_METADATA, error.target.constructor)
: undefined;
return {
property: error.property,
source: propertiesMapping?.get(error.property),
currentValue: error.value,
failedConstraints: this.toFailedConstraints(error.constraints) ?? [],
} satisfies ConfigPropertyValidationError;
}

private toFailedConstraints(constraints: ValidationError['constraints']) {
if (!constraints) {
return;
}
const constraintsKeys = Object.keys(constraints);
if (constraintsKeys.length === 0) {
return;
}
return constraintsKeys.map((name) => ({ name, details: constraints[name] }));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { PropertySource, PropertyTarget } from '../../loader/types';
import { FailedConstraint } from './failed-constraint';

export interface ConfigPropertyValidationError {
readonly property: PropertyTarget;
readonly source?: PropertySource;
readonly currentValue: any;
readonly failedConstraints: FailedConstraint[];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { PropertyTarget } from '../../loader/types';
import { ConfigPropertyValidationError } from './config.property.validation.error';
import { FailedConstraint } from './failed-constraint';

export interface ConfigSubtemplateValidationError {
readonly property: PropertyTarget;
readonly failedConstraints?: FailedConstraint[];
readonly children: (ConfigPropertyValidationError | ConfigSubtemplateValidationError)[];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Type } from '../../utils/type.interface';
import { ConfigPropertyValidationError } from './config.property.validation.error';
import { ConfigSubtemplateValidationError } from './config.subtemplate.validation.error';

export interface ConfigTemplateValidationError {
readonly template: Type;
readonly errors: (ConfigPropertyValidationError | ConfigSubtemplateValidationError)[];
}
11 changes: 11 additions & 0 deletions packages/core/lib/validator/errors/config.validation.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ConfigTemplateValidationError } from './config.template.validation.error';

export class ConfigValidationException extends Error {
readonly errors: ConfigTemplateValidationError[];

constructor(errors: ConfigTemplateValidationError[]) {
super();
this.errors = errors;
this.message = 'Following templates failed validation: ' + errors.map(({ template }) => template.name).join(', ');
}
}
4 changes: 4 additions & 0 deletions packages/core/lib/validator/errors/failed-constraint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface FailedConstraint {
readonly name: string;
readonly details?: string;
}
File renamed without changes.

This file was deleted.

This file was deleted.

0 comments on commit acf2baf

Please sign in to comment.