diff --git a/.eslintrc.js b/.eslintrc.js index 6d0f557..65e2ea3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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: [ { diff --git a/packages/core/lib/core.mocks.ts b/packages/core/lib/core.mocks.ts index 5f070ef..cdb5ca8 100644 --- a/packages/core/lib/core.mocks.ts +++ b/packages/core/lib/core.mocks.ts @@ -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 { @@ -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' }, - }, - ], - }, -]; diff --git a/packages/core/lib/manager/config.manager.ts b/packages/core/lib/manager/config.manager.ts index 52baea8..9f74213 100644 --- a/packages/core/lib/manager/config.manager.ts +++ b/packages/core/lib/manager/config.manager.ts @@ -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]; diff --git a/packages/core/lib/validator/__snapshots__/config.validator.spec.ts.snap b/packages/core/lib/validator/__snapshots__/config.validator.spec.ts.snap new file mode 100644 index 0000000..b9068c1 --- /dev/null +++ b/packages/core/lib/validator/__snapshots__/config.validator.spec.ts.snap @@ -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], + }, +] +`; diff --git a/packages/core/lib/validator/config.validator.mocks.ts b/packages/core/lib/validator/config.validator.mocks.ts new file mode 100644 index 0000000..0e2708f --- /dev/null +++ b/packages/core/lib/validator/config.validator.mocks.ts @@ -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' }, + }, + ], + }, +]; diff --git a/packages/core/lib/validator/config.validator.spec.ts b/packages/core/lib/validator/config.validator.spec.ts index e685b71..faf2133 100644 --- a/packages/core/lib/validator/config.validator.spec.ts +++ b/packages/core/lib/validator/config.validator.spec.ts @@ -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', () => { @@ -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(); }); }); diff --git a/packages/core/lib/validator/config.validator.ts b/packages/core/lib/validator/config.validator.ts index 168da88..e70b2e5 100644 --- a/packages/core/lib/validator/config.validator.ts +++ b/packages/core/lib/validator/config.validator.ts @@ -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((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] })); } } diff --git a/packages/core/lib/validator/errors/config.property.validation.error.ts b/packages/core/lib/validator/errors/config.property.validation.error.ts new file mode 100644 index 0000000..116cd8d --- /dev/null +++ b/packages/core/lib/validator/errors/config.property.validation.error.ts @@ -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[]; +} diff --git a/packages/core/lib/validator/errors/config.subtemplate.validation.error.ts b/packages/core/lib/validator/errors/config.subtemplate.validation.error.ts new file mode 100644 index 0000000..3dfc2d2 --- /dev/null +++ b/packages/core/lib/validator/errors/config.subtemplate.validation.error.ts @@ -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)[]; +} diff --git a/packages/core/lib/validator/errors/config.template.validation.error.ts b/packages/core/lib/validator/errors/config.template.validation.error.ts new file mode 100644 index 0000000..ccfb103 --- /dev/null +++ b/packages/core/lib/validator/errors/config.template.validation.error.ts @@ -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)[]; +} diff --git a/packages/core/lib/validator/errors/config.validation.exception.ts b/packages/core/lib/validator/errors/config.validation.exception.ts new file mode 100644 index 0000000..d3b9d3a --- /dev/null +++ b/packages/core/lib/validator/errors/config.validation.exception.ts @@ -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(', '); + } +} diff --git a/packages/core/lib/validator/errors/failed-constraint.ts b/packages/core/lib/validator/errors/failed-constraint.ts new file mode 100644 index 0000000..9b75b83 --- /dev/null +++ b/packages/core/lib/validator/errors/failed-constraint.ts @@ -0,0 +1,4 @@ +export interface FailedConstraint { + readonly name: string; + readonly details?: string; +} diff --git a/packages/core/lib/validator/exception/index.ts b/packages/core/lib/validator/errors/index.ts similarity index 100% rename from packages/core/lib/validator/exception/index.ts rename to packages/core/lib/validator/errors/index.ts diff --git a/packages/core/lib/validator/exception/config.validation.exception.options.ts b/packages/core/lib/validator/exception/config.validation.exception.options.ts deleted file mode 100644 index 0f1eeb9..0000000 --- a/packages/core/lib/validator/exception/config.validation.exception.options.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ValidationError } from 'class-validator'; -import { Type } from '../../utils/type.interface'; - -export interface ConfigValidationExceptionOptions { - template: Type; - errors: ValidationError[]; -} diff --git a/packages/core/lib/validator/exception/config.validation.exception.spec.ts b/packages/core/lib/validator/exception/config.validation.exception.spec.ts deleted file mode 100644 index 146cb5f..0000000 --- a/packages/core/lib/validator/exception/config.validation.exception.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import 'reflect-metadata'; -import { mockFailedValidation, TemplateMock } from '../../core.mocks'; -import { ConfigValidationException } from './config.validation.exception'; - -describe('ConfigValidationException', () => { - let exception: ConfigValidationException; - - beforeEach(() => { - exception = new ConfigValidationException([{ template: TemplateMock, errors: mockFailedValidation }]); - }); - - it('should format failed validation report', () => { - expect(exception.message).toContain('db2.subdb.password'); - }); -}); diff --git a/packages/core/lib/validator/exception/config.validation.exception.ts b/packages/core/lib/validator/exception/config.validation.exception.ts deleted file mode 100644 index df5085e..0000000 --- a/packages/core/lib/validator/exception/config.validation.exception.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { ValidationError } from 'class-validator'; -import { getBorderCharacters, SpanningCellConfig, table } from 'table'; -import { PROPERTIES_MAPPING_METADATA } from '../../loader/constants'; -import { PropertiesMapping, PropertySource, PropertyTarget } from '../../loader/types'; -import { ConfigValidationExceptionOptions } from './config.validation.exception.options'; - -const headers = ['Template', 'Property', 'Source', 'Current Value', 'Failed constraints']; - -// interface TemplateValidationErrors { -// template: ClassConstructor; -// errors: ValidationError[]; -// } - -interface ValidationError { - propertyTarget: PropertyTarget; - propertySource?: PropertySource; - failedConstraints: string[]; - currentValue: any; -} - -export class ConfigValidationException extends Error { - constructor(failedValidations: ConfigValidationExceptionOptions[]) { - super(); - this.name = ConfigValidationException.name; - - const tableRows = this.formatTableRows(failedValidations); - this.message = '\n' + this.formatMessage(tableRows); - } - - private formatMessage(tableRows: { templateTableData: any[][]; spanningCells: SpanningCellConfig }[]) { - const tableData = [headers, ...tableRows.flatMap(({ templateTableData }) => templateTableData)]; - return table(tableData, { - columns: [{ alignment: 'left', width: 16 }], - spanningCells: tableRows.map(({ spanningCells }) => spanningCells), - border: getBorderCharacters('ramac'), - }); - } - - private formatTableRows(failedValidations: ConfigValidationExceptionOptions[]) { - let spanningCellsRowIdx = 1; - return failedValidations.map((failedValidation) => { - const templateTableData = this.formatExceptionsReport(failedValidation.errors).map((row) => [ - '', - row.propertyTarget, - row.propertySource ?? '', - row.currentValue, - row.failedConstraints, - ]); - templateTableData[0][0] = failedValidation.template.name; - - const spanningCells: SpanningCellConfig = { - col: 0, - row: spanningCellsRowIdx, - rowSpan: templateTableData.length, - verticalAlignment: 'middle', - }; - spanningCellsRowIdx += templateTableData.length; - return { templateTableData, spanningCells }; - }); - } - - private formatExceptionsReport(errors: ValidationError[], parentPath = ''): ValidationError[] { - return errors.flatMap((error) => { - const propertyTarget = parentPath + error.property; - if (error.children && error.children.length > 0) { - return this.formatExceptionsReport(error.children, `${propertyTarget}.`); - } - - const propertiesMapping: PropertiesMapping = error.target - ? Reflect.getMetadata(PROPERTIES_MAPPING_METADATA, error.target.constructor) - : undefined; - return { - propertyTarget, - propertySource: propertiesMapping?.get(error.property), - failedConstraints: error.constraints ? Object.keys(error.constraints) : [], - currentValue: error.value, - }; - }); - } -} diff --git a/packages/core/lib/validator/index.ts b/packages/core/lib/validator/index.ts index 3d5b914..f72bc43 100644 --- a/packages/core/lib/validator/index.ts +++ b/packages/core/lib/validator/index.ts @@ -1 +1 @@ -export * from './exception'; +export * from './errors'; diff --git a/packages/core/test/core.e2e-spec.ts b/packages/core/test/core.e2e-spec.ts index 8d764e3..6cc1742 100644 --- a/packages/core/test/core.e2e-spec.ts +++ b/packages/core/test/core.e2e-spec.ts @@ -45,7 +45,7 @@ describe('@unifig/core (e2e)', () => { it('should fail to validate config', () => { expect( - manager.register({ + manager.registerOrReject({ template: ValidationTemplate, adapter: new PlainConfigAdapter({ port: 3000, db: { port: 5000 } }), })