├── .prettierignore ├── .yarnrc ├── test ├── global.d.ts ├── tsconfig.json ├── @types │ └── jest.d.ts ├── unit │ ├── messages.test.ts │ ├── decorators │ │ ├── link.test.ts │ │ ├── func.test.ts │ │ ├── boolean.test.ts │ │ ├── date.test.ts │ │ ├── number.test.ts │ │ ├── object.test.ts │ │ ├── array.test.ts │ │ └── string.test.ts │ ├── testUtil.ts │ ├── inheritance.test.ts │ ├── core.test.ts │ ├── joiful.test.ts │ ├── examples.test.ts │ └── validation.test.ts └── helpers │ └── setup.ts ├── img ├── logo-icon-245x245.png ├── logo-icon-245x245.afdesign ├── logo-icon-with-text-800x245.png └── logo-icon-with-text-800x245.afdesign ├── .huskyrc.json ├── src ├── tsconfig.json ├── decorators │ ├── link.ts │ ├── object.ts │ ├── function.ts │ ├── date.ts │ ├── boolean.ts │ ├── number.ts │ ├── common.ts │ ├── array.ts │ ├── string.ts │ └── any.ts ├── index.ts ├── joiful.ts ├── core.ts └── validation.ts ├── .editorconfig ├── docs ├── package.json └── yarn.lock ├── support ├── updatePackageVersion.ts └── package.ts ├── tsconfig.json ├── .gitignore ├── RELEASE_GUIDE.md ├── .github └── workflows │ ├── build.yml │ └── docs.yml ├── commitlint.config.json ├── package.json ├── tslint.json ├── README.md ├── CHANGELOG.md └── jest.config.js /.prettierignore: -------------------------------------------------------------------------------- 1 | **/* 2 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | save-exact true 2 | -------------------------------------------------------------------------------- /test/global.d.ts: -------------------------------------------------------------------------------- 1 | import '@types/jest'; 2 | -------------------------------------------------------------------------------- /img/logo-icon-245x245.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joiful-ts/joiful/HEAD/img/logo-icon-245x245.png -------------------------------------------------------------------------------- /img/logo-icon-245x245.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joiful-ts/joiful/HEAD/img/logo-icon-245x245.afdesign -------------------------------------------------------------------------------- /img/logo-icon-with-text-800x245.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joiful-ts/joiful/HEAD/img/logo-icon-with-text-800x245.png -------------------------------------------------------------------------------- /img/logo-icon-with-text-800x245.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joiful-ts/joiful/HEAD/img/logo-icon-with-text-800x245.afdesign -------------------------------------------------------------------------------- /.huskyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "yarn run check", 4 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS --config ./commitlint.config.json" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": false, 4 | "outDir": "../dist", 5 | }, 6 | "extends": "../tsconfig.json", 7 | "files": [ 8 | "./index.ts" 9 | ], 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 4 11 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "joiful-docs", 3 | "version": "1.0.0", 4 | "description": "API Docs for joiful", 5 | "license": "MIT", 6 | "private": true, 7 | "scripts": { 8 | "typedoc": "typedoc --out dist/ ../src/index.ts" 9 | }, 10 | "dependencies": { 11 | "typedoc": "0.21.6" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "typeRoots": [ 4 | "./@types", 5 | "../node_modules/@types" 6 | ] 7 | }, 8 | "extends": "../tsconfig.json", 9 | "include": [ 10 | "./helpers/setup.ts", 11 | "./unit/**/*.test.ts" 12 | ], 13 | "sourceMap": true, 14 | } 15 | -------------------------------------------------------------------------------- /support/updatePackageVersion.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import { rootPath } from 'get-root-path'; 3 | import * as path from 'path'; 4 | 5 | const sourcePackageFileName = path.join(rootPath, 'package.json'); 6 | const destinationPackageFileName = path.join(rootPath, 'dist/package.json'); 7 | 8 | const sourcePackage = JSON.parse(fs.readFileSync(sourcePackageFileName, 'utf-8')); 9 | const destinationPackage = JSON.parse(fs.readFileSync(destinationPackageFileName, 'utf-8')); 10 | 11 | destinationPackage.version = sourcePackage.version; 12 | 13 | fs.writeFileSync(destinationPackageFileName, JSON.stringify(destinationPackage, null, ' '), { encoding: 'utf-8' }); 14 | -------------------------------------------------------------------------------- /test/@types/jest.d.ts: -------------------------------------------------------------------------------- 1 | import Joi = require('joi'); 2 | import { Validator } from '../../src/validation'; 3 | 4 | interface ToBeValidOptions { 5 | Class?: { new(...args: any[]): any }; 6 | validator?: Validator; 7 | } 8 | 9 | declare global { 10 | namespace jest { 11 | interface Matchers { 12 | toBeValid(options?: ToBeValidOptions): void; 13 | toMatchSchemaMap(expectedSchemaMap: Joi.SchemaMap): void; 14 | } 15 | 16 | interface Expect { 17 | toBeValid(options?: ToBeValidOptions): void; 18 | toMatchSchemaMap(expectedSchemaMap: Joi.Schema): void; 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "3.4.5", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "emitDecoratorMetadata": true, 6 | "experimentalDecorators": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "module": "commonjs", 9 | "moduleResolution": "node", 10 | "noEmit": true, 11 | "noEmitOnError": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noImplicitReturns": true, 14 | "noImplicitThis": false, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "sourceMap": true, 18 | "strict": true, 19 | "target": "es6" 20 | }, 21 | "exclude": [ 22 | "dist", 23 | "node_modules" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IntelliJ project files 2 | .idea 3 | *.iml 4 | out 5 | gen 6 | 7 | # VS Code 8 | .vscode 9 | 10 | # Node 11 | # Logs 12 | logs 13 | *.log 14 | npm-debug.log* 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | 27 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (http://nodejs.org/api/addons.html) 34 | build/Release 35 | 36 | # Dependency directory 37 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 38 | node_modules 39 | 40 | # Other 41 | typings/ 42 | dist/ 43 | /publish/ 44 | *.orig 45 | docs/dist/ 46 | -------------------------------------------------------------------------------- /test/unit/messages.test.ts: -------------------------------------------------------------------------------- 1 | import { Validator } from '../../src/validation'; 2 | import { Joiful } from '../../src/joiful'; 3 | 4 | describe('messages', () => { 5 | let jf: Joiful; 6 | 7 | beforeEach(() => { 8 | jf = new Joiful(); 9 | }); 10 | 11 | it('default message', () => { 12 | let validator = new Validator(); 13 | 14 | class VerificationCode { 15 | @jf.string().exactLength(6) 16 | public code!: string; 17 | } 18 | 19 | let instance = new VerificationCode(); 20 | instance.code = 'abc'; 21 | 22 | let result = validator.validate(instance); 23 | expect(result).toHaveProperty('error'); 24 | expect(result.error).not.toBeNull(); 25 | expect(result.error!.details[0].message).toEqual('"code" length must be 6 characters long'); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /test/unit/decorators/link.test.ts: -------------------------------------------------------------------------------- 1 | import { testConstraint } from '../testUtil'; 2 | //import { array, string, link } from '../../../src'; 3 | import * as jf from '../../../src'; 4 | 5 | describe('link', () => { 6 | it('uninitialized link will throw', () => { 7 | expect(() => { 8 | class Node { 9 | @jf.link() 10 | child?: Node; 11 | } 12 | return Node; 13 | }).toThrow(new Error('Invalid reference key: ')); 14 | }); 15 | 16 | describe('link named schema (explicit)', () => { 17 | testConstraint( 18 | () => { 19 | class Foo { 20 | @jf.number().integer() 21 | a?: number; 22 | 23 | @jf.link('a') 24 | b?: number; 25 | } 26 | return Foo; 27 | }, 28 | [{ a: 1, b: 1 }], 29 | [{ a: 1, b: 3.14 }], 30 | ); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /RELEASE_GUIDE.md: -------------------------------------------------------------------------------- 1 | ### Guide to publishing a new release 2 | 3 | Publishing a new release is done manually, not via CI. Do not change the package version yourself. This is done automatically based on commit messages. 4 | 5 | Follow these steps: 6 | 7 | 1. Create feature / bug fix PR 8 | - **Do not manually change version or change log in PR** 9 | - Merge PR into master 10 | 11 | 2. Create a new release: 12 | 13 | `yarn run release` 14 | 15 | This will: 16 | - Checkout `master` 17 | - Pull down the latest code 18 | - Build 19 | - Run linter 20 | - Run tests 21 | - Update change log with commits since last release 22 | - Automatically bump the package version 23 | - Will look through commits since previous release and increment patch, minor or major version based on commit messages. 24 | - Commit changes 25 | 26 | 3. Publish: 27 | 28 | `yarn run ship-it` 29 | 30 | This will: 31 | - Push up latest changes (from previous step), including version tag 32 | - Publishes to npm 33 | 34 | -------------------------------------------------------------------------------- /src/decorators/link.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { TypedPropertyDecorator } from '../core'; 3 | import { ModifierProviders, createPropertyDecorator, JoifulOptions } from './common'; 4 | import { AnySchemaModifiers, getAnySchemaModifierProviders } from './any'; 5 | 6 | export interface LinkSchemaModifiers extends AnySchemaModifiers { 7 | } 8 | 9 | export function getLinkSchemaModifierProviders(getJoi: () => typeof Joi) { 10 | const result: ModifierProviders = { 11 | ...getAnySchemaModifierProviders(getJoi), 12 | /* TODO: ref & concat */ 13 | }; 14 | return result; 15 | } 16 | 17 | export interface LinkSchemaDecorator extends 18 | LinkSchemaModifiers, 19 | TypedPropertyDecorator { 20 | } 21 | 22 | export const createLinkPropertyDecorator = ( 23 | reference: string | undefined, 24 | joifulOptions: JoifulOptions, 25 | ): LinkSchemaDecorator => { 26 | return createPropertyDecorator()( 27 | ({ joi }) => joi.link(reference), 28 | getLinkSchemaModifierProviders, 29 | joifulOptions, 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: push 4 | 5 | jobs: 6 | check: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [10.x, 12.x, 14.x, 15.x, 16.x] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v2 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | 21 | - name: Get Node.js version 22 | id: get-node-version 23 | run: echo "::set-output name=node-version::$(node --version)" 24 | 25 | - name: Install Yarn 26 | if: ${{ env.ACT }} 27 | run: npm i yarn -g 28 | 29 | - name: Get yarn cache directory path 30 | id: get-yarn-cache-path 31 | run: | 32 | yarn cache dir 33 | echo "::set-output name=yarn-cache-path::$(yarn cache dir)" 34 | 35 | - uses: actions/cache@v2 36 | id: yarn-cache 37 | with: 38 | path: ${{ steps.get-yarn-cache-path.outputs.yarn-cache-path }} 39 | key: ${{ runner.os }}_node-${{ steps.get-node-version.outputs.node-version }}_yarn-${{ hashFiles('**/yarn.lock') }} 40 | 41 | - name: 📦 Install Dependencies 42 | run: yarn install --frozen-lockfile 43 | 44 | - name: 🔨 Build 45 | run: yarn build 46 | 47 | - name: 👕 Lint 48 | run: yarn lint 49 | 50 | - name: 🔬 Tests 51 | run: yarn test 52 | -------------------------------------------------------------------------------- /commitlint.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "body-leading-blank": [ 4 | 1, 5 | "always" 6 | ], 7 | "footer-leading-blank": [ 8 | 1, 9 | "always" 10 | ], 11 | "header-max-length": [ 12 | 2, 13 | "always", 14 | 72 15 | ], 16 | "scope-case": [ 17 | 2, 18 | "always", 19 | "lower-case" 20 | ], 21 | "subject-case": [ 22 | 2, 23 | "never", 24 | [ 25 | "sentence-case", 26 | "start-case", 27 | "pascal-case", 28 | "upper-case" 29 | ] 30 | ], 31 | "subject-empty": [ 32 | 2, 33 | "never" 34 | ], 35 | "subject-full-stop": [ 36 | 2, 37 | "never", 38 | "." 39 | ], 40 | "type-case": [ 41 | 2, 42 | "always", 43 | "lower-case" 44 | ], 45 | "type-empty": [ 46 | 2, 47 | "never" 48 | ], 49 | "type-enum": [ 50 | 2, 51 | "always", 52 | [ 53 | "build", 54 | "chore", 55 | "ci", 56 | "docs", 57 | "feat", 58 | "fix", 59 | "perf", 60 | "refactor", 61 | "revert", 62 | "style", 63 | "test" 64 | ] 65 | ] 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Joiful } from './joiful'; 2 | import { Validator } from './validation'; 3 | 4 | export { AnySchemaDecorator } from './decorators/any'; 5 | export { ArrayPropertyDecoratorOptions, ArraySchemaDecorator } from './decorators/array'; 6 | export { BooleanSchemaDecorator } from './decorators/boolean'; 7 | export { DateSchemaDecorator } from './decorators/date'; 8 | export { FunctionSchemaDecorator } from './decorators/function'; 9 | export { LinkSchemaDecorator } from './decorators/link'; 10 | export { NumberSchemaDecorator } from './decorators/number'; 11 | export { ObjectSchemaDecorator } from './decorators/object'; 12 | export { StringSchemaDecorator } from './decorators/string'; 13 | 14 | export { Validator, MultipleValidationError, ValidationResult, isValidationPass, isValidationFail } from './validation'; 15 | export { Joiful } from './joiful'; 16 | 17 | export const DEFAULT_INSTANCE = new Joiful(); 18 | 19 | const DEFAULT_VALIDATOR = new Validator(); 20 | const { validate, validateAsClass, validateArrayAsClass } = DEFAULT_VALIDATOR; 21 | 22 | const { 23 | any, 24 | array, 25 | boolean, 26 | date, 27 | func, 28 | joi, 29 | link, 30 | number, 31 | object, 32 | string, 33 | validateParams, 34 | getSchema, 35 | hasSchema, 36 | } = DEFAULT_INSTANCE; 37 | 38 | export { 39 | any, 40 | array, 41 | boolean, 42 | date, 43 | func, 44 | joi, 45 | link, 46 | number, 47 | object, 48 | string, 49 | validate, 50 | validateAsClass, 51 | validateArrayAsClass, 52 | validateParams, 53 | getSchema, 54 | hasSchema, 55 | }; 56 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish API Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - 65-api-docs 8 | 9 | jobs: 10 | build-and-publish-docs: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Use Node.js 16 17 | uses: actions/setup-node@v2 18 | with: 19 | node-version: 16 20 | 21 | - name: Get Node.js version 22 | id: get-node-version 23 | run: echo "::set-output name=node-version::$(node --version)" 24 | 25 | - name: Install Yarn 26 | if: ${{ env.ACT }} 27 | run: npm i yarn -g 28 | 29 | - name: Get yarn cache directory path 30 | id: get-yarn-cache-path 31 | run: | 32 | yarn cache dir 33 | echo "::set-output name=yarn-cache-path::$(yarn cache dir)" 34 | 35 | - uses: actions/cache@v2 36 | id: yarn-cache 37 | with: 38 | path: ${{ steps.get-yarn-cache-path.outputs.yarn-cache-path }} 39 | key: ${{ runner.os }}_node-${{ steps.get-node-version.outputs.node-version }}_yarn-${{ hashFiles('**/yarn.lock') }} 40 | 41 | - name: 📦 Install Dependencies 42 | run: | 43 | yarn install --frozen-lockfile 44 | cd docs 45 | yarn install --frozen-lockfile 46 | 47 | - name: 📚 Docs - 🔨 Build 48 | working-directory: docs 49 | run: yarn typedoc 50 | 51 | - name: 📚 Docs - 🚀 Publish 52 | uses: JamesIves/github-pages-deploy-action@4.1.4 53 | with: 54 | branch: gh-pages # The branch the action should deploy to. 55 | folder: docs/dist # The folder the action should deploy. 56 | -------------------------------------------------------------------------------- /src/decorators/object.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { TypedPropertyDecorator, getJoiSchema, AnyClass } from '../core'; 3 | import { ModifierProviders, JoifulOptions, createPropertyDecorator } from './common'; 4 | import { AnySchemaModifiers, getAnySchemaModifierProviders } from './any'; 5 | 6 | export interface ObjectSchemaModifiers extends AnySchemaModifiers { 7 | keys(keyShemaMap: Joi.SchemaMap | ((joi: typeof Joi) => Joi.SchemaMap)): this; 8 | } 9 | 10 | export function getObjectSchemaModifierProviders(getJoi: () => typeof Joi) { 11 | const result: ModifierProviders = { 12 | ...getAnySchemaModifierProviders(getJoi), 13 | keys: (keyShemaMap) => ({ schema }) => schema.keys( 14 | (typeof keyShemaMap === 'function') ? 15 | keyShemaMap(getJoi()) : 16 | keyShemaMap, 17 | ), 18 | }; 19 | return result; 20 | } 21 | 22 | export interface ObjectSchemaDecorator extends 23 | ObjectSchemaModifiers, 24 | TypedPropertyDecorator { 25 | } 26 | 27 | export interface ObjectPropertyDecoratorOptions { 28 | objectClass?: AnyClass; 29 | } 30 | 31 | export const createObjectPropertyDecorator = ( 32 | options: ObjectPropertyDecoratorOptions | undefined, 33 | joifulOptions: JoifulOptions, 34 | ): ObjectSchemaDecorator => ( 35 | createPropertyDecorator()( 36 | ({ joi, target, propertyKey }) => { 37 | const elementType = (options && options.objectClass) ? 38 | options.objectClass : 39 | Reflect.getMetadata('design:type', target, propertyKey); 40 | 41 | const schema = ( 42 | (elementType && elementType !== Object) && getJoiSchema(elementType, joi) 43 | ) || joi.object(); 44 | 45 | return schema; 46 | }, 47 | getObjectSchemaModifierProviders, 48 | joifulOptions, 49 | ) 50 | ); 51 | -------------------------------------------------------------------------------- /src/decorators/function.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { TypedPropertyDecorator } from '../core'; 3 | import { ModifierProviders, createPropertyDecorator, JoifulOptions } from './common'; 4 | import { AnySchemaModifiers, getAnySchemaModifierProviders } from './any'; 5 | 6 | export interface FunctionSchemaModifiers extends AnySchemaModifiers { 7 | /** 8 | * Specifies the arity of the function. 9 | * @param argumentCount The number of arguments the function should contain. 10 | */ 11 | arity(argumentCount: number): this; 12 | 13 | /** 14 | * Specifies the maximum arity of the function. 15 | * @param maxArgumentCount The maximum number of arguments the function should contain. 16 | */ 17 | maxArity(maxArgumentCount: number): this; 18 | 19 | /** 20 | * Specifies the minimum arity of the function. 21 | * @param minArgumentCount The minimum number of arguments the function should contain. 22 | */ 23 | minArity(minArgumentCount: number): this; 24 | } 25 | 26 | export function getFunctionSchemaModifierProviders(getJoi: () => typeof Joi) { 27 | const result: ModifierProviders = { 28 | ...getAnySchemaModifierProviders(getJoi), 29 | arity: (argumentCount: number) => ({ schema }) => schema.arity(argumentCount), 30 | maxArity: (maxArgumentCount: number) => ({ schema }) => schema.maxArity(maxArgumentCount), 31 | minArity: (minArgumentCount: number) => ({ schema }) => schema.minArity(minArgumentCount), 32 | }; 33 | return result; 34 | } 35 | 36 | export interface FunctionSchemaDecorator extends 37 | FunctionSchemaModifiers, 38 | TypedPropertyDecorator { 39 | } 40 | 41 | export const createFunctionPropertyDecorator = (joifulOptions: JoifulOptions): FunctionSchemaDecorator => ( 42 | createPropertyDecorator()( 43 | ({ joi }) => joi.func(), 44 | getFunctionSchemaModifierProviders, 45 | joifulOptions, 46 | ) 47 | ); 48 | -------------------------------------------------------------------------------- /src/decorators/date.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { TypedPropertyDecorator } from '../core'; 3 | import { ModifierProviders, createPropertyDecorator, JoifulOptions } from './common'; 4 | import { AnySchemaModifiers, getAnySchemaModifierProviders } from './any'; 5 | 6 | export interface DateSchemaModifiers extends AnySchemaModifiers { 7 | /** 8 | * Coerces value to a Date object from string value in valid ISO 8601 date format. 9 | */ 10 | iso(): this; 11 | 12 | /** 13 | * Specifies the maximum value. 14 | * @param limit The maximum value. 15 | */ 16 | max(limit: number | 'now' | string | Date): this; 17 | 18 | /** 19 | * Specifies the minimum value. 20 | * @param limit The minimum value. 21 | */ 22 | min(limit: number | 'now' | string | Date): this; 23 | 24 | /** 25 | * Coerces value to a Date object from a timestamp interval from Unix/Javascript Time 26 | * @param type unix or javaScript 27 | */ 28 | timestamp(type?: 'unix' | 'javascript'): this; 29 | } 30 | 31 | export function getDateSchemaModifierProviders(getJoi: () => typeof Joi) { 32 | const result: ModifierProviders = { 33 | ...getAnySchemaModifierProviders(getJoi), 34 | iso: () => ({ schema }) => schema.iso(), 35 | max: (limit: number | 'now' | string | Date) => ({ schema }) => schema.max(limit as any), 36 | min: (limit: number | 'now' | string | Date) => ({ schema }) => schema.min(limit as any), 37 | timestamp: (type?: 'unix' | 'javascript') => ({ schema }) => schema.timestamp(type), 38 | }; 39 | return result; 40 | } 41 | 42 | export interface DateSchemaDecorator extends 43 | DateSchemaModifiers, 44 | TypedPropertyDecorator { 45 | } 46 | 47 | export const createDatePropertyDecorator = (joifulOptions: JoifulOptions): DateSchemaDecorator => ( 48 | createPropertyDecorator()( 49 | ({ joi }) => joi.date(), 50 | getDateSchemaModifierProviders, 51 | joifulOptions, 52 | ) 53 | ); 54 | -------------------------------------------------------------------------------- /test/unit/decorators/func.test.ts: -------------------------------------------------------------------------------- 1 | import { testConstraint } from '../testUtil'; 2 | import { func } from '../../../src'; 3 | 4 | describe('func', () => { 5 | testConstraint( 6 | () => { 7 | class Calculator { 8 | @func() 9 | pi!: () => number; 10 | } 11 | return Calculator; 12 | }, 13 | [ 14 | { pi: () => Math.PI }, 15 | { pi: () => { } }, 16 | { pi: (...args: any[]) => args }, 17 | {}, 18 | ], 19 | [ 20 | { pi: Math.PI as any }, 21 | ], 22 | ); 23 | 24 | describe('arity', () => { 25 | testConstraint( 26 | () => { 27 | class Calculator { 28 | @func().arity(2) 29 | add?: (v1: number, v2: number) => number; 30 | } 31 | return Calculator; 32 | }, 33 | [{ add: (v1: number, v2: number) => v1 + v2 }], 34 | [ 35 | { add: (v1: number) => v1 }, 36 | { add: ((v1: number, v2: number, v3: number) => v1 + v2 + v3) as any }, 37 | ], 38 | ); 39 | }); 40 | 41 | describe('minArity and maxArity', () => { 42 | type AddFunction = (v1: number, v2: number) => number; 43 | type CurriableAddFunction = (v1: number) => (v2: number) => number; 44 | 45 | testConstraint( 46 | () => { 47 | class Calculator { 48 | @func().minArity(1).maxArity(2) 49 | add?: AddFunction | CurriableAddFunction; 50 | } 51 | return Calculator; 52 | }, 53 | [ 54 | { add: (v1: number) => (v2: number) => v1 + v2 }, 55 | { add: (v1: number, v2: number) => v1 + v2 }, 56 | ], 57 | [ 58 | { add: () => 0 }, 59 | { add: ((v1: number, v2: number, v3: number) => v1 + v2 + v3) as any }, 60 | ], 61 | ); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /support/package.ts: -------------------------------------------------------------------------------- 1 | import { copyFile, mkdirp, readFile, writeFile } from 'fs-extra'; 2 | import { dirname, join } from 'path'; 3 | import { rootPath } from 'get-root-path'; 4 | 5 | interface Dependencies { 6 | [name: string]: string; 7 | } 8 | 9 | interface Scripts { 10 | [name: string]: string; 11 | } 12 | 13 | interface PackageJson { 14 | name: string; 15 | version: string; 16 | description: string; 17 | license: string; 18 | main: string; 19 | types: string; 20 | private?: boolean; 21 | scripts?: Scripts; 22 | dependencies?: Dependencies; 23 | devDependencies?: Dependencies; 24 | peerDependencies?: Dependencies; 25 | repository?: string; 26 | author?: string; 27 | tags?: string; 28 | } 29 | 30 | async function readRootPackageJson(): Promise { 31 | return JSON.parse(await readFile(join(rootPath, 'package.json'), 'utf8')); 32 | } 33 | 34 | async function packageForDistribution() { 35 | const distPath = join(rootPath, 'dist'); 36 | 37 | const packageJson = await readRootPackageJson(); 38 | 39 | // Make it publishable 40 | delete packageJson.private; 41 | 42 | // Reference these from the root of the dist directory 43 | packageJson.main = packageJson.main.replace(/dist\//g, ''); 44 | packageJson.types = packageJson.types.replace(/dist\//g, ''); 45 | delete packageJson.scripts; 46 | delete packageJson.devDependencies; 47 | 48 | // Write it out to the dist directory 49 | await writeFile(join(distPath, 'package.json'), JSON.stringify(packageJson, null, 2)); 50 | 51 | // Copy some other files to publish 52 | const filesToCopy = [ 53 | 'README.md', 54 | // 'images/logo.png', 55 | ]; 56 | await Promise.all( 57 | filesToCopy.map(async (relativePath) => { 58 | const absoluteSourceFileName = join(rootPath, relativePath); 59 | const absoluteDestFileName = join(distPath, relativePath); 60 | await mkdirp(dirname(absoluteDestFileName)); 61 | await copyFile(absoluteSourceFileName, absoluteDestFileName); 62 | }), 63 | ); 64 | } 65 | 66 | if (require.main === module) { 67 | packageForDistribution() 68 | .then(() => process.stdout.write('Package complete\n')) 69 | .catch((err) => process.stdout.write(`Package failed: ${err.message || err}\n`)); 70 | } 71 | -------------------------------------------------------------------------------- /test/helpers/setup.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { omit } from 'lodash'; 3 | import { getJoi, getJoiSchema } from '../../src/core'; 4 | import { Validator } from '../../src/validation'; 5 | import { ToBeValidOptions } from '../@types/jest'; 6 | 7 | const tryToStringify = (value: any) => { 8 | try { 9 | return JSON.stringify(value, null, ' '); 10 | } catch (err) { 11 | return null; 12 | } 13 | }; 14 | 15 | expect.extend({ 16 | toBeValid(candidate: any, options: ToBeValidOptions) { 17 | const validator = (options && options.validator) || new Validator(); 18 | const Class = options && options.Class; 19 | const result = Class ? 20 | validator.validateAsClass(candidate, Class) : 21 | validator.validate(candidate); 22 | 23 | const pass = result.error === null; 24 | // tslint:disable-next-line:no-invalid-this 25 | const isNot = this.isNot; 26 | const errorMessage = result.error && (result.error.message || result.error); 27 | 28 | const candidateAsString = tryToStringify(candidate); 29 | 30 | const message = 31 | `expected candidate to ${isNot ? 'fail' : 'pass'} validation` + 32 | (!candidateAsString ? '' : `:\n\n ${candidateAsString.replace(/\n/gm, '\n ')}\n\n${errorMessage}`.trim()); 33 | 34 | return { 35 | pass, 36 | message: () => message, 37 | }; 38 | }, 39 | toMatchSchemaMap(Class: any, expectedSchemaMap: Joi.SchemaMap, options?: { joi?: typeof Joi }) { 40 | const SCHEMA_PROPERTIES_TO_IGNORE = ['$_super']; 41 | 42 | const trimSchema = (schema: Joi.ObjectSchema | undefined) => 43 | schema && omit(schema, SCHEMA_PROPERTIES_TO_IGNORE); 44 | 45 | // tslint:disable-next-line:no-invalid-this 46 | const isNot = this.isNot; 47 | let pass = false; 48 | let message = `expected Class to ${isNot ? 'not ' : ''}have schema matching expected schema`; 49 | const joi = getJoi(options); 50 | const schema = getJoiSchema(Class, joi); 51 | const expectedSchema = joi.object().keys(expectedSchemaMap); 52 | 53 | try { 54 | expect(trimSchema(schema)).toEqual(trimSchema(expectedSchema)); 55 | pass = true; 56 | } catch { 57 | } 58 | 59 | return { 60 | pass, 61 | message: () => message, 62 | }; 63 | }, 64 | }); 65 | -------------------------------------------------------------------------------- /src/decorators/boolean.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { TypedPropertyDecorator } from '../core'; 3 | import { ModifierProviders, JoifulOptions, createPropertyDecorator } from './common'; 4 | import { AnySchemaModifiers, getAnySchemaModifierProviders } from './any'; 5 | 6 | export interface BooleanSchemaModifiers extends AnySchemaModifiers { 7 | /** 8 | * Allows for additional values to be considered valid booleans by converting them to false during validation. 9 | * Requires the validation convert option to be true. 10 | * String comparisons are by default case insensitive, see boolean.insensitive() to change this behavior. 11 | */ 12 | falsy(value: string | number, ...values: Array): this; 13 | 14 | /** 15 | * Allows the values provided to truthy and falsy as well as the "true" and "false" default conversion 16 | * (when not in strict() mode) to be matched in a case insensitive manner. 17 | * @param enabled Optional parameter defaulting to true which allows you 18 | * to reset the behavior of sensitive by providing a falsy value 19 | */ 20 | sensitive(enabled?: boolean): this; 21 | 22 | /** 23 | * Allows for additional values to be considered valid booleans by converting them to true during validation. 24 | * Requires the validation convert option to be true. 25 | * String comparisons are by default case insensitive, see boolean.insensitive() to change this behavior. 26 | */ 27 | truthy(value: string | number, ...values: Array): this; 28 | } 29 | 30 | export function getBooleanSchemaModifierProviders(getJoi: () => typeof Joi) { 31 | const result: ModifierProviders = { 32 | ...getAnySchemaModifierProviders(getJoi), 33 | falsy: (value: string | number, ...values: Array) => 34 | ({ schema }) => schema.falsy(value, ...values), 35 | sensitive: (enabled = true) => ({ schema }) => schema.sensitive(enabled), 36 | truthy: (value: string | number, ...values: Array) => 37 | ({ schema }) => schema.truthy(value, ...values), 38 | }; 39 | return result; 40 | } 41 | 42 | export interface BooleanSchemaDecorator extends 43 | BooleanSchemaModifiers, 44 | TypedPropertyDecorator { 45 | } 46 | 47 | export const createBooleanPropertyDecorator = (joifulOptions: JoifulOptions): BooleanSchemaDecorator => ( 48 | createPropertyDecorator()( 49 | ({ joi }) => joi.boolean(), 50 | getBooleanSchemaModifierProviders, 51 | joifulOptions, 52 | ) 53 | ); 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "joiful", 3 | "version": "3.0.2", 4 | "description": "TypeScript Declarative Validation. Decorate your class properties to validate them using Joi.", 5 | "license": "MIT", 6 | "repository": "https://github.com/joiful-ts/joiful", 7 | "private": true, 8 | "author": "Laurence Dougal Myers ", 9 | "contributors": [ 10 | "Ben (https://github.com/codeandcats)", 11 | "Laurence Dougal Myers " 12 | ], 13 | "keywords": [ 14 | "class", 15 | "constrain", 16 | "constraint", 17 | "declarative", 18 | "decorator", 19 | "decorators", 20 | "joi", 21 | "model", 22 | "schema", 23 | "tsdv", 24 | "tsdv-joi", 25 | "typescript", 26 | "validate", 27 | "validator", 28 | "validators", 29 | "validation" 30 | ], 31 | "engines": { 32 | "node": ">=v10.22.0", 33 | "npm": ">=3.10.10", 34 | "yarn": ">=1.13.0" 35 | }, 36 | "main": "dist/index.js", 37 | "types": "dist/index.d.ts", 38 | "scripts": { 39 | "prebuild": "yarn run clean", 40 | "build": "tsc -p ./src && ts-node ./support/package.ts", 41 | "check": "yarn run build && yarn run lint && yarn test", 42 | "clean": "rimraf ./dist", 43 | "lint": "tslint -c ./tslint.json --project ./tsconfig.json --format stylish", 44 | "release": "git checkout master && git pull origin master && yarn run check && standard-version && ts-node ./support/updatePackageVersion.ts", 45 | "ship-it": "git push --follow-tags origin master && cd ./dist && yarn publish && cd .. && yarn run clean", 46 | "test": "jest", 47 | "watch": "tsc -p tsconfig.json -w --noEmit" 48 | }, 49 | "dependencies": { 50 | "joi": "17.3.0" 51 | }, 52 | "devDependencies": { 53 | "@commitlint/cli": "11.0.0", 54 | "@commitlint/prompt-cli": "11.0.0", 55 | "@types/fs-extra": "9.0.7", 56 | "@types/jest": "26.0.20", 57 | "@types/lodash": "4.14.168", 58 | "@types/node": "12.12.29", 59 | "@types/reflect-metadata": "0.1.0", 60 | "@types/rimraf": "3.0.0", 61 | "case": "1.6.3", 62 | "flatted": "3.1.1", 63 | "fs-extra": "9.1.0", 64 | "get-root-path": "2.0.2", 65 | "husky": "4.3.8", 66 | "jest": "26.6.3", 67 | "jest-helpers": "3.1.1", 68 | "lodash": "4.17.21", 69 | "reflect-metadata": "0.1.13", 70 | "rimraf": "3.0.2", 71 | "standard-version": "9.1.1", 72 | "ts-jest": "26.5.2", 73 | "ts-node": "9.1.1", 74 | "tslint": "6.1.3", 75 | "typescript": "4.1.3", 76 | "typestrict": "1.0.2" 77 | }, 78 | "peerDependencies": { 79 | "reflect-metadata": "0.1.13" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/decorators/number.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { TypedPropertyDecorator } from '../core'; 3 | import { ModifierProviders, JoifulOptions, createPropertyDecorator } from './common'; 4 | import { AnySchemaModifiers, getAnySchemaModifierProviders } from './any'; 5 | 6 | export interface NumberSchemaModifiers extends AnySchemaModifiers { 7 | /** 8 | * Specifies that the value must be greater than limit. 9 | * @param limit The amount that the value must be greater than. 10 | */ 11 | greater(limit: number): this; 12 | 13 | /** 14 | * Requires the number to be an integer (no floating point). 15 | */ 16 | integer(): this; 17 | 18 | /** 19 | * Specifies that the value must be less than limit. 20 | * @param limit The amount that the value must be less than. 21 | */ 22 | less(limit: number): this; 23 | 24 | /** 25 | * Specifies the maximum value. 26 | * @param limit The maximum value. 27 | */ 28 | max(limit: number): this; 29 | 30 | /** 31 | * Specifies the minimum value. 32 | * @param limit The minimum value. 33 | */ 34 | min(limit: number): this; 35 | 36 | /** 37 | * Specifies that a value must be a multiple of base. 38 | * @param base 39 | */ 40 | multiple(base: number): this; 41 | 42 | /** 43 | * Requires the number to be negative. 44 | */ 45 | negative(): this; 46 | 47 | /** 48 | * Requires the number to be positive. 49 | */ 50 | positive(): this; 51 | 52 | /** 53 | * Specifies the maximum number of decimal places. 54 | * @param limit The maximum number of decimal places allowed. 55 | */ 56 | precision(limit: number): this; 57 | } 58 | 59 | export function getNumberSchemaModifierProviders(getJoi: () => typeof Joi) { 60 | const result: ModifierProviders = { 61 | ...getAnySchemaModifierProviders(getJoi), 62 | greater: (limit: number) => ({ schema }) => schema.greater(limit), 63 | integer: () => ({ schema }) => schema.integer(), 64 | less: (limit: number) => ({ schema }) => schema.less(limit), 65 | max: (limit: number) => ({ schema }) => schema.max(limit), 66 | min: (limit: number) => ({ schema }) => schema.min(limit), 67 | multiple: (base: number) => ({ schema }) => schema.multiple(base), 68 | negative: () => ({ schema }) => schema.negative(), 69 | positive: () => ({ schema }) => schema.positive(), 70 | precision: (limit: number) => ({ schema }) => schema.precision(limit), 71 | }; 72 | return result; 73 | } 74 | 75 | export interface NumberSchemaDecorator extends 76 | NumberSchemaModifiers, 77 | TypedPropertyDecorator { 78 | } 79 | 80 | export const createNumberPropertyDecorator = (joifulOptions: JoifulOptions): NumberSchemaDecorator => ( 81 | createPropertyDecorator()( 82 | ({ joi }) => joi.number(), 83 | getNumberSchemaModifierProviders, 84 | joifulOptions, 85 | ) 86 | ); 87 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": "typestrict", 4 | "jsRules": {}, 5 | "rules": { 6 | "arrow-parens": true, 7 | "ban-ts-ignore": true, 8 | "import-spacing": true, 9 | "indent": { 10 | "options": [ 11 | "spaces", 12 | 4 13 | ] 14 | }, 15 | "max-line-length": { 16 | "options": { 17 | "limit": 120 18 | } 19 | }, 20 | "no-debugger": true, 21 | "no-console": true, 22 | "new-parens": true, 23 | "only-arrow-functions": { 24 | "options": [ 25 | "allow-declarations", 26 | "allow-named-functions" 27 | ] 28 | }, 29 | "no-consecutive-blank-lines": [ 30 | true, 31 | 2 32 | ], 33 | "no-irregular-whitespace": true, 34 | "no-trailing-whitespace": { 35 | "options": [ 36 | "ignore-template-strings" 37 | ] 38 | }, 39 | "no-unused-variable": false, 40 | "number-literal-format": true, 41 | "quotemark": { 42 | "options": [ 43 | "single", 44 | "avoid-template", 45 | "avoid-escape" 46 | ] 47 | }, 48 | "semicolon": [ 49 | true, 50 | "always" 51 | ], 52 | "space-before-function-paren": { 53 | "options": { 54 | "anonymous": "always", 55 | "asyncArrow": "always", 56 | "constructor": "never", 57 | "method": "never", 58 | "named": "never" 59 | } 60 | }, 61 | "trailing-comma": { 62 | "options": { 63 | "multiline": "always", 64 | "singleline": "never" 65 | } 66 | }, 67 | "typedef-whitespace": [ 68 | true, 69 | { 70 | "call-signature": "nospace", 71 | "index-signature": "nospace", 72 | "parameter": "nospace", 73 | "property-declaration": "nospace", 74 | "variable-declaration": "nospace" 75 | }, 76 | { 77 | "call-signature": "onespace", 78 | "index-signature": "onespace", 79 | "parameter": "onespace", 80 | "property-declaration": "onespace", 81 | "variable-declaration": "onespace" 82 | } 83 | ], 84 | "whitespace": { 85 | "options": [ 86 | "check-branch", 87 | "check-decl", 88 | "check-operator", 89 | "check-module", 90 | "check-separator", 91 | "check-rest-spread", 92 | "check-type", 93 | "check-type-operator", 94 | "check-preblock" 95 | ] 96 | } 97 | }, 98 | "rulesDirectory": [] 99 | } 100 | -------------------------------------------------------------------------------- /test/unit/decorators/boolean.test.ts: -------------------------------------------------------------------------------- 1 | import { testConstraint } from '../testUtil'; 2 | import { boolean } from '../../../src'; 3 | 4 | describe('boolean', () => { 5 | testConstraint( 6 | () => { 7 | class MarketingOptIn { 8 | @boolean() 9 | joinMailingList?: boolean; 10 | } 11 | return MarketingOptIn; 12 | }, 13 | [{ joinMailingList: true }, { joinMailingList: false }], 14 | [{ joinMailingList: 'yep' as any }], 15 | ); 16 | 17 | describe('truthy', () => { 18 | testConstraint( 19 | () => { 20 | class MarketingOptIn { 21 | @boolean().truthy('yes').truthy('yeah', 'yeppers') 22 | joinMailingList?: boolean; 23 | } 24 | return MarketingOptIn; 25 | }, 26 | [ 27 | { joinMailingList: true }, 28 | { joinMailingList: false }, 29 | { joinMailingList: 'yes' as any }, 30 | { joinMailingList: 'yeah' as any }, 31 | { joinMailingList: 'yeppers' as any }, 32 | ], 33 | [{ joinMailingList: 'fo shizzle my nizzle' as any }], 34 | ); 35 | }); 36 | 37 | describe('falsy', () => { 38 | testConstraint( 39 | () => { 40 | class MarketingOptIn { 41 | @boolean().falsy('no').falsy('nah', 'nope') 42 | joinMailingList?: boolean; 43 | } 44 | return MarketingOptIn; 45 | }, 46 | [ 47 | { joinMailingList: true }, 48 | { joinMailingList: false }, 49 | { joinMailingList: 'no' as any }, 50 | { joinMailingList: 'nah' as any }, 51 | { joinMailingList: 'nope' as any }, 52 | ], 53 | [{ joinMailingList: 'no way jose' as any }], 54 | ); 55 | }); 56 | 57 | describe('insensitive', () => { 58 | testConstraint( 59 | () => { 60 | class MarketingOptIn { 61 | @boolean().truthy('y').falsy('n').sensitive(false) 62 | joinMailingList?: boolean; 63 | } 64 | return MarketingOptIn; 65 | }, 66 | [ 67 | { joinMailingList: true }, 68 | { joinMailingList: false }, 69 | { joinMailingList: 'y' as any }, 70 | { joinMailingList: 'Y' as any }, 71 | { joinMailingList: 'n' as any }, 72 | { joinMailingList: 'N' as any }, 73 | ], 74 | [{ joinMailingList: 'no' as any }], 75 | ); 76 | 77 | testConstraint( 78 | () => { 79 | class MarketingOptIn { 80 | @boolean().truthy('y').falsy('n').sensitive() 81 | joinMailingList?: boolean; 82 | } 83 | return MarketingOptIn; 84 | }, 85 | [ 86 | { joinMailingList: true }, 87 | { joinMailingList: false }, 88 | { joinMailingList: 'y' as any }, 89 | { joinMailingList: 'n' as any }, 90 | ], 91 | [ 92 | { joinMailingList: 'Y' as any }, 93 | { joinMailingList: 'N' as any }, 94 | ], 95 | ); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/joiful.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { JoifulOptions } from './decorators/common'; 3 | import { createAnyPropertyDecorator } from './decorators/any'; 4 | import { createArrayPropertyDecorator, ArrayPropertyDecoratorOptions } from './decorators/array'; 5 | import { createBooleanPropertyDecorator } from './decorators/boolean'; 6 | import { createDatePropertyDecorator } from './decorators/date'; 7 | import { createFunctionPropertyDecorator } from './decorators/function'; 8 | import { createLinkPropertyDecorator } from './decorators/link'; 9 | import { createNumberPropertyDecorator } from './decorators/number'; 10 | import { createObjectPropertyDecorator, ObjectPropertyDecoratorOptions } from './decorators/object'; 11 | import { createStringPropertyDecorator } from './decorators/string'; 12 | import { Validator, createValidatePropertyDecorator } from './validation'; 13 | import { AnyClass, checkJoiIsCompatible, getJoi, getJoiSchema } from './core'; 14 | 15 | export class Joiful { 16 | constructor(private readonly options: JoifulOptions = {}) { 17 | checkJoiIsCompatible(options.joi); 18 | } 19 | 20 | get joi() { 21 | return getJoi(this.options); 22 | } 23 | 24 | /** 25 | * Property decorator that allows the property to be any type. 26 | */ 27 | any = () => createAnyPropertyDecorator(this.options); 28 | 29 | /** 30 | * Property decorator that constrains the property to be an array. 31 | */ 32 | array = (options?: ArrayPropertyDecoratorOptions) => createArrayPropertyDecorator(options, this.options); 33 | 34 | /** 35 | * Property decorator that constrains the property to be a boolean. 36 | */ 37 | boolean = () => createBooleanPropertyDecorator(this.options); 38 | 39 | /** 40 | * Property decorator that constrains the property to be a Date. 41 | */ 42 | date = () => createDatePropertyDecorator(this.options); 43 | 44 | /** 45 | * Property decorator that constrains the property to be a Function. 46 | */ 47 | func = () => createFunctionPropertyDecorator(this.options); 48 | 49 | /** 50 | * Property decorator that constrains the property to another schema. 51 | * This allows defining classes that reference themself. e.g. 52 | * 53 | * @example 54 | * class TreeNode { 55 | * @jf.string().required() 56 | * title: string; 57 | * 58 | * @jf.array().items((joi) => joi.link('...')) 59 | * children: TreeNode[]; 60 | * } 61 | */ 62 | link = (ref?: string) => createLinkPropertyDecorator(ref, this.options); 63 | 64 | /** 65 | * Property decorator that constrains the property to be a number. 66 | */ 67 | number = () => createNumberPropertyDecorator(this.options); 68 | 69 | /** 70 | * Property decorator that constrains the property to be an object. 71 | */ 72 | object = (options?: ObjectPropertyDecoratorOptions) => createObjectPropertyDecorator(options, this.options); 73 | 74 | /** 75 | * Property decorator that constrains the property to be a string. 76 | */ 77 | string = () => createStringPropertyDecorator(this.options); 78 | 79 | /** 80 | * Method decorator that validates the parameters passed into the method. 81 | */ 82 | validateParams = (options?: { validator?: Validator }) => createValidatePropertyDecorator(options); 83 | 84 | /** 85 | * Returns the Joi schema associated with a class or undefined if there isn't one. 86 | */ 87 | getSchema = (Class: AnyClass): Joi.ObjectSchema | undefined => { 88 | return getJoiSchema(Class, this.joi); 89 | } 90 | 91 | /** 92 | * Returns whether the given class has a Joi schema associated with it 93 | */ 94 | hasSchema = (Class: AnyClass) => Boolean(this.getSchema(Class)); 95 | } 96 | -------------------------------------------------------------------------------- /src/decorators/common.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { getJoi, TypedPropertyDecorator, MapAllowUnions, StringOrSymbolKey, updateWorkingSchema } from '../core'; 3 | 4 | export class NotImplemented extends Error { 5 | constructor(feature: string) { 6 | super(`${feature} is not implemented`); 7 | } 8 | } 9 | 10 | export type LabelProvider = >( 11 | propertyKey: TKey, 12 | target: TClass, 13 | ) => string | undefined | null; 14 | 15 | export interface JoifulOptions { 16 | joi?: typeof Joi; 17 | labelProvider?: LabelProvider | undefined; 18 | } 19 | 20 | type MethodNames = { 21 | [K in keyof T]: T extends (...args: any[]) => any ? K : never; 22 | }; 23 | 24 | interface DecoratorContext { 25 | schema: TSchema; 26 | options?: JoifulOptions; 27 | } 28 | 29 | export type ModifierProviders< 30 | TSchema extends Joi.Schema, 31 | TModifiers, 32 | > = { 33 | [K in keyof MethodNames]: TModifiers[K] extends (...args: any[]) => any ? 34 | (...args: Parameters) => (context: DecoratorContext) => TSchema : 35 | never; 36 | }; 37 | 38 | export interface GetBaseSchemaFunction { 39 | , TKey extends StringOrSymbolKey>(options: { 40 | joi: typeof Joi, 41 | target: TClass, 42 | propertyKey: TKey, 43 | }): TSchema; 44 | } 45 | 46 | function forEachModifierProvider( 47 | modifierProviders: ModifierProviders, 48 | callback: ( 49 | modifierName: string, 50 | modifierProvider: (...args: any[]) => (context: DecoratorContext) => TSchema, 51 | ) => void, 52 | ) { 53 | Object 54 | .keys(modifierProviders) 55 | .forEach((modifierName) => { 56 | const modifierProvider = modifierProviders[modifierName]; 57 | callback(modifierName, modifierProvider); 58 | }); 59 | } 60 | 61 | function indexable(value: T): T & { [key: string]: TValue } { 62 | return value as any; 63 | } 64 | 65 | export type PropertyDecorator = ( 66 | TypedPropertyDecorator & 67 | TSchemaModifiers 68 | ); 69 | 70 | export const createPropertyDecorator = () => ( 71 | ( 72 | getBaseSchema: GetBaseSchemaFunction, 73 | getModifierProviders: (getJoi: () => typeof Joi) => ModifierProviders, 74 | options: JoifulOptions, 75 | ) => { 76 | let schema: TSchema | undefined; 77 | let modifiersToApply: ((context: DecoratorContext) => TSchema)[] = []; 78 | const modifierProviders = getModifierProviders(() => getJoi(options)); 79 | 80 | const decoratorUntyped: TypedPropertyDecorator = (target, propertyKey) => { 81 | const joi = getJoi(options); 82 | 83 | schema = getBaseSchema({ joi, target, propertyKey }); 84 | 85 | if (options.labelProvider) { 86 | const label = options.labelProvider(propertyKey, target); 87 | if (typeof label === 'string') { 88 | schema = schema.label(label) as TSchema; 89 | } 90 | } 91 | 92 | modifiersToApply.forEach((modifierToApply) => { 93 | schema = modifierToApply({ schema: schema!, options }); 94 | }); 95 | 96 | updateWorkingSchema(target, propertyKey, schema); 97 | }; 98 | 99 | const decorator = decoratorUntyped as PropertyDecorator; 100 | 101 | forEachModifierProvider(modifierProviders, (modifierName, modifierProvider) => { 102 | indexable(decoratorUntyped)[modifierName] = (...args: any[]) => { 103 | modifiersToApply.push(modifierProvider(...args)); 104 | return decorator; 105 | }; 106 | }); 107 | 108 | return decorator; 109 | } 110 | ); 111 | -------------------------------------------------------------------------------- /test/unit/testUtil.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { rootPath } from 'get-root-path'; 3 | import * as path from 'path'; 4 | import * as fs from 'fs'; 5 | import { Validator } from '../../src/validation'; 6 | import { parseVersionString } from '../../src/core'; 7 | 8 | export function notNil(value: T): Exclude { 9 | if (value === null || value === undefined) { 10 | throw new Error('Unexpected nil value'); 11 | } 12 | return value as Exclude; 13 | } 14 | 15 | export function testConstraint( 16 | classFactory: () => { new(...args: any[]): T }, 17 | valid: T[], 18 | invalid?: T[], 19 | validationOptions?: Joi.ValidationOptions, 20 | ) { 21 | const validator = new Validator(validationOptions); 22 | 23 | it('should validate successful candidates', () => { 24 | // tslint:disable-next-line: no-inferred-empty-object-type 25 | const Class = classFactory(); 26 | for (let val of valid) { 27 | expect(val).toBeValid({ validator, Class: Class }); 28 | } 29 | }); 30 | 31 | if (invalid && invalid.length) { 32 | it('should invalidate unsuccessful candidates', () => { 33 | // tslint:disable-next-line: no-inferred-empty-object-type 34 | const Class = classFactory(); 35 | for (let val of invalid) { 36 | expect(val).not.toBeValid({ validator, Class: Class }); 37 | } 38 | }); 39 | } 40 | } 41 | 42 | type Converted = { 43 | [K in keyof T]?: any; 44 | }; 45 | 46 | export interface TestConversionOptions { 47 | getClass: () => { new(...args: any[]): T }; 48 | conversions: { input: T, output: Converted }[]; 49 | valid?: T[]; 50 | invalid?: T[]; 51 | } 52 | 53 | export function testConversion(options: TestConversionOptions) { 54 | const { getClass, conversions, valid, invalid } = options; 55 | 56 | it('should convert property using validator', () => { 57 | // tslint:disable-next-line: no-inferred-empty-object-type 58 | const Class = getClass(); 59 | const validator = new Validator({ convert: true }); 60 | 61 | conversions.forEach(({ input, output }) => { 62 | const result = validator.validateAsClass(input, Class); 63 | expect(result.error).toBeFalsy(); 64 | expect(result.value).toEqual(output); 65 | }); 66 | }); 67 | 68 | if (valid && valid.length) { 69 | it('should not fail for candidates even when convert option is disabled in validator', () => { 70 | // tslint:disable-next-line: no-inferred-empty-object-type 71 | const Class = getClass(); 72 | const validator = new Validator({ convert: false }); 73 | 74 | valid.forEach((input) => { 75 | expect(input).toBeValid({ Class: Class, validator }); 76 | }); 77 | }); 78 | } 79 | 80 | if (invalid && invalid.length) { 81 | it('should fail for candidates when convert option is disabled in validator', () => { 82 | // tslint:disable-next-line: no-inferred-empty-object-type 83 | const Class = getClass(); 84 | const validator = new Validator({ convert: false }); 85 | 86 | invalid.forEach((input) => { 87 | expect(input).not.toBeValid({ Class: Class, validator }); 88 | }); 89 | }); 90 | } 91 | } 92 | 93 | interface PackageDependencies { 94 | [name: string]: string; 95 | } 96 | 97 | interface PackageJson { 98 | peerDependencies?: PackageDependencies; 99 | dependencies?: PackageDependencies; 100 | devDependencies?: PackageDependencies; 101 | } 102 | 103 | export async function getJoifulDependencyVersion(dependencyName: string) { 104 | const joifulPackageFileName = path.join(rootPath, 'package.json'); 105 | const joifulPackageJson: PackageJson = await new Promise( 106 | (resolve, reject) => fs.readFile( 107 | joifulPackageFileName, 'utf-8', (err, content) => err ? reject(err) : resolve(JSON.parse(content)), 108 | ), 109 | ); 110 | const allDependencies: PackageDependencies = { 111 | ...joifulPackageJson.peerDependencies, 112 | ...joifulPackageJson.dependencies, 113 | ...joifulPackageJson.devDependencies, 114 | }; 115 | return parseVersionString(allDependencies[dependencyName]); 116 | } 117 | -------------------------------------------------------------------------------- /test/unit/inheritance.test.ts: -------------------------------------------------------------------------------- 1 | import './testUtil'; 2 | import { Joiful } from '../../src/joiful'; 3 | import { Validator } from '../../src/validation'; 4 | import { getMergedWorkingSchemas, getWorkingSchema } from '../../src/core'; 5 | import { notNil } from './testUtil'; 6 | 7 | let jf: Joiful; 8 | 9 | beforeEach(() => { 10 | jf = new Joiful(); 11 | }); 12 | 13 | describe('Inheritance', () => { 14 | const validator = new Validator({ 15 | abortEarly: false, 16 | presence: 'required', 17 | }); 18 | 19 | it('Working schemas in inheritance chains are correctly merged', () => { 20 | class ParentClass { 21 | @jf.number().min(10) 22 | foo!: number; 23 | } 24 | class ChildClass extends ParentClass { 25 | @jf.number().min(10) 26 | bar!: number; 27 | } 28 | 29 | const parentSchemaUnmerged = notNil(getWorkingSchema(ParentClass.prototype)); 30 | expect(parentSchemaUnmerged.foo).toBeDefined(); 31 | expect(parentSchemaUnmerged.bar).toBeUndefined(); 32 | 33 | const parentSchema = notNil(getMergedWorkingSchemas(ParentClass.prototype)); 34 | expect(parentSchema.foo).toBeDefined(); 35 | expect(parentSchema.bar).toBeUndefined(); 36 | 37 | const childSchema = notNil(getMergedWorkingSchemas(ChildClass.prototype)); 38 | expect(childSchema.foo).toBeDefined(); 39 | expect(childSchema.bar).toBeDefined(); 40 | }); 41 | 42 | it("Inheriting classes apply both the parent's validations, and the child's validations", () => { 43 | class ParentClass { 44 | @jf.number().min(10) 45 | foo!: number; 46 | } 47 | class ChildClass extends ParentClass { 48 | @jf.number().min(10) 49 | bar!: number; 50 | } 51 | 52 | const instance = new ChildClass(); 53 | const result = validator.validate(instance); 54 | expect(result.error).toBeTruthy(); 55 | expect(result.error!.details).toHaveLength(2); 56 | }); 57 | 58 | it('Child validations do not apply when validating the parent class', () => { 59 | class ParentClass { 60 | @jf.number().min(10) 61 | foo!: number; 62 | } 63 | 64 | class ChildClass extends ParentClass { 65 | @jf.number().min(10) 66 | bar!: number; 67 | } 68 | 69 | const instance = new ParentClass(); 70 | const result = validator.validate(instance); 71 | expect(ChildClass).toBeTruthy(); 72 | expect(result.error).toBeTruthy(); 73 | expect(result.error!.details).toHaveLength(1); 74 | }); 75 | 76 | it("Child validations can override the parent's validations", () => { 77 | class ParentClass { 78 | @jf.number().min(10) 79 | foo!: number; 80 | } 81 | class ChildClass extends ParentClass { 82 | @jf.number().min(0) 83 | foo!: number; 84 | } 85 | 86 | const instance = new ChildClass(); 87 | instance.foo = 1; 88 | expect(instance).toBeValid({ validator }); 89 | }); 90 | 91 | it('Grandchild classes apply validations from parent and grandparent classes', () => { 92 | class ParentClass { 93 | @jf.number().min(10) 94 | foo!: number; 95 | } 96 | class ChildClass extends ParentClass { 97 | @jf.number().min(10) 98 | bar!: number; 99 | } 100 | class GrandchildClass extends ChildClass { 101 | @jf.number().min(10) 102 | baz!: number; 103 | } 104 | 105 | const instance = new GrandchildClass(); 106 | const result = validator.validate(instance); 107 | expect(result.error).toBeTruthy(); 108 | expect(result.error!.details).toHaveLength(3); 109 | }); 110 | 111 | it('Grandchild classes without any validations apply validations from the grandparent class', () => { 112 | class ParentClass { 113 | @jf.number().min(10) 114 | foo!: number; 115 | } 116 | class ChildClass extends ParentClass { 117 | bar!: number; 118 | } 119 | class GrandchildClass extends ChildClass { 120 | baz!: number; 121 | } 122 | 123 | const instance = new GrandchildClass(); 124 | const result = validator.validate(instance); 125 | expect(result.error).toBeTruthy(); 126 | expect(result.error!.details).toHaveLength(1); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /test/unit/decorators/date.test.ts: -------------------------------------------------------------------------------- 1 | import { testConstraint } from '../testUtil'; 2 | import { date } from '../../../src'; 3 | import { Validator } from '../../../src/validation'; 4 | 5 | describe('date', () => { 6 | testConstraint( 7 | () => { 8 | class Age { 9 | @date() 10 | dateOfBirth?: Date; 11 | } 12 | return Age; 13 | }, 14 | [ 15 | {}, 16 | { dateOfBirth: new Date(2000, 0, 1) }, 17 | ], 18 | [ 19 | { dateOfBirth: 'not a date' as any }, 20 | ], 21 | ); 22 | 23 | describe('iso', () => { 24 | testConstraint( 25 | () => { 26 | class Age { 27 | @date().iso() 28 | dateOfBirth?: Date; 29 | } 30 | return Age; 31 | }, 32 | [ 33 | { dateOfBirth: '2017-02-20' as any }, 34 | { dateOfBirth: '2017-02-20T22:55:12Z' as any }, 35 | { dateOfBirth: '2017-02-20T22:55:12' as any }, 36 | { dateOfBirth: '2017-02-20T22:55' as any }, 37 | { dateOfBirth: '2017-02-20T22:55:12+1100' as any }, 38 | { dateOfBirth: '2017-02-20T22:55:12+11:00' as any }, 39 | ], 40 | [ 41 | { dateOfBirth: '20-02-2017' as any }, 42 | { dateOfBirth: '20/02/2017' as any }, 43 | { dateOfBirth: '2017/02/20' as any }, 44 | { dateOfBirth: '2017-02-20T22' as any }, 45 | { dateOfBirth: '2017-02-20T22:55:' as any }, 46 | ], 47 | ); 48 | }); 49 | 50 | describe('max', () => { 51 | const now = Date.now(); 52 | 53 | testConstraint( 54 | () => { 55 | class Age { 56 | @date().max(now) 57 | dateOfBirth?: Date; 58 | } 59 | return Age; 60 | }, 61 | [ 62 | {}, 63 | { dateOfBirth: now as any }, 64 | { dateOfBirth: now - 1 as any }, 65 | ], 66 | [ 67 | { dateOfBirth: now + 1 as any }, 68 | ], 69 | ); 70 | }); 71 | 72 | describe('min', () => { 73 | const earliestDateOfBirth = (new Date(1900, 0, 1)).getTime(); 74 | 75 | testConstraint( 76 | () => { 77 | class Age { 78 | @date().min(earliestDateOfBirth) 79 | dateOfBirth?: Date; 80 | } 81 | return Age; 82 | }, 83 | [ 84 | {}, 85 | { dateOfBirth: earliestDateOfBirth as any }, 86 | { dateOfBirth: earliestDateOfBirth + 1 as any }, 87 | ], 88 | [ 89 | { dateOfBirth: earliestDateOfBirth - 1 as any }, 90 | ], 91 | ); 92 | }); 93 | 94 | describe('timestamp', () => { 95 | describe('using javascript time (the default)', () => { 96 | it('should coerce a numeric date to a JS Date', () => { 97 | class AgeVerification { 98 | @date().timestamp('javascript') 99 | dateOfBirth?: Date; 100 | } 101 | 102 | const SECONDS_IN_DAY = 60 * 60 * 24; 103 | const MILLISECONDS_IN_DAY = SECONDS_IN_DAY * 1000; 104 | 105 | const ageVerification = { dateOfBirth: MILLISECONDS_IN_DAY }; 106 | const validator = new Validator(); 107 | const result = validator.validateAsClass(ageVerification, AgeVerification); 108 | expect(result.value.dateOfBirth).toBeInstanceOf(Date); 109 | expect(result.value.dateOfBirth).toEqual(new Date(Date.UTC(1970, 0, 2))); 110 | }); 111 | }); 112 | 113 | describe('using javascript time (the default)', () => { 114 | it('should coerce a (numeric) unix timestamp to a JS Date', () => { 115 | class AgeVerification { 116 | @date().timestamp('unix') 117 | dateOfBirth?: Date; 118 | } 119 | 120 | const SECONDS_IN_DAY = 60 * 60 * 24; 121 | const ageVerification = { dateOfBirth: SECONDS_IN_DAY }; 122 | const validator = new Validator(); 123 | const result = validator.validateAsClass(ageVerification, AgeVerification); 124 | expect(result.value.dateOfBirth).toBeInstanceOf(Date); 125 | expect(result.value.dateOfBirth).toEqual(new Date(Date.UTC(1970, 0, 2))); 126 | }); 127 | }); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /src/decorators/array.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { TypedPropertyDecorator, AnyClass, getJoiSchema } from '../core'; 3 | import { ModifierProviders, JoifulOptions, createPropertyDecorator } from './common'; 4 | import { AnySchemaModifiers, getAnySchemaModifierProviders } from './any'; 5 | 6 | type AllowedPropertyTypes = any[]; 7 | 8 | export interface ArraySchemaModifiers extends AnySchemaModifiers { 9 | /** 10 | * Requires the array to be an exact length. 11 | */ 12 | exactLength(limit: number): this; 13 | 14 | /** 15 | * List the types allowed for the array values. 16 | */ 17 | items(type: Joi.Schema, ...types: Joi.Schema[]): this; 18 | 19 | /** 20 | * List the types allowed for the array values. 21 | */ 22 | items(itemsSchemaBuilder: (joi: typeof Joi) => Joi.Schema | Joi.Schema[]): this; 23 | 24 | /** 25 | * Specifies the maximum array length. 26 | * @param limit The maximum length. 27 | */ 28 | max(limit: number): this; 29 | 30 | /** 31 | * Specifies the minimum array length. 32 | * @param limit The minimum length. 33 | */ 34 | min(limit: number): this; 35 | 36 | /** 37 | * List the types in sequence order for the array values.. 38 | */ 39 | ordered(type: Joi.Schema, ...types: Joi.Schema[]): this; 40 | 41 | /** 42 | * List the types in sequence order for the array values.. 43 | */ 44 | ordered(itemsSchemaBuilder: (joi: typeof Joi) => Joi.Schema[]): this; 45 | 46 | /** 47 | * Allow single values to be checked against rules as if it were provided as an array. 48 | * enabled can be used with a falsy value to go back to the default behavior. 49 | */ 50 | single(enabled?: boolean | any): this; 51 | 52 | /** 53 | * Allow this array to be sparse. enabled can be used with a falsy value to go back to the default behavior. 54 | */ 55 | sparse(enabled?: boolean | any): this; 56 | 57 | /** 58 | * Requires the array values to be unique. 59 | */ 60 | unique(): this; 61 | } 62 | 63 | export function getArraySchemaModifierProviders(getJoi: () => typeof Joi) { 64 | const result: ModifierProviders = { 65 | ...getAnySchemaModifierProviders(getJoi), 66 | 67 | items: (...args: any[]) => ({ schema }) => { 68 | const [firstArg] = args; 69 | 70 | const itemSchemas: Joi.Schema[] = []; 71 | 72 | if (args.length === 1 && typeof firstArg === 'function') { 73 | const itemSchemaBuilder = firstArg as (joi: typeof Joi) => Joi.Schema | Joi.Schema[]; 74 | const result = itemSchemaBuilder(getJoi()); 75 | itemSchemas.push(...result instanceof Array ? result : [result]); 76 | } else { 77 | itemSchemas.push(...args); 78 | } 79 | 80 | return schema.items(...itemSchemas); 81 | }, 82 | 83 | exactLength: (length: number) => ({ schema }) => schema.length(length), 84 | 85 | max: (limit: number) => ({ schema }) => schema.max(limit), 86 | 87 | min: (limit: number) => ({ schema }) => schema.min(limit), 88 | 89 | ordered: (...args: any[]) => ({ schema }) => { 90 | const [firstArg] = args; 91 | 92 | const itemSchemas: Joi.Schema[] = []; 93 | 94 | if (args.length === 1 && typeof firstArg === 'function') { 95 | const itemSchemaBuilder = firstArg as (joi: typeof Joi) => Joi.Schema[]; 96 | const result = itemSchemaBuilder(getJoi()); 97 | itemSchemas.push(...result); 98 | } else { 99 | itemSchemas.push(...args); 100 | } 101 | 102 | return schema.ordered(...itemSchemas); 103 | }, 104 | 105 | single: (enabled?: boolean | any) => ({ schema }) => schema.single(enabled), 106 | 107 | sparse: (enabled?: boolean | any) => ({ schema }) => schema.sparse(enabled), 108 | 109 | unique: () => ({ schema }) => schema.unique(), 110 | }; 111 | 112 | return result; 113 | } 114 | 115 | export interface ArraySchemaDecorator extends 116 | ArraySchemaModifiers, 117 | TypedPropertyDecorator { 118 | } 119 | 120 | export interface ArrayPropertyDecoratorOptions { 121 | elementClass?: AnyClass; 122 | } 123 | 124 | export const createArrayPropertyDecorator = ( 125 | options: ArrayPropertyDecoratorOptions | undefined, 126 | joifulOptions: JoifulOptions, 127 | ): ArraySchemaDecorator => { 128 | return createPropertyDecorator, ArraySchemaModifiers>()( 129 | ({ joi }) => { 130 | let schema = joi.array(); 131 | 132 | const elementClass = (options && options.elementClass); 133 | 134 | if (elementClass) { 135 | const elementSchema = getJoiSchema(elementClass, joi); 136 | if (elementSchema) { 137 | schema = schema.items(elementSchema); 138 | } 139 | } 140 | 141 | return schema; 142 | }, 143 | getArraySchemaModifierProviders, 144 | joifulOptions, 145 | ); 146 | }; 147 | -------------------------------------------------------------------------------- /test/unit/core.test.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import * as jf from '../../src'; 3 | import { 4 | getJoi, 5 | getJoiSchema, 6 | getJoiVersion, 7 | JOI_VERSION, 8 | checkJoiIsCompatible, 9 | IncompatibleJoiVersion, 10 | parseVersionString, 11 | } from '../../src/core'; 12 | import { getJoifulDependencyVersion } from './testUtil'; 13 | import { stringify } from 'flatted'; 14 | 15 | describe('checkJoiIsCompatible', () => { 16 | it('should not error if version of joi passed in matches expected major version', () => { 17 | checkJoiIsCompatible(Joi); 18 | }); 19 | 20 | it('should error if version of joi passed in is different to major version of joi used by joiful', () => { 21 | expect(() => checkJoiIsCompatible({ version: '-1.0.0' } as any as typeof Joi)) 22 | .toThrowError(new IncompatibleJoiVersion({ major: '-1', minor: '0', patch: '0' })); 23 | }); 24 | 25 | it('should not error if no joi instance is passed in', () => { 26 | checkJoiIsCompatible(undefined); 27 | }); 28 | }); 29 | 30 | describe('getJoi', () => { 31 | it('should return the default Joi instance when no options are passed', () => { 32 | expect(getJoi()).toBe(Joi); 33 | }); 34 | 35 | it('should return the default Joi instance when given undefined', () => { 36 | expect(getJoi(undefined)).toBe(Joi); 37 | }); 38 | 39 | it('should return the default Joi instance when given an empty object', () => { 40 | expect(getJoi({})).toBe(Joi); 41 | }); 42 | 43 | it('should return the given Joi instance', () => { 44 | const customJoi = Joi.extend((joi) => { 45 | return { 46 | type: 'string', 47 | base: joi.string(), 48 | }; 49 | }); 50 | const result = getJoi({ joi: customJoi }); 51 | expect(result).toBe(customJoi); 52 | expect(result).not.toBe(Joi); 53 | }); 54 | }); 55 | 56 | describe('getJoiSchema', () => { 57 | it('should return the Joi schema to use for a decorated class', () => { 58 | class Cat { 59 | @jf.string() 60 | name!: string; 61 | } 62 | 63 | const expected = jf.joi.object().keys({ 64 | name: jf.joi.string(), 65 | }); 66 | const schema = getJoiSchema(Cat, jf.joi); 67 | expect(Object.keys(schema!)).toEqual(Object.keys(expected)); 68 | }); 69 | 70 | it('should return no schema when class is not decorated', () => { 71 | class Dog { 72 | name!: string; 73 | } 74 | 75 | expect(getJoiSchema(Dog, jf.joi)).toBeUndefined(); 76 | }); 77 | 78 | it('should support inheritance in classes', () => { 79 | class Animal { 80 | @jf.string() 81 | name!: string; 82 | } 83 | 84 | class Mammal extends Animal { 85 | @jf.number().min(2) 86 | nippleCount!: number; 87 | } 88 | 89 | class Cat extends Mammal { 90 | @jf.number().min(1).max(5) 91 | fluffinessIndex!: number; 92 | } 93 | 94 | const expectedSchema = jf.joi.object().keys({ 95 | name: jf.joi.string(), 96 | nippleCount: jf.joi.number().min(2), 97 | fluffinessIndex: jf.joi.number().min(1).max(5), 98 | }); 99 | 100 | expect(stringify(getJoiSchema(Cat, jf.joi))).toEqual(stringify(expectedSchema)); 101 | }); 102 | }); 103 | 104 | describe('getJoiVersion', () => { 105 | const mockJoi = (version: string | undefined) => ({ version }) as any as typeof Joi; 106 | 107 | it('should return the version of joi', () => { 108 | expect(getJoiVersion(Joi)).not.toEqual({ 109 | major: '?', 110 | minor: '?', 111 | patch: '?', 112 | }); 113 | 114 | expect(getJoiVersion(mockJoi('1.2.3'))).toEqual({ 115 | major: '1', 116 | minor: '2', 117 | patch: '3', 118 | }); 119 | 120 | expect(getJoiVersion(mockJoi('1.2'))).toEqual({ 121 | major: '1', 122 | minor: '2', 123 | patch: '', 124 | }); 125 | }); 126 | 127 | it('should not error if joi not defined', () => { 128 | expect(getJoiVersion(mockJoi(undefined))).toEqual({ 129 | major: '?', 130 | minor: '?', 131 | patch: '?', 132 | }); 133 | 134 | expect(getJoiVersion(undefined)).toEqual({ 135 | major: '?', 136 | minor: '?', 137 | patch: '?', 138 | }); 139 | }); 140 | }); 141 | 142 | describe('JOI_VERSION', () => { 143 | it('should match the version of joi referenced in Joifuls package dependencies', async () => { 144 | const expectedJoiVersion = await getJoifulDependencyVersion('joi'); 145 | expect(expectedJoiVersion.major).toBeTruthy(); 146 | 147 | const actualJoiVersion = JOI_VERSION; 148 | expect(actualJoiVersion).toEqual(expectedJoiVersion); 149 | }); 150 | }); 151 | 152 | describe('parseVersionString', () => { 153 | it('should parse a version string and return it as object', () => { 154 | expect(parseVersionString('1.2.3')).toEqual({ 155 | major: '1', 156 | minor: '2', 157 | patch: '3', 158 | }); 159 | }); 160 | 161 | it('should handle blank versions', () => { 162 | expect(parseVersionString('')).toEqual({ 163 | major: '', 164 | minor: '', 165 | patch: '', 166 | }); 167 | }); 168 | }); 169 | -------------------------------------------------------------------------------- /test/unit/decorators/number.test.ts: -------------------------------------------------------------------------------- 1 | import { testConstraint } from '../testUtil'; 2 | import { number } from '../../../src'; 3 | import { Validator } from '../../../src/validation'; 4 | 5 | describe('number', () => { 6 | testConstraint( 7 | () => { 8 | class AgeVerificationForm { 9 | @number() 10 | age?: number; 11 | } 12 | return AgeVerificationForm; 13 | }, 14 | [{ age: 18 }, {}], 15 | [{ age: 'like way old' as any }], 16 | ); 17 | 18 | describe('greater', () => { 19 | testConstraint( 20 | () => { 21 | class AgeVerificationForm { 22 | @number().greater(17) 23 | age?: number; 24 | } 25 | return AgeVerificationForm; 26 | }, 27 | [{ age: 18 }], 28 | [{ age: 17 }], 29 | ); 30 | }); 31 | 32 | describe('integer', () => { 33 | testConstraint( 34 | () => { 35 | class CatAdoptionForm { 36 | @number().integer() 37 | catCount?: number; 38 | } 39 | return CatAdoptionForm; 40 | }, 41 | [{ catCount: 3 }, { catCount: 50 }], 42 | [{ catCount: 0.5 }], 43 | ); 44 | }); 45 | 46 | describe('less', () => { 47 | testConstraint( 48 | () => { 49 | class CatAdoptionForm { 50 | @number().less(10) 51 | catCount?: number; 52 | } 53 | return CatAdoptionForm; 54 | }, 55 | [{ catCount: 1 }], 56 | [{ catCount: 10 }], 57 | ); 58 | }); 59 | 60 | describe('max', () => { 61 | testConstraint( 62 | () => { 63 | class CatAdoptionForm { 64 | @number().max(9) 65 | catCount?: number; 66 | } 67 | return CatAdoptionForm; 68 | }, 69 | [{ catCount: 1 }], 70 | [{ catCount: 10 }], 71 | ); 72 | }); 73 | 74 | describe('min', () => { 75 | testConstraint( 76 | () => { 77 | class CatAdoptionForm { 78 | @number().min(1) 79 | catCount?: number; 80 | } 81 | return CatAdoptionForm; 82 | }, 83 | [{ catCount: 1 }], 84 | [{ catCount: 0 }], 85 | ); 86 | }); 87 | 88 | describe('multiple', () => { 89 | testConstraint( 90 | () => { 91 | class GamingConsole { 92 | @number().multiple(8) 93 | bits?: number; 94 | } 95 | return GamingConsole; 96 | }, 97 | [{ bits: 8 }, { bits: 16 }, { bits: 32 }, { bits: 64 }], 98 | [{ bits: 4 }, { bits: 10 }], 99 | ); 100 | }); 101 | 102 | describe('negative', () => { 103 | testConstraint( 104 | () => { 105 | class Adjustment { 106 | @number().negative() 107 | amount?: number; 108 | } 109 | return Adjustment; 110 | }, 111 | [{ amount: -1 }, { amount: -1.1 }], 112 | [{ amount: 1 }, { amount: 0 }], 113 | ); 114 | }); 115 | 116 | describe('positive', () => { 117 | testConstraint( 118 | () => { 119 | class WorkplaceStatus { 120 | @number().positive() 121 | daysSinceLastAccident?: number; 122 | } 123 | return WorkplaceStatus; 124 | }, 125 | [{ daysSinceLastAccident: 1 }], 126 | [{ daysSinceLastAccident: -1 }, { daysSinceLastAccident: 0 }], 127 | ); 128 | }); 129 | 130 | describe('precision', () => { 131 | describe('with strict mode', () => { 132 | testConstraint( 133 | () => { 134 | class Deposit { 135 | @number().strict().precision(2) 136 | amount?: number; 137 | } 138 | return Deposit; 139 | }, 140 | [{ amount: 1.11 }, { amount: 1.1 }, { amount: 1 }], 141 | [{ amount: 1.123 }], 142 | ); 143 | }); 144 | 145 | describe('without strict mode', () => { 146 | it('should pass validation but round the value', () => { 147 | const getDepositClass = () => { 148 | class Deposit { 149 | @number().precision(2) 150 | amount?: number; 151 | } 152 | return Deposit; 153 | }; 154 | 155 | const deposit = new (getDepositClass())(); 156 | deposit.amount = 1.009; 157 | 158 | const validator = new Validator(); 159 | let result = validator.validate(deposit); 160 | 161 | expect(result.error).toBeNull(); 162 | expect(result.value).not.toEqual(deposit); 163 | expect(result.value).toEqual({ amount: 1.01 }); 164 | 165 | deposit.amount = 1.001; 166 | result = validator.validate(deposit); 167 | 168 | expect(result.value).toEqual({ amount: 1 }); 169 | }); 170 | }); 171 | }); 172 | }); 173 | -------------------------------------------------------------------------------- /src/core.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import * as Joi from 'joi'; 3 | 4 | export const getJoi = (options: { joi?: typeof Joi } | undefined = {}) => options.joi || Joi; 5 | 6 | export const WORKING_SCHEMA_KEY = 'tsdv:working-schema'; 7 | export const SCHEMA_KEY = 'tsdv:schema'; 8 | export const JOI_VERSION = getJoiVersion(Joi); 9 | 10 | export type WorkingSchema = { [index: string]: Joi.Schema }; 11 | 12 | export interface Constructor { 13 | new(...args: any[]): T; 14 | } 15 | 16 | export type AnyClass = Constructor; 17 | 18 | export type StringKey = Extract; 19 | export type StringOrSymbolKey = Extract; 20 | 21 | /** 22 | * If a given type extends the desired type, return the given type. Otherwise, return the desired type. 23 | * So, you can do stuff like this: 24 | * 25 | * ```typescript 26 | * interface Foo { 27 | * bar: null; 28 | * baz: number; 29 | * boz: string; 30 | * biz: string | null; 31 | * } 32 | * 33 | * const bars1: AllowUnions[] = [ 34 | * 'sdf', // Type 'string' is not assignable to type 'number'. 35 | * null, // Type 'null' is not assignable to type 'number'. 36 | * 123 37 | * ]; 38 | * 39 | * const bars2: AllowUnions[] = [ 40 | * 'sdf', 41 | * null, // Type 'null' is not assignable to type 'string'. 42 | * 123 // Type 'number' is not assignable to type 'string'. 43 | * ]; 44 | * 45 | * const bars3: AllowUnions[] = [ 46 | * 'sdf', 47 | * null, 48 | * 123 // Type 'number' is not assignable to type 'string | null'. 49 | * ]; 50 | * ``` 51 | * 52 | * Notice that you pass the TOriginal type parameter, which is identical to the TType type parameter. This is because 53 | * the "extends" condition will narrow the type of TType to just TDesired. So, "string | null" will be narrowed to 54 | * "string", but we actually want to return the original "string | null". 55 | * 56 | * By returning the TDesired when there's no match, we get nice error messages that state what the desired type was. 57 | */ 58 | export type AllowUnions = TType extends TDesired ? TOriginal : TDesired; 59 | 60 | export type MapAllowUnions = { 61 | [K in TKey]: AllowUnions; 62 | }; 63 | 64 | // The default PropertyDecorator type is not very type safe, we'll use a stricter version. 65 | export type TypedPropertyDecorator = ( 66 | , TKey extends StringOrSymbolKey>( 67 | target: TClass, 68 | propertyKey: TKey, 69 | ) => void 70 | ); 71 | 72 | export function getWorkingSchema(target: TClass): WorkingSchema | undefined { 73 | let workingSchema: WorkingSchema = Reflect.getOwnMetadata(WORKING_SCHEMA_KEY, target); 74 | return workingSchema; 75 | } 76 | 77 | export function getMergedWorkingSchemas(target: object): WorkingSchema | undefined { 78 | const parentPrototype = Object.getPrototypeOf(target); 79 | const parentSchema = ( 80 | parentPrototype && 81 | (parentPrototype.constructor !== Object) && 82 | getMergedWorkingSchemas(parentPrototype) 83 | ); 84 | 85 | const workingSchema = getWorkingSchema(target); 86 | 87 | if (workingSchema || parentSchema) { 88 | return { 89 | ...parentSchema, 90 | ...workingSchema, 91 | }; 92 | } 93 | 94 | return undefined; 95 | } 96 | 97 | export function getJoiSchema(Class: AnyClass, joi: Pick): Joi.ObjectSchema | undefined { 98 | const isSchemaDefined = Reflect.hasOwnMetadata(SCHEMA_KEY, Class.prototype); 99 | if (isSchemaDefined) { 100 | return Reflect.getOwnMetadata(SCHEMA_KEY, Class.prototype); 101 | } 102 | 103 | let workingSchema = getMergedWorkingSchemas(Class.prototype); 104 | const joiSchema: Joi.ObjectSchema | undefined = ( 105 | workingSchema ? joi.object().keys(workingSchema) : undefined 106 | ); 107 | Reflect.defineMetadata(SCHEMA_KEY, joiSchema, Class.prototype); 108 | 109 | return joiSchema; 110 | } 111 | 112 | export function updateWorkingSchema>( 113 | target: TClass, 114 | propertyKey: TKey, 115 | schema: Joi.Schema, 116 | ) { 117 | let workingSchema = getWorkingSchema(target); 118 | if (!workingSchema) { 119 | workingSchema = {}; 120 | Reflect.defineMetadata(WORKING_SCHEMA_KEY, workingSchema, target); 121 | } 122 | workingSchema[String(propertyKey)] = schema; 123 | } 124 | 125 | export interface Version { 126 | major: string; 127 | minor: string; 128 | patch: string; 129 | } 130 | 131 | export function parseVersionString(version: string) { 132 | const [major, minor, patch] = (version).split('.'); 133 | return { 134 | major: major || '', 135 | minor: minor || '', 136 | patch: patch || '', 137 | }; 138 | } 139 | 140 | export function getJoiVersion(joi: typeof Joi | undefined): Version { 141 | const versionString = ((joi || {}) as any)['version'] || '?.?.?'; 142 | return parseVersionString(versionString); 143 | } 144 | 145 | export class IncompatibleJoiVersion extends Error { 146 | constructor(actualVersion: Version) { 147 | super(`Cannot use Joi v${actualVersion} with Joiful. Joiful requires Joi v${JOI_VERSION.major}.x.x`); 148 | } 149 | } 150 | 151 | export function checkJoiIsCompatible(joi: typeof Joi | undefined) { 152 | if (joi) { 153 | const actualVersion = getJoiVersion(joi); 154 | if (JOI_VERSION.major !== actualVersion.major) { 155 | throw new IncompatibleJoiVersion(actualVersion); 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

4 | 5 |

TypeScript Declarative Validation for Joi

6 |

7 | 8 |
9 | 10 | [![npm version](https://badge.fury.io/js/joiful.svg)](https://badge.fury.io/js/joiful) 11 | [![GitHub Actions Build Status](https://github.com/joiful-ts/joiful/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/joiful-ts/joiful/actions/workflows/build.yml) 12 | [![codecov](https://codecov.io/gh/joiful-ts/joiful/branch/master/graph/badge.svg)](https://codecov.io/gh/joiful-ts/joiful) 13 | [![Dependabot Status](https://api.dependabot.com/badges/status?host=github&repo=joiful-ts/joiful)](https://dependabot.com) 14 | 15 | [API Docs](https://joiful-ts.github.io/joiful/) 16 | 17 | ## Why Joiful? 18 | 19 | This lib allows you to apply Joi validation constraints on class properties, by using decorators. 20 | 21 | This means you can combine your type schema and your validation schema in one go! 22 | 23 | Calling `Validator.validateAsClass()` allows you to validate any object as if it were an instance of a given class. 24 | 25 | ## Installation 26 | 27 | `npm add joiful reflect-metadata` 28 | 29 | Or 30 | 31 | `yarn add joiful reflect-metadata`. 32 | 33 | You must enable experimental decorators and metadata in your TypeScript configuration. 34 | 35 | `tsconfig.json` 36 | 37 | ```json 38 | { 39 | "compilerOptions": { 40 | "emitDecoratorMetadata": true, 41 | "experimentalDecorators": true 42 | } 43 | } 44 | ``` 45 | 46 | ## Basic Usage 47 | 48 | Ensure you import `reflect-metadata` as the first import in your application's entry point. 49 | 50 | `index.ts` 51 | 52 | ```typescript 53 | import 'reflect-metadata'; 54 | 55 | ... 56 | ``` 57 | 58 | Then you can start using joiful like this. 59 | 60 | ```typescript 61 | import * as jf from 'joiful'; 62 | 63 | class SignUp { 64 | @jf.string().required() 65 | username: string; 66 | 67 | @jf 68 | .string() 69 | .required() 70 | .min(8) 71 | password: string; 72 | 73 | @jf.date() 74 | dateOfBirth: Date; 75 | 76 | @jf.boolean().required() 77 | subscribedToNewsletter: boolean; 78 | } 79 | 80 | const signUp = new SignUp(); 81 | signUp.username = 'rick.sanchez'; 82 | signUp.password = 'wubbalubbadubdub'; 83 | 84 | const { error } = jf.validate(signUp); 85 | 86 | console.log(error); // Error will either be undefined or a standard joi validation error 87 | ``` 88 | 89 | ## Validate plain old javascript objects 90 | 91 | Don't like creating instances of classes? Don't worry, you don't have to. You can validate a plain old javascript object as if it were an instance of a class. 92 | 93 | ```typescript 94 | const signUp = { 95 | username: 'rick.sanchez', 96 | password: 'wubbalubbadubdub', 97 | }; 98 | 99 | const result = jf.validateAsClass(signUp, SignUp); 100 | ``` 101 | 102 | ## Custom decorator constraints 103 | 104 | Want to create your own shorthand versions of decorators? Simply create a function like below. 105 | 106 | `customDecorators.ts` 107 | 108 | ```typescript 109 | import * as jf from 'joiful'; 110 | 111 | const password = () => 112 | jf 113 | .string() 114 | .min(8) 115 | .regex(/[a-z]/) 116 | .regex(/[A-Z]/) 117 | .regex(/[0-9]/) 118 | .required(); 119 | ``` 120 | 121 | `changePassword.ts` 122 | 123 | ```typescript 124 | import { password } from './customDecorators'; 125 | 126 | class ChangePassword { 127 | @password() 128 | newPassword: string; 129 | } 130 | ``` 131 | 132 | ## Validating array properties 133 | 134 | ```typescript 135 | class SimpleTodoList { 136 | @jf.array().items(joi => joi.string()) 137 | todos?: string[]; 138 | } 139 | ``` 140 | 141 | To validate an array of objects that have their own joiful validation: 142 | 143 | ```typescript 144 | class Actor { 145 | @string().required() 146 | name!: string; 147 | } 148 | 149 | class Movie { 150 | @string().required() 151 | name!: string; 152 | 153 | @array({ elementClass: Actor }).required() 154 | actors!: Actor[]; 155 | } 156 | ``` 157 | 158 | ## Validating object properties 159 | 160 | To validate an object subproperty that has its own joiful validation: 161 | 162 | ```typescript 163 | class Address { 164 | @string() 165 | line1?: string; 166 | 167 | @string() 168 | line2?: string; 169 | 170 | @string().required() 171 | city!: string; 172 | 173 | @string().required() 174 | state!: string; 175 | 176 | @string().required() 177 | country!: string; 178 | } 179 | 180 | class Contact { 181 | @string().required() 182 | name!: string; 183 | 184 | @object().optional() 185 | address?: Address; 186 | } 187 | ``` 188 | 189 | ## API Docs 190 | 191 | joiful has extensive JSDoc / TSDoc comments. 192 | 193 | [You can browse the generated API docs online.](https://joiful-ts.github.io/joiful/) 194 | 195 | ## Got a question? 196 | 197 | The joiful API is designed to closely match the joi API. One exception is validating the length of a `string`, `array`, etc, which is performed using `.exactLength(n)` rather than `.length(n)`. If you're familiar with the joi API, you should find joiful very easy to pickup. 198 | 199 | If there's something you're not sure of you can see how it's done by looking at the unit tests. There is 100% coverage so most likely you'll find your scenario there. Otherwise feel free to [open an issue](https://github.com/joiful-ts/joiful/issues). 200 | 201 | ## Contributing 202 | 203 | Got an issue or a feature request? [Log it](https://github.com/joiful-ts/joiful/issues). 204 | 205 | [Pull-requests](https://github.com/joiful-ts/joiful/pulls) are also very welcome. 206 | 207 | ## Alternatives 208 | 209 | - [class-validator](https://github.com/typestack/class-validator): usable in both Node.js and the browser. Mostly designed for validating string values. Can't validate plain objects, only class instances. 210 | - [joi-extract-type](https://github.com/TCMiranda/joi-extract-type): provides native type extraction from Joi Schemas. Augments the Joi type definitions. 211 | - [typesafe-joi](https://github.com/hjkcai/typesafe-joi): automatically infers type information of validated objects, via the standard Joi schema API. 212 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [3.0.2](https://github.com/joiful-ts/joiful/compare/v3.0.1...v3.0.2) (2021-04-24) 6 | 7 | ### [3.0.1](https://github.com/joiful-ts/joiful/compare/v3.0.0...v3.0.1) (2021-04-24) 8 | 9 | ## [3.0.0](https://github.com/joiful-ts/joiful/compare/v2.0.1...v3.0.0) (2021-01-30) 10 | 11 | 12 | ### ⚠ BREAKING CHANGES 13 | 14 | * **core:** https://github.com/sideway/joi/issues/2262 15 | 16 | Co-authored-by: Benji 17 | 18 | ### Features 19 | 20 | * **core:** upgrade to @hapi/joi 16.0.0 ([edbd4b2](https://github.com/joiful-ts/joiful/commit/edbd4b28d9314f1189705a7495fee8b4d718f26e)) 21 | * **core:** upgrade to sideway/joi 17.3.0 ([e3def02](https://github.com/joiful-ts/joiful/commit/e3def026d7a0b87c4431118c5cad6d993d95fdbd)) 22 | 23 | ### [2.0.1](https://github.com/joiful-ts/joiful/compare/v2.0.0...v2.0.1) (2020-04-27) 24 | 25 | 26 | ### Bug Fixes 27 | 28 | * **validation:** issue 117: address feedback from code review ([76cdcaf](https://github.com/joiful-ts/joiful/commit/76cdcaf0d142d45c4e95068c2821b5669f1d504c)) 29 | * **validation:** issue 117: validator now accepts custom joi ([505f9cd](https://github.com/joiful-ts/joiful/commit/505f9cdfed471e183dd39ccc916466248eaabb93)) 30 | 31 | ## [2.0.0](https://github.com/joiful-ts/joiful/compare/v1.1.9...v2.0.0) (2020-03-05) 32 | 33 | 34 | ### Features 35 | 36 | * add getSchema & hasSchema functions ([3e9f9f5](https://github.com/joiful-ts/joiful/commit/3e9f9f5f4638d84666db0c7de5c6883686ed9307)) 37 | 38 | ### [1.1.9](https://github.com/joiful-ts/joiful/compare/v1.1.8...v1.1.9) (2019-12-07) 39 | 40 | ### [1.1.8](https://github.com/joiful-ts/joiful/compare/v1.1.7...v1.1.8) (2019-12-07) 41 | 42 | ### [1.1.7](https://github.com/joiful-ts/joiful/compare/v1.1.6...v1.1.7) (2019-12-07) 43 | 44 | ### [1.1.6](https://github.com/joiful-ts/joiful/compare/v1.1.5...v1.1.6) (2019-10-23) 45 | 46 | ### [1.1.5](https://github.com/joiful-ts/joiful/compare/v1.1.4...v1.1.5) (2019-10-23) 47 | 48 | ### [1.1.4](https://github.com/joiful-ts/joiful/compare/v1.1.3...v1.1.4) (2019-10-23) 49 | 50 | ### [1.1.3](https://github.com/joiful-ts/joiful/compare/v1.1.2...v1.1.3) (2019-10-17) 51 | 52 | ### [1.1.2](https://github.com/joiful-ts/joiful/compare/v1.1.1...v1.1.2) (2019-10-13) 53 | 54 | ### [1.1.1](https://github.com/joiful-ts/joiful/compare/v1.1.0...v1.1.1) (2019-10-13) 55 | 56 | ## [1.1.0](https://github.com/joiful-ts/joiful/compare/v0.0.13...v1.1.0) (2019-10-05) 57 | 58 | 59 | ### Bug Fixes 60 | 61 | * **build:** run check in precommit ([08a6497](https://github.com/joiful-ts/joiful/commit/08a6497)) 62 | * **core:** remove nested. replaced by object(Class) ([679c42c](https://github.com/joiful-ts/joiful/commit/679c42c)) 63 | * **core:** update to require node 8.10 ([49f32da](https://github.com/joiful-ts/joiful/commit/49f32da)) 64 | * **test:** fix bad calls in tests ([0c0281f](https://github.com/joiful-ts/joiful/commit/0c0281f)) 65 | * **test:** fix path to tsconfig file for tests ([58e61c8](https://github.com/joiful-ts/joiful/commit/58e61c8)) 66 | * **test:** rename fluent.ts to fluent.test.ts ([cb746bb](https://github.com/joiful-ts/joiful/commit/cb746bb)) 67 | * **test:** rename number.ts to number.test.ts ([597aeb5](https://github.com/joiful-ts/joiful/commit/597aeb5)) 68 | 69 | 70 | ### Features 71 | 72 | * **arrays:** add fluent api syntax to Ordered decorator ([f0885eb](https://github.com/joiful-ts/joiful/commit/f0885eb)) 73 | * **core:** add fluent api to Items decorator ([484b2c9](https://github.com/joiful-ts/joiful/commit/484b2c9)) 74 | * **core:** ensure Joiful is used only with compatible joi versions ([5ff4dae](https://github.com/joiful-ts/joiful/commit/5ff4dae)) 75 | * **core:** export decorators for default instance ([67addd8](https://github.com/joiful-ts/joiful/commit/67addd8)) 76 | * **core:** export validation funcs from index.ts ([00dc37f](https://github.com/joiful-ts/joiful/commit/00dc37f)) 77 | * **core:** fluent api for individual schema decorators ([0ac8efd](https://github.com/joiful-ts/joiful/commit/0ac8efd)) 78 | * **core:** new fluent decorator interface ([7991044](https://github.com/joiful-ts/joiful/commit/7991044)) 79 | * **core:** support fluent api via Joi decorator ([606b151](https://github.com/joiful-ts/joiful/commit/606b151)) 80 | * **core:** upgrade to joi 15, make joi normal dependency ([1314228](https://github.com/joiful-ts/joiful/commit/1314228)) 81 | * **lint:** add formatting linting rules ([025d242](https://github.com/joiful-ts/joiful/commit/025d242)) 82 | * **tests:** expect(x).toBeValid() will now display the candidate that failed ([3289111](https://github.com/joiful-ts/joiful/commit/3289111)) 83 | 84 | ## [1.0.0](https://github.com/joiful-ts/joiful/compare/v0.0.13...v1.0.0) (2019-08-30) 85 | 86 | 87 | ### Bug Fixes 88 | 89 | * **build:** run check in precommit ([08a6497](https://github.com/joiful-ts/joiful/commit/08a6497)) 90 | * **core:** remove nested. replaced by object(Class) ([679c42c](https://github.com/joiful-ts/joiful/commit/679c42c)) 91 | * **core:** update to require node 8.10 ([49f32da](https://github.com/joiful-ts/joiful/commit/49f32da)) 92 | * **test:** fix bad calls in tests ([0c0281f](https://github.com/joiful-ts/joiful/commit/0c0281f)) 93 | * **test:** fix path to tsconfig file for tests ([58e61c8](https://github.com/joiful-ts/joiful/commit/58e61c8)) 94 | * **test:** rename fluent.ts to fluent.test.ts ([cb746bb](https://github.com/joiful-ts/joiful/commit/cb746bb)) 95 | * **test:** rename number.ts to number.test.ts ([597aeb5](https://github.com/joiful-ts/joiful/commit/597aeb5)) 96 | 97 | 98 | ### Features 99 | 100 | * **arrays:** add fluent api syntax to Ordered decorator ([f0885eb](https://github.com/joiful-ts/joiful/commit/f0885eb)) 101 | * **core:** add fluent api to Items decorator ([484b2c9](https://github.com/joiful-ts/joiful/commit/484b2c9)) 102 | * **core:** ensure Joiful is used only with compatible joi versions ([5ff4dae](https://github.com/joiful-ts/joiful/commit/5ff4dae)) 103 | * **core:** export decorators for default instance ([67addd8](https://github.com/joiful-ts/joiful/commit/67addd8)) 104 | * **core:** export validation funcs from index.ts ([00dc37f](https://github.com/joiful-ts/joiful/commit/00dc37f)) 105 | * **core:** fluent api for individual schema decorators ([0ac8efd](https://github.com/joiful-ts/joiful/commit/0ac8efd)) 106 | * **core:** new fluent decorator interface ([7991044](https://github.com/joiful-ts/joiful/commit/7991044)) 107 | * **core:** support fluent api via Joi decorator ([606b151](https://github.com/joiful-ts/joiful/commit/606b151)) 108 | * **core:** upgrade to joi 15, make joi normal dependency ([1314228](https://github.com/joiful-ts/joiful/commit/1314228)) 109 | * **lint:** add formatting linting rules ([025d242](https://github.com/joiful-ts/joiful/commit/025d242)) 110 | * **tests:** expect(x).toBeValid() will now display the candidate that failed ([3289111](https://github.com/joiful-ts/joiful/commit/3289111)) 111 | -------------------------------------------------------------------------------- /test/unit/decorators/object.test.ts: -------------------------------------------------------------------------------- 1 | import { testConstraint } from '../testUtil'; 2 | import { joi, object, string } from '../../../src'; 3 | 4 | describe('object', () => { 5 | describe('when not specifying object class and inferring class from property', () => { 6 | testConstraint( 7 | () => { 8 | class Address { 9 | @string() 10 | line1?: string; 11 | 12 | @string() 13 | line2?: string; 14 | 15 | @string().required() 16 | city!: string; 17 | 18 | @string().required() 19 | state!: string; 20 | 21 | @string().required() 22 | country!: string; 23 | } 24 | 25 | class Contact { 26 | @string().required() 27 | name!: string; 28 | 29 | @object().optional() 30 | address?: Address; 31 | } 32 | 33 | return Contact; 34 | }, 35 | [ 36 | { 37 | name: 'John Smith', 38 | address: { 39 | city: 'Melbourne', 40 | state: 'Victoria', 41 | country: 'Australia', 42 | }, 43 | }, 44 | { 45 | name: 'Jane Smith', 46 | }, 47 | ], 48 | [ 49 | { 50 | name: 'Joe Shabadoo', 51 | address: { 52 | } as any, 53 | }, 54 | { 55 | name: 'Joe Shabadoo', 56 | address: 1 as any, 57 | }, 58 | ], 59 | ); 60 | }); 61 | 62 | describe('when specifying object class', () => { 63 | testConstraint( 64 | () => { 65 | class Address { 66 | @string() 67 | line1?: string; 68 | 69 | @string() 70 | line2?: string; 71 | 72 | @string().required() 73 | city!: string; 74 | 75 | @string().required() 76 | state!: string; 77 | 78 | @string().required() 79 | country!: string; 80 | } 81 | 82 | class Contact { 83 | @string().required() 84 | name!: string; 85 | 86 | @object({ objectClass: Address }).optional() 87 | address?: Address; 88 | } 89 | 90 | return Contact; 91 | }, 92 | [ 93 | { 94 | name: 'John Smith', 95 | address: { 96 | city: 'Melbourne', 97 | state: 'Victoria', 98 | country: 'Australia', 99 | }, 100 | }, 101 | { 102 | name: 'Jane Smith', 103 | }, 104 | ], 105 | [ 106 | { 107 | name: 'Joe Shabadoo', 108 | address: { 109 | } as any, 110 | }, 111 | { 112 | name: 'Joe Shabadoo', 113 | address: 1 as any, 114 | }, 115 | ], 116 | ); 117 | }); 118 | 119 | describe('keys', () => { 120 | interface Address { 121 | line1?: string; 122 | line2?: string; 123 | city: string; 124 | state: string; 125 | country: string; 126 | } 127 | 128 | describe('when using callback', () => { 129 | testConstraint( 130 | () => { 131 | class Contact { 132 | @string().required() 133 | name!: string; 134 | 135 | @object() 136 | .keys((joi) => ({ 137 | city: joi.string().required(), 138 | state: joi.string().required(), 139 | country: joi.string().required(), 140 | })) 141 | .optional() 142 | address?: Address; 143 | } 144 | 145 | return Contact; 146 | }, 147 | [ 148 | { 149 | name: 'John Smith', 150 | address: { 151 | city: 'Melbourne', 152 | state: 'Victoria', 153 | country: 'Australia', 154 | }, 155 | }, 156 | { 157 | name: 'Jane Smith', 158 | }, 159 | ], 160 | [ 161 | { 162 | name: 'Joe Shabadoo', 163 | address: { 164 | } as any, 165 | }, 166 | { 167 | name: 'Joe Shabadoo', 168 | address: 1 as any, 169 | }, 170 | ], 171 | ); 172 | }); 173 | 174 | describe('when not using callback', () => { 175 | testConstraint( 176 | () => { 177 | class Contact { 178 | @string().required() 179 | name!: string; 180 | 181 | @object() 182 | .keys({ 183 | city: joi.string().required(), 184 | state: joi.string().required(), 185 | country: joi.string().required(), 186 | }) 187 | .optional() 188 | address?: Address; 189 | } 190 | 191 | return Contact; 192 | }, 193 | [ 194 | { 195 | name: 'John Smith', 196 | address: { 197 | city: 'Melbourne', 198 | state: 'Victoria', 199 | country: 'Australia', 200 | }, 201 | }, 202 | { 203 | name: 'Jane Smith', 204 | }, 205 | ], 206 | [ 207 | { 208 | name: 'Joe Shabadoo', 209 | address: { 210 | } as any, 211 | }, 212 | { 213 | name: 'Joe Shabadoo', 214 | address: 1 as any, 215 | }, 216 | ], 217 | ); 218 | }); 219 | }); 220 | }); 221 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after the first failure 9 | // bail: false, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/var/folders/xy/tjvj5dgn14n77wqvhlqgp6sh0000gn/T/jest_dx", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | collectCoverage: true, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | collectCoverageFrom: [ 25 | 'src/**/*.ts' 26 | ], 27 | 28 | // The directory where Jest should output its coverage files 29 | coverageDirectory: "coverage", 30 | 31 | // An array of regexp pattern strings used to skip coverage collection 32 | coveragePathIgnorePatterns: [ 33 | "/node_modules/" 34 | ], 35 | 36 | // A list of reporter names that Jest uses when writing coverage reports 37 | coverageReporters: [ 38 | // "json", 39 | "text", 40 | "lcov", 41 | // "clover" 42 | ], 43 | 44 | // An object that configures minimum threshold enforcement for coverage results 45 | coverageThreshold: { 46 | "global": { 47 | "branches": 100, 48 | "functions": 100, 49 | "lines": 100, 50 | "statements": 100 51 | } 52 | }, 53 | 54 | // Make calling deprecated APIs throw helpful error messages 55 | // errorOnDeprecated: false, 56 | 57 | // Force coverage collection from ignored files usin a array of glob patterns 58 | // forceCoverageMatch: [], 59 | 60 | // A path to a module which exports an async function that is triggered once before all test suites 61 | // globalSetup: null, 62 | 63 | // A path to a module which exports an async function that is triggered once after all test suites 64 | // globalTeardown: null, 65 | 66 | // A set of global variables that need to be available in all test environments 67 | globals: { 68 | "ts-jest": { 69 | "tsConfig": "./test/tsconfig.json" 70 | } 71 | }, 72 | 73 | // An array of directory names to be searched recursively up from the requiring module's location 74 | // moduleDirectories: [ 75 | // "node_modules" 76 | // ], 77 | 78 | // An array of file extensions your modules use 79 | "moduleFileExtensions": [ 80 | "ts", 81 | "tsx", 82 | "js", 83 | "jsx" 84 | ], 85 | 86 | // A map from regular expressions to module names that allow to stub out resources with a single module 87 | // moduleNameMapper: {}, 88 | 89 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 90 | // modulePathIgnorePatterns: [], 91 | 92 | // Activates notifications for test results 93 | // notify: false, 94 | 95 | // An enum that specifies notification mode. Requires { notify: true } 96 | // notifyMode: "always", 97 | 98 | // A preset that is used as a base for Jest's configuration 99 | // preset: null, 100 | 101 | // Run tests from one or more projects 102 | // projects: null, 103 | 104 | // Use this configuration option to add custom reporters to Jest 105 | // reporters: undefined, 106 | 107 | // Automatically reset mock state between every test 108 | // resetMocks: false, 109 | 110 | // Reset the module registry before running each individual test 111 | // resetModules: false, 112 | 113 | // A path to a custom resolver 114 | // resolver: null, 115 | 116 | // Automatically restore mock state between every test 117 | // restoreMocks: false, 118 | 119 | // The root directory that Jest should scan for tests and modules within 120 | // rootDir: null, 121 | 122 | // A list of paths to directories that Jest should use to search for files in 123 | // roots: [ 124 | // "" 125 | // ], 126 | 127 | // Allows you to use a custom runner instead of Jest's default test runner 128 | // runner: "jest-runner", 129 | 130 | // The paths to modules that run some code to configure or set up the testing environment before each test 131 | setupFiles: [ 132 | "reflect-metadata" 133 | ], 134 | 135 | // The path to a module that runs some code to configure or set up the testing framework before each test 136 | setupFilesAfterEnv: [ 137 | "./test/helpers/setup.ts" 138 | ], 139 | 140 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 141 | // snapshotSerializers: [], 142 | 143 | // The test environment that will be used for testing 144 | testEnvironment: "node", 145 | 146 | // Options that will be passed to the testEnvironment 147 | // testEnvironmentOptions: {}, 148 | 149 | // Adds a location field to test results 150 | // testLocationInResults: false, 151 | 152 | // The glob patterns Jest uses to detect test files 153 | testMatch: [ 154 | "**/?(*.)+(spec|test).ts?(x)" 155 | ], 156 | 157 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 158 | // testPathIgnorePatterns: [ 159 | // "/node_modules/" 160 | // ], 161 | 162 | // The regexp pattern Jest uses to detect test files 163 | // "testRegex": "\\.tsx?$", 164 | 165 | // This option allows the use of a custom results processor 166 | // testResultsProcessor: null, 167 | 168 | // This option allows use of a custom test runner 169 | // testRunner: "jasmine2", 170 | 171 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 172 | // testURL: "about:blank", 173 | 174 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 175 | // timers: "real", 176 | 177 | // A map from regular expressions to paths to transformers 178 | "transform": { 179 | "^.+\\.tsx?$": "ts-jest" 180 | }, 181 | 182 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 183 | // transformIgnorePatterns: [ 184 | // "/node_modules/" 185 | // ], 186 | 187 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 188 | // unmockedModulePathPatterns: undefined, 189 | 190 | // Indicates whether each individual test should be reported during the run 191 | // verbose: null, 192 | 193 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 194 | // watchPathIgnorePatterns: [], 195 | 196 | // Whether to use watchman for file crawling 197 | // watchman: true, 198 | }; 199 | -------------------------------------------------------------------------------- /src/decorators/string.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { TypedPropertyDecorator } from '../core'; 3 | import { ModifierProviders, JoifulOptions, createPropertyDecorator } from './common'; 4 | import { AnySchemaModifiers, getAnySchemaModifierProviders } from './any'; 5 | import { EmailOptions } from 'joi'; 6 | 7 | export interface StringSchemaModifiers extends AnySchemaModifiers { 8 | /** 9 | * The string doesn't only contain alphanumeric characters. 10 | */ 11 | alphanum(): this; 12 | 13 | /** 14 | * The string is not a valid credit card number. 15 | */ 16 | creditCard(): this; 17 | 18 | /** 19 | * Specifies that value must be a valid e-mail. 20 | */ 21 | email(options?: EmailOptions): this; 22 | 23 | /** 24 | * Specifies the exact string length required. 25 | * @param length The required string length. 26 | * @param encoding If specified, the string length is calculated in bytes using the provided encoding. 27 | */ 28 | exactLength(length: number, encoding?: string): this; 29 | 30 | /** 31 | * Requires the string value to be a valid GUID. 32 | * @param options Optional. options.version specifies one or more acceptable versions. 33 | * Can be an array or string with the following values: uuidv1, uuidv2, uuidv3, uuidv4, or uuidv5. 34 | * If no version is specified then it is assumed to be a generic guid which will not validate the 35 | * version or variant of the guid and just check for general structure format. 36 | */ 37 | guid(options?: Joi.GuidOptions): this; 38 | 39 | /** 40 | * Requires the string value to be a valid hexadecimal string. 41 | */ 42 | hex(): this; 43 | 44 | /** 45 | * Requires the string value to be a valid hostname as per RFC1123. 46 | */ 47 | hostname(): this; 48 | 49 | /** 50 | * Allows the value to match any value in the allowed list or disallowed list in a case insensitive comparison. 51 | * e.g. `@jf.string().valid('a').insensitive()` 52 | */ 53 | insensitive(): this; 54 | 55 | /** 56 | * Requires the string value to be a valid ip address. 57 | * @param options optional settings: 58 | * version - One or more IP address versions to validate against. Valid values: ipv4, ipv6, ipvfuture 59 | * cidr - Used to determine if a CIDR is allowed or not. Valid values: optional, required, forbidden 60 | */ 61 | ip(options?: Joi.IpOptions): this; 62 | 63 | /** 64 | * Requires the string value to be in valid ISO 8601 date format. 65 | * If the validation convert option is on (enabled by default), 66 | * the string will be forced to simplified extended ISO format (ISO 8601). 67 | * Be aware that this operation uses javascript Date object, 68 | * which does not support the full ISO format, so a few formats might not pass when using convert. 69 | */ 70 | isoDate(): this; 71 | 72 | /** 73 | * Specifies that the string must be in lowercase. 74 | */ 75 | lowercase(): this; 76 | 77 | /** 78 | * Specifies the maximum length. 79 | * @param length The maximum length. 80 | */ 81 | max(length: number): this; 82 | 83 | /** 84 | * Specifies the minimum length. 85 | * @param length The minimum length. 86 | */ 87 | min(length: number): this; 88 | 89 | /** 90 | * Defines a pattern rule. 91 | * @param pattern A regular expression object the string value must match against. 92 | * @param name Optional name for patterns (useful with multiple patterns). 93 | */ 94 | pattern(pattern: RegExp, name?: string): this; 95 | 96 | /** 97 | * Defines a pattern rule. 98 | * @param pattern A regular expression object the string value must match against. 99 | * @param name Optional name for patterns (useful with multiple patterns). 100 | */ 101 | regex(pattern: RegExp, name?: string): this; 102 | 103 | /** 104 | * Replace characters matching the given pattern with the specified replacement string. 105 | * @param pattern A regular expression object to match against, or a string of which 106 | * all occurrences will be replaced. 107 | * @param replacement The string that will replace the pattern. 108 | */ 109 | replace(pattern: RegExp, replacement: string): this; 110 | 111 | /** 112 | * Requires the string value to only contain a-z, A-Z, 0-9, and underscore _. 113 | */ 114 | token(): this; 115 | 116 | /** 117 | * Requires the string value to contain no whitespace before or after. 118 | * If the validation convert option is on (enabled by default), the string will be trimmed. 119 | */ 120 | trim(): this; 121 | 122 | /** 123 | * Specifies that the string must be in uppercase. 124 | */ 125 | uppercase(): this; 126 | 127 | /** 128 | * Requires the string value to be a valid RFC 3986 URI. 129 | * @param options Optional settings: 130 | * scheme - Specifies one or more acceptable Schemes, should only include the scheme name. 131 | * Can be an Array or String (strings are automatically escaped for use in a Regular Expression). 132 | * allowRelative - Allow relative URIs. Defaults to false. 133 | * relativeOnly - Restrict only relative URIs. Defaults to false. 134 | * allowQuerySquareBrackets - Allows unencoded square brackets inside the query string. 135 | * This is NOT RFC 3986 compliant but query strings like abc[]=123&abc[]=456 are very 136 | * common these days. Defaults to false. 137 | * domain - Validate the domain component using the options specified in string.domain(). 138 | */ 139 | uri(options?: Joi.UriOptions): this; 140 | } 141 | 142 | export function getStringSchemaModifierProviders(getJoi: () => typeof Joi) { 143 | const result: ModifierProviders = { 144 | ...getAnySchemaModifierProviders(getJoi), 145 | alphanum: () => ({ schema }) => schema.alphanum(), 146 | creditCard: () => ({ schema }) => schema.creditCard(), 147 | email: (options?: Joi.EmailOptions) => ({ schema }) => schema.email(options), 148 | exactLength: (length: number) => ({ schema }) => schema.length(length), 149 | guid: (options?: Joi.GuidOptions) => ({ schema }) => schema.guid(options), 150 | hex: () => ({ schema }) => schema.hex(), 151 | hostname: () => ({ schema }) => schema.hostname(), 152 | insensitive: () => ({ schema }) => schema.insensitive(), 153 | ip: (options?: Joi.IpOptions) => ({ schema }) => schema.ip(options), 154 | isoDate: () => ({ schema }) => schema.isoDate(), 155 | lowercase: () => ({ schema }) => schema.lowercase(), 156 | max: (length: number) => ({ schema }) => schema.max(length), 157 | min: (length: number) => ({ schema }) => schema.min(length), 158 | pattern: (pattern: RegExp, name?: string) => ({ schema }) => schema.regex(pattern, name), 159 | regex: (pattern: RegExp, name?: string) => ({ schema }) => schema.regex(pattern, name), 160 | replace: (pattern: RegExp, replacement: string) => ({ schema }) => schema.replace(pattern, replacement), 161 | token: () => ({ schema }) => schema.token(), 162 | trim: () => ({ schema }) => schema.trim(), 163 | uppercase: () => ({ schema }) => schema.uppercase(), 164 | uri: (options?: Joi.UriOptions) => ({ schema }) => schema.uri(options), 165 | }; 166 | return result; 167 | } 168 | 169 | export interface StringSchemaDecorator extends 170 | StringSchemaModifiers, 171 | TypedPropertyDecorator { 172 | } 173 | 174 | export const createStringPropertyDecorator = (joifulOptions: JoifulOptions): StringSchemaDecorator => ( 175 | createPropertyDecorator()( 176 | ({ joi }) => joi.string(), 177 | getStringSchemaModifierProviders, 178 | joifulOptions, 179 | ) 180 | ); 181 | -------------------------------------------------------------------------------- /docs/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | balanced-match@^1.0.0: 6 | version "1.0.2" 7 | resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" 8 | integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== 9 | 10 | brace-expansion@^1.1.7: 11 | version "1.1.11" 12 | resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" 13 | integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== 14 | dependencies: 15 | balanced-match "^1.0.0" 16 | concat-map "0.0.1" 17 | 18 | concat-map@0.0.1: 19 | version "0.0.1" 20 | resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" 21 | integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= 22 | 23 | fs.realpath@^1.0.0: 24 | version "1.0.0" 25 | resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" 26 | integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= 27 | 28 | glob@^7.1.7: 29 | version "7.1.7" 30 | resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90" 31 | integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== 32 | dependencies: 33 | fs.realpath "^1.0.0" 34 | inflight "^1.0.4" 35 | inherits "2" 36 | minimatch "^3.0.4" 37 | once "^1.3.0" 38 | path-is-absolute "^1.0.0" 39 | 40 | handlebars@^4.7.7: 41 | version "4.7.7" 42 | resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" 43 | integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA== 44 | dependencies: 45 | minimist "^1.2.5" 46 | neo-async "^2.6.0" 47 | source-map "^0.6.1" 48 | wordwrap "^1.0.0" 49 | optionalDependencies: 50 | uglify-js "^3.1.4" 51 | 52 | inflight@^1.0.4: 53 | version "1.0.6" 54 | resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" 55 | integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= 56 | dependencies: 57 | once "^1.3.0" 58 | wrappy "1" 59 | 60 | inherits@2: 61 | version "2.0.4" 62 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" 63 | integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== 64 | 65 | json5@^2.2.0: 66 | version "2.2.0" 67 | resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3" 68 | integrity sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA== 69 | dependencies: 70 | minimist "^1.2.5" 71 | 72 | lru-cache@^5.1.1: 73 | version "5.1.1" 74 | resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" 75 | integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== 76 | dependencies: 77 | yallist "^3.0.2" 78 | 79 | lunr@^2.3.9: 80 | version "2.3.9" 81 | resolved "https://registry.yarnpkg.com/lunr/-/lunr-2.3.9.tgz#18b123142832337dd6e964df1a5a7707b25d35e1" 82 | integrity sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow== 83 | 84 | marked@^2.1.1: 85 | version "2.1.3" 86 | resolved "https://registry.yarnpkg.com/marked/-/marked-2.1.3.tgz#bd017cef6431724fd4b27e0657f5ceb14bff3753" 87 | integrity sha512-/Q+7MGzaETqifOMWYEA7HVMaZb4XbcRfaOzcSsHZEith83KGlvaSG33u0SKu89Mj5h+T8V2hM+8O45Qc5XTgwA== 88 | 89 | minimatch@^3.0.0, minimatch@^3.0.4: 90 | version "3.0.4" 91 | resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" 92 | integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== 93 | dependencies: 94 | brace-expansion "^1.1.7" 95 | 96 | minimist@^1.2.5: 97 | version "1.2.5" 98 | resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" 99 | integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== 100 | 101 | neo-async@^2.6.0: 102 | version "2.6.2" 103 | resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" 104 | integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== 105 | 106 | once@^1.3.0: 107 | version "1.4.0" 108 | resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" 109 | integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= 110 | dependencies: 111 | wrappy "1" 112 | 113 | onigasm@^2.2.5: 114 | version "2.2.5" 115 | resolved "https://registry.yarnpkg.com/onigasm/-/onigasm-2.2.5.tgz#cc4d2a79a0fa0b64caec1f4c7ea367585a676892" 116 | integrity sha512-F+th54mPc0l1lp1ZcFMyL/jTs2Tlq4SqIHKIXGZOR/VkHkF9A7Fr5rRr5+ZG/lWeRsyrClLYRq7s/yFQ/XhWCA== 117 | dependencies: 118 | lru-cache "^5.1.1" 119 | 120 | path-is-absolute@^1.0.0: 121 | version "1.0.1" 122 | resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" 123 | integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= 124 | 125 | progress@^2.0.3: 126 | version "2.0.3" 127 | resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" 128 | integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== 129 | 130 | shiki@^0.9.3: 131 | version "0.9.7" 132 | resolved "https://registry.yarnpkg.com/shiki/-/shiki-0.9.7.tgz#9c760254798a9bbc6df52bbd26f888486f780079" 133 | integrity sha512-rOoAmwRWDiGKjQ1GaSKmbp1J5CamCera+I+DMM3wG/phbwNYQPt1mrjBBZbK66v80Vl1/A9TTLgXVHMbgtOCIQ== 134 | dependencies: 135 | json5 "^2.2.0" 136 | onigasm "^2.2.5" 137 | vscode-textmate "5.2.0" 138 | 139 | source-map@^0.6.1: 140 | version "0.6.1" 141 | resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" 142 | integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== 143 | 144 | typedoc-default-themes@^0.12.10: 145 | version "0.12.10" 146 | resolved "https://registry.yarnpkg.com/typedoc-default-themes/-/typedoc-default-themes-0.12.10.tgz#614c4222fe642657f37693ea62cad4dafeddf843" 147 | integrity sha512-fIS001cAYHkyQPidWXmHuhs8usjP5XVJjWB8oZGqkTowZaz3v7g3KDZeeqE82FBrmkAnIBOY3jgy7lnPnqATbA== 148 | 149 | typedoc@0.21.6: 150 | version "0.21.6" 151 | resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.21.6.tgz#854bfa2d6b3ac818ac70aa4734a4d1ba93695595" 152 | integrity sha512-+4u3PEBjQdaL5/yfus5WJbjIdQHv7E/FpZq3cNki9BBdGmZhqnTF6JLIXDQ2EfVggojOJG9/soB5QVFgXRYnIw== 153 | dependencies: 154 | glob "^7.1.7" 155 | handlebars "^4.7.7" 156 | lunr "^2.3.9" 157 | marked "^2.1.1" 158 | minimatch "^3.0.0" 159 | progress "^2.0.3" 160 | shiki "^0.9.3" 161 | typedoc-default-themes "^0.12.10" 162 | 163 | uglify-js@^3.1.4: 164 | version "3.14.1" 165 | resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.14.1.tgz#e2cb9fe34db9cb4cf7e35d1d26dfea28e09a7d06" 166 | integrity sha512-JhS3hmcVaXlp/xSo3PKY5R0JqKs5M3IV+exdLHW99qKvKivPO4Z8qbej6mte17SOPqAOVMjt/XGgWacnFSzM3g== 167 | 168 | vscode-textmate@5.2.0: 169 | version "5.2.0" 170 | resolved "https://registry.yarnpkg.com/vscode-textmate/-/vscode-textmate-5.2.0.tgz#01f01760a391e8222fe4f33fbccbd1ad71aed74e" 171 | integrity sha512-Uw5ooOQxRASHgu6C7GVvUxisKXfSgW4oFlO+aa+PAkgmH89O3CXxEEzNRNtHSqtXFTl0nAC1uYj0GMSH27uwtQ== 172 | 173 | wordwrap@^1.0.0: 174 | version "1.0.0" 175 | resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" 176 | integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= 177 | 178 | wrappy@1: 179 | version "1.0.2" 180 | resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" 181 | integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= 182 | 183 | yallist@^3.0.2: 184 | version "3.1.1" 185 | resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" 186 | integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== 187 | -------------------------------------------------------------------------------- /src/validation.ts: -------------------------------------------------------------------------------- 1 | import { getJoiSchema, AnyClass, WORKING_SCHEMA_KEY, Constructor } from './core'; 2 | import * as Joi from 'joi'; 3 | import 'reflect-metadata'; 4 | 5 | /** 6 | * The minimal implementation of Joi required for this module. 7 | * (Do this for type safety in testing, without needing to mock the whole of Joi.) 8 | */ 9 | type JoiForValidator = Pick; 10 | 11 | export class NoValidationSchemaForClassError extends Error { 12 | constructor(Class: AnyClass) { 13 | const className = Class && Class.name || ''; 14 | const classNameText = className ? ` ${className}` : ''; 15 | const message = `No validation schema was found for class${classNameText}. Did you forget to decorate the class?`; 16 | super(message); 17 | } 18 | } 19 | 20 | export class MultipleValidationError extends Error { 21 | constructor( 22 | public readonly errors: Joi.ValidationError[], 23 | ) { 24 | super(); 25 | 26 | (Object).setPrototypeOf(this, MultipleValidationError.prototype); 27 | } 28 | } 29 | 30 | export interface ValidationResultPass { 31 | error: null; 32 | errors: null; 33 | warning: null; 34 | value: T; 35 | } 36 | 37 | export interface ValidationResultFail { 38 | error: Joi.ValidationError; 39 | errors: null; 40 | /* TODO implements `warning()` 41 | https://github.com/sideway/joi/blob/v17.3.0/API.md#anywarningcode-context 42 | */ 43 | warning: null; 44 | value: T; 45 | } 46 | 47 | export type ValidationResult = ValidationResultPass | ValidationResultFail; 48 | 49 | /** 50 | * Returns true if validation result passed validation. 51 | * @param validationResult The validation result to test. 52 | */ 53 | export function isValidationPass( 54 | validationResult: ValidationResult, 55 | ): validationResult is ValidationResultPass { 56 | return !validationResult.error; 57 | } 58 | 59 | /** 60 | * Returns true if validation result failed validation. 61 | * @param validationResult The validation result to test. 62 | */ 63 | export function isValidationFail( 64 | validationResult: ValidationResult, 65 | ): validationResult is ValidationResultFail { 66 | return !!validationResult.error; 67 | } 68 | 69 | export class InvalidValidationTarget extends Error { 70 | constructor() { 71 | super('Cannot validate null or undefined'); 72 | } 73 | } 74 | 75 | export interface ValidationOptions extends Joi.ValidationOptions { 76 | joi?: JoiForValidator; 77 | } 78 | 79 | export class Validator { 80 | constructor( 81 | private defaultOptions?: ValidationOptions, 82 | ) { 83 | } 84 | 85 | /** 86 | * Issue #117: Joi's `validate()` method dies when we pass it our own validation options, so we need to strip it 87 | * out. 88 | * @url https://github.com/joiful-ts/joiful/issues/117 89 | */ 90 | protected extractOptions(options: ValidationOptions | undefined): { 91 | joi: JoiForValidator; 92 | joiOptions?: Joi.ValidationOptions; 93 | } { 94 | if (!options) { 95 | return { 96 | joi: Joi, 97 | }; 98 | } else { 99 | const { joi, ...rest } = options; 100 | return { 101 | joi: joi || Joi, 102 | joiOptions: rest, 103 | }; 104 | } 105 | } 106 | 107 | /** 108 | * Validates an instance of a decorated class. 109 | * @param target Instance of decorated class to validate. 110 | * @param options Optional validation options to use. These override any default options. 111 | */ 112 | validate = (target: T, options?: ValidationOptions): ValidationResult => { 113 | if (target === null || target === undefined) { 114 | throw new InvalidValidationTarget(); 115 | } 116 | return this.validateAsClass(target, target.constructor as AnyClass, options); 117 | } 118 | 119 | /** 120 | * Validates a plain old javascript object against a decorated class. 121 | * @param target Object to validate. 122 | * @param clz Decorated class to validate against. 123 | * @param options Optional validation options to use. These override any default options. 124 | */ 125 | validateAsClass = < 126 | TClass extends Constructor, 127 | TInstance = TClass extends Constructor ? TInstance : never 128 | >( 129 | target: Partial | null | undefined, 130 | Class: TClass, 131 | options: ValidationOptions | undefined = this.defaultOptions, 132 | ): ValidationResult => { 133 | if (target === null || target === undefined) { 134 | throw new InvalidValidationTarget(); 135 | } 136 | 137 | const {joi, joiOptions} = this.extractOptions(options); 138 | const classSchema = getJoiSchema(Class, joi); 139 | 140 | if (!classSchema) { 141 | throw new NoValidationSchemaForClassError(Class); 142 | } 143 | 144 | const result = joiOptions ? 145 | classSchema.validate(target, joiOptions) : 146 | classSchema.validate(target); 147 | 148 | return { 149 | error: (result.error ? result.error : null), 150 | errors: null, 151 | warning: null, 152 | value: result.value as TInstance, 153 | } as ValidationResult; 154 | } 155 | 156 | /** 157 | * Validates an array of plain old javascript objects against a decorated class. 158 | * @param target Objects to validate. 159 | * @param clz Decorated class to validate against. 160 | * @param options Optional validation options to use. These override any default options. 161 | */ 162 | validateArrayAsClass = < 163 | TClass extends Constructor, 164 | TInstance = TClass extends Constructor ? TInstance : never 165 | >( 166 | target: Partial[], 167 | Class: TClass, 168 | options: ValidationOptions | undefined = this.defaultOptions, 169 | ): ValidationResult => { 170 | if (target === null || target === undefined) { 171 | throw new InvalidValidationTarget(); 172 | } 173 | 174 | const {joi, joiOptions} = this.extractOptions(options); 175 | const classSchema = getJoiSchema(Class, joi); 176 | if (!classSchema) { 177 | throw new NoValidationSchemaForClassError(Class); 178 | } 179 | const arraySchema = joi.array().items(classSchema); 180 | 181 | const result = joiOptions ? 182 | arraySchema.validate(target, joiOptions) : 183 | arraySchema.validate(target); 184 | return { 185 | error: (result.error ? result.error : null), 186 | errors: null, 187 | warning: null, 188 | value: result.value as TInstance[], 189 | } as ValidationResult; 190 | } 191 | } 192 | 193 | export const createValidatePropertyDecorator = (options: { validator?: Validator } | undefined): MethodDecorator => { 194 | const validator = (options || { validator: undefined }).validator || new Validator(); 195 | 196 | return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { 197 | const original = descriptor.value; 198 | descriptor.value = function (this: any, ...args: any[]) { 199 | const types = Reflect.getMetadata('design:paramtypes', target, propertyKey); 200 | const failures: Joi.ValidationError[] = []; 201 | const newArgs: any[] = []; 202 | for (let i = 0; i < args.length; i++) { 203 | const arg = args[i]; 204 | const argType = types[i]; 205 | // TODO: Use `getWorkingSchema`? 206 | const workingSchema = Reflect.getMetadata(WORKING_SCHEMA_KEY, argType.prototype); 207 | if (workingSchema) { 208 | let result = validator.validateAsClass(arg, argType); 209 | if (result.error != null) { 210 | failures.push(result.error); 211 | } 212 | newArgs.push(result.value); 213 | } else { 214 | newArgs.push(arg); 215 | } 216 | } 217 | if (failures.length > 0) { 218 | throw new MultipleValidationError(failures); 219 | } else { 220 | return original.apply(this, newArgs); 221 | } 222 | }; 223 | return descriptor; 224 | }; 225 | }; 226 | -------------------------------------------------------------------------------- /test/unit/joiful.test.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { testConstraint } from './testUtil'; 3 | import { Joiful, string, boolean } from '../../src'; 4 | import { Validator, MultipleValidationError } from '../../src'; 5 | import * as Case from 'case'; 6 | import { IncompatibleJoiVersion } from '../../src/core'; 7 | 8 | describe('joiful', () => { 9 | describe('when using the default instance of Joiful', () => { 10 | class Login { 11 | @string() 12 | .email() 13 | .required() 14 | .label('Email Address') 15 | emailAddress!: string; 16 | 17 | @string() 18 | .empty('') 19 | .required() 20 | .exactLength(8) 21 | .label('Password') 22 | password!: string; 23 | } 24 | 25 | testConstraint( 26 | () => { 27 | return Login; 28 | }, 29 | [ 30 | { emailAddress: 'email@example.com', password: 'password' }, 31 | ], 32 | [ 33 | { emailAddress: 'nope', password: 'password' }, 34 | { emailAddress: 'email@example.com', password: '' }, 35 | { emailAddress: 'email@example.com', password: 'nope' }, 36 | ], 37 | ); 38 | }); 39 | 40 | describe('when constructing an isolated instance of Joiful', () => { 41 | let jf: Joiful; 42 | 43 | beforeEach(() => { 44 | jf = new Joiful(); 45 | }); 46 | 47 | testConstraint( 48 | () => { 49 | class Login { 50 | @jf.string() 51 | .email() 52 | .required() 53 | .label('Email Address') 54 | emailAddress!: string; 55 | 56 | @jf.string() 57 | .empty('') 58 | .required() 59 | .exactLength(8) 60 | .label('Password') 61 | password!: string; 62 | } 63 | return Login; 64 | }, 65 | [ 66 | { emailAddress: 'email@example.com', password: 'password' }, 67 | ], 68 | [ 69 | { emailAddress: 'nope', password: 'password' }, 70 | { emailAddress: 'email@example.com', password: '' }, 71 | { emailAddress: 'email@example.com', password: 'nope' }, 72 | ], 73 | ); 74 | 75 | it('should error if joi version does not match the major version of joi expected by joiful', () => { 76 | const createJoiful = () => { 77 | const jf = new Joiful({ 78 | joi: { version: '-1.0.0' } as any as typeof Joi, 79 | }); 80 | return jf; 81 | }; 82 | 83 | expect(createJoiful).toThrowError(new IncompatibleJoiVersion({ major: '-1', minor: '0', patch: '0' })); 84 | }); 85 | 86 | describe('and specifying a label provider', () => { 87 | beforeEach(() => { 88 | jf = new Joiful({ 89 | labelProvider: (propertyKey) => Case.sentence(`${propertyKey}`), 90 | }); 91 | }); 92 | 93 | it('should use the label provider to generate property labels', () => { 94 | class MarketingForm { 95 | @jf.boolean().required() 96 | signUpForSpam!: boolean; 97 | } 98 | 99 | const validator = new Validator(); 100 | const result = validator.validateAsClass({}, MarketingForm); 101 | 102 | expect(result.error).toBeTruthy(); 103 | expect(result.error!.message).toContain('Sign up for spam'); 104 | expect(result.error!.message).not.toContain('signUpForSpam'); 105 | }); 106 | 107 | it('should allow explicit label calls to override automatically generated labels', () => { 108 | class MarketingForm { 109 | @jf.boolean().required() 110 | signUpForSpam!: boolean; 111 | 112 | @jf.boolean().required().label('Free candy') 113 | allowSellingOfMyData!: boolean; 114 | } 115 | 116 | const validator = new Validator({ abortEarly: false }); 117 | const result = validator.validateAsClass({}, MarketingForm); 118 | 119 | expect(result.error).toBeTruthy(); 120 | 121 | expect(result.error!.message).toContain('Sign up for spam'); 122 | expect(result.error!.message).not.toContain('signUpForSpam'); 123 | 124 | expect(result.error!.message).toContain('Free candy'); 125 | expect(result.error!.message).not.toContain('allowSellingOfMyData'); 126 | }); 127 | 128 | it('should not effect labels of classes decorated using Joiful default instance decorators', () => { 129 | class AnotherMarketingForm { 130 | @boolean().required() 131 | signUpForSpam!: boolean; 132 | } 133 | 134 | const validator = new Validator(); 135 | const result = validator.validateAsClass({}, AnotherMarketingForm); 136 | 137 | expect(result.error).toBeTruthy(); 138 | expect(result.error!.message).not.toContain('Sign up for spam'); 139 | expect(result.error!.message).toContain('signUpForSpam'); 140 | }); 141 | 142 | it('should not generate labels if output of label provider is not a string', () => { 143 | jf = new Joiful({ 144 | labelProvider: () => undefined, 145 | }); 146 | 147 | const getForm = () => { 148 | class MarketingForm { 149 | @jf.boolean().required() 150 | signUpForSpam!: boolean; 151 | } 152 | return MarketingForm; 153 | }; 154 | 155 | const validator = new Validator(); 156 | const result = validator.validateAsClass({}, getForm()); 157 | 158 | expect(result.error).toBeTruthy(); 159 | expect(result.error!.message).toContain('signUpForSpam'); 160 | }); 161 | }); 162 | 163 | it('should provide method to get the Joi schema for a class', () => { 164 | class ForgotPassword { 165 | emailAddress?: string; 166 | } 167 | 168 | expect(jf.getSchema(ForgotPassword)).toBe(undefined); 169 | 170 | class Login { 171 | @jf.string().email().required() 172 | emailAddress?: string; 173 | 174 | @jf.string().min(8).required() 175 | password?: string; 176 | } 177 | 178 | expect(jf.getSchema(Login)).toBeTruthy(); 179 | }); 180 | 181 | it('should provide method to test if class has a schema', () => { 182 | class ForgotPassword { 183 | emailAddress?: string; 184 | } 185 | 186 | expect(jf.hasSchema(ForgotPassword)).toBe(false); 187 | 188 | class Login { 189 | @jf.string().email().required() 190 | emailAddress?: string; 191 | 192 | @jf.string().min(8).required() 193 | password?: string; 194 | } 195 | 196 | expect(jf.hasSchema(Login)).toBe(true); 197 | }); 198 | }); 199 | }); 200 | 201 | describe('validate', () => { 202 | let jf: Joiful; 203 | 204 | beforeEach(() => jf = new Joiful()); 205 | 206 | it('automatically validates arguments passed into a method', () => { 207 | class Passcode { 208 | @jf.string().alphanum().exactLength(6) 209 | code!: string; 210 | } 211 | 212 | class PasscodeChecker { 213 | @jf.validateParams() 214 | check(passcode: Passcode, basicArg: number) { 215 | expect(passcode).not.toBeNull(); 216 | expect(basicArg).not.toBeNull(); 217 | } 218 | } 219 | 220 | const passcode = new Passcode(); 221 | passcode.code = 'abc'; 222 | 223 | const checker = new PasscodeChecker(); 224 | expect(() => checker.check(passcode, 5)).toThrow(MultipleValidationError); 225 | 226 | passcode.code = 'abcdef'; 227 | checker.check(passcode, 5); 228 | }); 229 | 230 | it('can use a custom validator', () => { 231 | class Passcode { 232 | @jf.string().alphanum().exactLength(6) 233 | code!: string; 234 | } 235 | 236 | const validator = new Validator(); 237 | jest.spyOn(validator, 'validateAsClass').mockImplementation((value: any) => ({ 238 | error: null, 239 | errors: null, 240 | warning: null, 241 | value, 242 | })); 243 | 244 | class PasscodeChecker { 245 | @jf.validateParams({ validator }) 246 | check(passcode: Passcode, basicArg: number) { 247 | expect(passcode).not.toBeNull(); 248 | expect(basicArg).not.toBeNull(); 249 | } 250 | } 251 | 252 | const passcode = { code: 'abcdef' }; 253 | 254 | const checker = new PasscodeChecker(); 255 | checker.check(passcode, 5); 256 | 257 | expect(validator.validateAsClass).toHaveBeenCalledWith(passcode, Passcode); 258 | }); 259 | }); 260 | -------------------------------------------------------------------------------- /test/unit/examples.test.ts: -------------------------------------------------------------------------------- 1 | //import { getJoiSchema } from '../../src/core'; 2 | import * as Joi from 'joi'; 3 | import { Joiful, array, object, string, Validator } from '../../src'; 4 | import { testConstraint } from './testUtil'; 5 | import { StringSchema } from 'joi'; 6 | 7 | describe('Examples', () => { 8 | it('class with methods', () => { 9 | class ClassToValidate { 10 | @string().exactLength(5) 11 | public myProperty!: string; 12 | 13 | public myMethod() { 14 | 15 | } 16 | } 17 | 18 | const instance = new ClassToValidate(); 19 | instance.myProperty = 'abcde'; 20 | 21 | expect(instance).toBeValid(); 22 | 23 | //instance.myMethod(); 24 | }); 25 | 26 | it('class with unvalidated properties', () => { 27 | class ClassToValidate { 28 | @string().exactLength(5) 29 | public myProperty!: string; 30 | 31 | public myOtherProperty!: string; 32 | } 33 | 34 | const instance = new ClassToValidate(); 35 | instance.myProperty = 'abcde'; 36 | instance.myOtherProperty = 'abcde'; 37 | 38 | expect(instance).not.toBeValid(); 39 | }); 40 | 41 | it('class with static properties', () => { 42 | class ClassToValidate { 43 | static STATIC_PROPERTY = 'bloop'; 44 | 45 | @string().exactLength(5) 46 | public myProperty!: string; 47 | 48 | } 49 | 50 | const instance = new ClassToValidate(); 51 | instance.myProperty = 'abcde'; 52 | 53 | expect(instance).toBeValid(); 54 | }); 55 | 56 | it('nested class', () => { 57 | class InnerClass { 58 | @string() 59 | public innerProperty!: string; 60 | } 61 | 62 | class ClassToValidate { 63 | @object() 64 | public myProperty!: InnerClass; 65 | } 66 | 67 | const instance = new ClassToValidate(); 68 | instance.myProperty = { 69 | innerProperty: 'abcde', 70 | }; 71 | 72 | expect(instance).toBeValid(); 73 | 74 | instance.myProperty.innerProperty = 1234; 75 | expect(instance).not.toBeValid(); 76 | }); 77 | 78 | it('link for recursive data structures', () => { 79 | class TreeNode { 80 | @string().required() 81 | tagName!: string; 82 | 83 | // . - the link 84 | // .. - the children array 85 | // ... - the TreeNode class 86 | @array().items((joi) => joi.link('...')) 87 | children!: TreeNode[]; 88 | } 89 | 90 | const instance = new TreeNode(); 91 | instance.tagName = 'outer'; 92 | instance.children = [ 93 | { 94 | tagName: 'inner', 95 | children: [], 96 | }, 97 | ]; 98 | 99 | expect(instance).toBeValid(); 100 | }); 101 | 102 | describe('creating your own reusable decorators', () => { 103 | // Remember you may need to create your own decroators in a separate 104 | // file to where they are being used, to ensure that they exist before 105 | // they are ran against your class. In the example below we get around 106 | // that trap by creating our class in a function, so the decorators 107 | // execution is delayed until the function gets called 108 | 109 | const password = () => string() 110 | .min(8) 111 | .regex(/[a-z]/) 112 | .regex(/[A-Z]/) 113 | .regex(/[0-9]/) 114 | .required(); 115 | 116 | testConstraint( 117 | () => { 118 | class SetPasswordForm { 119 | @password() 120 | password!: string; 121 | } 122 | return SetPasswordForm; 123 | }, 124 | [ 125 | { password: 'Password123' }, 126 | ], 127 | [ 128 | {}, 129 | { password: 'password123' }, 130 | { password: 'PASSWORD123' }, 131 | { password: 'Password' }, 132 | { password: 'Pass123' }, 133 | ], 134 | ); 135 | }); 136 | 137 | /** 138 | * @see https://hapi.dev/family/joi/api/?v=15.1.1#extendextension 139 | */ 140 | it('Extending Joi for custom validation', () => { 141 | // Custom validation functions must be added by using Joi's "extend" mechanism. 142 | 143 | // These are utility types you may find useful to replace the return of a function. 144 | // These types are derived from: https://stackoverflow.com/a/50014868 145 | type ReplaceReturnType unknown, TNewReturn> = 146 | (...a: Parameters) => TNewReturn; 147 | 148 | // We are going to create a new instance of Joi, with our extended functionality: a custom validation 149 | // function that checks if each character in a string has "alternating case" (that is, each character has a 150 | // case different to those either side of it). 151 | 152 | // For our own peace of mind, we're first going to update the type of the Joi instance to include our new 153 | // schema. 154 | 155 | interface ExtendedStringSchema extends StringSchema { 156 | alternatingCase(): this; // We're adding this method, only for string schemas. 157 | } 158 | 159 | // Need to alias this, because `interface Foo extends typeof Joi` doesn't work. 160 | type OriginalJoi = typeof Joi; 161 | 162 | interface CustomJoi extends OriginalJoi { 163 | // This allows us to use our extended string schema, in place of Joi's original StringSchema. 164 | // E.g. instead of `Joi.string()` returning `StringSchema`, it now returns `ExtendedStringSchema`. 165 | string: ReplaceReturnType; 166 | } 167 | 168 | // This is our where we define our custom rule. Please read the Joi documentation for more info. 169 | // NOTE: we must explicitly provide the type annotation of `CustomJoi`. 170 | const customJoi: CustomJoi = Joi.extend((joi) => { 171 | return { 172 | base: joi.string(), // The base Joi schema 173 | type: 'string', 174 | 175 | rules: { 176 | alternatingCase: { 177 | validate(value: string, helpers) { 178 | // Your validation implementation would go here. 179 | if (value.length < 2) { 180 | return true; 181 | } 182 | let lastCase = null; 183 | for (let char of value) { 184 | const charIsUppercase = /[A-Z]/.test(char); 185 | if (charIsUppercase === lastCase) { // Not alternating case 186 | // Validation failures must return a Joi error. 187 | // You'll need to allow a suspicious use of "this" here, so that we can access the 188 | // Joi instance's `createError()` method. 189 | // tslint:disable-next-line:no-invalid-this 190 | return helpers.error('string.case'); 191 | } 192 | lastCase = charIsUppercase; 193 | } 194 | return value; 195 | }, 196 | }, 197 | }, 198 | }; 199 | }); 200 | 201 | // This function is how we're going to make use of our custom validator. 202 | function alternatingCase(options: { schema: Joi.Schema, joi: typeof Joi }): Joi.Schema { 203 | // (TODO: remove the `as CustomJoi` assertion. Requires making Joiful, JoifulOptions etc generic.) 204 | return (options.joi as CustomJoi).string().alternatingCase(); 205 | } 206 | 207 | const customJoiful = new Joiful({ 208 | joi: customJoi, 209 | }); 210 | 211 | class ThingToValidate { 212 | // Note that we must _always_ use our own `customJoiful` for all decorators, instead of importing them 213 | // directly from Joiful (e.g. `customJoiful.string()` vs `jf.string()`) 214 | // Failing to do so means Joiful will use the default instance of Joi, which could cause inconsistent 215 | // behaviour, and prevent us from using our custom validator. 216 | @customJoiful.string().custom(alternatingCase) 217 | public propertyToValidate: string; 218 | 219 | constructor( 220 | propertyToValidate: string, 221 | ) { 222 | this.propertyToValidate = propertyToValidate; 223 | } 224 | } 225 | 226 | // Finally, we need to pass our custom Joi instance to our Validator instance. 227 | const validator = new Validator({ 228 | joi: customJoi, 229 | }); 230 | 231 | // Execute & verify 232 | let instance = new ThingToValidate( 233 | 'aBcDeFgH', 234 | ); 235 | const assertionOptions = { validator }; 236 | expect(instance).toBeValid(assertionOptions); 237 | 238 | instance = new ThingToValidate( 239 | 'AbCdEfGh', 240 | ); 241 | expect(instance).toBeValid(assertionOptions); 242 | 243 | instance = new ThingToValidate( 244 | 'abcdefgh', 245 | ); 246 | expect(instance).not.toBeValid(assertionOptions); 247 | }); 248 | }); 249 | -------------------------------------------------------------------------------- /src/decorators/any.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { createPropertyDecorator, JoifulOptions, ModifierProviders, NotImplemented } from './common'; 3 | import { TypedPropertyDecorator } from '../core'; 4 | 5 | export interface AnySchemaModifiers { 6 | /** 7 | * Whitelists values. 8 | * Note that this list of allowed values is in addition to any other permitted values. 9 | * To create an exclusive list of values, use the `Valid` decorator. 10 | * @param values Values to be whitelisted. 11 | */ 12 | allow(value: any, ...values: any[]): this; 13 | 14 | /** 15 | * Adds the provided values into the allowed whitelist for property 16 | * and marks them as the only valid values allowed. 17 | * @param values The only valid values this property can accept. 18 | */ 19 | valid(value: any, ...values: any[]): this; 20 | valid(values: any[]): this; 21 | 22 | /** 23 | * Adds the provided values into the allowed whitelist for property 24 | * and marks them as the only valid values allowed. 25 | */ 26 | only(): this; 27 | 28 | /** 29 | * Adds the provided values into the allowed whitelist for property 30 | * and marks them as the only valid values allowed. 31 | * @param values The only valid values this property can accept. 32 | */ 33 | equal(value: any, ...values: any[]): this; 34 | equal(values: any[]): this; 35 | 36 | /** 37 | * Blacklists values for this property. 38 | * @param values Values to be blacklisted. 39 | */ 40 | invalid(value: any, ...values: any[]): this; 41 | invalid(values: any[]): this; 42 | 43 | /** 44 | * Blacklists values for this property. 45 | * @param values Values to be blacklisted. 46 | */ 47 | disallow(value: any, ...values: any[]): this; 48 | disallow(values: any[]): this; 49 | 50 | /** 51 | * Blacklists values for this property. 52 | * @param values Values to be blacklisted. 53 | */ 54 | not(value: any, ...values: any[]): this; 55 | not(values: any[]): this; 56 | 57 | /** 58 | * Marks a key as required which will not allow undefined as value. All keys are optional by default. 59 | */ 60 | required(): this; 61 | 62 | /** 63 | * Marks a key as optional which will allow undefined as values. 64 | * Used to annotate the schema for readability as all keys are optional by default. 65 | */ 66 | optional(): this; 67 | 68 | /** 69 | * Marks a key as forbidden which will not allow any value except undefined. Used to explicitly forbid keys. 70 | */ 71 | forbidden(): this; 72 | 73 | /** 74 | * Marks a key to be removed from a resulting object or array after validation. Used to sanitize output. 75 | */ 76 | strip(): this; 77 | 78 | /** 79 | * Annotates the key 80 | */ 81 | description(desc: string): this; 82 | 83 | /** 84 | * Annotates the key 85 | */ 86 | note(notes: string | string[]): this; 87 | 88 | /** 89 | * Annotates the key 90 | */ 91 | tag(tag: string, ...tags: string[]): this; 92 | tag(tags: string | string[]): this; 93 | 94 | /** 95 | * Attaches metadata to the key. 96 | */ 97 | meta(meta: Object): this; 98 | 99 | /** 100 | * Annotates the key with an example value, must be valid. 101 | */ 102 | example(value: any): this; 103 | 104 | /** 105 | * Annotates the key with an unit name. 106 | */ 107 | unit(name: string): this; 108 | 109 | /** 110 | * Overrides the global validate() options for the current key and any sub-key. 111 | */ 112 | options(options: Joi.ValidationOptions): this; 113 | 114 | /** 115 | * Sets the options.convert options to false which prevent type casting for the current key and any child keys. 116 | */ 117 | strict(isStrict?: boolean): this; 118 | 119 | /** 120 | * Sets a default value. 121 | * @param value - the value. 122 | */ 123 | default(value: any): this; 124 | 125 | /** 126 | * Overrides the key name in error messages. 127 | * @param label The label to use. 128 | */ 129 | label(label: string): this; 130 | 131 | /** 132 | * Outputs the original untouched value instead of the casted value. 133 | */ 134 | raw(isRaw?: boolean): this; 135 | 136 | /** 137 | * Considers anything that matches the schema to be empty (undefined). 138 | * @param schema - any object or joi schema to match. An undefined schema unsets that rule. 139 | */ 140 | empty(schema?: any): this; 141 | 142 | /** 143 | * Overrides the default joi error with a custom error if the rule fails where: 144 | * @param err - can be: 145 | * an instance of `Error` - the override error. 146 | * a `function (errors)`, taking an array of errors as argument, where it must either: 147 | * return a `string` - substitutes the error message with this text 148 | * return a single `object` or an `Array` of it, where: 149 | * `type` - optional parameter providing the type of the error (eg. `number.min`). 150 | * `message` - optional parameter if `template` is provided, containing the text of the error. 151 | * `template` - optional parameter if `message` is provided, containing a template string, 152 | * using the same format as usual joi language errors. 153 | * `context` - optional parameter, to provide context to your error if you are using the `template`. 154 | * return an `Error` - same as when you directly provide an `Error`, 155 | * but you can customize the error message based on the errors. 156 | * Note that if you provide an `Error`, it will be returned as-is, unmodified and undecorated with any of the 157 | * normal joi error properties. If validation fails and another error is found before the error 158 | * override, that error will be returned and the override will be ignored (unless the `abortEarly` 159 | * option has been set to `false`). 160 | */ 161 | error(err: Error | Joi.ValidationErrorFunction): this; 162 | 163 | /** 164 | * Allows specify schemas directly via Joi's schema api. 165 | */ 166 | custom: (schemaBuilder: (options: { schema: Joi.Schema, joi: typeof Joi }) => Joi.Schema) => this; 167 | } 168 | 169 | export function getAnySchemaModifierProviders(getJoi: () => typeof Joi) { 170 | const result: ModifierProviders = { 171 | allow: (value: any, ...values: any[]) => ({ schema }) => 172 | schema.allow(...(value instanceof Array ? [...value, ...values] : [value, ...values])) as TSchema, 173 | valid: (value: any, ...values: any[]) => ({ schema }) => 174 | schema.valid(...(value instanceof Array ? [...value, ...values] : [value, ...values])) as TSchema, 175 | only: () => ({ schema }) => schema.only() as TSchema, 176 | equal: (value: any, ...values: any[]) => ({ schema }) => 177 | schema.equal(...(value instanceof Array ? [...value, ...values] : [value, ...values])) as TSchema, 178 | 179 | required: () => ({ schema }) => schema.required() as TSchema, 180 | optional: () => ({ schema }) => schema.optional() as TSchema, 181 | 182 | invalid: (value: any, ...values: any[]) => ({ schema }) => schema.invalid(value, ...values) as TSchema, 183 | disallow: (value: any, ...values: any[]) => ({ schema }) => schema.disallow(value, ...values) as TSchema, 184 | not: (value: any, ...values: any[]) => ({ schema }) => schema.not(value, ...values) as TSchema, 185 | 186 | forbidden: () => ({ schema }) => schema.forbidden() as TSchema, 187 | 188 | strip: () => ({ schema }) => schema.strip() as TSchema, 189 | 190 | description: (description: string) => ({ schema }) => schema.description(description) as TSchema, 191 | 192 | note: (notes: string | string[]) => ({ schema }) => schema.note(notes as any) as TSchema, 193 | 194 | tag: (tag: string | string[], ...tags: string[]) => ({ schema }) => 195 | schema.tag(...(tag instanceof Array ? [...tag, ...tags] : [tag, ...tags])) as TSchema, 196 | 197 | meta: (meta: Object) => ({ schema }) => schema.meta(meta) as TSchema, 198 | 199 | example: (value: any) => ({ schema }) => schema.example(value) as TSchema, 200 | 201 | unit: (name: string) => ({ schema }) => schema.unit(name) as TSchema, 202 | 203 | options: (options: Joi.ValidationOptions) => ({ schema }) => schema.options(options) as TSchema, 204 | 205 | strict: (isStrict = true) => ({ schema }) => schema.strict(isStrict) as TSchema, 206 | 207 | default: (value: any) => ({ schema }) => schema.default(value) as TSchema, 208 | 209 | label: (label: string) => ({ schema }) => schema.label(label) as TSchema, 210 | 211 | raw: (isRaw = true) => ({ schema }) => schema.raw(isRaw) as TSchema, 212 | 213 | empty: (schema?: any) => ({ schema: existingSchema }) => existingSchema.empty(schema) as TSchema, 214 | 215 | error: (err: Error | Joi.ValidationErrorFunction) => ({ schema }) => { 216 | if (!schema.error) { 217 | throw new NotImplemented('Joi.error'); 218 | } 219 | return schema.error(err) as TSchema; 220 | }, 221 | 222 | custom: (schemaBuilder) => ({ schema }) => schemaBuilder({ schema, joi: getJoi() }) as TSchema, 223 | }; 224 | return result; 225 | } 226 | 227 | export interface AnySchemaDecorator extends 228 | AnySchemaModifiers, 229 | TypedPropertyDecorator { 230 | } 231 | 232 | export const createAnyPropertyDecorator = (joifulOptions: JoifulOptions): AnySchemaDecorator => ( 233 | createPropertyDecorator()( 234 | ({ joi }) => joi.any(), 235 | getAnySchemaModifierProviders, 236 | joifulOptions, 237 | ) 238 | ); 239 | -------------------------------------------------------------------------------- /test/unit/decorators/array.test.ts: -------------------------------------------------------------------------------- 1 | import { testConstraint } from '../testUtil'; 2 | import { array, joi, string } from '../../../src'; 3 | import { Validator } from '../../../src/validation'; 4 | 5 | describe('array', () => { 6 | describe('without an element type', () => { 7 | testConstraint( 8 | () => { 9 | class Pizza { 10 | @array().required() 11 | toppings!: string[]; 12 | } 13 | return Pizza; 14 | }, 15 | [ 16 | { toppings: ['pepperoni', 'cheese'] }, 17 | { toppings: [1, 3] }, 18 | { toppings: [1, 'cheese'] }, 19 | { toppings: [] }, 20 | ], 21 | [ 22 | { 23 | toppings: 'cheese', 24 | }, 25 | { 26 | toppings: 1 as any, 27 | }, 28 | ], 29 | ); 30 | }); 31 | 32 | describe('with an element type', () => { 33 | testConstraint( 34 | () => { 35 | class Actor { 36 | @string().required() 37 | name!: string; 38 | } 39 | 40 | class Movie { 41 | @string().required() 42 | name!: string; 43 | 44 | @array({ elementClass: Actor }).required() 45 | actors!: Actor[]; 46 | } 47 | 48 | return Movie; 49 | }, 50 | [ 51 | { 52 | name: 'The Faketrix', 53 | actors: [ 54 | { name: 'Laurence Fishberg' }, 55 | { name: 'Keanu Wick' }, 56 | { name: 'Carrie-Anne More' }, 57 | ], 58 | }, 59 | ], 60 | [ 61 | { 62 | name: 'The Faketrix', 63 | actors: [ 64 | { name: 'Laurence Fishberg' }, 65 | {}, 66 | { name: 'Carrie-Anne More' }, 67 | ], 68 | }, 69 | { 70 | name: 'The Faketrix', 71 | actors: [1, 2, 3], 72 | }, 73 | { 74 | name: 'The Faketrix', 75 | actors: [ 76 | 'Laurence Fishberg', 77 | 'Keanu Wick', 78 | 'Carrie-Anne More', 79 | ], 80 | }, 81 | ], 82 | ); 83 | }); 84 | 85 | describe('with an element type without schema', () => { 86 | it('should pass validation with an invalid object', () => { 87 | class Actor { 88 | name!: string; 89 | } 90 | 91 | class Movie { 92 | @string().required() 93 | name!: string; 94 | 95 | @array({ elementClass: Actor }).required() 96 | actors!: Actor[]; 97 | } 98 | 99 | const movie = { 100 | name: 'The Faketrix', 101 | actors: [ 102 | { name: 'Laurence Fishberg' }, 103 | { boo: 'Carrie-Anne More' }, 104 | ], 105 | }; 106 | 107 | const validator = new Validator(); 108 | 109 | const result = validator.validateAsClass(movie, Movie); 110 | 111 | expect(result.error).toBeFalsy(); 112 | 113 | }); 114 | }); 115 | 116 | describe('items', () => { 117 | testConstraint( 118 | () => { 119 | class MoviesQuiz { 120 | @array() 121 | .items( 122 | (joi) => [ 123 | joi.string().empty('').required().min(6), 124 | joi.number().required(), 125 | ], 126 | ) 127 | favouriteMovies!: (string | number)[]; 128 | } 129 | 130 | return MoviesQuiz; 131 | }, 132 | [{ favouriteMovies: ['Citizen Kane', 'Casablanca', 'The Matrix Reloaded: Revenge of the Smiths', 7] }], 133 | [{ favouriteMovies: [''] }, { favouriteMovies: [false as any] }], 134 | ); 135 | 136 | testConstraint( 137 | () => { 138 | class MoviesQuiz { 139 | @array() 140 | .items( 141 | joi.string().empty('').required().min(6), 142 | joi.number().required(), 143 | ) 144 | favouriteMovies!: (string | number)[]; 145 | } 146 | 147 | return MoviesQuiz; 148 | }, 149 | [{ favouriteMovies: ['Citizen Kane', 'Casablanca', 'The Matrix Reloaded: Revenge of the Smiths', 7] }], 150 | [{ favouriteMovies: [''] }, { favouriteMovies: [false as any] }], 151 | ); 152 | }); 153 | 154 | describe('exactLength', () => { 155 | testConstraint( 156 | () => { 157 | class TodoList { 158 | @array().items((joi) => joi.string()).exactLength(2) 159 | todos?: string[]; 160 | } 161 | return TodoList; 162 | }, 163 | [ 164 | { todos: ['Write todo app', 'Feed the cats'] }, 165 | ], 166 | [ 167 | { todos: [] }, 168 | { todos: ['Write todo app'] }, 169 | { todos: ['Write todo app', 'Feed the cats', 'Do the things'] }, 170 | ], 171 | ); 172 | }); 173 | 174 | describe('max', () => { 175 | testConstraint( 176 | () => { 177 | class TodoList { 178 | @array().max(2) 179 | todos?: string[]; 180 | } 181 | return TodoList; 182 | }, 183 | [ 184 | { todos: [] }, 185 | { todos: ['Write todo app'] }, 186 | { todos: ['Write todo app', 'Feed the cats'] }, 187 | ], 188 | [ 189 | { todos: ['Write todo app', 'Feed the cats', 'Do the things'] }, 190 | ], 191 | ); 192 | }); 193 | 194 | describe('min', () => { 195 | testConstraint( 196 | () => { 197 | class TodoList { 198 | @array().min(1) 199 | todos?: string[]; 200 | } 201 | return TodoList; 202 | }, 203 | [ 204 | { todos: ['Write todo app'] }, 205 | { todos: ['Write todo app', 'Feed the cats'] }, 206 | ], 207 | [ 208 | { todos: [] }, 209 | ], 210 | ); 211 | }); 212 | 213 | describe('ordered', () => { 214 | type CsvRowValues = [string, string, number]; 215 | 216 | testConstraint( 217 | () => { 218 | class CsvRow { 219 | @array().ordered((joi) => [ 220 | joi.string().required(), 221 | joi.string().required(), 222 | joi.number(), 223 | ]) 224 | values!: CsvRowValues; 225 | } 226 | 227 | return CsvRow; 228 | }, 229 | [ 230 | { values: ['John', 'Doh', 36] }, 231 | { values: ['Jane', 'Doh'] }, 232 | ], 233 | [ 234 | { values: [] }, 235 | { values: ['Joey Joey Joe'] }, 236 | { values: ['Joey Joey Joe'] }, 237 | { values: [1, 2, 3] as any }, 238 | ], 239 | ); 240 | 241 | testConstraint( 242 | () => { 243 | class CsvRow { 244 | @array().ordered( 245 | joi.string().required(), 246 | joi.string().required(), 247 | joi.number(), 248 | ) 249 | values!: CsvRowValues; 250 | } 251 | 252 | return CsvRow; 253 | }, 254 | [ 255 | { values: ['John', 'Doh', 36] }, 256 | { values: ['Jane', 'Doh'] }, 257 | ], 258 | [ 259 | { values: [] }, 260 | { values: ['Joey Joey Joe'] }, 261 | { values: ['Joey Joey Joe'] }, 262 | { values: [1, 2, 3] as any }, 263 | ], 264 | ); 265 | }); 266 | 267 | describe('single', () => { 268 | describe('when enabled', () => { 269 | testConstraint( 270 | () => { 271 | class TodoList { 272 | @array().single() 273 | todos?: string[] | string; 274 | } 275 | return TodoList; 276 | }, 277 | [ 278 | { todos: ['Write todo app'] }, 279 | { todos: ['Write todo app', 'Feed the cats'] }, 280 | { todos: 'Pass a single todo' }, 281 | ], 282 | [], 283 | ); 284 | }); 285 | 286 | describe('when disabled', () => { 287 | testConstraint( 288 | () => { 289 | class TodoList { 290 | @array().single(false) 291 | todos?: string[] | string; 292 | } 293 | return TodoList; 294 | }, 295 | [ 296 | { todos: ['Write todo app'] }, 297 | { todos: ['Write todo app', 'Feed the cats'] }, 298 | ], 299 | [ 300 | { todos: 'Pass a single todo' }, 301 | ], 302 | ); 303 | }); 304 | }); 305 | 306 | describe('sparse', () => { 307 | describe('when enabled', () => { 308 | const getTodoListClass = () => { 309 | class TodoList { 310 | @array().sparse() 311 | todos!: string[]; 312 | } 313 | return TodoList; 314 | }; 315 | 316 | const validTodoLists: InstanceType>[] = [ 317 | { todos: [] }, 318 | { todos: [] }, 319 | ]; 320 | 321 | validTodoLists[0].todos = ['Write todo app', 'Feed the cats']; 322 | validTodoLists[1].todos[0] = 'Write todo app'; 323 | validTodoLists[1].todos[99] = 'Feed the cats'; 324 | 325 | testConstraint( 326 | getTodoListClass, 327 | validTodoLists, 328 | [], 329 | ); 330 | }); 331 | 332 | describe('when disabled', () => { 333 | const invalidTodos: string[] = []; 334 | invalidTodos[0] = 'Write todo app'; 335 | invalidTodos[99] = 'Feed the cats'; 336 | 337 | testConstraint( 338 | () => { 339 | class TodoList { 340 | @array().sparse(false) 341 | todos!: string[]; 342 | } 343 | return TodoList; 344 | }, 345 | [{ todos: ['Write todo app', 'Feed the cats'] }], 346 | [{ todos: invalidTodos }], 347 | ); 348 | }); 349 | }); 350 | 351 | describe('unique', () => { 352 | testConstraint( 353 | () => { 354 | class Primes { 355 | @array().unique() 356 | values!: number[]; 357 | } 358 | return Primes; 359 | }, 360 | [ 361 | { values: [] }, 362 | { values: [2] }, 363 | { values: [2, 3, 5, 7, 11] }, 364 | ], 365 | [ 366 | { values: [2, 2, 3] }, 367 | ], 368 | ); 369 | }); 370 | }); 371 | -------------------------------------------------------------------------------- /test/unit/validation.test.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { partialOf } from 'jest-helpers'; 3 | import { mocked } from 'ts-jest/utils'; 4 | import { 5 | string, 6 | validate, 7 | validateAsClass, 8 | validateArrayAsClass, 9 | Validator, 10 | ValidationResult, 11 | isValidationPass, 12 | isValidationFail, 13 | } from '../../src'; 14 | import { InvalidValidationTarget, NoValidationSchemaForClassError } from '../../src/validation'; 15 | 16 | interface ResetPasswordForm { 17 | emailAddress?: string; 18 | } 19 | 20 | describe('ValidationResult', () => { 21 | let valid: ValidationResult; 22 | let invalid: ValidationResult; 23 | 24 | beforeEach(() => { 25 | valid = { 26 | value: { 27 | emailAddress: 'joe@example.com', 28 | }, 29 | error: null, 30 | errors: null, 31 | warning: null, 32 | }; 33 | invalid = { 34 | value: { 35 | emailAddress: 'joe', 36 | }, 37 | error: { 38 | name: 'ValidationError', 39 | message: 'Invalid email', 40 | isJoi: true, 41 | details: [ 42 | { 43 | message: "'email' is not a valid email", 44 | type: 'email', 45 | path: ['emailAddress'], 46 | }, 47 | ], 48 | annotate: () => '', 49 | _original: null, 50 | } as Joi.ValidationError, 51 | errors: null, 52 | warning: null, 53 | }; 54 | }); 55 | 56 | describe('isValidationPass', () => { 57 | it('returns true if validation result was a pass', () => { 58 | expect(isValidationPass(valid)).toBe(true); 59 | }); 60 | 61 | it('returns false if validation result was a fail', () => { 62 | expect(isValidationPass(invalid)).toBe(false); 63 | }); 64 | }); 65 | 66 | describe('isValidationFail', () => { 67 | it('returns true if validation result was a fail', () => { 68 | expect(isValidationFail(invalid)).toBe(true); 69 | }); 70 | 71 | it('returns false if validation result was a pass', () => { 72 | expect(isValidationFail(valid)).toBe(false); 73 | }); 74 | }); 75 | }); 76 | 77 | describe('Validation', () => { 78 | type ValidatorLike = Pick; 79 | 80 | function getLoginClass() { 81 | // Define the class for each test, so that the schema is re-created every time. 82 | class Login { 83 | @string() 84 | emailAddress?: string; 85 | 86 | @string() 87 | password?: string; 88 | } 89 | return Login; 90 | } 91 | 92 | let Login: ReturnType; 93 | let login: InstanceType; 94 | let loginSchema: Joi.ObjectSchema; 95 | let loginArraySchema: Joi.ArraySchema; 96 | let joi: typeof Joi; 97 | 98 | function mockJoiValidateSuccess(value: T) { 99 | mocked(loginSchema).validate.mockReturnValueOnce({ 100 | value: value, 101 | }); 102 | mocked(loginArraySchema).validate.mockReturnValueOnce({ 103 | value: value, 104 | }); 105 | } 106 | 107 | function assertValidateInvocation(schema: Joi.Schema, value: T) { 108 | expect(schema.validate).toHaveBeenCalledTimes(1); 109 | expect(schema.validate).toHaveBeenCalledWith(value, {}); 110 | } 111 | 112 | function assertValidateSuccess(result: ValidationResult, expectedValue: T) { 113 | expect(result.value).toEqual(expectedValue); 114 | expect(result.error).toBe(null); 115 | expect(result.errors).toBe(null); 116 | expect(result.warning).toBe(null); 117 | } 118 | 119 | function assertValidateFailure(result: ValidationResult, expectedValue: T) { 120 | expect(result.value).toEqual(expectedValue); 121 | expect(result.error).toBeTruthy(); 122 | expect(result.errors).toBe(null); 123 | expect(result.warning).toBe(null); 124 | } 125 | 126 | beforeEach(() => { 127 | Login = getLoginClass(); 128 | loginSchema = partialOf({ 129 | validate: jest.fn(), 130 | }); 131 | loginArraySchema = partialOf({ 132 | validate: jest.fn(), 133 | }); 134 | login = new Login(); 135 | login.emailAddress = 'joe@example.com'; 136 | joi = partialOf({ 137 | array: jest.fn().mockReturnValue({ 138 | // Required for `validateArrayAsClass()` 139 | items: jest.fn().mockReturnValue(loginArraySchema), 140 | }), 141 | object: jest.fn().mockReturnValue({ 142 | // Required for `getJoiSchema()` 143 | keys: jest.fn().mockReturnValue(loginSchema), 144 | }), 145 | }); 146 | }); 147 | 148 | describe('Validator constructor', () => { 149 | it('should use validation options of the Joi instance by default', () => { 150 | const validator = new Validator(); 151 | const result = validator.validate(login); 152 | assertValidateSuccess(result, login); 153 | }); 154 | 155 | it('should optionally accept validation options to use', () => { 156 | const validator = new Validator({ presence: 'required' }); 157 | const result = validator.validate(login); 158 | assertValidateFailure(result, login); 159 | }); 160 | 161 | it('should support a custom instance of Joi', () => { 162 | mockJoiValidateSuccess(login); 163 | const validator = new Validator({ joi }); 164 | const result = validator.validate(login); 165 | assertValidateSuccess(result, login); 166 | assertValidateInvocation(loginSchema, login); 167 | }); 168 | }); 169 | 170 | describe.each([ 171 | ['new instance', () => new Validator()], 172 | ['default instance', () => ({ 173 | validate, 174 | validateAsClass, 175 | validateArrayAsClass, 176 | })], 177 | ] as [string, () => ValidatorLike][])( 178 | 'Validator - %s', 179 | ( 180 | _testSuiteDescription: string, 181 | validatorFactory: () => Pick, 182 | ) => { 183 | let validator: ValidatorLike; 184 | 185 | beforeEach(() => { 186 | validator = validatorFactory(); 187 | }); 188 | 189 | describe('validate', () => { 190 | it('should validate an instance of a decorated class', () => { 191 | const result = validator.validate(login); 192 | assertValidateSuccess(result, login); 193 | }); 194 | 195 | it('should optionally accept validation options to use', () => { 196 | const result = validator.validate(login, { presence: 'required' }); 197 | assertValidateFailure(result, login); 198 | }); 199 | 200 | it('should support a custom instance of Joi', () => { 201 | mockJoiValidateSuccess(login); 202 | const result = validator.validate(login, { joi }); 203 | assertValidateSuccess(result, login); 204 | assertValidateInvocation(loginSchema, login); 205 | }); 206 | 207 | it('should error when trying to validate null', () => { 208 | expect(() => validator.validate(null)).toThrowError(new InvalidValidationTarget()); 209 | }); 210 | }); 211 | 212 | describe('validateAsClass', () => { 213 | it('should accept a plain old javascript object to validate', () => { 214 | const result = validator.validateAsClass({ ...login }, Login); 215 | assertValidateSuccess(result, login); 216 | }); 217 | 218 | it('should optionally accept validation options to use', () => { 219 | const result = validator.validateAsClass({ ...login }, Login, { presence: 'required' }); 220 | assertValidateFailure(result, login); 221 | }); 222 | 223 | it('should support a custom instance of Joi', () => { 224 | const inputValue = { ...login }; 225 | mockJoiValidateSuccess(inputValue); 226 | const result = validator.validateAsClass(inputValue, Login, { joi }); 227 | assertValidateSuccess(result, login); 228 | assertValidateInvocation(loginSchema, login); 229 | }); 230 | 231 | it('should error when trying to validate null', () => { 232 | expect(() => validator.validateAsClass(null, Login)).toThrowError(new InvalidValidationTarget()); 233 | }); 234 | 235 | it('should error when class does not have an associated schema', () => { 236 | class AgeForm { 237 | age?: number; 238 | } 239 | const validate = () => validator.validateAsClass( 240 | { 241 | name: 'Joe', 242 | }, 243 | AgeForm, 244 | ); 245 | expect(validate).toThrowError(new NoValidationSchemaForClassError(AgeForm)); 246 | }); 247 | }); 248 | 249 | describe('validateArrayAsClass', () => { 250 | it('should accept an array of plain old javascript objects to validate', () => { 251 | const result = validator.validateArrayAsClass([{ ...login }], Login); 252 | assertValidateSuccess(result, [login]); 253 | }); 254 | 255 | it('should optionally accept validation options to use', () => { 256 | const result = validator.validateArrayAsClass([{ ...login }], Login, { presence: 'required' }); 257 | assertValidateFailure(result, [login]); 258 | }); 259 | 260 | it('should support a custom instance of Joi', () => { 261 | const inputValue = [{ ...login }]; 262 | mockJoiValidateSuccess(inputValue); 263 | const result = validator.validateArrayAsClass(inputValue, Login, { joi }); 264 | assertValidateSuccess(result, [login]); 265 | assertValidateInvocation(loginArraySchema, [login]); 266 | }); 267 | 268 | it('should error when trying to validate null', () => { 269 | expect( 270 | () => validator.validateArrayAsClass(null as any, Login), 271 | ).toThrowError(new InvalidValidationTarget()); 272 | }); 273 | 274 | it('should error when items class does not have an associated schema', () => { 275 | class AgeForm { 276 | age?: number; 277 | } 278 | const validate = () => validator.validateArrayAsClass( 279 | [{ 280 | name: 'Joe', 281 | }], 282 | AgeForm, 283 | ); 284 | expect(validate).toThrowError(new NoValidationSchemaForClassError(AgeForm)); 285 | }); 286 | }); 287 | }); 288 | 289 | describe('On-demand schema generation', () => { 290 | it('should only convert working schema to a final schema once - validate', () => { 291 | expect(joi.object).not.toHaveBeenCalled(); 292 | 293 | mockJoiValidateSuccess(login); 294 | validate(login, { joi }); 295 | expect(joi.object).toHaveBeenCalledTimes(1); 296 | 297 | mockJoiValidateSuccess(login); 298 | validate(login, { joi }); 299 | expect(joi.object).toHaveBeenCalledTimes(1); 300 | }); 301 | 302 | it('should only convert working schema to a final schema once - validateAsClass', () => { 303 | expect(joi.object).not.toHaveBeenCalled(); 304 | 305 | mockJoiValidateSuccess(login); 306 | validateAsClass(login, Login, { joi }); 307 | expect(joi.object).toHaveBeenCalledTimes(1); 308 | 309 | mockJoiValidateSuccess(login); 310 | validateAsClass(login, Login, { joi }); 311 | expect(joi.object).toHaveBeenCalledTimes(1); 312 | }); 313 | 314 | it('should only convert working schema to a final schema once, and always creates a new array schema - validateArrayAsClass', () => { 315 | expect(joi.object).not.toHaveBeenCalled(); 316 | expect(joi.array).not.toHaveBeenCalled(); 317 | 318 | mockJoiValidateSuccess([login]); 319 | validateArrayAsClass([login], Login, { joi }); 320 | expect(joi.object).toHaveBeenCalledTimes(1); 321 | expect(joi.array).toHaveBeenCalledTimes(1); 322 | 323 | mockJoiValidateSuccess([login]); 324 | validateArrayAsClass([login], Login, { joi }); 325 | expect(joi.object).toHaveBeenCalledTimes(1); 326 | expect(joi.array).toHaveBeenCalledTimes(2); 327 | }); 328 | }); 329 | }); 330 | 331 | describe('NoValidationSchemaForClassError', () => { 332 | it('should have a helpful message', () => { 333 | expect(new NoValidationSchemaForClassError(class { 334 | emailAddress?: string; 335 | }).message).toEqual( 336 | 'No validation schema was found for class. Did you forget to decorate the class?', 337 | ); 338 | }); 339 | 340 | it('should have a helpful message including classname if it has one', () => { 341 | class ForgotPassword { 342 | emailAddress?: string; 343 | } 344 | expect(new NoValidationSchemaForClassError(ForgotPassword).message).toEqual( 345 | 'No validation schema was found for class ForgotPassword. Did you forget to decorate the class?', 346 | ); 347 | }); 348 | }); 349 | -------------------------------------------------------------------------------- /test/unit/decorators/string.test.ts: -------------------------------------------------------------------------------- 1 | import { testConstraint, testConversion } from '../testUtil'; 2 | import { string } from '../../../src'; 3 | 4 | describe('string', () => { 5 | testConstraint( 6 | () => { 7 | class ForgotPassword { 8 | @string() 9 | username?: string; 10 | } 11 | return ForgotPassword; 12 | }, 13 | [ 14 | { username: 'joe' }, 15 | ], 16 | [ 17 | { username: 1 as any }, 18 | ], 19 | ); 20 | 21 | describe('alphanum', () => { 22 | testConstraint( 23 | () => { 24 | class SetPasscode { 25 | @string().alphanum() 26 | code?: string; 27 | } 28 | return SetPasscode; 29 | }, 30 | [{ code: 'abcdEFG12390' }], 31 | [{ code: '!@#$' }], 32 | ); 33 | }); 34 | 35 | describe('creditCard', () => { 36 | testConstraint( 37 | () => { 38 | class PaymentDetails { 39 | @string().creditCard() 40 | creditCardNumber?: string; 41 | } 42 | return PaymentDetails; 43 | }, 44 | [{ creditCardNumber: '4444333322221111' }], 45 | [ 46 | { creditCardNumber: 'abcd' }, 47 | { creditCardNumber: '1234' }, 48 | { creditCardNumber: '4444-3333-2222-1111' }, 49 | ], 50 | ); 51 | }); 52 | 53 | describe('email', () => { 54 | testConstraint( 55 | () => { 56 | class ResetPassword { 57 | @string().email() 58 | emailAddress?: string; 59 | } 60 | return ResetPassword; 61 | }, 62 | [ 63 | { emailAddress: 'monkey@see.com' }, 64 | { emailAddress: 'howdy+there@pardner.co.kr' }, 65 | ], 66 | [ 67 | { emailAddress: 'monkey@do' }, 68 | { emailAddress: '' }, 69 | { emailAddress: '123.com' }, 70 | ], 71 | ); 72 | }); 73 | 74 | describe('guid', () => { 75 | testConstraint( 76 | () => { 77 | class ObjectWithId { 78 | @string().guid() 79 | id?: string; 80 | } 81 | return ObjectWithId; 82 | }, 83 | [ 84 | { id: '3F2504E0-4F89-41D3-9A0C-0305E82C3301' }, 85 | { id: '3f2504e0-4f89-41d3-9a0c-0305e82c3301' }, 86 | ], 87 | [ 88 | { id: '123' }, 89 | { id: 'abc' }, 90 | ], 91 | ); 92 | }); 93 | 94 | describe('hex', () => { 95 | testConstraint( 96 | () => { 97 | class SetColor { 98 | @string().hex() 99 | color?: string; 100 | } 101 | return SetColor; 102 | }, 103 | [ 104 | { color: 'AB' }, 105 | { color: '0123456789abcdef' }, 106 | { color: '0123456789ABCDEF' }, 107 | { color: '123' }, 108 | { color: 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' }, 109 | ], 110 | [ 111 | { color: '0xf' }, 112 | { color: '0x0F' }, 113 | { color: '0xAB' }, 114 | { color: 'A B' }, 115 | { color: 'jj' }, 116 | ], 117 | ); 118 | }); 119 | 120 | describe('hostname', () => { 121 | testConstraint( 122 | () => { 123 | class Server { 124 | @string().hostname() 125 | hostName?: string; 126 | } 127 | return Server; 128 | }, 129 | [ 130 | { hostName: 'www.thisisnotarealdomainnameoratleastihopeitsnot.com.au' }, 131 | { hostName: 'www.zxcv.ninja' }, 132 | { hostName: '127.0.0.1' }, 133 | { hostName: 'shonkydodgersprelovedautomobiles.ninja' }, 134 | ], 135 | [ 136 | { hostName: 'https://www.thisisnotarealdomainnameoratleastihopeitsnot.com.au' }, 137 | { hostName: 'www.zxcv.ninja/hello' }, 138 | { hostName: 'https://zxcv.ninja?query=meow' }, 139 | ], 140 | ); 141 | }); 142 | 143 | describe('insensitive', () => { 144 | testConstraint( 145 | () => { 146 | class UserRegistration { 147 | @string().allow( 148 | 'male', 149 | 'female', 150 | 'intersex', 151 | 'other', 152 | ).insensitive() 153 | gender?: string; 154 | } 155 | return UserRegistration; 156 | }, 157 | [ 158 | { gender: 'female' }, 159 | { gender: 'FEMALE' }, 160 | { gender: 'fEmAlE' }, 161 | ], 162 | ); 163 | }); 164 | 165 | describe('ip', () => { 166 | describe('no options', () => { 167 | testConstraint( 168 | () => { 169 | class Server { 170 | @string().ip({ version: 'ipv4' }) 171 | ipAddress?: string; 172 | } 173 | return Server; 174 | }, 175 | [ 176 | { ipAddress: '127.0.0.1' }, 177 | { ipAddress: '127.0.0.1/24' }, 178 | ], 179 | [ 180 | { ipAddress: 'abc.def.ghi.jkl' }, 181 | { ipAddress: '123' }, 182 | { ipAddress: '2001:0db8:0000:0000:0000:ff00:0042:8329' }, 183 | ], 184 | ); 185 | }); 186 | 187 | describe('ip', () => { 188 | describe('IPv4', () => { 189 | testConstraint( 190 | () => { 191 | class Server { 192 | @string().ip({ version: 'ipv4' }) 193 | ipAddress?: string; 194 | } 195 | return Server; 196 | }, 197 | [ 198 | { ipAddress: '127.0.0.1' }, 199 | { ipAddress: '127.0.0.1/24' }, 200 | ], 201 | [ 202 | { ipAddress: '2001:0db8:0000:0000:0000:ff00:0042:8329' }, 203 | ], 204 | ); 205 | }); 206 | 207 | describe('IPv6', () => { 208 | testConstraint( 209 | () => { 210 | class Server { 211 | @string().ip({ version: 'ipv6' }) 212 | ipAddress?: string; 213 | } 214 | return Server; 215 | }, 216 | [ 217 | { ipAddress: '2001:0db8:0000:0000:0000:ff00:0042:8329' }, 218 | { ipAddress: '2001:db8:0:0:0:ff00:42:8329' }, 219 | { ipAddress: '2001:db8::ff00:42:8329' }, 220 | { ipAddress: '::1' }, 221 | ], 222 | [ 223 | { ipAddress: '127.0.0.1' }, 224 | { ipAddress: '127.0.0.1/24' }, 225 | ], 226 | ); 227 | }); 228 | }); 229 | 230 | describe('CIDR required', () => { 231 | testConstraint( 232 | () => { 233 | class Server { 234 | @string().ip({ cidr: 'required' }) 235 | ipAddress?: string; 236 | } 237 | return Server; 238 | }, 239 | [ 240 | { ipAddress: '127.0.0.1/24' }, 241 | { ipAddress: '2001:db8:abcd:8000::/50' }, 242 | ], 243 | [ 244 | { ipAddress: '127.0.0.1' }, 245 | { ipAddress: '2001:0db8:0000:0000:0000:ff00:0042:8329' }, 246 | ], 247 | ); 248 | }); 249 | }); 250 | 251 | describe('isoDate', () => { 252 | testConstraint( 253 | () => { 254 | class AgeVerification { 255 | @string().isoDate() 256 | dateOfBirth?: string; 257 | } 258 | return AgeVerification; 259 | }, 260 | [ 261 | { dateOfBirth: '2016-05-20' }, 262 | { dateOfBirth: '2016-05-20T23:09:53+00:00' }, 263 | { dateOfBirth: '2016-05-20T23:09:53Z' }, 264 | { dateOfBirth: '2016-05-20T23:09:53' }, 265 | ], 266 | [ 267 | { dateOfBirth: '20-05-2016' }, 268 | { dateOfBirth: '23:09:53' }, 269 | { dateOfBirth: 'abcd' }, 270 | { dateOfBirth: String(new Date().valueOf()) }, 271 | ], 272 | ); 273 | }); 274 | 275 | describe('lowercase', () => { 276 | testConversion({ 277 | getClass: () => { 278 | class UserRegistration { 279 | @string().lowercase() 280 | userName?: string; 281 | } 282 | return UserRegistration; 283 | }, 284 | conversions: [ 285 | { 286 | input: { userName: 'ABCD123' }, 287 | output: { userName: 'abcd123' }, 288 | }, 289 | ], 290 | valid: [], 291 | invalid: [{ userName: 'INVALID' }], 292 | }); 293 | }); 294 | 295 | describe('max', () => { 296 | testConstraint( 297 | () => { 298 | class UserRegistration { 299 | @string().max(10) 300 | userName?: string; 301 | } 302 | return UserRegistration; 303 | }, 304 | [{ userName: 'bobby' }], 305 | [{ userName: 'littlebobbytables' }], 306 | ); 307 | }); 308 | 309 | describe('min', () => { 310 | testConstraint( 311 | () => { 312 | class UserRegistration { 313 | @string().min(5) 314 | userName?: string; 315 | } 316 | return UserRegistration; 317 | }, 318 | [{ userName: 'bobby' }, { userName: 'bobbyt' }], 319 | [{ userName: 'bob' }], 320 | ); 321 | }); 322 | 323 | const testRegEx = (alias: 'regex' | 'pattern') => { 324 | type RegExDecorator = ReturnType['regex']; 325 | 326 | describe(alias, () => { 327 | testConstraint( 328 | () => { 329 | class Authentication { 330 | @(string()[alias] as RegExDecorator)(/please/) 331 | magicWord?: string; 332 | } 333 | return Authentication; 334 | }, 335 | [ 336 | { magicWord: 'please' }, 337 | { magicWord: 'pretty-please' }, 338 | { magicWord: 'pleasewithcherriesontop' }, 339 | ], 340 | [ 341 | { magicWord: 'letmein' }, 342 | { magicWord: 'PLEASE' }, 343 | ], 344 | ); 345 | 346 | testConstraint( 347 | () => { 348 | class Authentication { 349 | @(string()[alias] as RegExDecorator)(/please/i) 350 | magicWord?: string; 351 | } 352 | return Authentication; 353 | }, 354 | [ 355 | { magicWord: 'please' }, 356 | { magicWord: 'pretty-PLEASE' }, 357 | ], 358 | ); 359 | }); 360 | }; 361 | 362 | testRegEx('regex'); 363 | testRegEx('pattern'); 364 | 365 | describe('replace', () => { 366 | testConversion({ 367 | getClass: () => { 368 | class UserRegistration { 369 | @string().replace(/sex/g, 'gender') 370 | userName?: string; 371 | } 372 | return UserRegistration; 373 | }, 374 | conversions: [ 375 | { 376 | input: { userName: 'expertsexchange' }, 377 | output: { userName: 'expertgenderchange' }, 378 | }, 379 | ], 380 | }); 381 | }); 382 | 383 | describe('token', () => { 384 | testConstraint( 385 | () => { 386 | class CodeSearch { 387 | @string().token() 388 | identifier?: string; 389 | } 390 | return CodeSearch; 391 | }, 392 | [ 393 | { identifier: 'abcdEFG12390' }, 394 | { identifier: '_' }, 395 | ], 396 | [ 397 | { identifier: '!@#$' }, 398 | { identifier: ' ' }, 399 | ], 400 | ); 401 | }); 402 | 403 | describe('trim', () => { 404 | testConversion({ 405 | getClass: () => { 406 | class Person { 407 | @string().trim() 408 | name?: string; 409 | } 410 | return Person; 411 | }, 412 | conversions: [ 413 | { 414 | input: { name: 'Joe' }, 415 | output: { name: 'Joe' }, 416 | }, 417 | { 418 | input: { name: 'Joe ' }, 419 | output: { name: 'Joe' }, 420 | }, 421 | { 422 | input: { name: ' Joe ' }, 423 | output: { name: 'Joe' }, 424 | }, 425 | { 426 | input: { name: '\n\r\t\t\nJoe \t ' }, 427 | output: { name: 'Joe' }, 428 | }, 429 | ], 430 | valid: [ 431 | { name: 'Joe' }, 432 | { name: 'Joey Joe Joe' }, 433 | ], 434 | invalid: [ 435 | { name: ' Joe ' }, 436 | { name: 'Joe ' }, 437 | { name: ' ' }, 438 | { name: 'Joe\t' }, 439 | { name: '\nJoe' }, 440 | { name: '\r' }, 441 | { name: '' }, 442 | ], 443 | }); 444 | }); 445 | 446 | describe('uppercase', () => { 447 | testConversion({ 448 | getClass: () => { 449 | class UserRegistration { 450 | @string().uppercase() 451 | userName?: string; 452 | } 453 | return UserRegistration; 454 | }, 455 | conversions: [ 456 | { 457 | input: { userName: 'abcd123' }, 458 | output: { userName: 'ABCD123' }, 459 | }, 460 | ], 461 | valid: [], 462 | invalid: [{ userName: 'invalid' }], 463 | }); 464 | }); 465 | 466 | describe('uri', () => { 467 | describe('with no options', () => { 468 | testConstraint( 469 | () => { 470 | class WebsiteRegistration { 471 | @string().uri() 472 | url?: string; 473 | } 474 | return WebsiteRegistration; 475 | }, 476 | [{ url: 'https://my.site.com' }], 477 | [ 478 | { url: '!@#$' }, 479 | { url: ' ' }, 480 | ], 481 | ); 482 | }); 483 | 484 | describe('with a scheme', () => { 485 | testConstraint( 486 | () => { 487 | class MyClass { 488 | @string().uri({ 489 | scheme: 'git', 490 | }) 491 | url?: string; 492 | } 493 | return MyClass; 494 | }, 495 | [{ url: 'git://my.site.com' }], 496 | [{ url: 'https://my.site.com' }], 497 | ); 498 | }); 499 | }); 500 | }); 501 | --------------------------------------------------------------------------------