├── .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
7 |
8 |
9 |
10 | [](https://badge.fury.io/js/joiful)
11 | [](https://github.com/joiful-ts/joiful/actions/workflows/build.yml)
12 | [](https://codecov.io/gh/joiful-ts/joiful)
13 | [](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 |
--------------------------------------------------------------------------------