├── src ├── errors │ ├── validate.error.ts │ ├── index.ts │ ├── validation.error.ts │ └── processor-validate.error.ts ├── schema │ ├── index.ts │ ├── schema.ts │ └── storage.ts ├── validators │ ├── number │ │ ├── index.ts │ │ ├── max-value.validator.ts │ │ └── min-value.validator.ts │ ├── index.ts │ ├── string │ │ ├── index.ts │ │ ├── max-length.validator.ts │ │ ├── min-length.validator.ts │ │ └── email.validator.ts │ └── validator.ts ├── index.ts ├── processors │ ├── index.ts │ ├── email.processor.ts │ ├── boolean.processor.ts │ ├── enum.processor.ts │ ├── float.processor.ts │ ├── integer.processor.ts │ ├── string.processor.ts │ ├── json.processor.ts │ ├── date.processor.ts │ ├── field.processor.ts │ └── array.processor.ts └── fields │ ├── utils.ts │ └── index.ts ├── tests ├── fields │ ├── mock │ │ └── enum.mock.ts │ ├── utils.test.ts │ └── fields.test.ts ├── validators │ ├── mocks │ │ └── validator.mock.ts │ ├── string │ │ ├── email.validator.test.ts │ │ ├── max-length.validator.test.ts │ │ └── min-length.validator.test.ts │ └── number │ │ ├── max-value.validator.test.ts │ │ └── min-value.validator.test.ts ├── processors │ ├── mocks │ │ └── processor.mock.ts │ ├── email.processor.test.ts │ ├── enum.processor.test.ts │ ├── json.processor.test.ts │ ├── string.processor.test.ts │ ├── boolean.processor.test.ts │ ├── integer.processor.test.ts │ ├── float.processor.test.ts │ ├── array.processor.test.ts │ ├── date.processor.test.ts │ └── field.processor.test.ts └── schemas │ ├── mocks │ └── schema.mock.ts │ ├── storage.test.ts │ └── nested-schema.test.ts ├── .husky └── pre-commit ├── jest.config.ts ├── .eslintrc.json ├── tsconfig.json ├── LICENSE ├── .gitignore ├── package.json └── README.md /src/errors/validate.error.ts: -------------------------------------------------------------------------------- 1 | export class ValidateError extends Error {} 2 | -------------------------------------------------------------------------------- /src/schema/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./schema"; 2 | export * from "./storage"; 3 | -------------------------------------------------------------------------------- /tests/fields/mock/enum.mock.ts: -------------------------------------------------------------------------------- 1 | export enum EnumMock { 2 | a = "a", 3 | b = 1, 4 | } 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /src/validators/number/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./max-value.validator"; 2 | export * from "./min-value.validator"; 3 | -------------------------------------------------------------------------------- /src/validators/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./string"; 2 | export * from "./number"; 3 | export { Validator } from "./validator"; 4 | -------------------------------------------------------------------------------- /src/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./validation.error"; 2 | export * from "./processor-validate.error"; 3 | export * from "./validate.error"; 4 | -------------------------------------------------------------------------------- /src/validators/string/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./max-length.validator"; 2 | export * from "./min-length.validator"; 3 | export * from "./email.validator"; 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./errors"; 2 | export * from "./fields"; 3 | export * from "./processors"; 4 | export * from "./schema"; 5 | export * from "./validators"; 6 | -------------------------------------------------------------------------------- /src/errors/validation.error.ts: -------------------------------------------------------------------------------- 1 | import { ValidationErrors } from "../schema"; 2 | 3 | export class ValidationError extends Error { 4 | errors: ValidationErrors; 5 | 6 | constructor(errors: ValidationErrors) { 7 | super(); 8 | this.errors = errors; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/validators/mocks/validator.mock.ts: -------------------------------------------------------------------------------- 1 | import { Validator } from "../../../src/validators"; 2 | 3 | export class ValidatorMock extends Validator { 4 | constructor() { 5 | super(); 6 | } 7 | 8 | validate(): void { 9 | console.log("validate"); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/validators/validator.ts: -------------------------------------------------------------------------------- 1 | export abstract class Validator { 2 | protected constructor(protected readonly errorMessage?: string) {} 3 | 4 | /** 5 | * Used to validate and throw error if the validation fails 6 | * @param value 7 | */ 8 | abstract validate(value: T): void; 9 | } 10 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | restoreMocks: true, 4 | maxWorkers: 1, 5 | preset: "ts-jest", 6 | testEnvironment: "node", 7 | testMatch: ["**/tests/**/*.test.ts"], 8 | coveragePathIgnorePatterns: ["./node_modules", "./tests"], 9 | collectCoverage: true, 10 | reporters: ["default"], 11 | }; 12 | -------------------------------------------------------------------------------- /tests/processors/mocks/processor.mock.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig, FieldProcessor } from "../../../src/processors"; 2 | 3 | export class ProcessorMock extends FieldProcessor< 4 | FieldConfig, 5 | unknown, 6 | unknown 7 | > { 8 | initialiseValidators(): void {} 9 | 10 | toInternalValue(data: unknown): unknown { 11 | return data; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/processors/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./field.processor"; 2 | export * from "./string.processor"; 3 | export * from "./boolean.processor"; 4 | export * from "./float.processor"; 5 | export * from "./integer.processor"; 6 | export * from "./email.processor"; 7 | export * from "./date.processor"; 8 | export * from "./json.processor"; 9 | export * from "./array.processor"; 10 | export * from "./enum.processor"; 11 | -------------------------------------------------------------------------------- /src/errors/processor-validate.error.ts: -------------------------------------------------------------------------------- 1 | import { ValidationErrors } from "../schema"; 2 | 3 | export type ProcessorErrorMessages = 4 | | string[] 5 | | { [key: string]: string[] | ValidationErrors | ProcessorErrorMessages }; 6 | 7 | export class ProcessorValidateError extends Error { 8 | messages: ProcessorErrorMessages; 9 | 10 | constructor(messages: ProcessorErrorMessages) { 11 | super(); 12 | this.messages = messages; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/processors/email.processor.ts: -------------------------------------------------------------------------------- 1 | import { EmailValidator } from "../validators"; 2 | import { StringFieldConfig, StringFieldProcessor } from "./string.processor"; 3 | 4 | export type EmailFieldConfig = StringFieldConfig; 5 | 6 | export class EmailFieldProcessor extends StringFieldProcessor { 7 | initialiseValidators(): void { 8 | super.initialiseValidators(); 9 | 10 | this.validators.push(new EmailValidator()); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/validators/number/max-value.validator.ts: -------------------------------------------------------------------------------- 1 | import { Validator } from "../validator"; 2 | import { ValidateError } from "../../errors"; 3 | 4 | export class MaxValueValidator extends Validator { 5 | constructor( 6 | protected readonly maxValue: number, 7 | protected readonly errorMessage?: string 8 | ) { 9 | super(errorMessage); 10 | } 11 | 12 | validate(value: number): void { 13 | if (value > this.maxValue) { 14 | throw new ValidateError(`max value allowed is ${this.maxValue}`); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/validators/number/min-value.validator.ts: -------------------------------------------------------------------------------- 1 | import { Validator } from "../validator"; 2 | import { ValidateError } from "../../errors"; 3 | 4 | export class MinValueValidator extends Validator { 5 | constructor( 6 | protected readonly minValue: number, 7 | protected readonly errorMessage?: string 8 | ) { 9 | super(errorMessage); 10 | } 11 | 12 | validate(value: number): void { 13 | if (value < this.minValue) { 14 | throw new ValidateError(`min length allowed is ${this.minValue}`); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/validators/string/max-length.validator.ts: -------------------------------------------------------------------------------- 1 | import { Validator } from "../validator"; 2 | import { ValidateError } from "../../errors"; 3 | 4 | export class MaxLengthValidator extends Validator { 5 | constructor( 6 | protected readonly maxLength: number, 7 | protected readonly errorMessage?: string 8 | ) { 9 | super(errorMessage); 10 | } 11 | 12 | validate(value: string): void { 13 | if (value.length > this.maxLength) { 14 | throw new ValidateError(`max length allowed is ${this.maxLength}`); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/validators/string/min-length.validator.ts: -------------------------------------------------------------------------------- 1 | import { Validator } from "../validator"; 2 | import { ValidateError } from "../../errors"; 3 | 4 | export class MinLengthValidator extends Validator { 5 | constructor( 6 | protected readonly minLength: number, 7 | protected readonly errorMessage?: string 8 | ) { 9 | super(errorMessage); 10 | } 11 | 12 | validate(value: string): void { 13 | if (value.length < this.minLength) { 14 | throw new ValidateError(`min length allowed is ${this.minLength}`); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/validators/string/email.validator.ts: -------------------------------------------------------------------------------- 1 | import { Validator } from "../validator"; 2 | import { ValidateError } from "../../errors"; 3 | 4 | export class EmailValidator extends Validator { 5 | emailRegex = new RegExp( 6 | "^[-!#$%&'*+/0-9=?A-Z^_a-z{|}~](\\.?[-!#$%&'*+/0-9=?A-Z^_a-z{|}~])*@[a-zA-Z](-?[a-zA-Z0-9])*(\\.[a-zA-Z](-?[a-zA-Z0-9])*)+$" 7 | ); 8 | constructor(protected readonly errorMessage?: string) { 9 | super(errorMessage); 10 | } 11 | 12 | validate(value: string): void { 13 | if (!this.emailRegex.test(value)) { 14 | throw new ValidateError("Email is not of valid format"); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": false, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "prettier" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaVersion": "latest", 14 | "sourceType": "module" 15 | }, 16 | "plugins": ["@typescript-eslint"], 17 | "rules": { 18 | "linebreak-style": ["error", "unix"], 19 | "quotes": ["error", "double"], 20 | "semi": ["error", "always"], 21 | "@typescript-eslint/explicit-function-return-type": ["error"], 22 | "@typescript-eslint/no-empty-function": ["off"] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "outDir": "./lib", 5 | "baseUrl": ".", 6 | "declaration": true, 7 | "noUnusedParameters": false, 8 | "module": "commonjs", 9 | "emitDecoratorMetadata": true, 10 | "esModuleInterop": true, 11 | "experimentalDecorators": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noImplicitAny": true, 14 | "noUnusedLocals": true, 15 | "sourceMap": true, 16 | "resolveJsonModule": true, 17 | "strict": true, 18 | "strictPropertyInitialization": false, 19 | "strictNullChecks": false, 20 | "lib": ["esnext"] 21 | }, 22 | "include": ["src"], 23 | "exclude": ["node_modules", "tests"] 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License (ISC) 2 | Copyright (c) 2022 Linnify 3 | 4 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, 5 | provided that the above copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING 8 | ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, 9 | DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, 10 | WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE 11 | USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /src/processors/boolean.processor.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig, FieldProcessor } from "./field.processor"; 2 | import { ProcessorValidateError } from "../errors"; 3 | 4 | export type BooleanFieldConfig = FieldConfig; 5 | 6 | export class BooleanFieldProcessor extends FieldProcessor< 7 | BooleanFieldConfig, 8 | string | boolean | number, 9 | boolean 10 | > { 11 | trueValues = [true, "true", "TRUE", "1", 1]; 12 | falseValue = [false, "false", "FALSE", "0", 0]; 13 | 14 | toInternalValue(data: string | boolean | number): boolean { 15 | if (this.trueValues.includes(data)) { 16 | return true; 17 | } else if (this.falseValue.includes(data)) { 18 | return false; 19 | } 20 | 21 | throw new ProcessorValidateError(["Not a valid boolean"]); 22 | } 23 | 24 | initialiseValidators(): void {} 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # These are some examples of commonly ignored file patterns. 2 | # You should customize this list as applicable to your project. 3 | # Learn more about .gitignore: 4 | # https://www.atlassian.com/git/tutorials/saving-changes/gitignore 5 | 6 | # Node artifact files 7 | node_modules/ 8 | dist/ 9 | 10 | # Compiled Java class files 11 | *.class 12 | 13 | # Compiled Python bytecode 14 | *.py[cod] 15 | 16 | # Log files 17 | *.log 18 | 19 | # Package files 20 | *.jar 21 | 22 | # Maven 23 | target/ 24 | 25 | # JetBrains IDE 26 | .idea/ 27 | 28 | # Unit test reports 29 | TEST*.xml 30 | 31 | # Generated by MacOS 32 | .DS_Store 33 | 34 | # Generated by Windows 35 | Thumbs.db 36 | 37 | # Applications 38 | *.app 39 | *.exe 40 | *.war 41 | 42 | # Large media files 43 | *.mp4 44 | *.tiff 45 | *.avi 46 | *.flv 47 | *.mov 48 | *.wmv 49 | 50 | coverage/ 51 | lib/ -------------------------------------------------------------------------------- /tests/validators/string/email.validator.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { ValidateError } from "../../../src/errors"; 3 | import { EmailValidator } from "../../../src/validators"; 4 | 5 | describe("EmailValidator", () => { 6 | describe("validate method", function () { 7 | it("Should throw error when value is not valid email", () => { 8 | const value = `.${faker.internet.email()}`; 9 | const validator = new EmailValidator(); 10 | 11 | expect(() => validator.validate(value)).toThrowError(ValidateError); 12 | }); 13 | 14 | it("Should not throw error when value is not valid email", () => { 15 | const value = faker.internet.email(); 16 | const validator = new EmailValidator(); 17 | 18 | expect(() => validator.validate(value)).not.toThrowError(ValidateError); 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/processors/enum.processor.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig, FieldProcessor } from "./field.processor"; 2 | import { ProcessorValidateError } from "../errors"; 3 | 4 | type EnumAbstract = { 5 | [key: string]: number | string; 6 | }; 7 | 8 | export type EnumFieldConfig = 9 | FieldConfig & 10 | Partial<{ 11 | /** 12 | * The enum which will be validated against 13 | */ 14 | enum: T; 15 | }>; 16 | 17 | export class EnumFieldProcessor extends FieldProcessor< 18 | EnumFieldConfig, 19 | unknown, 20 | T[keyof T] 21 | > { 22 | toInternalValue(data: unknown): T[keyof T] { 23 | if (typeof data !== "string" && typeof data !== "number") { 24 | throw new ProcessorValidateError(["Not a valid enum value"]); 25 | } 26 | 27 | if (!Object.values(this.configuration.enum).includes(data)) { 28 | throw new ProcessorValidateError(["Not a valid enum value"]); 29 | } 30 | 31 | return data as T[keyof T]; 32 | } 33 | 34 | initialiseValidators(): void {} 35 | } 36 | -------------------------------------------------------------------------------- /tests/schemas/mocks/schema.mock.ts: -------------------------------------------------------------------------------- 1 | import { NestedField, Schema } from "../../../src"; 2 | import { StringField, IntegerField, BooleanField } from "../../../src"; 3 | 4 | export class SchemaMock extends Schema {} 5 | 6 | export type SimplePayload = { 7 | firstName: string; 8 | age: number; 9 | active: boolean; 10 | }; 11 | 12 | export class SimpleSchema extends Schema { 13 | @StringField() 14 | firstName: string; 15 | 16 | @IntegerField() 17 | age: number; 18 | 19 | @BooleanField() 20 | active: boolean; 21 | 22 | async validate(): Promise { 23 | await super.validate(); 24 | await this.validateFunc(); 25 | } 26 | 27 | async validateFunc(): Promise {} 28 | } 29 | 30 | export type NestedPayload = { 31 | firstName: string; 32 | simpleSchema: SimplePayload; 33 | otherSimpleSchema: SimplePayload; 34 | }; 35 | 36 | export class NestedSchema extends Schema { 37 | @StringField() 38 | firstName: string; 39 | 40 | @NestedField({ schema: SimpleSchema, required: true }) 41 | simpleSchema: SimplePayload; 42 | 43 | @NestedField({ schema: SimpleSchema, required: false }) 44 | otherSimpleSchema: SimplePayload; 45 | } 46 | -------------------------------------------------------------------------------- /tests/processors/email.processor.test.ts: -------------------------------------------------------------------------------- 1 | import { EmailFieldProcessor } from "../../src/processors"; 2 | import { Validator } from "../../src/validators"; 3 | import { EmailValidator } from "../../src/validators"; 4 | import { StringFieldProcessor } from "../../src/processors"; 5 | 6 | describe("EmailFieldProcessor", () => { 7 | describe("initialiseValidators method", () => { 8 | it.each([ 9 | { 10 | testName: "add email validator and check inherited method is called", 11 | config: {}, 12 | expectedValidators: [new EmailValidator()], 13 | }, 14 | ])("Should $testName", ({ config, expectedValidators }) => { 15 | const initialiseValidatorsMock = jest 16 | .spyOn(StringFieldProcessor.prototype, "initialiseValidators") 17 | .mockImplementationOnce(() => {}); 18 | 19 | const processor = new EmailFieldProcessor(config); 20 | 21 | expect(initialiseValidatorsMock).toBeCalledTimes(1); 22 | expect( 23 | ( 24 | processor as EmailFieldProcessor & { 25 | validators: Validator[]; 26 | } 27 | ).validators 28 | ).toMatchObject(expectedValidators); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/processors/float.processor.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig, FieldProcessor } from "./field.processor"; 2 | import { ProcessorValidateError } from "../errors"; 3 | import { MaxValueValidator, MinValueValidator } from "../validators"; 4 | 5 | export type FloatFieldConfig = FieldConfig & 6 | Partial<{ 7 | minValue: number; 8 | maxValue: number; 9 | }>; 10 | 11 | export class FloatFieldProcessor extends FieldProcessor< 12 | FloatFieldConfig, 13 | number, 14 | number 15 | > { 16 | errorMessage = "Not a valid float number"; 17 | 18 | toInternalValue(data: number): number { 19 | if (!(typeof data === "number") && !(typeof data === "string")) { 20 | throw new ProcessorValidateError([this.errorMessage]); 21 | } 22 | 23 | if (Number.isNaN(+data)) { 24 | throw new ProcessorValidateError([this.errorMessage]); 25 | } 26 | 27 | return +data; 28 | } 29 | 30 | initialiseValidators(): void { 31 | if (this.configuration.minValue) { 32 | this.validators.push(new MinValueValidator(this.configuration.minValue)); 33 | } 34 | if (this.configuration.maxValue) { 35 | this.validators.push(new MaxValueValidator(this.configuration.maxValue)); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/processors/integer.processor.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig, FieldProcessor } from "./field.processor"; 2 | import { ProcessorValidateError } from "../errors"; 3 | import { MaxValueValidator, MinValueValidator } from "../validators"; 4 | 5 | export type IntegerFieldConfig = FieldConfig & 6 | Partial<{ 7 | minValue: number; 8 | maxValue: number; 9 | }>; 10 | 11 | export class IntegerFieldProcessor extends FieldProcessor< 12 | IntegerFieldConfig, 13 | number, 14 | number 15 | > { 16 | errorMessage = "Not a valid integer number"; 17 | 18 | toInternalValue(data: number): number { 19 | if (!(typeof data === "number") && !(typeof data === "string")) { 20 | throw new ProcessorValidateError([this.errorMessage]); 21 | } 22 | 23 | if (!Number.isInteger(+data)) { 24 | throw new ProcessorValidateError([this.errorMessage]); 25 | } 26 | 27 | return +data; 28 | } 29 | 30 | initialiseValidators(): void { 31 | if (this.configuration.minValue) { 32 | this.validators.push(new MinValueValidator(this.configuration.minValue)); 33 | } 34 | if (this.configuration.maxValue) { 35 | this.validators.push(new MaxValueValidator(this.configuration.maxValue)); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/processors/string.processor.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig, FieldProcessor } from "./field.processor"; 2 | import { ProcessorValidateError } from "../errors"; 3 | import { MaxLengthValidator, MinLengthValidator } from "../validators"; 4 | 5 | export type StringFieldConfig = FieldConfig & 6 | Partial<{ 7 | /** 8 | * The max length allowed for the field 9 | */ 10 | maxLength: number; 11 | /** 12 | * The min length allowed for the field 13 | */ 14 | minLength: number; 15 | }>; 16 | 17 | export class StringFieldProcessor< 18 | T extends StringFieldConfig = StringFieldConfig 19 | > extends FieldProcessor { 20 | toInternalValue(data: string): string { 21 | if (!(typeof data === "string")) { 22 | throw new ProcessorValidateError(["Not a valid string"]); 23 | } 24 | 25 | return data; 26 | } 27 | 28 | initialiseValidators(): void { 29 | if (this.configuration.maxLength) { 30 | this.validators.push( 31 | new MaxLengthValidator(this.configuration.maxLength) 32 | ); 33 | } 34 | if (this.configuration.minLength) { 35 | this.validators.push( 36 | new MinLengthValidator(this.configuration.minLength) 37 | ); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/validators/number/max-value.validator.test.ts: -------------------------------------------------------------------------------- 1 | import { MaxValueValidator } from "../../../src/validators"; 2 | import { faker } from "@faker-js/faker"; 3 | import { ValidateError } from "../../../src/errors"; 4 | 5 | describe("MaxValueValidator", () => { 6 | describe("validate method", () => { 7 | it("Should throw error when value is higher than max value", () => { 8 | const maxValue = faker.datatype.number(); 9 | const validator = new MaxValueValidator(maxValue); 10 | const value = faker.datatype.number({ min: maxValue + 1 }); 11 | 12 | expect(() => validator.validate(value)).toThrowError(ValidateError); 13 | }); 14 | 15 | it("Should not throw error when value is smaller than max value", () => { 16 | const maxValue = faker.datatype.number(); 17 | const validator = new MaxValueValidator(maxValue); 18 | const value = faker.datatype.number({ max: maxValue + 1 }); 19 | 20 | expect(() => validator.validate(value)).not.toThrowError(ValidateError); 21 | }); 22 | 23 | it("Should not throw error when value is equal to max value", () => { 24 | const maxValue = faker.datatype.number(); 25 | const validator = new MaxValueValidator(maxValue); 26 | 27 | expect(() => validator.validate(maxValue)).not.toThrowError( 28 | ValidateError 29 | ); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /tests/validators/number/min-value.validator.test.ts: -------------------------------------------------------------------------------- 1 | import { MinValueValidator } from "../../../src/validators"; 2 | import { faker } from "@faker-js/faker"; 3 | import { ValidateError } from "../../../src/errors"; 4 | 5 | describe("MinValueValidator", () => { 6 | describe("validate method", () => { 7 | it("Should throw error when value is smaller than min value", () => { 8 | const minValue = faker.datatype.number(); 9 | const validator = new MinValueValidator(minValue); 10 | const value = faker.datatype.number({ max: minValue - 1 }); 11 | 12 | expect(() => validator.validate(value)).toThrowError(ValidateError); 13 | }); 14 | 15 | it("Should not throw error when value is higher than min value", () => { 16 | const minValue = faker.datatype.number(); 17 | const validator = new MinValueValidator(minValue); 18 | const value = faker.datatype.number({ min: minValue + 1 }); 19 | 20 | expect(() => validator.validate(value)).not.toThrowError(ValidateError); 21 | }); 22 | 23 | it("Should not throw error when value is equal to max value", () => { 24 | const minValue = faker.datatype.number(); 25 | const validator = new MinValueValidator(minValue); 26 | 27 | expect(() => validator.validate(minValue)).not.toThrowError( 28 | ValidateError 29 | ); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/processors/json.processor.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig, FieldProcessor } from "./field.processor"; 2 | import { ProcessorValidateError } from "../errors"; 3 | 4 | export type JsonFieldConfig = FieldConfig; 5 | 6 | export type JsonObject = { 7 | [key: string]: JsonArray | Json | string | number | boolean; 8 | }; 9 | 10 | export type JsonArray = Json[] | string[] | number[] | boolean[]; 11 | 12 | export type Json = JsonObject | JsonArray; 13 | 14 | export class JsonFieldProcessor extends FieldProcessor< 15 | JsonFieldConfig, 16 | Json, 17 | Json 18 | > { 19 | errorMessage = "Not a valid json"; 20 | 21 | toInternalValue(data: Json): Json { 22 | if (typeof data === "bigint") { 23 | throw new ProcessorValidateError([this.errorMessage]); 24 | } 25 | 26 | if (typeof data === "number") { 27 | throw new ProcessorValidateError([this.errorMessage]); 28 | } 29 | 30 | if (typeof data === "boolean") { 31 | throw new ProcessorValidateError([this.errorMessage]); 32 | } 33 | 34 | if (data instanceof Date) { 35 | throw new ProcessorValidateError([this.errorMessage]); 36 | } 37 | 38 | try { 39 | if (typeof data === "string") { 40 | return JSON.parse(data) as Json; 41 | } else { 42 | return JSON.parse(JSON.stringify(data)) as Json; 43 | } 44 | } catch { 45 | throw new ProcessorValidateError([this.errorMessage]); 46 | } 47 | } 48 | 49 | initialiseValidators(): void {} 50 | } 51 | -------------------------------------------------------------------------------- /tests/validators/string/max-length.validator.test.ts: -------------------------------------------------------------------------------- 1 | import { MaxLengthValidator } from "../../../src/validators"; 2 | import { faker } from "@faker-js/faker"; 3 | import { ValidateError } from "../../../src/errors"; 4 | 5 | describe("MaxLengthValidator", () => { 6 | describe("validate method", () => { 7 | it("Should throw error when value has a longer length than max value", () => { 8 | const maxLength = faker.datatype.number(); 9 | const validator = new MaxLengthValidator(maxLength); 10 | const value = faker.datatype.string( 11 | maxLength + faker.datatype.number({ min: 1 }) 12 | ); 13 | 14 | expect(() => validator.validate(value)).toThrowError(ValidateError); 15 | }); 16 | 17 | it("Should not throw error when value has a smaller length than max value", () => { 18 | const maxLength = faker.datatype.number(); 19 | const validator = new MaxLengthValidator(maxLength); 20 | const value = faker.datatype.string( 21 | maxLength - faker.datatype.number({ min: 1 }) 22 | ); 23 | 24 | expect(() => validator.validate(value)).not.toThrowError(ValidateError); 25 | }); 26 | 27 | it("Should not throw error when value has the same length as max value", () => { 28 | const maxLength = faker.datatype.number(); 29 | const validator = new MaxLengthValidator(maxLength); 30 | const value = faker.datatype.string(maxLength); 31 | 32 | expect(() => validator.validate(value)).not.toThrowError(ValidateError); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/validators/string/min-length.validator.test.ts: -------------------------------------------------------------------------------- 1 | import { MinLengthValidator } from "../../../src/validators"; 2 | import { faker } from "@faker-js/faker"; 3 | import { ValidateError } from "../../../src/errors"; 4 | 5 | describe("MinLengthValidator", () => { 6 | describe("validate method", () => { 7 | it("Should throw error when value has a smaller length than min value", () => { 8 | const minLength = faker.datatype.number(); 9 | const validator = new MinLengthValidator(minLength); 10 | const value = faker.datatype.string( 11 | minLength - faker.datatype.number({ min: 1 }) 12 | ); 13 | 14 | expect(() => validator.validate(value)).toThrowError(ValidateError); 15 | }); 16 | 17 | it("Should not throw error when value has a longer length than min value", () => { 18 | const minLength = faker.datatype.number(); 19 | const validator = new MinLengthValidator(minLength); 20 | const value = faker.datatype.string( 21 | minLength + faker.datatype.number({ min: 1 }) 22 | ); 23 | 24 | expect(() => validator.validate(value)).not.toThrowError(ValidateError); 25 | }); 26 | 27 | it("Should not throw error when value has the same length as min value", () => { 28 | const minLength = faker.datatype.number(); 29 | const validator = new MinLengthValidator(minLength); 30 | const value = faker.datatype.string(minLength); 31 | 32 | expect(() => validator.validate(value)).not.toThrowError(ValidateError); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/processors/enum.processor.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { ProcessorValidateError } from "../../src"; 3 | import { EnumFieldProcessor } from "../../src/processors"; 4 | import { EnumMock } from "../fields/mock/enum.mock"; 5 | 6 | describe("EnumFieldProcessor", () => { 7 | describe("toInternalValue method", () => { 8 | it.each([ 9 | { 10 | testName: "throw error when value is a number", 11 | value: faker.datatype.number(), 12 | expectedError: true, 13 | }, 14 | { 15 | testName: "throw error when value is a boolean", 16 | value: faker.datatype.boolean(), 17 | expectedError: true, 18 | }, 19 | { 20 | testName: "return the string when value is a string", 21 | value: faker.datatype.string(), 22 | expectedError: true, 23 | }, 24 | { 25 | testName: "return string value when value part of enum", 26 | value: EnumMock.a, 27 | expectedError: false, 28 | }, 29 | { 30 | testName: "return number value when value part of enum", 31 | value: EnumMock.b, 32 | expectedError: false, 33 | }, 34 | ])("Should $testName", ({ value, expectedError }) => { 35 | const processor = new EnumFieldProcessor({ enum: EnumMock }); 36 | 37 | if (expectedError) { 38 | expect(() => processor.toInternalValue(value as string)).toThrowError( 39 | new ProcessorValidateError(["Not a valid string"]) 40 | ); 41 | } else { 42 | expect(processor.toInternalValue(value as string)).toEqual(value); 43 | } 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/processors/date.processor.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig, FieldProcessor } from "./field.processor"; 2 | import { ProcessorValidateError } from "../errors"; 3 | import { parse, parseISO } from "date-fns"; 4 | 5 | export type DateFieldConfig = Partial<{ 6 | formats: string[]; 7 | }> & 8 | FieldConfig; 9 | 10 | export class DateFieldProcessor extends FieldProcessor< 11 | DateFieldConfig, 12 | string | Date, 13 | Date 14 | > { 15 | initialiseValidators(): void {} 16 | 17 | toInternalValue(data: string | Date): Date { 18 | if (data instanceof Date) { 19 | return data; 20 | } 21 | 22 | if (typeof data !== "string") { 23 | throw new ProcessorValidateError(["Not a valid date"]); 24 | } 25 | 26 | if ( 27 | !this.configuration.formats || 28 | this.configuration.formats.length === 0 29 | ) { 30 | return this.parseIsoDate(data); 31 | } 32 | 33 | return this.parseDateFormat(data); 34 | } 35 | 36 | private parseDateFormat(value: string): Date { 37 | for (const format of this.configuration.formats) { 38 | const date = parse(value, format, new Date()); 39 | 40 | if (date instanceof Date && !isNaN(date.valueOf())) { 41 | return date; 42 | } 43 | } 44 | 45 | throw new ProcessorValidateError([ 46 | `Date must have format ${this.configuration.formats.join(", ")}`, 47 | ]); 48 | } 49 | 50 | private parseIsoDate(value: string): Date { 51 | const date = parseISO(value); 52 | 53 | if (date instanceof Date && isNaN(date.valueOf())) { 54 | throw new ProcessorValidateError(["Date must have ISO 8601 format"]); 55 | } 56 | 57 | return date; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/fields/utils.ts: -------------------------------------------------------------------------------- 1 | import { SchemaMetadataStorage } from "../schema"; 2 | import { FieldConfig, FieldProcessor, ProcessorClass } from "../processors"; 3 | import { DecoratorFieldConfig, NestedFieldConfiguration } from "."; 4 | import { Schema } from "../schema"; 5 | 6 | /** 7 | * Registers a nested validator decorator 8 | * @param schemaClass - The validator field target class 9 | * @param propertyKey - The property key for which the validator is added 10 | * @param configuration - The validator field configuration 11 | */ 12 | export const registerNestedSchemaField = , K>( 13 | schemaClass: object, 14 | propertyKey: string, 15 | configuration: NestedFieldConfiguration 16 | ): void => { 17 | SchemaMetadataStorage.storage.addNestedSchemaDefinition( 18 | schemaClass.constructor.name, 19 | propertyKey, 20 | configuration 21 | ); 22 | }; 23 | 24 | /** 25 | * Registers a validator decorator 26 | * @param schemaClass - The validator field target class 27 | * @param propertyKey - The property key for which the validator is added 28 | * @param configuration - The validator field configuration 29 | * @param processorClass - The class used for processing the property 30 | * @example 31 | * registerValidationField( 32 | * UserValidator, 33 | * 'BooleanField', 34 | * 'disabled', 35 | * {field: 'isDisabled'}, 36 | * (value: string | string[]): boolean => {...} 37 | * ) 38 | */ 39 | export const registerField = < 40 | C extends FieldConfig, 41 | T extends ProcessorClass, C>, 42 | Context = unknown 43 | >( 44 | schemaClass: object, 45 | propertyKey: string, 46 | configuration: DecoratorFieldConfig, 47 | processorClass: T 48 | ): void => { 49 | SchemaMetadataStorage.storage.addSchemaDefinition( 50 | schemaClass.constructor.name, 51 | propertyKey, 52 | configuration, 53 | processorClass 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dataffy/themis", 3 | "version": "1.3.4", 4 | "description": "", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "test": "jest", 10 | "format": "prettier --write .", 11 | "format:check": "prettier --check \"./**/*.ts\"", 12 | "lint": "eslint . --ext .ts", 13 | "lint:fix": "eslint . --fix --ext .ts", 14 | "prepare": "husky install && npm run build", 15 | "prepublishOnly": "npm run test", 16 | "preversion": "npm run lint", 17 | "version": "npm run format && git add -A src", 18 | "postversion": "git push && git push --tags" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/dataffy/schema-validation.git" 23 | }, 24 | "keywords": [ 25 | "validation", 26 | "validate", 27 | "validator", 28 | "decorators", 29 | "schema", 30 | "sanitization", 31 | "sanitize", 32 | "sanitise", 33 | "schema-validator", 34 | "schema-validation", 35 | "assert", 36 | "typescript" 37 | ], 38 | "author": "", 39 | "license": "ISC", 40 | "bugs": { 41 | "url": "https://github.com/dataffy/schema-validation/issues" 42 | }, 43 | "homepage": "https://github.com/dataffy/schema-validation#readme", 44 | "dependencies": { 45 | "date-fns": "^2.29.2" 46 | }, 47 | "devDependencies": { 48 | "@faker-js/faker": "^7.5.0", 49 | "@types/jest": "^29.0.0", 50 | "@types/node": "^18.7.14", 51 | "@typescript-eslint/eslint-plugin": "^5.36.1", 52 | "@typescript-eslint/parser": "^5.36.1", 53 | "eslint-config-prettier": "^8.5.0", 54 | "husky": "^8.0.1", 55 | "jest": "^28.0.8", 56 | "lint-staged": "^13.0.3", 57 | "prettier": "^2.7.1", 58 | "ts-jest": "^28.0.8", 59 | "ts-node": "^10.9.1", 60 | "typescript": "^4.8.2" 61 | }, 62 | "lint-staged": { 63 | "*.ts": [ 64 | "npm run format:check", 65 | "npm run lint:fix" 66 | ] 67 | }, 68 | "files": [ 69 | "lib/**/*" 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /tests/fields/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { SchemaMetadataStorage } from "../../src/schema"; 2 | import { SchemaMock } from "../schemas/mocks/schema.mock"; 3 | import { NestedFieldConfiguration } from "../../src/fields"; 4 | import { registerField, registerNestedSchemaField } from "../../src/fields"; 5 | import { FieldConfig } from "../../src/processors"; 6 | import { ProcessorMock } from "../processors/mocks/processor.mock"; 7 | 8 | describe("Fields Utils", () => { 9 | describe("registerNestedSchemaField", () => { 10 | it("should register nested field successfully", () => { 11 | const schemaClass = SchemaMock; 12 | const propertyKey = "field"; 13 | const configuration = { 14 | schema: SchemaMock, 15 | } as NestedFieldConfiguration; 16 | 17 | const addClassNestedValidatorDefinitionMock = jest 18 | .spyOn(SchemaMetadataStorage.prototype, "addNestedSchemaDefinition") 19 | .mockImplementationOnce(() => {}); 20 | 21 | registerNestedSchemaField(schemaClass, propertyKey, configuration); 22 | 23 | expect(addClassNestedValidatorDefinitionMock).toBeCalledTimes(1); 24 | 25 | expect(addClassNestedValidatorDefinitionMock).toBeCalledWith( 26 | schemaClass.constructor.name, 27 | propertyKey, 28 | configuration 29 | ); 30 | }); 31 | }); 32 | describe("registerField", () => { 33 | it("should register field successfully", () => { 34 | const schemaClass = SchemaMock; 35 | const propertyKey = "field"; 36 | const configuration = { 37 | nullable: true, 38 | } as FieldConfig; 39 | const processorClass = ProcessorMock; 40 | 41 | const addClassValidatorDefinitionMock = jest 42 | .spyOn(SchemaMetadataStorage.prototype, "addSchemaDefinition") 43 | .mockImplementationOnce(() => {}); 44 | 45 | registerField(schemaClass, propertyKey, configuration, processorClass); 46 | 47 | expect(addClassValidatorDefinitionMock).toBeCalledTimes(1); 48 | expect(addClassValidatorDefinitionMock).toBeCalledWith( 49 | schemaClass.constructor.name, 50 | propertyKey, 51 | configuration, 52 | processorClass 53 | ); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /tests/processors/json.processor.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { Json, JsonFieldProcessor, ProcessorValidateError } from "../../src"; 3 | 4 | describe("JsonProcessor", () => { 5 | describe("toInternalValue method", () => { 6 | it.each([ 7 | { 8 | testName: "throw error when value is a string", 9 | value: faker.datatype.string(), 10 | parsedValue: null, 11 | expectedError: true, 12 | parsingNeeded: false, 13 | }, 14 | { 15 | testName: "throw error when value is a boolean", 16 | value: faker.datatype.boolean(), 17 | expectedError: true, 18 | parsingNeeded: false, 19 | }, 20 | { 21 | testName: "throw error when value is infinity", 22 | value: Infinity, 23 | expectedError: true, 24 | parsingNeeded: false, 25 | }, 26 | { 27 | testName: "throw error when value is a float number", 28 | value: faker.datatype.number({ precision: 0.1 }) + 0.01, 29 | expectedError: true, 30 | parsingNeeded: false, 31 | }, 32 | { 33 | testName: "throw error when value is a big int", 34 | value: faker.datatype.bigInt(), 35 | expectedError: true, 36 | parsingNeeded: false, 37 | }, 38 | { 39 | testName: "throw error when value is a date time", 40 | value: faker.datatype.datetime(), 41 | expectedError: true, 42 | parsingNeeded: false, 43 | }, 44 | { 45 | testName: "throw error when value is a integer number", 46 | value: faker.datatype.number(), 47 | expectedError: true, 48 | parsingNeeded: false, 49 | }, 50 | { 51 | testName: "return the json object when value is json", 52 | value: faker.datatype.json(), 53 | expectedError: false, 54 | parsingNeeded: true, 55 | }, 56 | { 57 | testName: 58 | "return the json object when value is valid json in string format", 59 | value: JSON.parse(faker.datatype.json()), 60 | expectedError: false, 61 | parsingNeeded: false, 62 | }, 63 | ])("Should $testName", ({ value, expectedError, parsingNeeded }) => { 64 | const processor = new JsonFieldProcessor({}); 65 | 66 | if (expectedError) { 67 | expect(() => 68 | processor.toInternalValue(value as unknown as Json) 69 | ).toThrowError(new ProcessorValidateError([processor.errorMessage])); 70 | } else if (parsingNeeded) { 71 | expect(processor.toInternalValue(value as unknown as Json)).toEqual( 72 | JSON.parse(value) 73 | ); 74 | } else { 75 | expect(processor.toInternalValue(value as unknown as Json)).toEqual( 76 | value 77 | ); 78 | } 79 | }); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /tests/processors/string.processor.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { StringFieldProcessor } from "../../src/processors"; 3 | import { MaxLengthValidator, MinLengthValidator } from "../../src/validators"; 4 | import { Validator } from "../../src/validators"; 5 | import { ProcessorValidateError } from "../../src"; 6 | 7 | describe("StringProcessor", () => { 8 | describe("toInternalValue method", () => { 9 | it.each([ 10 | { 11 | testName: "throw error when value is a number", 12 | value: faker.datatype.number(), 13 | expectedError: true, 14 | }, 15 | { 16 | testName: "throw error when value is a boolean", 17 | value: faker.datatype.boolean(), 18 | expectedError: true, 19 | }, 20 | { 21 | testName: "return the string when value is a string", 22 | value: faker.datatype.string(), 23 | expectedError: false, 24 | }, 25 | ])("Should $testName", ({ value, expectedError }) => { 26 | const processor = new StringFieldProcessor({}); 27 | 28 | if (expectedError) { 29 | expect(() => processor.toInternalValue(value as string)).toThrowError( 30 | new ProcessorValidateError(["Not a valid string"]) 31 | ); 32 | } else { 33 | expect(processor.toInternalValue(value as string)).toEqual(value); 34 | } 35 | }); 36 | }); 37 | 38 | describe("initialiseValidators method", () => { 39 | const maxLength = faker.datatype.number(); 40 | const minLength = faker.datatype.number(); 41 | 42 | it.each([ 43 | { 44 | testName: "not add any validators when config is empty", 45 | config: {}, 46 | expectedValidators: [], 47 | }, 48 | { 49 | testName: "add max length validator when config has maxLength value", 50 | config: { maxLength }, 51 | expectedValidators: [new MaxLengthValidator(maxLength)], 52 | }, 53 | { 54 | testName: "add min length validator when config has minLength value", 55 | config: { minLength }, 56 | expectedValidators: [new MinLengthValidator(minLength)], 57 | }, 58 | { 59 | testName: 60 | "add min length and max length validators when config has minLength and maxLength values", 61 | config: { minLength, maxLength }, 62 | expectedValidators: [ 63 | new MaxLengthValidator(maxLength), 64 | new MinLengthValidator(minLength), 65 | ], 66 | }, 67 | ])("Should $testName", ({ config, expectedValidators }) => { 68 | const processor = new StringFieldProcessor(config); 69 | 70 | expect( 71 | ( 72 | processor as StringFieldProcessor & { 73 | validators: Validator[]; 74 | } 75 | ).validators 76 | ).toMatchObject(expectedValidators); 77 | }); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /tests/schemas/storage.test.ts: -------------------------------------------------------------------------------- 1 | import { SchemaMetadataStorage } from "../../src/schema"; 2 | import { SchemaMock } from "./mocks/schema.mock"; 3 | import { ProcessorMock } from "../processors/mocks/processor.mock"; 4 | import { DecoratorConfig, DecoratorFieldConfig } from "../../src/fields"; 5 | import { FieldConfig } from "../../src/processors"; 6 | 7 | describe("SchemaMetadataStorage", () => { 8 | beforeEach(() => { 9 | ( 10 | SchemaMetadataStorage as unknown as SchemaMetadataStorage & { 11 | instance: SchemaMetadataStorage; 12 | } 13 | ).instance = null; 14 | }); 15 | 16 | describe("registerSchemaClass method", () => { 17 | it("Should mark the class as registered when the function is called", () => { 18 | const schemaStorage = SchemaMetadataStorage.storage; 19 | 20 | schemaStorage.addSchemaDefinition( 21 | SchemaMock.name, 22 | "property", 23 | {}, 24 | ProcessorMock 25 | ); 26 | 27 | expect(() => 28 | schemaStorage.registerSchemaClass(SchemaMock.name) 29 | ).not.toThrowError(); 30 | }); 31 | 32 | it("Should throw error when the class is not configured before calling the function", () => { 33 | const schemaStorage = SchemaMetadataStorage.storage; 34 | 35 | expect(() => 36 | schemaStorage.registerSchemaClass(SchemaMock.name) 37 | ).toThrowError( 38 | `${SchemaMock.name} is not configured in storage. Use addSchemaDefinition method to add the configuration` 39 | ); 40 | }); 41 | }); 42 | 43 | describe("addSchemaDefinition method", () => { 44 | it("Should add default config for schema and configure field when there is no existing config for the schemaClassName", () => { 45 | const schemaStorage = SchemaMetadataStorage.storage; 46 | const schemaClassName = SchemaMock.constructor.name; 47 | const fieldConfig: FieldConfig = { 48 | nullable: false, 49 | required: true, 50 | }; 51 | const decoratorConfig: DecoratorConfig = { 52 | fromField: "field1", 53 | }; 54 | const configuration: DecoratorFieldConfig = { 55 | ...decoratorConfig, 56 | ...fieldConfig, 57 | }; 58 | 59 | const propertyKey = "property"; 60 | 61 | schemaStorage.addSchemaDefinition( 62 | schemaClassName, 63 | propertyKey, 64 | configuration, 65 | ProcessorMock 66 | ); 67 | 68 | const schemaClassMetadata = 69 | schemaStorage.getSchemaClassMetadata(schemaClassName); 70 | 71 | expect(schemaClassMetadata.nestedValidators).toEqual({}); 72 | expect(schemaClassMetadata.registered).toEqual(false); 73 | expect(schemaClassMetadata.properties).toEqual({ 74 | [propertyKey]: { 75 | processorClass: ProcessorMock, 76 | fieldConfig: fieldConfig, 77 | configuration: decoratorConfig, 78 | }, 79 | }); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /tests/processors/boolean.processor.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { BooleanFieldProcessor } from "../../src/processors"; 3 | import { ProcessorValidateError } from "../../src"; 4 | 5 | describe("BooleanProcessor", () => { 6 | describe("toInternalValue method", () => { 7 | it.each([ 8 | { 9 | testName: "throw error when value is a random string", 10 | value: faker.datatype.string(10), 11 | expectedError: true, 12 | }, 13 | { 14 | testName: "throw error when value is a number different than 0 and 1", 15 | value: faker.datatype.number({ min: 2 }), 16 | expectedError: true, 17 | }, 18 | { 19 | testName: "throw error when value is a float number", 20 | value: faker.datatype.number({ precision: 0.1 }), 21 | expectedError: true, 22 | }, 23 | { 24 | testName: "return false when value is 0", 25 | value: 0, 26 | expectedError: false, 27 | expectedValue: false, 28 | }, 29 | { 30 | testName: "return false when value is FALSE", 31 | value: "FALSE", 32 | expectedError: false, 33 | expectedValue: false, 34 | }, 35 | { 36 | testName: "return false when value is false", 37 | value: "false", 38 | expectedError: false, 39 | expectedValue: false, 40 | }, 41 | { 42 | testName: "return false when value is false", 43 | value: false, 44 | expectedError: false, 45 | expectedValue: false, 46 | }, 47 | { 48 | testName: "return false when value is 0", 49 | value: "0", 50 | expectedError: false, 51 | expectedValue: false, 52 | }, 53 | { 54 | testName: "return true when value is 1", 55 | value: 1, 56 | expectedError: false, 57 | expectedValue: true, 58 | }, 59 | { 60 | testName: "return false when value is TRUE", 61 | value: "TRUE", 62 | expectedError: false, 63 | expectedValue: true, 64 | }, 65 | { 66 | testName: "return false when value is true", 67 | value: "true", 68 | expectedError: false, 69 | expectedValue: true, 70 | }, 71 | { 72 | testName: "return false when value is true", 73 | value: true, 74 | expectedError: false, 75 | expectedValue: true, 76 | }, 77 | { 78 | testName: "return false when value is 1", 79 | value: "1", 80 | expectedError: false, 81 | expectedValue: true, 82 | }, 83 | ])("Should $testName", ({ value, expectedError, expectedValue }) => { 84 | const processor = new BooleanFieldProcessor({}); 85 | 86 | if (expectedError) { 87 | expect(() => processor.toInternalValue(value)).toThrowError( 88 | new ProcessorValidateError(["Not a valid boolean"]) 89 | ); 90 | } else { 91 | expect(processor.toInternalValue(value as number)).toEqual( 92 | expectedValue 93 | ); 94 | } 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/processors/field.processor.ts: -------------------------------------------------------------------------------- 1 | import { Validator } from "../validators"; 2 | import { ProcessorValidateError } from "../errors"; 3 | 4 | export type FieldConfig = Partial<{ 5 | /** 6 | * Specifies if the field is nullable. Default value is true 7 | */ 8 | nullable: boolean; 9 | /** 10 | * Specifies if the field is required. Default value is true 11 | */ 12 | required: boolean; 13 | /** 14 | * Validators against which the value is checked 15 | */ 16 | validators: Validator[]; 17 | }>; 18 | 19 | export type ProcessorClass< 20 | T extends FieldProcessor, 21 | C extends FieldConfig = FieldConfig, 22 | U = unknown, 23 | K = unknown, 24 | Context = unknown 25 | > = new (configuration: C, context?: Context) => T; 26 | 27 | export abstract class FieldProcessor< 28 | T extends FieldConfig, 29 | U, 30 | K, 31 | Context = unknown 32 | > { 33 | protected validators: Validator[] = []; 34 | 35 | constructor( 36 | protected readonly configuration: T, 37 | protected readonly context?: Context 38 | ) { 39 | if (this.configuration.validators) { 40 | this.validators = [...this.configuration.validators]; 41 | } 42 | this.initialiseValidators(); 43 | } 44 | 45 | /** 46 | * Transforms the incoming data into the value used internally 47 | * @abstract 48 | * @param data 49 | */ 50 | abstract toInternalValue(data: U): K; 51 | 52 | /** 53 | * Used to initialise the validators based on the field configuration 54 | */ 55 | abstract initialiseValidators(): void; 56 | 57 | async validate(data: U): Promise { 58 | const isEmpty = this.checkEmptyValues(data); 59 | 60 | if (isEmpty) { 61 | return data as undefined | null; 62 | } 63 | 64 | const value = await this.toInternalValue(data); 65 | 66 | this.runValidators(value); 67 | 68 | return value; 69 | } 70 | 71 | /** 72 | * Runs all the validators configured on the field and throws an error containing all the 73 | * validation errors 74 | * @param data 75 | * @private 76 | */ 77 | private runValidators(data: K): void { 78 | const errors: string[] = []; 79 | 80 | this.validators.forEach((validator: Validator) => { 81 | try { 82 | validator.validate(data); 83 | } catch (error) { 84 | errors.push((error as Error).message); 85 | } 86 | }); 87 | 88 | if (errors.length !== 0) { 89 | throw new ProcessorValidateError(errors); 90 | } 91 | } 92 | 93 | /** 94 | * Checks the data for empty values and throws error if data is not valid. 95 | * 96 | * @param data 97 | * @private 98 | * @returns True if value is empty and valid; False if value is not empty 99 | */ 100 | private checkEmptyValues(data: U): boolean { 101 | if (data === undefined && !(this.configuration.required === false)) { 102 | throw new ProcessorValidateError(["Value is required"]); 103 | } else if (data === null && this.configuration.nullable === false) { 104 | throw new ProcessorValidateError(["Value cannot be null"]); 105 | } 106 | 107 | return data === undefined || data === null; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /tests/processors/integer.processor.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { IntegerFieldProcessor } from "../../src/processors"; 3 | import { MaxValueValidator, MinValueValidator } from "../../src/validators"; 4 | import { Validator } from "../../src/validators"; 5 | import { ProcessorValidateError } from "../../src"; 6 | 7 | describe("IntegerProcessor", () => { 8 | describe("toInternalValue method", () => { 9 | it.each([ 10 | { 11 | testName: "throw error when value is a string", 12 | value: faker.datatype.string(), 13 | expectedError: true, 14 | }, 15 | { 16 | testName: "throw error when value is a boolean", 17 | value: faker.datatype.boolean(), 18 | expectedError: true, 19 | }, 20 | { 21 | testName: "throw error when value is infinity", 22 | value: Infinity, 23 | expectedError: true, 24 | }, 25 | { 26 | testName: "throw error when value is a float number", 27 | value: faker.datatype.number({ precision: 0.1 }) + 0.01, 28 | expectedError: true, 29 | }, 30 | { 31 | testName: "return the integer number when value is integer", 32 | value: faker.datatype.number(), 33 | expectedError: false, 34 | }, 35 | { 36 | testName: 37 | "return the integer number when value is integer but of string format", 38 | value: `${faker.datatype.number({ precision: 11 })}`, 39 | expectedError: false, 40 | }, 41 | ])("Should $testName", ({ value, expectedError }) => { 42 | const processor = new IntegerFieldProcessor({}); 43 | 44 | if (expectedError) { 45 | expect(() => processor.toInternalValue(value as number)).toThrowError( 46 | new ProcessorValidateError([processor.errorMessage]) 47 | ); 48 | } else { 49 | expect(processor.toInternalValue(value as number)).toEqual(+value); 50 | } 51 | }); 52 | }); 53 | 54 | describe("initialiseValidators method", () => { 55 | const maxValue = faker.datatype.number(); 56 | const minValue = faker.datatype.number(); 57 | 58 | it.each([ 59 | { 60 | testName: "not add any validators when config is empty", 61 | config: {}, 62 | expectedValidators: [], 63 | }, 64 | { 65 | testName: "add max value validator when config has maxValue value", 66 | config: { maxValue }, 67 | expectedValidators: [new MaxValueValidator(maxValue)], 68 | }, 69 | { 70 | testName: "add min value validator when config has minValue value", 71 | config: { minValue }, 72 | expectedValidators: [new MinValueValidator(minValue)], 73 | }, 74 | { 75 | testName: 76 | "add min value and max value validators when config has minValue and maxValue values", 77 | config: { minValue, maxValue }, 78 | expectedValidators: [ 79 | new MinValueValidator(minValue), 80 | new MaxValueValidator(maxValue), 81 | ], 82 | }, 83 | ])("Should $testName", ({ config, expectedValidators }) => { 84 | const processor = new IntegerFieldProcessor(config); 85 | 86 | expect( 87 | ( 88 | processor as IntegerFieldProcessor & { 89 | validators: Validator[]; 90 | } 91 | ).validators 92 | ).toMatchObject(expectedValidators); 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /tests/processors/float.processor.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { FloatFieldProcessor } from "../../src/processors"; 3 | import { MaxValueValidator, MinValueValidator } from "../../src/validators"; 4 | import { Validator } from "../../src/validators"; 5 | import { ProcessorValidateError } from "../../src"; 6 | 7 | describe("FloatProcessor", () => { 8 | describe("toInternalValue method", () => { 9 | it.each([ 10 | { 11 | testName: "throw error when value is a string", 12 | value: faker.datatype.string(), 13 | expectedError: true, 14 | }, 15 | { 16 | testName: "throw error when value is a boolean", 17 | value: faker.datatype.boolean(), 18 | expectedError: true, 19 | }, 20 | { 21 | testName: "return the number when value is an integer number", 22 | value: faker.datatype.number({ precision: 1 }), 23 | expectedError: false, 24 | }, 25 | { 26 | testName: "return infinity when value is infinity", 27 | value: Infinity, 28 | expectedError: false, 29 | }, 30 | { 31 | testName: "return the float number when the value is float", 32 | value: faker.datatype.number({ precision: 0.1 }), 33 | expectedError: false, 34 | }, 35 | { 36 | testName: "return the integer number when value is string", 37 | value: `${faker.datatype.number()}`, 38 | expectedError: false, 39 | }, 40 | { 41 | testName: "return the float number when value is string", 42 | value: `${faker.datatype.number({ precision: 0.01 })}`, 43 | expectedError: false, 44 | }, 45 | ])("Should $testName", ({ value, expectedError }) => { 46 | const processor = new FloatFieldProcessor({}); 47 | 48 | if (expectedError) { 49 | expect(() => processor.toInternalValue(value as number)).toThrowError( 50 | new ProcessorValidateError([processor.errorMessage]) 51 | ); 52 | } else { 53 | expect(processor.toInternalValue(value as number)).toEqual(+value); 54 | } 55 | }); 56 | }); 57 | 58 | describe("initialiseValidators method", () => { 59 | const maxValue = faker.datatype.number(); 60 | const minValue = faker.datatype.number(); 61 | 62 | it.each([ 63 | { 64 | testName: "not add any validators when config is empty", 65 | config: {}, 66 | expectedValidators: [], 67 | }, 68 | { 69 | testName: "add max value validator when config has maxValue value", 70 | config: { maxValue }, 71 | expectedValidators: [new MaxValueValidator(maxValue)], 72 | }, 73 | { 74 | testName: "add min value validator when config has minValue value", 75 | config: { minValue }, 76 | expectedValidators: [new MinValueValidator(minValue)], 77 | }, 78 | { 79 | testName: 80 | "add min value and max value validators when config has minValue and maxValue values", 81 | config: { minValue, maxValue }, 82 | expectedValidators: [ 83 | new MinValueValidator(minValue), 84 | new MaxValueValidator(maxValue), 85 | ], 86 | }, 87 | ])("Should $testName", ({ config, expectedValidators }) => { 88 | const processor = new FloatFieldProcessor(config); 89 | 90 | expect( 91 | ( 92 | processor as FloatFieldProcessor & { 93 | validators: Validator[]; 94 | } 95 | ).validators 96 | ).toMatchObject(expectedValidators); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /tests/processors/array.processor.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArrayFieldProcessor, 3 | StringFieldProcessor, 4 | ProcessorValidateError, 5 | } from "../../src"; 6 | import { SimpleSchema } from "../schemas/mocks/schema.mock"; 7 | 8 | describe("ArrayProcessor", () => { 9 | describe("toInternalValue method", () => { 10 | it.each([ 11 | { 12 | testName: 13 | "return processed data when child is field processor and values are valid", 14 | expectedError: undefined, 15 | values: ["first", "second"], 16 | expectedValues: ["first", "second"], 17 | child: StringFieldProcessor, 18 | }, 19 | { 20 | testName: 21 | "throw processor error when child is field processor and one of the values is invalid", 22 | expectedError: { 23 | "1": ["Not a valid string"], 24 | }, 25 | values: ["first", 1], 26 | child: StringFieldProcessor, 27 | }, 28 | { 29 | testName: "throw processor error when values type is not array", 30 | expectedError: ["Not a valid array"], 31 | values: 1, 32 | child: StringFieldProcessor, 33 | }, 34 | { 35 | testName: 36 | "throw error when child is field processor and error is not ProcessorValidateError", 37 | unexpectedError: "Unexpected Error", 38 | child: StringFieldProcessor, 39 | values: ["value"], 40 | }, 41 | { 42 | testName: 43 | "return processed data when child is schema and values are valid", 44 | expectedError: undefined, 45 | values: [ 46 | { firstName: "Schema", age: 10, active: "TRUE" }, 47 | { firstName: "Nested Schema", age: 1, active: 0 }, 48 | ], 49 | expectedValues: [ 50 | { firstName: "Schema", age: 10, active: true }, 51 | { firstName: "Nested Schema", age: 1, active: false }, 52 | ], 53 | child: SimpleSchema, 54 | }, 55 | { 56 | testName: 57 | "throw processor error when child is schema and one of the values is invalid", 58 | expectedError: { 59 | "0": { 60 | active: ["Not a valid boolean"], 61 | }, 62 | "1": { 63 | firstName: ["Not a valid string"], 64 | age: ["Value is required"], 65 | }, 66 | }, 67 | values: [ 68 | { firstName: "Schema", age: 10, active: 100 }, 69 | { firstName: 1, active: 0 }, 70 | ], 71 | child: SimpleSchema, 72 | }, 73 | { 74 | testName: 75 | "throw error when child is field schema and error is not ValidationError", 76 | unexpectedError: "Unexpected Error", 77 | child: SimpleSchema, 78 | values: [{ firstName: "value" }], 79 | }, 80 | ])( 81 | "Should $testName", 82 | async ({ 83 | child, 84 | values, 85 | expectedValues, 86 | expectedError, 87 | unexpectedError, 88 | }) => { 89 | const processor = new ArrayFieldProcessor({ child }); 90 | 91 | if (expectedError) { 92 | try { 93 | await processor.toInternalValue(values as unknown[]); 94 | expect(true).toEqual(false); 95 | } catch (error) { 96 | expect(error).toBeInstanceOf(ProcessorValidateError); 97 | expect((error as ProcessorValidateError).messages).toEqual( 98 | expectedError 99 | ); 100 | } 101 | } else if (unexpectedError) { 102 | jest.spyOn(child.prototype, "validate").mockImplementationOnce(() => { 103 | throw new Error(unexpectedError); 104 | }); 105 | await expect(() => 106 | processor.toInternalValue(values as unknown[]) 107 | ).rejects.toThrowError(unexpectedError); 108 | } else { 109 | const processedValues = await processor.toInternalValue( 110 | values as unknown[] 111 | ); 112 | expect(processedValues).toEqual(expectedValues); 113 | } 114 | } 115 | ); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /tests/schemas/nested-schema.test.ts: -------------------------------------------------------------------------------- 1 | import { NestedSchema, SimpleSchema } from "./mocks/schema.mock"; 2 | import { faker } from "@faker-js/faker"; 3 | import { ValidationError } from "../../src"; 4 | 5 | describe("Schema", () => { 6 | describe("Nested Schema", () => { 7 | it("should throw error missing nested field and required field", async () => { 8 | const data = { 9 | firstName: faker.datatype.string(), 10 | simpleSchema: { 11 | firstName: faker.datatype.string(), 12 | active: faker.datatype.boolean(), 13 | }, 14 | }; 15 | jest 16 | .spyOn(SimpleSchema.prototype, "validateFunc") 17 | .mockImplementation(async (): Promise => {}); 18 | 19 | const schema = new NestedSchema(data, {}, { partialValidation: false }); 20 | try { 21 | await schema.validate(); 22 | expect(true).toEqual(false); 23 | } catch (e) { 24 | expect(e).toBeInstanceOf(ValidationError); 25 | expect((e as ValidationError).errors).toEqual({ 26 | simpleSchema: { age: ["Value is required"] }, 27 | }); 28 | } 29 | }); 30 | it("should throw error missing nested schema if required", async () => { 31 | const data = { 32 | firstName: faker.datatype.string(), 33 | otherSimpleSchema: { 34 | firstName: faker.datatype.string(), 35 | age: faker.datatype.number(), 36 | active: faker.datatype.boolean(), 37 | }, 38 | }; 39 | jest 40 | .spyOn(SimpleSchema.prototype, "validateFunc") 41 | .mockImplementation(async (): Promise => {}); 42 | 43 | const schema = new NestedSchema(data, {}, { partialValidation: false }); 44 | try { 45 | await schema.validate(); 46 | expect(true).toEqual(false); 47 | } catch (e) { 48 | expect(e).toBeInstanceOf(ValidationError); 49 | expect((e as ValidationError).errors).toEqual({ 50 | simpleSchema: ["Missing field simpleSchema"], 51 | }); 52 | } 53 | }); 54 | 55 | it("should throw error missing nested field and not required field", async () => { 56 | const data = { 57 | firstName: faker.datatype.string(), 58 | simpleSchema: { 59 | firstName: faker.datatype.string(), 60 | age: faker.datatype.number(), 61 | active: faker.datatype.boolean(), 62 | }, 63 | otherSimpleSchema: { 64 | firstName: faker.datatype.string(), 65 | active: faker.datatype.boolean(), 66 | }, 67 | }; 68 | 69 | jest 70 | .spyOn(SimpleSchema.prototype, "validateFunc") 71 | .mockImplementation(async (): Promise => {}); 72 | 73 | const schema = new NestedSchema(data, {}, { partialValidation: false }); 74 | try { 75 | await schema.validate(); 76 | expect(true).toEqual(false); 77 | } catch (e) { 78 | expect(e).toBeInstanceOf(ValidationError); 79 | expect((e as ValidationError).errors).toEqual({ 80 | otherSimpleSchema: { age: ["Value is required"] }, 81 | }); 82 | } 83 | }); 84 | 85 | it("should throw error missing nested field and not required field", async () => { 86 | const data = { 87 | firstName: faker.datatype.string(), 88 | simpleSchema: { 89 | firstName: faker.datatype.string(), 90 | age: faker.datatype.number(), 91 | active: faker.datatype.boolean(), 92 | }, 93 | otherSimpleSchema: { 94 | firstName: faker.datatype.string(), 95 | active: faker.datatype.boolean(), 96 | age: faker.datatype.number(), 97 | }, 98 | }; 99 | 100 | jest 101 | .spyOn(SimpleSchema.prototype, "validateFunc") 102 | .mockImplementation(async (): Promise => { 103 | throw new Error("Failed!"); 104 | }); 105 | 106 | const schema = new NestedSchema(data, {}, { partialValidation: false }); 107 | try { 108 | await schema.validate(); 109 | expect(true).toEqual(false); 110 | } catch (e) { 111 | expect(e).toEqual(new Error("Failed!")); 112 | } 113 | }); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /src/processors/array.processor.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig, FieldProcessor, ProcessorClass } from "./field.processor"; 2 | import { Options, Schema, SchemaClass } from "../schema"; 3 | import { 4 | ProcessorErrorMessages, 5 | ProcessorValidateError, 6 | ValidationError, 7 | } from "../errors"; 8 | 9 | export type ArrayFieldType< 10 | C extends FieldConfig = FieldConfig, 11 | U = unknown, 12 | K = unknown 13 | > = ProcessorClass>; 14 | 15 | export type ArrayChildType< 16 | C extends FieldConfig = FieldConfig, 17 | Payload = unknown, 18 | Context = unknown, 19 | O extends Options = Options 20 | > = 21 | | ArrayFieldType 22 | | SchemaClass, Payload, O, Context>; 23 | 24 | export type ArrayFieldConfig< 25 | T extends FieldConfig, 26 | Payload = unknown, 27 | Context = any 28 | > = FieldConfig & { 29 | /** 30 | * The processor or schema used for validating the array values 31 | */ 32 | child: ArrayChildType; 33 | /** 34 | * The configuration for the processor 35 | */ 36 | childConfig?: T; 37 | }; 38 | 39 | export class ArrayFieldProcessor< 40 | T, 41 | K = unknown, 42 | U = unknown, 43 | Context = unknown 44 | > extends FieldProcessor< 45 | ArrayFieldConfig, 46 | K[], 47 | Promise, 48 | Context 49 | > { 50 | initialiseValidators(): void {} 51 | 52 | async toInternalValue(data: K[]): Promise { 53 | if (!(data instanceof Array)) { 54 | throw new ProcessorValidateError(["Not a valid array"]); 55 | } 56 | 57 | const childType = this.getBaseClass(this.configuration.child); 58 | 59 | if (childType.name === FieldProcessor.name) { 60 | return await this.processorToInternalValue(data); 61 | } else if (childType.name === Schema.name) { 62 | return await this.schemaToInternalValue(data); 63 | } 64 | } 65 | 66 | private async processorToInternalValue(data: K[]): Promise { 67 | const processorClass = this.configuration.child as ProcessorClass< 68 | FieldProcessor 69 | >; 70 | const processor = new processorClass( 71 | this.configuration.childConfig || {}, 72 | this.context 73 | ); 74 | const errors: Record = {}; 75 | const processedData: U[] = []; 76 | 77 | for (let index = 0; index < data.length; index++) { 78 | try { 79 | const processedValue = await processor.validate(data[index]); 80 | processedData.push(processedValue); 81 | } catch (error) { 82 | if (error instanceof ProcessorValidateError) { 83 | errors[index] = error.messages; 84 | } else { 85 | throw error; 86 | } 87 | } 88 | } 89 | 90 | if (Object.keys(errors).length !== 0) { 91 | throw new ProcessorValidateError(errors); 92 | } 93 | 94 | return processedData; 95 | } 96 | 97 | private async schemaToInternalValue(data: K[]): Promise { 98 | const schemaClass = this.configuration.child as SchemaClass< 99 | Schema, 100 | U, 101 | Options, 102 | Context 103 | >; 104 | const errors: Record = {}; 105 | const processedData: U[] = []; 106 | 107 | for (let index = 0; index < data.length; index++) { 108 | const schema = new schemaClass( 109 | data[index] as Record, 110 | this.context 111 | ); 112 | 113 | try { 114 | await schema.validate(); 115 | processedData.push(schema.toData()); 116 | } catch (error) { 117 | if (error instanceof ValidationError) { 118 | errors[index] = error.errors; 119 | } else { 120 | throw error; 121 | } 122 | } 123 | } 124 | 125 | if (Object.keys(errors).length !== 0) { 126 | throw new ProcessorValidateError(errors); 127 | } 128 | 129 | return processedData; 130 | } 131 | 132 | private getBaseClass( 133 | child: ArrayChildType 134 | ): ArrayChildType { 135 | const baseClass = Object.getPrototypeOf(child); 136 | 137 | if (baseClass && baseClass.name) { 138 | return this.getBaseClass(baseClass); 139 | } 140 | 141 | return child; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /tests/processors/date.processor.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { DateFieldProcessor } from "../../src/processors"; 3 | import { parse } from "date-fns"; 4 | import { ProcessorValidateError } from "../../src"; 5 | 6 | describe("DateProcessor", () => { 7 | describe("toInternalValue method", () => { 8 | const currentDate = new Date(); 9 | 10 | it.each([ 11 | { 12 | testName: "throw error when value is a number", 13 | value: faker.datatype.number(), 14 | expectedError: true, 15 | errorMessage: "Not a valid date", 16 | formats: [], 17 | expectedResult: undefined, 18 | }, 19 | { 20 | testName: "throw error when value is a string but not a date format", 21 | value: faker.datatype.string(), 22 | expectedError: true, 23 | errorMessage: "Date must have ISO 8601 format", 24 | formats: [], 25 | expectedResult: undefined, 26 | }, 27 | { 28 | testName: "throw error when value is a boolean", 29 | value: faker.datatype.boolean(), 30 | expectedError: true, 31 | errorMessage: "Not a valid date", 32 | formats: [], 33 | expectedResult: undefined, 34 | }, 35 | { 36 | testName: 37 | "return the date when value is a iso date string and no formats specified", 38 | value: currentDate.toISOString(), 39 | expectedError: false, 40 | errorMessage: "", 41 | formats: [], 42 | expectedResult: currentDate, 43 | }, 44 | { 45 | testName: 46 | "throw error when date is not in iso format and no formats specified", 47 | value: "03/29/2022", 48 | expectedError: true, 49 | errorMessage: "Date must have ISO 8601 format", 50 | formats: [], 51 | expectedResult: currentDate, 52 | }, 53 | { 54 | testName: "return the date when value is date already", 55 | value: currentDate, 56 | expectedError: false, 57 | errorMessage: "", 58 | formats: [], 59 | expectedResult: currentDate, 60 | }, 61 | { 62 | testName: 63 | "throw error when value is a date string and does not follow specified format", 64 | value: currentDate.toISOString(), 65 | expectedError: true, 66 | errorMessage: "Date must have format MM/dd/yyyy", 67 | formats: ["MM/dd/yyyy"], 68 | expectedResult: currentDate, 69 | }, 70 | { 71 | testName: 72 | "return the date when value is a date string and follows only specified format", 73 | value: "03/29/2022", 74 | expectedError: false, 75 | errorMessage: "", 76 | formats: ["MM/dd/yyyy"], 77 | expectedResult: parse("03/29/2022", "MM/dd/yyyy", currentDate), 78 | }, 79 | { 80 | testName: 81 | "return the date when value is a date string and follows one of the specified format", 82 | value: "01-10-2025", 83 | expectedError: false, 84 | errorMessage: "", 85 | formats: ["MM/dd/yyyy", "dd-MM-yyyy"], 86 | expectedResult: parse("01-10-2025", "dd-MM-yyyy", currentDate), 87 | }, 88 | { 89 | testName: 90 | "throw error when value is a date string and does not follow specified format", 91 | value: "01/10/2025", 92 | expectedError: true, 93 | errorMessage: "Date must have format MM dd yyyy, dd-MM-yyyy", 94 | formats: ["MM dd yyyy", "dd-MM-yyyy"], 95 | expectedResult: new Date(2025, 10, 1), 96 | }, 97 | ])( 98 | "Should $testName", 99 | ({ value, expectedError, errorMessage, formats, expectedResult }) => { 100 | jest.useFakeTimers({ now: new Date() }); 101 | 102 | const processor = new DateFieldProcessor({ formats }); 103 | 104 | if (expectedError) { 105 | expect(() => processor.toInternalValue(value as string)).toThrowError( 106 | new ProcessorValidateError([errorMessage]) 107 | ); 108 | } else { 109 | expect(processor.toInternalValue(value as string)).toEqual( 110 | expectedResult 111 | ); 112 | } 113 | 114 | jest.useRealTimers(); 115 | } 116 | ); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /tests/fields/fields.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArrayField, 3 | BooleanField, 4 | DateField, 5 | EnumField, 6 | FloatField, 7 | IntegerField, 8 | JsonField, 9 | NestedField, 10 | NestedFieldConfiguration, 11 | StringField, 12 | } from "../../src/fields"; 13 | import * as fieldsUtils from "../../src/fields/utils"; 14 | import { SchemaMock } from "../schemas/mocks/schema.mock"; 15 | import { 16 | ArrayFieldProcessor, 17 | BooleanFieldConfig, 18 | BooleanFieldProcessor, 19 | FloatFieldConfig, 20 | FloatFieldProcessor, 21 | IntegerFieldConfig, 22 | IntegerFieldProcessor, 23 | JsonFieldConfig, 24 | JsonFieldProcessor, 25 | StringFieldConfig, 26 | StringFieldProcessor, 27 | } from "../../src/processors"; 28 | import { DateFieldConfig, DateFieldProcessor } from "../../src/processors"; 29 | import { faker } from "@faker-js/faker"; 30 | import { EmailFieldConfig, EmailFieldProcessor } from "../../src/processors"; 31 | import { EmailField } from "../../src/fields"; 32 | import { EnumFieldProcessor } from "../../src/processors/enum.processor"; 33 | import { EnumMock } from "./mock/enum.mock"; 34 | 35 | describe("Fields", () => { 36 | it.each([ 37 | { 38 | fieldName: "StringField", 39 | field: StringField, 40 | configuration: { 41 | maxLength: 10, 42 | } as StringFieldConfig, 43 | processor: StringFieldProcessor, 44 | }, 45 | { 46 | fieldName: "IntegerField", 47 | field: IntegerField, 48 | configuration: { 49 | minValue: 10, 50 | } as IntegerFieldConfig, 51 | processor: IntegerFieldProcessor, 52 | }, 53 | { 54 | fieldName: "BooleanField", 55 | field: BooleanField, 56 | configuration: { 57 | nullable: true, 58 | } as BooleanFieldConfig, 59 | processor: BooleanFieldProcessor, 60 | }, 61 | { 62 | fieldName: "FloatField", 63 | field: FloatField, 64 | configuration: { 65 | minValue: 10, 66 | } as FloatFieldConfig, 67 | processor: FloatFieldProcessor, 68 | }, 69 | { 70 | fieldName: "DateField", 71 | field: DateField, 72 | configuration: { 73 | formats: ["MM/dd/yyyy"], 74 | } as DateFieldConfig, 75 | processor: DateFieldProcessor, 76 | }, 77 | { 78 | fieldName: "EmailField", 79 | field: EmailField, 80 | configuration: { 81 | maxLength: faker.datatype.number({ max: 5 }), 82 | } as EmailFieldConfig, 83 | processor: EmailFieldProcessor, 84 | }, 85 | { 86 | fieldName: "JsonField", 87 | field: JsonField, 88 | configuration: { 89 | nullable: true, 90 | } as JsonFieldConfig, 91 | processor: JsonFieldProcessor, 92 | }, 93 | { 94 | fieldName: "ArrayField", 95 | field: ArrayField, 96 | configuration: { 97 | child: StringFieldProcessor, 98 | }, 99 | processor: ArrayFieldProcessor, 100 | }, 101 | { 102 | fieldName: "EnumField", 103 | field: EnumField, 104 | configuration: { 105 | enum: EnumMock, 106 | }, 107 | processor: EnumFieldProcessor, 108 | }, 109 | ])( 110 | "Should register field successfully for $fieldName", 111 | ({ field, configuration, processor }) => { 112 | const propertyKey = "field"; 113 | const target = SchemaMock; 114 | const registerFieldMock = jest 115 | .spyOn(fieldsUtils, "registerField") 116 | .mockImplementationOnce(() => {}); 117 | 118 | field(configuration as any)(target, propertyKey); 119 | 120 | expect(registerFieldMock).toBeCalledTimes(1); 121 | 122 | expect(registerFieldMock).toBeCalledWith( 123 | target, 124 | propertyKey, 125 | configuration, 126 | processor 127 | ); 128 | } 129 | ); 130 | it("Should register nested field successfully for NestedField", () => { 131 | const field = NestedField; 132 | const propertyKey = "field"; 133 | const configuration = { 134 | schema: SchemaMock, 135 | } as NestedFieldConfiguration; 136 | const target = SchemaMock; 137 | 138 | const registerNestedSchemaField = jest 139 | .spyOn(fieldsUtils, "registerNestedSchemaField") 140 | .mockImplementationOnce(() => {}); 141 | 142 | field(configuration)(target, propertyKey); 143 | 144 | expect(registerNestedSchemaField).toBeCalledTimes(1); 145 | expect(registerNestedSchemaField).toBeCalledWith( 146 | target, 147 | propertyKey, 148 | configuration 149 | ); 150 | }); 151 | }); 152 | -------------------------------------------------------------------------------- /src/schema/schema.ts: -------------------------------------------------------------------------------- 1 | import { SchemaClassConfiguration, SchemaMetadataStorage } from "./storage"; 2 | import { FieldConfig, FieldProcessor, ProcessorClass } from "../processors"; 3 | import { ProcessorValidateError, ValidationError } from "../errors"; 4 | 5 | export type SchemaClass< 6 | T extends Schema, 7 | U, 8 | O extends Options = Options, 9 | Context = unknown 10 | > = new (obj: Record, context?: Context, options?: O) => T; 11 | 12 | export type ValidationErrors = { [key: string]: ValidationErrors | string[] }; 13 | export type Options = { 14 | partialValidation: boolean; 15 | }; 16 | 17 | export class Schema { 18 | protected initialData: Record; 19 | protected options: Options; 20 | protected validatedFields: T; 21 | protected readonly context?: Context; 22 | 23 | constructor( 24 | obj: Record, 25 | context?: Context, 26 | options?: Options 27 | ) { 28 | SchemaMetadataStorage.storage.registerSchemaClass(this.constructor.name); 29 | this.initialData = obj; 30 | this.validatedFields = {} as T; 31 | this.options = options; 32 | this.context = context; 33 | } 34 | 35 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 36 | async validate(): Promise { 37 | await this.processValidation(); 38 | } 39 | 40 | private async validateFields(): Promise { 41 | const validateClassMetadata = 42 | SchemaMetadataStorage.storage.getSchemaClassMetadata( 43 | this.constructor.name 44 | ); 45 | 46 | const errors: ValidationErrors = {}; 47 | 48 | for (const validatorProperty of Object.keys( 49 | validateClassMetadata.properties 50 | )) { 51 | const propertyConfiguration = 52 | validateClassMetadata.properties[validatorProperty]; 53 | const fromField = 54 | propertyConfiguration.configuration.fromField || validatorProperty; 55 | 56 | try { 57 | const attribute = this.initialData[fromField]; 58 | const processor = new propertyConfiguration.processorClass( 59 | propertyConfiguration.fieldConfig || {}, 60 | this.context 61 | ); 62 | this.validatedFields[validatorProperty as keyof T] = 63 | (await processor.validate(attribute)) as T[keyof T]; 64 | } catch (error) { 65 | if ( 66 | error instanceof ProcessorValidateError && 67 | !this.options?.partialValidation 68 | ) { 69 | errors[validatorProperty] = error.messages; 70 | } 71 | } 72 | } 73 | 74 | const nestedErrors = await this.nestedFields(validateClassMetadata); 75 | 76 | return { 77 | ...errors, 78 | ...nestedErrors, 79 | }; 80 | } 81 | 82 | private async nestedFields( 83 | validateClassMetadata: SchemaClassConfiguration< 84 | FieldConfig, 85 | ProcessorClass> 86 | > 87 | ): Promise { 88 | const errors: ValidationErrors = {}; 89 | 90 | for (const validatorProperty of Object.keys( 91 | validateClassMetadata.nestedValidators 92 | )) { 93 | const validatorConfig = 94 | validateClassMetadata.nestedValidators[validatorProperty]; 95 | const fromField = validatorConfig.fromField || validatorProperty; 96 | 97 | if (this.initialData[fromField] === undefined) { 98 | if ( 99 | !this.options?.partialValidation && 100 | validatorConfig.required === true 101 | ) { 102 | errors[validatorProperty] = [`Missing field ${validatorProperty}`]; 103 | } 104 | continue; 105 | } 106 | 107 | const validator = new validatorConfig.schema( 108 | this.initialData[fromField] as Record, 109 | this.context, 110 | this.options 111 | ); 112 | 113 | let validatorErrors: ValidationErrors = {}; 114 | 115 | try { 116 | await validator.validate(); 117 | } catch (e) { 118 | if (e instanceof ValidationError) { 119 | validatorErrors = e.errors; 120 | } else { 121 | throw e; 122 | } 123 | } 124 | 125 | if (Object.keys(validatorErrors).length !== 0) { 126 | errors[validatorProperty] = validatorErrors; 127 | continue; 128 | } 129 | 130 | this.validatedFields[validatorProperty as keyof T] = validator.toData(); 131 | } 132 | return errors; 133 | } 134 | 135 | private async processValidation(): Promise { 136 | const validationErrors: ValidationErrors = await this.validateFields(); 137 | 138 | if (Object.keys(validationErrors).length !== 0) { 139 | throw new ValidationError(validationErrors); 140 | } 141 | } 142 | 143 | toData(): T { 144 | return this.validatedFields as T; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /tests/processors/field.processor.test.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig, FieldProcessor } from "../../src/processors"; 2 | import { faker } from "@faker-js/faker"; 3 | import { ProcessorValidateError, ValidateError } from "../../src/errors"; 4 | import { ProcessorMock } from "./mocks/processor.mock"; 5 | import { ValidatorMock } from "../validators/mocks/validator.mock"; 6 | import { MinLengthValidator } from "../../src/validators"; 7 | import { Validator } from "../../src/validators"; 8 | 9 | describe("FieldProcessor", () => { 10 | describe("validate method", () => { 11 | it.each([ 12 | { 13 | testName: 14 | "throw required error when default config and value is undefined", 15 | config: {}, 16 | value: undefined, 17 | expectedError: "Value is required", 18 | expectedResult: undefined, 19 | }, 20 | { 21 | testName: "throw nullable error when nullable false and value is null", 22 | config: { nullable: false }, 23 | value: null, 24 | expectedError: "Value cannot be null", 25 | expectedResult: undefined, 26 | }, 27 | { 28 | testName: "return undefined when required false and value is undefined", 29 | config: { required: false }, 30 | value: undefined, 31 | expectedError: undefined, 32 | expectedResult: undefined, 33 | }, 34 | { 35 | testName: "return null when nullable true and value is null", 36 | config: { nullable: true }, 37 | value: null, 38 | expectedError: undefined, 39 | expectedResult: null, 40 | }, 41 | ])( 42 | "Should check empty values and $testName", 43 | async ({ config, value, expectedError, expectedResult }) => { 44 | const processor = new ProcessorMock(config); 45 | 46 | if (expectedError) { 47 | await expect(processor.validate(value)).rejects.toThrowError( 48 | new ProcessorValidateError([expectedError]) 49 | ); 50 | } else { 51 | const result = await processor.validate(value); 52 | expect(result).toEqual(expectedResult); 53 | } 54 | } 55 | ); 56 | 57 | it.each([ 58 | { 59 | name: "another string", 60 | valueFn: (): string => "Another string", 61 | value: "String", 62 | }, 63 | { 64 | name: "string from number", 65 | valueFn: (value: string | number): string => value.toString(), 66 | value: faker.datatype.number(), 67 | }, 68 | ])( 69 | "Should use internally the result when toInternalValue returns: $name", 70 | async ({ valueFn, value }) => { 71 | const expectedResult = valueFn(value); 72 | const processor = new ProcessorMock({}); 73 | 74 | jest 75 | .spyOn(processor, "toInternalValue") 76 | .mockImplementationOnce(() => valueFn(value)); 77 | 78 | const validationResult = await processor.validate(value); 79 | 80 | expect(validationResult).toEqual(expectedResult); 81 | } 82 | ); 83 | 84 | it.each([ 85 | { 86 | testName: "throw error when validation passed successfully", 87 | validators: [], 88 | expectValidationError: "Validation error", 89 | }, 90 | { 91 | testName: "not throw error when validation passed successfully", 92 | validators: [], 93 | expectValidationError: false, 94 | }, 95 | { 96 | testName: "not throw error when validation passed successfully", 97 | validators: [new MinLengthValidator(3)], 98 | expectValidationError: false, 99 | }, 100 | ])("Should $testName", async ({ validators, expectValidationError }) => { 101 | const validator = new ValidatorMock(); 102 | class DummyFieldProcessor extends FieldProcessor< 103 | FieldConfig, 104 | string, 105 | string 106 | > { 107 | initialiseValidators(): void { 108 | this.validators.push(validator); 109 | } 110 | 111 | toInternalValue(data: string): string { 112 | return data; 113 | } 114 | } 115 | 116 | const validateSpy = jest 117 | .spyOn(validator, "validate") 118 | .mockImplementationOnce(() => { 119 | if (expectValidationError) { 120 | throw new ValidateError(expectValidationError as string); 121 | } 122 | }); 123 | const processor = new DummyFieldProcessor({ validators }); 124 | const value = faker.datatype.string(); 125 | 126 | if (expectValidationError) { 127 | try { 128 | await processor.validate(value); 129 | expect(true).toEqual(false); 130 | } catch (error) { 131 | expect(error).toBeInstanceOf(ProcessorValidateError); 132 | expect((error as ProcessorValidateError).messages.length).toEqual(1); 133 | expect( 134 | ((error as ProcessorValidateError).messages as string[])[0] 135 | ).toEqual(expectValidationError); 136 | } 137 | } else { 138 | const validationResult = await processor.validate(value); 139 | expect(validationResult).toEqual(value); 140 | expect( 141 | ( 142 | processor as ProcessorMock & { 143 | validators: Validator[]; 144 | } 145 | ).validators 146 | ).toMatchObject([...validators, validator]); 147 | } 148 | 149 | expect(validateSpy).toBeCalledTimes(1); 150 | expect(validateSpy).toBeCalledWith(value); 151 | }); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /src/schema/storage.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig, FieldProcessor, ProcessorClass } from "../processors"; 2 | import { 3 | DecoratorConfig, 4 | DecoratorFieldConfig, 5 | decoratorFields, 6 | NestedFieldConfiguration, 7 | } from "../fields"; 8 | import { Schema } from "./schema"; 9 | 10 | export type PropertyConfiguration< 11 | C extends FieldConfig, 12 | T extends ProcessorClass, C> 13 | > = { 14 | processorClass: any; 15 | fieldConfig: C; 16 | configuration: DecoratorConfig; 17 | }; 18 | 19 | export type SchemaClassConfiguration< 20 | C extends FieldConfig, 21 | T extends ProcessorClass, C> 22 | > = { 23 | registered: boolean; 24 | properties: { [propertyKey: string]: PropertyConfiguration }; 25 | nestedValidators: { 26 | [propertyKey: string]: NestedFieldConfiguration, any>; 27 | }; 28 | }; 29 | 30 | type Configs = { 31 | fieldConfig: T; 32 | decoratorConfig: DecoratorConfig; 33 | }; 34 | 35 | export class SchemaMetadataStorage { 36 | private static instance: SchemaMetadataStorage; 37 | protected schemaClasses: { 38 | [schemaClassName: string]: SchemaClassConfiguration< 39 | FieldConfig, 40 | ProcessorClass> 41 | >; 42 | } = {}; 43 | 44 | private constructor() {} 45 | 46 | /** 47 | * Returns the ValidationFieldsMetadataStorage instance 48 | */ 49 | static get storage(): SchemaMetadataStorage { 50 | if (!this.instance) { 51 | this.instance = new SchemaMetadataStorage(); 52 | } 53 | 54 | return this.instance; 55 | } 56 | 57 | /** 58 | * Used to mark the validator class as registered. If a validator class is not 59 | * registered using this method, it will throw an error when trying to use it for 60 | * getting the validation fields metadata 61 | * @param schemaClassName 62 | */ 63 | registerSchemaClass(schemaClassName: string): void { 64 | if (!this.schemaClasses[schemaClassName]) { 65 | throw new Error( 66 | `${schemaClassName} is not configured in storage. Use addSchemaDefinition method to add the configuration` 67 | ); 68 | } 69 | 70 | this.schemaClasses[schemaClassName].registered = true; 71 | } 72 | 73 | /** 74 | * Add the validator definition metadata for the specified schemaClassName 75 | * 76 | * @param schemaClassName - The validator class name for which the validation field is being registered 77 | * @param propertyKey - The key that the validator field is used on 78 | * @param configuration - The configuration of the validator 79 | * @param processorClass - The class used for processing the property 80 | * 81 | */ 82 | addSchemaDefinition< 83 | C extends FieldConfig, 84 | T extends ProcessorClass, C> 85 | >( 86 | schemaClassName: string, 87 | propertyKey: string, 88 | configuration: DecoratorFieldConfig, 89 | processorClass: T 90 | ): void { 91 | if (!this.schemaClasses[schemaClassName]) { 92 | this.schemaClasses[schemaClassName] = { 93 | registered: false, 94 | properties: {}, 95 | nestedValidators: {}, 96 | }; 97 | } 98 | 99 | const configs: Configs = this.getDecoratorAndFieldConfig(configuration); 100 | 101 | this.schemaClasses[schemaClassName].properties[propertyKey] = { 102 | processorClass, 103 | fieldConfig: configs.fieldConfig, 104 | configuration: configs.decoratorConfig || {}, 105 | }; 106 | } 107 | 108 | /** 109 | * Add the validator definition metadata for the specified schemaClassName 110 | * 111 | * @param schemaClassName - The validator class name for which the validation field is being registered 112 | * @param propertyKey - The key that the validator field is used on 113 | * @param configuration - The configuration of the validator 114 | * 115 | */ 116 | addNestedSchemaDefinition, K>( 117 | schemaClassName: string, 118 | propertyKey: string, 119 | configuration: NestedFieldConfiguration 120 | ): void { 121 | if (!this.schemaClasses[schemaClassName]) { 122 | this.schemaClasses[schemaClassName] = { 123 | registered: false, 124 | properties: {}, 125 | nestedValidators: {}, 126 | }; 127 | } 128 | 129 | this.schemaClasses[schemaClassName].nestedValidators[propertyKey] = 130 | configuration; 131 | } 132 | 133 | /** 134 | * Used for retrieving all the validator class metadata for the specified validator class 135 | * 136 | * @param schemaClass 137 | */ 138 | getSchemaClassMetadata( 139 | schemaClass: string 140 | ): SchemaClassConfiguration< 141 | FieldConfig, 142 | ProcessorClass> 143 | > { 144 | return this.schemaClasses[schemaClass]; 145 | } 146 | 147 | private getDecoratorAndFieldConfig( 148 | decoratorFieldConfig: DecoratorFieldConfig 149 | ): Configs { 150 | if (!decoratorFieldConfig) { 151 | return { 152 | fieldConfig: null, 153 | decoratorConfig: null, 154 | }; 155 | } 156 | 157 | const decoratorConfig: DecoratorConfig = {}; 158 | const config: DecoratorFieldConfig = { ...decoratorFieldConfig }; 159 | 160 | decoratorFields.forEach((field: keyof DecoratorConfig) => { 161 | decoratorConfig[field] = decoratorFieldConfig[field]; 162 | delete config[field]; 163 | }); 164 | 165 | return { 166 | fieldConfig: config, 167 | decoratorConfig, 168 | }; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/fields/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EmailFieldConfig, 3 | EmailFieldProcessor, 4 | FieldConfig, 5 | JsonFieldConfig, 6 | JsonFieldProcessor, 7 | } from "../processors"; 8 | import { 9 | BooleanFieldConfig, 10 | BooleanFieldProcessor, 11 | FloatFieldConfig, 12 | FloatFieldProcessor, 13 | IntegerFieldConfig, 14 | IntegerFieldProcessor, 15 | StringFieldConfig, 16 | StringFieldProcessor, 17 | } from "../processors"; 18 | import { registerField, registerNestedSchemaField } from "./utils"; 19 | import { Schema, SchemaClass } from "../schema"; 20 | import { DateFieldConfig, DateFieldProcessor } from "../processors"; 21 | import { 22 | ArrayFieldConfig, 23 | ArrayFieldProcessor, 24 | } from "../processors/array.processor"; 25 | import { 26 | EnumFieldConfig, 27 | EnumFieldProcessor, 28 | } from "../processors/enum.processor"; 29 | 30 | export * from "./utils"; 31 | 32 | export type ValidationField = ( 33 | configuration?: DecoratorFieldConfig 34 | ) => (target: object, propertyKey: string) => void; 35 | 36 | export type DecoratorConfig = Partial<{ 37 | fromField: string; 38 | }>; 39 | 40 | export type DecoratorFieldConfig = DecoratorConfig & T; 41 | 42 | export const decoratorFields: (keyof DecoratorConfig)[] = ["fromField"]; 43 | 44 | /** 45 | * Used to register the class property as a string field 46 | * @param configuration 47 | * @constructor 48 | * @example 49 | * export class UserSchema { 50 | * @StringField() 51 | * firstName: string 52 | * } 53 | */ 54 | export const StringField: ValidationField = 55 | (configuration?: DecoratorFieldConfig) => 56 | (target: object, propertyKey: string): void => { 57 | registerField(target, propertyKey, configuration, StringFieldProcessor); 58 | }; 59 | 60 | /** 61 | * Used to register the class property as an integer field 62 | * @param configuration 63 | * @constructor 64 | * @example 65 | * export class UserSchema { 66 | * @IntegerField() 67 | * followers: number 68 | * } 69 | */ 70 | export const IntegerField: ValidationField = 71 | (configuration?: DecoratorFieldConfig) => 72 | (target: object, propertyKey: string): void => { 73 | registerField(target, propertyKey, configuration, IntegerFieldProcessor); 74 | }; 75 | 76 | /** 77 | * Used to register the class property as a boolean field 78 | * @param configuration 79 | * @constructor 80 | * @example 81 | * export class UserSchema { 82 | * @StringField() 83 | * admin: boolean 84 | * } 85 | */ 86 | export const BooleanField: ValidationField = 87 | (configuration?: DecoratorFieldConfig) => 88 | (target: object, propertyKey: string): void => { 89 | registerField(target, propertyKey, configuration, BooleanFieldProcessor); 90 | }; 91 | 92 | /** 93 | * Used to register the class property as a json field 94 | * @param configuration 95 | * @constructor 96 | * @example 97 | * export class UserSchema { 98 | * @JsonField() 99 | * metadata: JsonValue 100 | * } 101 | */ 102 | export const JsonField: ValidationField = 103 | (configuration?: DecoratorFieldConfig) => 104 | (target: object, propertyKey: string): void => { 105 | registerField(target, propertyKey, configuration, JsonFieldProcessor); 106 | }; 107 | 108 | /** 109 | * Used to register the class property as a boolean field 110 | * @param configuration 111 | * @constructor 112 | * @example 113 | * export class EventSchema { 114 | * @FloatField() 115 | * price: number 116 | * } 117 | */ 118 | export const FloatField: ValidationField = 119 | (configuration?: DecoratorFieldConfig) => 120 | (target: object, propertyKey: string): void => { 121 | registerField(target, propertyKey, configuration, FloatFieldProcessor); 122 | }; 123 | 124 | /** 125 | * Used to register the class property as a date field 126 | * @param configuration 127 | * @constructor 128 | * @example 129 | * export class EventSchema { 130 | * @DateField() 131 | * startDate: Date 132 | * } 133 | */ 134 | export const DateField: ValidationField = 135 | (configuration?: DecoratorFieldConfig) => 136 | (target: object, propertyKey: string): void => { 137 | registerField(target, propertyKey, configuration, DateFieldProcessor); 138 | }; 139 | 140 | /** 141 | * Used to register the class property as an email field 142 | * @param configuration 143 | * @constructor 144 | * @example 145 | * export class UserSchema { 146 | * @EmailField() 147 | * email: string 148 | * } 149 | */ 150 | export const EmailField: ValidationField = 151 | (configuration?: DecoratorFieldConfig) => 152 | (target: object, propertyKey: string): void => { 153 | registerField(target, propertyKey, configuration, EmailFieldProcessor); 154 | }; 155 | 156 | export const ArrayField = 157 | ( 158 | configuration: DecoratorFieldConfig> 159 | ) => 160 | (target: object, propertyKey: string): void => { 161 | registerField(target, propertyKey, configuration, ArrayFieldProcessor); 162 | }; 163 | 164 | export type NestedFieldConfiguration, U> = { 165 | schema: SchemaClass; 166 | } & DecoratorConfig & 167 | FieldConfig; 168 | 169 | /** 170 | * Nested field that registers the field as nested 171 | * @param configuration 172 | * @constructor 173 | */ 174 | export const NestedField = 175 | , K>(configuration: NestedFieldConfiguration) => 176 | (target: object, propertyKey: string): void => { 177 | registerNestedSchemaField(target, propertyKey, configuration); 178 | }; 179 | 180 | /** 181 | * Used to register the class property as an enum field 182 | * @param configuration 183 | * @constructor 184 | * @example 185 | * export class UserSchema { 186 | * @EnumField({enum: Status}}) 187 | * status: Status 188 | * } 189 | */ 190 | 191 | export const EnumField: ValidationField = 192 | (configuration?: DecoratorFieldConfig) => 193 | (target: object, propertyKey: string): void => { 194 | registerField(target, propertyKey, configuration, EnumFieldProcessor); 195 | }; 196 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Dataffy Themis - The advanced validation library

2 | 3 | 4 |
5 |
6 |

7 | Themis is a validation and processing library that helps you always make sure your data is correct. 8 |
9 | · 10 | Dataffy 11 |

12 |
13 | 14 | 15 |
16 | Table of Contents 17 |
    18 |
  1. 19 | About The Project 20 |
  2. 21 |
  3. 22 | Getting Started 23 |
  4. 24 |
  5. Usage
  6. 25 |
26 |
27 | 28 | 29 | 30 | ## About The Project 31 | 32 | Themis is a flexible validation library built on 3 layers used for validation. Each upper layer is based on the previous layer and adds extra functionality. 33 | 34 | Layers from top to bottom: 35 | 36 | - `Schema + Fields` - Used to validate and transform an entire object. 37 | - `Processors` - Used to validate and transform a value. Uses validators behind the scene 38 | - `Validators` - Used to validate a value against some requirements (e.g: max value, max length, etc.) 39 | 40 | 41 | 42 | ## Getting Started 43 | 44 | #### NPM 45 | 46 | ```bash 47 | npm install @dataffy/themis 48 | ``` 49 | 50 | #### Yarn 51 | 52 | ```bash 53 | yarn add @dataffy/themis 54 | ``` 55 | 56 | 57 | 58 | ## Usage 59 | 60 | ### Schemas 61 | 62 | Schemas are used to validate and transform an object to the desired representation 63 | 64 | ```typescript 65 | import { 66 | DateField, 67 | IntegerField, 68 | FloatField, 69 | Schema, 70 | StringField, 71 | } from "@dataffy/themis"; 72 | 73 | export type CreateEventPayload = { 74 | name: string; 75 | price: number; 76 | date: Date; 77 | maxCapacity: number; 78 | }; 79 | 80 | export class CreateEventSchema extends Schema { 81 | @StringField({ 82 | maxLength: 100, 83 | }) 84 | name: string; 85 | 86 | @FloatField({ 87 | minValue: 5.5, 88 | }) 89 | price: number; 90 | 91 | @DateField({ 92 | formats: ["dd/MM/yyyy"], 93 | }) 94 | date: Date; 95 | 96 | @IntegerField({ 97 | required: false, 98 | nullable: true, 99 | fromField: "max_capacity", 100 | }) 101 | maxCapacity: number; 102 | } 103 | 104 | const payload = { 105 | name: "Dataffy Themis", 106 | price: 0, 107 | date: "01/01/2022", 108 | max_capacity: 40, 109 | }; 110 | const createUserSchema = new CreateUserSchema(payload); 111 | 112 | createUserSchema 113 | .validate() 114 | .then(() => 115 | console.log( 116 | "Validation was successful. Use .toData() to get the validated data" 117 | ) 118 | ) 119 | .catch((error) => console.log("Validation failed")); 120 | 121 | const validatedData = createUserSchema.toData(); 122 | ``` 123 | 124 | ### Fields 125 | 126 | Fields are decorators used on Schema properties to annotate how the field needs to be processed. Behind the scenes, the fields specify which Processor is used for the field type. 127 | 128 | Field Decorator Configuration: 129 | 130 | - `fromField` - Specifies from which field the value will be taken, processed and placed in the property name. e.g: Using a decorator with fromField: `first_name` on a property `firstName` will process the value and place it in the `firstName` property on the validated data 131 | 132 | Example implementation of a Decorator. This example can be used for creating custom decorators: 133 | 134 | ```typescript 135 | export const StringField: ValidationField = 136 | (configuration?: DecoratorFieldConfig) => 137 | (target: object, propertyKey: string): void => { 138 | registerField(target, propertyKey, configuration, StringFieldProcessor); 139 | }; 140 | ``` 141 | 142 | ### Nested Fields 143 | 144 | Nested fields allow specifying a Schema that is used for validating and transforming the nested object. 145 | 146 | ```typescript 147 | @NestedField({ schema: FileSchema }) 148 | profileImage: FileSchema; 149 | ``` 150 | 151 | ### Processors 152 | 153 | Processors are used to validate and transform a specific value into the desired one. 154 | 155 | Generic Field Configurations: 156 | 157 | - `required` - Specifies if the field is required. Default value is true 158 | - `nullable` - Specifies if the field is nullable. Default value is true 159 | - `validators` - Extra validators against which the value is checked 160 | 161 | Each field can have extra field configurations. 162 | 163 | | Field | Processor | Configuration | 164 | | ----------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 165 | | `@StringField()` | `StringFieldProcessor` |
  • `maxLength` - The max length allowed for the field
  • `minLength` - The min length allowed for the field
| 166 | | `@BooleanField()` | `BooleanFieldProcessor` | | 167 | | `@DateField()` | `DateFieldProcessor` |
  • `formats` - Array with the accepted string formats for the date
| 168 | | `@IntegerField()` | `IntegerFieldProcessor` |
  • `maxValue` - The max value allowed for the field
  • `minValue` - The min value allowed for the field
| 169 | | `@FloatField()` | `FloatFieldProcessor` |
  • `maxValue` - The max value allowed for the field
  • `minValue` - The min value allowed for the field
| 170 | | `@EmailField()` | `EmailFieldProcessor` | | 171 | | `@JsonField()` | `JsonFieldProcessor` | | 172 | | `@ArrayField()` | `ArrayFieldProcessor` |
  • `child` - The type of values the array has. It can be a Schema class or Processor class
  • `childConfig` - Used to specify the config for the child, if the child is a processor class
| 173 | 174 | Creating a custom processor: 175 | 176 | ```typescript 177 | import { FieldProcessor, MinValueValidator } from "@dataffy/themis"; 178 | 179 | export type CustomFieldConfig = FieldConfig & 180 | Partial<{ 181 | // Your field config 182 | }>; 183 | 184 | export class CustomProcessor extends FieldProcessor< 185 | CustomFieldConfig, 186 | number, 187 | number 188 | > { 189 | toInternalValue(data: number): number { 190 | // Validate value and transform it to expected response 191 | } 192 | 193 | initialiseValidators(): void { 194 | // Push validators into the validators property based on the configuration properties 195 | if (this.configuration.minValue) { 196 | this.validators.push(new MinValueValidator(this.configuration.minValue)); 197 | } 198 | } 199 | } 200 | ``` 201 | 202 | ### Validators 203 | 204 | Validators are the basic unit for the library. They are used for checking if a value matches the expected requirements. 205 | 206 | ```typescript 207 | const maxLength = 50; 208 | const validator = new MaxValueValidator(maxLength); 209 | validator.validate(30); 210 | ``` 211 | 212 | 213 | 214 | ## License 215 | 216 | Distributed under the ISC License. See `LICENSE` for more information. 217 | --------------------------------------------------------------------------------