├── .eslintignore ├── .gitignore ├── .eslintrc.json ├── .adonisrc.json ├── adonis-typings ├── index.ts ├── validator.ts ├── requests.ts └── shared.ts ├── japaFile.ts ├── .editorconfig ├── tsconfig.json ├── test ├── messages.spec.ts ├── classValidate.spec.ts ├── cases │ └── classes.ts └── validation.spec.ts ├── src ├── index.ts ├── decorators.ts └── utils.ts ├── package.json ├── providers └── ClassValidatorProvider.ts └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | build 2 | app -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | app 4 | .vscode -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:adonis/typescriptPackage", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /.adonisrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript": true, 3 | "providers": [ 4 | "@adonisjs/core" 5 | ] 6 | } -------------------------------------------------------------------------------- /adonis-typings/index.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | -------------------------------------------------------------------------------- /japaFile.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | import { configure } from "japa"; 3 | 4 | /** 5 | * Configure test runner 6 | */ 7 | configure({ 8 | files: ["test/**/*.spec.ts"], 9 | }); 10 | -------------------------------------------------------------------------------- /adonis-typings/validator.ts: -------------------------------------------------------------------------------- 1 | declare module "@ioc:Adonis/ClassValidator" { 2 | import { ValidateDecorator } from "@ioc:Adonis/ClassValidator/Shared"; 3 | import { 4 | rules, 5 | schema, 6 | validator, 7 | ValidationException, 8 | } from "@ioc:Adonis/Core/Validator"; 9 | 10 | const validate: ValidateDecorator; 11 | 12 | export { validate, schema, rules, validator, ValidationException }; 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.json] 12 | insert_final_newline = ignore 13 | 14 | [**.min.js] 15 | indent_style = ignore 16 | insert_final_newline = ignore 17 | 18 | [MakeFile] 19 | indent_style = space 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@adonisjs/mrm-preset/_tsconfig", 3 | "compilerOptions": { 4 | "resolveJsonModule": true, 5 | "target": "es6", 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "strict": true, 9 | "experimentalDecorators": true, 10 | "emitDecoratorMetadata": true, 11 | "noUnusedLocals": true, 12 | "noImplicitAny": false, 13 | "pretty": true, 14 | "lib": ["esnext"], 15 | "types": ["@types/node", "@adonisjs/core"] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /adonis-typings/requests.ts: -------------------------------------------------------------------------------- 1 | declare module "@ioc:Adonis/Core/Request" { 2 | import { ClassValidatorArg, Class } from "@ioc:Adonis/ClassValidator/Shared"; 3 | 4 | interface RequestContract { 5 | /** 6 | * Validate current request using a schema class. The data is 7 | * optional here, since request can pre-fill it for us. 8 | * 9 | * @param validatorClass Class to use foe validation. 10 | * @param args Custom config. 11 | */ 12 | classValidate( 13 | validatorClass: Class, 14 | args?: ClassValidatorArg 15 | ): Promise; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/messages.spec.ts: -------------------------------------------------------------------------------- 1 | import test from "japa"; 2 | import { Assert } from "japa/build/src/Assert"; 3 | import { User, NoSchema } from "./cases/classes"; 4 | import { getValidatorBag } from "../src/utils"; 5 | 6 | test.group("Validation Message", () => { 7 | test("doesn't get message on empty schema", (assert: Assert) => { 8 | assert.deepEqual(getValidatorBag(NoSchema).messages, {}); 9 | }); 10 | 11 | test("validation messages are generated accurately for nested class messages", (assert: Assert) => { 12 | assert.deepEqual(getValidatorBag(User).messages, { 13 | "profile.url": "Invalid URL specified.", 14 | "addresses.*.point.required": "Field is required.", 15 | "addresses.minLength": "Length must be at least 2.", 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { validate } from "./decorators"; 2 | export { getValidatorBag } from "./utils"; 3 | import { getValidatorBag } from "./utils"; 4 | import { plainToClass } from "class-transformer"; 5 | import { schema } from "@adonisjs/validator/build/src/Schema"; 6 | import { validator } from "@adonisjs/validator/build/src/Validator"; 7 | import { Class, ClassValidatorArg } from "@ioc:Adonis/ClassValidator/Shared"; 8 | 9 | export class ClassValidator { 10 | /** 11 | * Validate data using a class schema. 12 | * @param validatorClass Validator class. 13 | * @param data Data to validate. 14 | * @param args Validator config. 15 | * @returns Validated data. 16 | */ 17 | public static async validate( 18 | validatorClass: Class, 19 | data: any, 20 | args?: Omit 21 | ): Promise { 22 | const validatorBag = getValidatorBag(validatorClass); 23 | 24 | const validatedData = await validator.validate({ 25 | schema: schema.create(validatorBag.schema), 26 | cacheKey: validatorBag.key, 27 | data, 28 | ...args, 29 | }); 30 | 31 | return plainToClass(validatorClass, validatedData); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "adonis-class-validator", 3 | "version": "0.2.0", 4 | "author": "Tochukwu Nkemdilim", 5 | "license": "MIT", 6 | "description": "Class validator for adonis framework", 7 | "main": "build/providers/ClassValidatorProvider.js", 8 | "keywords": [ 9 | "adonis", 10 | "validation", 11 | "schema", 12 | "macros" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/tnkemdilim/adonis-class-validator.git" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/tnkemdilim/adonis-prometheus/issues" 20 | }, 21 | "homepage": "https://github.com/tnkemdilim/adonis-class-validator", 22 | "types": "./build/adonis-typings/index.d.ts", 23 | "files": [ 24 | "build/adonis-typings", 25 | "build/providers", 26 | "build/src" 27 | ], 28 | "scripts": { 29 | "pretest": "npm run lint && tsc --noEmit", 30 | "test": "node -r @adonisjs/assembler/build/register japaFile.ts", 31 | "mrm": "mrm --preset=@adonisjs/mrm-preset", 32 | "lint": "eslint . --ext=.ts", 33 | "clean": "rm -rf build", 34 | "compile": "npm run lint && npm run clean && tsc", 35 | "build": "npm run compile", 36 | "prepublishOnly": "npm run test && npm run build" 37 | }, 38 | "adonisjs": { 39 | "types": "adonis-class-validator", 40 | "providers": [ 41 | "@adonisjs/validator", 42 | "adonis-class-validator" 43 | ] 44 | }, 45 | "dependencies": { 46 | "class-transformer": "^0.4.0", 47 | "reflect-metadata": "^0.1.13" 48 | }, 49 | "peerDependencies": { 50 | "@adonisjs/validator": "^11.0.1" 51 | }, 52 | "devDependencies": { 53 | "@adonisjs/assembler": "^5.3.5", 54 | "@adonisjs/core": "^5.1.6", 55 | "@adonisjs/http-server": "^5.0.0", 56 | "@adonisjs/mrm-preset": "^3.0.0", 57 | "@adonisjs/require-ts": "^2.0.4", 58 | "@adonisjs/validator": "^11.0.4", 59 | "eslint": "^7.26.0", 60 | "eslint-config-prettier": "^8.3.0", 61 | "eslint-plugin-adonis": "^1.3.1", 62 | "japa": "^3.1.1", 63 | "luxon": "^1.27.0", 64 | "mrm": "^3.0.1", 65 | "typescript": "^4.2.4" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /test/classValidate.spec.ts: -------------------------------------------------------------------------------- 1 | import test from "japa"; 2 | import { DateTime } from "luxon"; 3 | import { ClassValidator } from "../src"; 4 | import { validate } from "../src/decorators"; 5 | import { Assert } from "japa/build/src/Assert"; 6 | import { AddressPoint, NoSchema } from "./cases/classes"; 7 | import { schema } from "@adonisjs/validator/build/src/Schema"; 8 | import { ValidationException } from "@adonisjs/validator/build/src/ValidationException"; 9 | 10 | test.group("ClassValidator", () => { 11 | test("ClassValidator.validate(...) formats and transforms correctly", async (assert: Assert) => { 12 | const dob = "2001-02-12"; 13 | const format = "yyyy-MM-dd"; 14 | 15 | class DateSchema { 16 | @validate(schema.date({ format })) 17 | public dob!: DateTime; 18 | } 19 | 20 | const actual = await ClassValidator.validate(DateSchema, { dob }); 21 | assert.deepEqual(actual, { dob: DateTime.fromFormat(dob, format) }); 22 | }); 23 | 24 | test("ClassValidator.validate(...) validates correctly", async (assert: Assert) => { 25 | const expected: AddressPoint = { uniqueId: "hello world" }; 26 | const actual = await ClassValidator.validate(AddressPoint, expected); 27 | assert.deepEqual(actual, expected); 28 | }); 29 | 30 | test("ClassValidator.validate(...) fails on invalid input validation", async (assert: Assert) => { 31 | assert.plan(1); 32 | 33 | try { 34 | const expected = { uniqueId: 2 }; 35 | await ClassValidator.validate(AddressPoint, expected); 36 | } catch (err) { 37 | assert.instanceOf(err, ValidationException); 38 | } 39 | }); 40 | 41 | test("ClassValidator.validate(...) fails on empty input validation", async (assert: Assert) => { 42 | assert.plan(1); 43 | 44 | try { 45 | await ClassValidator.validate(AddressPoint, {}); 46 | } catch (err) { 47 | assert.instanceOf(err, ValidationException); 48 | } 49 | }); 50 | 51 | test("ClassValidator.validate(...) succeeds with empty validation schema", async (assert: Assert) => { 52 | const actual = await ClassValidator.validate(NoSchema, { hello: "hi" }); 53 | assert.deepEqual(actual as any, {}); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /adonis-typings/shared.ts: -------------------------------------------------------------------------------- 1 | declare module "@ioc:Adonis/ClassValidator/Shared" { 2 | import { 3 | ArrayType, 4 | BooleanType, 5 | CustomMessages, 6 | DateType, 7 | EnumSetType, 8 | EnumType, 9 | FileType, 10 | NumberType, 11 | ObjectType, 12 | SchemaArray, 13 | SchemaLiteral, 14 | SchemaObject, 15 | StringType, 16 | TypedSchema, 17 | ValidatorNode, 18 | } from "@ioc:Adonis/Core/Validator"; 19 | 20 | /** 21 | * Class constructor. 22 | */ 23 | export type Class = new (...args: any) => T; 24 | 25 | /** 26 | * Nested schema function. 27 | */ 28 | export type NestedSchemaTypeFn = (nested: TypedSchema) => SchemaType; 29 | 30 | /** 31 | * Decorator function. 32 | */ 33 | export type DecoratorFn = (target: any, propertyKey: string) => void; 34 | 35 | /** 36 | * Class validator schema. 37 | */ 38 | export type ClassValidatorBag = { 39 | messages: CustomMessages; 40 | schema: TypedSchema; 41 | key: string; 42 | }; 43 | 44 | /** 45 | * Types that have members. 46 | */ 47 | export type MemberType = { 48 | t?: { [x: string]: any } | undefined; 49 | getTree(): SchemaLiteral | SchemaArray | SchemaObject; 50 | }; 51 | 52 | /** 53 | * Supported class validation base schemas (leverages default Adonis types). 54 | */ 55 | export type SchemaType = 56 | | ReturnType 57 | | ReturnType 58 | | ReturnType 59 | | ReturnType 60 | | ReturnType 61 | | ReturnType 62 | | ReturnType 63 | | ReturnType 64 | | ReturnType 65 | | MemberType; 66 | 67 | /** 68 | * Append an optional field to the type. 69 | */ 70 | type WithOptional = Omit & Partial>; 71 | 72 | /** 73 | * Class validator argument 74 | */ 75 | export type ClassValidatorArg = Omit< 76 | WithOptional, "data">, 77 | "schema" 78 | >; 79 | 80 | /** 81 | * Validate decorator. 82 | */ 83 | export type ValidateDecorator = (( 84 | schema: SchemaType, 85 | messages?: CustomMessages 86 | ) => DecoratorFn) & { 87 | nested: ValidateNestedDecorator; 88 | }; 89 | 90 | /** 91 | * Nested validate decorator. 92 | */ 93 | export type ValidateNestedDecorator = ( 94 | nested: Class, 95 | schema: NestedSchemaTypeFn, 96 | messages?: CustomMessages 97 | ) => DecoratorFn; 98 | } 99 | -------------------------------------------------------------------------------- /test/cases/classes.ts: -------------------------------------------------------------------------------- 1 | import { validate } from "../../src"; 2 | import { rules } from "@adonisjs/validator/build/src/Rules"; 3 | import { schema } from "@adonisjs/validator/build/src/Schema"; 4 | 5 | export class NoSchema { 6 | public name!: String; 7 | } 8 | 9 | export class AddressPoint { 10 | @validate(schema.string({}, [rules.required()])) 11 | public uniqueId!: String; 12 | } 13 | 14 | export class GrandParentClass { 15 | @validate(schema.number()) 16 | public totalSiblings!: number; 17 | } 18 | 19 | export class ParentClass extends GrandParentClass { 20 | @validate(schema.number()) 21 | public id!: number; 22 | 23 | @validate(schema.string()) 24 | public firstName!: string; 25 | 26 | @validate(schema.string()) 27 | public lastName!: string; 28 | } 29 | 30 | export class ChildA extends ParentClass { 31 | @validate(schema.string(), { hello: "required" }) 32 | public alias!: string; 33 | } 34 | 35 | export class ChildB extends ParentClass { 36 | @validate(schema.string()) 37 | public status!: string; 38 | } 39 | 40 | export class ChildC extends ParentClass { 41 | @validate(schema.string()) 42 | public signature!: string; 43 | } 44 | 45 | class Address { 46 | @validate( 47 | schema.string({ escape: true }, [rules.required(), rules.minLength(10)]) 48 | ) 49 | public street!: String; 50 | 51 | @validate(schema.enum(["nigeria", "france", "canada"], [rules.required()])) 52 | public country: String | undefined; 53 | 54 | @validate(schema.number([rules.required()])) 55 | public zipcode!: String; 56 | 57 | @validate.nested( 58 | AddressPoint, 59 | (addressPoint) => schema.object([rules.required()]).members(addressPoint), 60 | { required: "Field is required." } 61 | ) 62 | public point!: AddressPoint; 63 | 64 | @validate.nested(AddressPoint, (addressPoint) => 65 | schema.object.optional().members(addressPoint) 66 | ) 67 | public optionalPoint!: AddressPoint; 68 | } 69 | 70 | export class User { 71 | @validate(schema.string()) 72 | public username!: string; 73 | 74 | @validate(schema.string({}, [rules.url()]), { url: "Invalid URL specified." }) 75 | public profile!: string; 76 | 77 | @validate.nested( 78 | Address, 79 | (address) => 80 | schema 81 | .array([rules.minLength(2)]) 82 | .members(schema.object().members(address)), 83 | { minLength: "Length must be at least 2." } 84 | ) 85 | public addresses!: Address[]; 86 | 87 | @validate(schema.date({ format: "yyyy-MM-dd HH:mm:ss" })) 88 | public date!: Date[]; 89 | } 90 | -------------------------------------------------------------------------------- /providers/ClassValidatorProvider.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | import { plainToClass } from "class-transformer"; 3 | import { ApplicationContract } from "@ioc:Adonis/Core/Application"; 4 | import { RequestConstructorContract } from "@ioc:Adonis/Core/Request"; 5 | import { Class, ClassValidatorArg } from "@ioc:Adonis/ClassValidator/Shared"; 6 | 7 | /* 8 | |-------------------------------------------------------------------------- 9 | | Provider 10 | |-------------------------------------------------------------------------- 11 | | 12 | | Your application is not ready when this file is loaded by the framework. 13 | | Hence, the top level imports relying on the IoC container will not work. 14 | | You must import them inside the life-cycle methods defined inside 15 | | the provider class. 16 | | 17 | | @example: 18 | | 19 | | public async ready () { 20 | | const Database = (await import('@ioc:Adonis/Lucid/Database')).default 21 | | const Event = (await import('@ioc:Adonis/Core/Event')).default 22 | | Event.on('db:query', Database.prettyPrint) 23 | | } 24 | | 25 | */ 26 | export default class ClassValidatorProvider { 27 | public static needsApplication = true; 28 | constructor(protected app: ApplicationContract) {} 29 | 30 | public async boot() { 31 | this.bindClassValidator(); 32 | this.registerRequestMacro(); 33 | } 34 | 35 | /** 36 | * Bind the class validator to the IOC. 37 | */ 38 | private bindClassValidator() { 39 | const adonisValidator = this.app.container.use("Adonis/Core/Validator"); 40 | 41 | this.app.container.singleton("Adonis/ClassValidator", () => { 42 | return { 43 | validate: require("../src").validate, 44 | ...adonisValidator, 45 | }; 46 | }); 47 | } 48 | 49 | /** 50 | * Register the `classValidate(...)` macros to the Request instance. 51 | */ 52 | private registerRequestMacro() { 53 | const { schema } = this.app.container.use("Adonis/Core/Validator"); 54 | 55 | this.app.container.withBindings( 56 | ["Adonis/Core/Request"], 57 | (request: RequestConstructorContract) => { 58 | const { getValidatorBag } = require("../src"); 59 | 60 | request.macro("classValidate", async function classValidate< 61 | T 62 | >(this: any, validatorClass: Class, args?: ClassValidatorArg): Promise { 63 | const validatorBag = getValidatorBag(validatorClass); 64 | const data = await this.validate({ 65 | schema: schema.create(validatorBag.schema), 66 | cacheKey: validatorBag.key, 67 | ...args, 68 | }); 69 | 70 | return plainToClass(validatorClass, data); 71 | }); 72 | } 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/decorators.ts: -------------------------------------------------------------------------------- 1 | import { 2 | nested, 3 | schemaIsDate, 4 | schemaIsArray, 5 | getValidatorBag, 6 | transformMessages, 7 | } from "./utils"; 8 | import { 9 | Type as typeAnnotate, 10 | Transform as transformAnnotate, 11 | } from "class-transformer"; 12 | import { 13 | Class, 14 | SchemaType, 15 | ValidateDecorator, 16 | NestedSchemaTypeFn, 17 | } from "@ioc:Adonis/ClassValidator/Shared"; 18 | import { DateTime } from "luxon"; 19 | import { CustomMessages } from "@ioc:Adonis/Core/Validator"; 20 | 21 | /** 22 | * Validate a decorated field in a class. 23 | * @param schema Adonis validation schema for field. 24 | * @param messages Custom messages for field 25 | * @returns void 26 | */ 27 | export const validate: ValidateDecorator = ( 28 | schema: SchemaType, 29 | messages?: CustomMessages 30 | ) => { 31 | return function (target: any, propertyKey: string) { 32 | const validatorBag = getValidatorBag(target); 33 | validatorBag.schema[propertyKey] = schema as any; 34 | 35 | validatorBag.messages = { 36 | ...validatorBag.messages, 37 | ...(messages ? transformMessages(propertyKey, messages) : {}), 38 | }; 39 | 40 | if (schemaIsDate(schema)) { 41 | typeAnnotate(() => String)(target, propertyKey); 42 | transformAnnotate(({ value }) => DateTime.fromISO(value))( 43 | target, 44 | propertyKey 45 | ); 46 | } 47 | }; 48 | }; 49 | 50 | /** 51 | * Validated a field with complex type using a custom validator class. 52 | * @param validatorClass Validator class to use for field. 53 | * @param schema Adonis validation schema 54 | * @param messages Custom messages for field. 55 | * @returns void 56 | */ 57 | validate.nested = ( 58 | validatorClass: Class, 59 | schema: NestedSchemaTypeFn, 60 | messages?: CustomMessages 61 | ) => { 62 | return function (target: any, propertyKey: string) { 63 | const validatorBag = getValidatorBag(target); 64 | const validatorSchema = schema(nested(validatorClass)); 65 | validatorBag.schema[propertyKey] = validatorSchema as any; 66 | 67 | const isArray = schemaIsArray(validatorSchema); 68 | const validatorClassMessages = getValidatorBag(validatorClass).messages; 69 | 70 | validatorBag.messages = { 71 | ...validatorBag.messages, 72 | // Messages added directly on field. 73 | ...(messages ? transformMessages(propertyKey, messages) : {}), 74 | // Messages added on nested field with reference to current field. 75 | ...transformMessages(propertyKey, validatorClassMessages, isArray), 76 | }; 77 | 78 | // Initialize nested property for type casting to a class. 79 | typeAnnotate(() => validatorClass)(target, propertyKey); 80 | }; 81 | }; 82 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClassValidatorBag, 3 | Class, 4 | SchemaType, 5 | } from "@ioc:Adonis/ClassValidator/Shared"; 6 | import { TypedSchema, CustomMessages } from "@ioc:Adonis/Core/Validator"; 7 | 8 | /** 9 | * Get the typed schema of a nested validator class. 10 | * @param nestedClass Validator class 11 | * @returns Typed schema of validator class. 12 | */ 13 | export const nested = (nestedClass: Class): TypedSchema => 14 | getValidatorBag(nestedClass).schema; 15 | 16 | /** 17 | * Get the validation bag of a target. 18 | * 19 | * If none exists, target is initialized with an empty bag. 20 | * @param target Target 21 | * @returns Validation Schema. 22 | */ 23 | export const getValidatorBag = (target: any): ClassValidatorBag => { 24 | if (target.constructor.name === "Object") { 25 | return { key: "", messages: {}, schema: {} }; 26 | } 27 | 28 | const prototype = target?.prototype || target; 29 | const metadataKey = `@${prototype.constructor.name}.classValidatorBag`; 30 | const metadata = Reflect.getMetadata(metadataKey, prototype); 31 | if (metadata) return metadata; 32 | 33 | const key = `${nonce()}.${prototype.constructor.name}`; 34 | const parentBag = getValidatorBag(Object.getPrototypeOf(target)); 35 | const validatorBag = { 36 | schema: Object.assign({}, parentBag.schema), 37 | messages: Object.assign({}, parentBag.messages), 38 | key, 39 | }; 40 | 41 | Reflect.defineMetadata(metadataKey, validatorBag, prototype); 42 | return Reflect.getMetadata(metadataKey, prototype); 43 | }; 44 | 45 | /** 46 | * Converts messages field name into a format compatible with Adonis. 47 | * @param fieldName Field name 48 | * @param messages Messages rules for the field specified 49 | * @returns Transformed messages 50 | */ 51 | export const transformMessages = ( 52 | fieldName: string, 53 | messages: CustomMessages, 54 | isArray = false 55 | ) => 56 | Object.entries(messages).reduce((prev, [key, value]) => { 57 | prev[`${fieldName}${isArray ? ".*." : "."}${key}`] = value; 58 | return prev; 59 | }, {}); 60 | 61 | /** 62 | * Check if a schema is an array schema. 63 | * @param schema Schema to check. 64 | * @returns If schema is an array. 65 | */ 66 | export const schemaIsArray = (schema: SchemaType) => 67 | "getTree" in schema && (schema as any).getTree().type === "array"; 68 | 69 | /** 70 | * Check if a schema is a date schema. 71 | * @param schema Schema to check. 72 | * @returns If schema is date. 73 | */ 74 | export const schemaIsDate = (schema: SchemaType) => { 75 | return "getTree" in schema && (schema as any).getTree().subtype === "date"; 76 | }; 77 | 78 | /** 79 | * Generate a unique and ever increasing nonce of length 15. 80 | * @returns Unique number 81 | */ 82 | export const nonce = ((length: number = 15) => { 83 | let [last, repeat] = [Date.now(), 0]; 84 | 85 | return () => { 86 | const now = Math.pow(10, 2) * +new Date(); 87 | if (now === last) repeat++; 88 | else [last, repeat] = [now, 0]; 89 | 90 | const s = (now + repeat).toString(); 91 | return +s.substr(s.length - length); 92 | }; 93 | })(); 94 | -------------------------------------------------------------------------------- /test/validation.spec.ts: -------------------------------------------------------------------------------- 1 | import test from "japa"; 2 | import { Assert } from "japa/build/src/Assert"; 3 | import { getValidatorBag } from "../src/utils"; 4 | import { rules } from "@adonisjs/validator/build/src/Rules"; 5 | import { schema } from "@adonisjs/validator/build/src/Schema"; 6 | import { 7 | User, 8 | NoSchema, 9 | ChildA, 10 | ChildB, 11 | ChildC, 12 | ParentClass, 13 | GrandParentClass, 14 | } from "./cases/classes"; 15 | 16 | test.group("Class Validation", () => { 17 | test("doesn't validate on empty schema", (assert: Assert) => { 18 | assert.deepEqual( 19 | schema.create(getValidatorBag(NoSchema).schema), 20 | schema.create({}) 21 | ); 22 | }); 23 | 24 | test("validation rules are correct for inherited classes", (assert: Assert) => { 25 | const grandParentRules = { 26 | totalSiblings: schema.number(), 27 | }; 28 | 29 | const parentRules = { 30 | ...grandParentRules, 31 | id: schema.number(), 32 | firstName: schema.string(), 33 | lastName: schema.string(), 34 | }; 35 | 36 | assert.deepEqual( 37 | schema.create(getValidatorBag(GrandParentClass).schema), 38 | schema.create(grandParentRules) 39 | ); 40 | 41 | assert.deepEqual( 42 | schema.create(getValidatorBag(ParentClass).schema), 43 | schema.create(parentRules) 44 | ); 45 | 46 | assert.deepEqual( 47 | schema.create(getValidatorBag(ChildA).schema), 48 | schema.create({ 49 | ...parentRules, 50 | alias: schema.string(), 51 | }) 52 | ); 53 | 54 | assert.deepEqual( 55 | schema.create(getValidatorBag(ChildB).schema), 56 | schema.create({ 57 | ...parentRules, 58 | status: schema.string(), 59 | }) 60 | ); 61 | 62 | assert.deepEqual( 63 | schema.create(getValidatorBag(ChildC).schema), 64 | schema.create({ 65 | ...parentRules, 66 | signature: schema.string(), 67 | }) 68 | ); 69 | }); 70 | 71 | test("validation rules are correct for nested class rules", (assert: Assert) => { 72 | assert.deepEqual( 73 | schema.create(getValidatorBag(User).schema), 74 | schema.create({ 75 | username: schema.string(), 76 | profile: schema.string({}, [rules.url()]), 77 | addresses: schema.array([rules.minLength(2)]).members( 78 | schema.object().members({ 79 | street: schema.string({ escape: true }, [ 80 | rules.required(), 81 | rules.minLength(10), 82 | ]), 83 | country: schema.enum( 84 | ["nigeria", "france", "canada"], 85 | [rules.required()] 86 | ), 87 | zipcode: schema.number([rules.required()]), 88 | point: schema.object([rules.required()]).members({ 89 | uniqueId: schema.string({}, [rules.required()]), 90 | }), 91 | optionalPoint: schema.object.optional().members({ 92 | uniqueId: schema.string({}, [rules.required()]), 93 | }), 94 | }) 95 | ), 96 | date: schema.date({ format: "yyyy-MM-dd HH:mm:ss" }), 97 | }) 98 | ); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Adonis Class Validator 2 | 3 | Adonis Class Validator provides a means to validate a request data using a class schema. 4 | 5 | On successful validation, the data returned from validation is an instance of the class schema used to validate the request. 6 | 7 | ## 🎁 Features 8 | 9 | - Convenient nesting of class rules. 10 | - Easy declaration of custom messages. 11 | - In-built caching of class schema. 12 | - Validate with existing V5 validator. 13 | - Support for all V5 validator features (`custom messages`, `creating custom rules`, `profiling`, `reporting` etc). 14 | 15 | ## 📦 Installing 16 | 17 | Simply run the following commands on your shell 18 | 19 | ```bash 20 | npm install adonis-class-validator 21 | node ace invoke adonis-class-validator 22 | ``` 23 | 24 | ## 📌 Example 25 | 26 | > We're making use of all the schemas and rules baked into Adonis. 😃 27 | 28 | ```ts 29 | // SignupPayload.ts 30 | import { validate, schema, rules } from "@ioc:Adonis/ClassValidator"; 31 | 32 | class SignupPayload { 33 | @validate(schema.string({}, [rules.required(), rules.email()]), { 34 | required: "Field {{name}} is required.", 35 | email: "Invalid email address", 36 | }) 37 | public email!: string; 38 | } 39 | ``` 40 | 41 | ```ts 42 | // SignupController.ts 43 | import { SignupPayload } from "App/Validators"; 44 | import { HttpContextContract } from "@ioc:Adonis/Core/HttpContext"; 45 | 46 | class SignupController { 47 | public async index({ request }: HttpContextContract) { 48 | const payload = await request.classValidate(SignupPayload); 49 | console.log(payload instanceof SignupPayload); // true 50 | } 51 | } 52 | ``` 53 | 54 | > For more examples, check [here](./test/cases/classes.ts) 55 | 56 | ## Standalone Validator 57 | 58 | If you want to make use of the validator class schema to validate any form of data (outside the controller), you can easily rely on the standalone `ClassValidator.validate(...)` helper function. 59 | 60 | > NOTE: If the validation fails, an instance of ValidationException is thrown. 61 | 62 | ```ts 63 | import { 64 | ClassValidator, 65 | ValidationException, 66 | } from "@ioc:Adonis/ClassValidator"; 67 | 68 | async function sendEmail() { 69 | try { 70 | const payload = await ClassValidator.validate(SignupPayload, { 71 | email: "hello@stallsone.com", 72 | }); 73 | } catch (err) { 74 | // if validation error occurs, `err` is an instance of `ValidationException` 75 | } 76 | } 77 | ``` 78 | 79 | ## ⚓️ Going Deeper 80 | 81 | There are currently 2 decorators supported for validation. They include: 82 | 83 | - `@validate()` : To validate primitive schemas such as `string`, `boolean`, `number`, `date`, `enum/enumSet`, `file`, `array([string|boolean|number|date|enum|file])`. 84 | - `@validate.nested()`: To nest class validator schemas through `array` and `object`. 85 | 86 | ### Nested Validation 87 | 88 | To nest a class validator schema, simply rely on the `@validate.nested()` decorator. It requires: 89 | 90 | - The `class validator schema` of the nested field. 91 | - A `callback` that whose: 92 | - `parameter`: is a adonis member equivalent of the validator schema. 93 | - `return type`: is the adonis schema to use to validate the nested field (which is either an `array` or `object`). 94 | - An `optional` custom message object. 95 | 96 | > Custom messages also support interpolation e.g. `Field {{name}} is required`. 97 | 98 | ```ts 99 | import { validate } from "@ioc:Adonis/ClassValidator"; 100 | 101 | class Address { 102 | @validate(schema.number([ 103 | rules.unique({ table: "users", column: "email" }) 104 | ]), 105 | { unique: 'Field must be unique.' }) 106 | public id!: number; 107 | } 108 | 109 | class User { 110 | @validate.nested( 111 | Address, 112 | (address) => schema 113 | .array([rules.minLength(2)]) 114 | .members(schema.object().members(address)), 115 | { minLength: "Field {{name}} must contain at least 2 addresses." } 116 | ) 117 | public addresses!: Address[]; 118 | ``` 119 | 120 | #### Custom Messages 121 | 122 | When `request.classValidate(...)` is called against the `User` schema [above](#nested-validation), the custom message generated and used for the failed validation will be: 123 | 124 | ```json 125 | { 126 | "addresses.minLength": "Field addresses must contain at least 2 addresses.", 127 | "addresses.*.unique": "Field must be unique." 128 | } 129 | ``` 130 | 131 | > As far as the decorated field schema is a `schema.array()` with a `.members(...) of nested validation class`, it infers that it as the deep matching (`.*.`) patter matcher. 132 | 133 | ### Empty Classes 134 | 135 | If no property in a class was decorated with `validate()`, an empty data will be returned (where each field will be undefined). 136 | 137 | ```ts 138 | // Notice there's no schema rule. 139 | class UserPayload { 140 | public firstname!: string; 141 | } 142 | 143 | // UserController.ts 144 | export default class UsersController { 145 | public async index({ request }: HttpContextContract) { 146 | const data = await request.classValidate(UserPayload); 147 | 148 | /** 149 | * Payload wasn't validated because the class doesn't 150 | * have a property decorated with a schema. 151 | */ 152 | console.log(data instanceof SignupPayload); // true 153 | 154 | /** 155 | * Data is empty because no property has a validator schema decorator. 156 | */ 157 | console.log(payload); // {} 158 | } 159 | } 160 | ``` 161 | 162 | ## 📝 Contributing 163 | 164 | If you find any issue, bug or missing feature, please kindly create an issue or submit a pull request. 165 | 166 | ## 🔖 License 167 | 168 | Adonis Class Validator is open-sourced software under MIT license. 169 | --------------------------------------------------------------------------------