├── .github ├── dependabot.yml └── workflows │ ├── main.yml │ └── size.yml ├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── renovate.json ├── src ├── example-errors.ts ├── gql-validation.filter.ts ├── index.ts ├── validation.pipe.spec.ts └── validation.pipe.ts ├── tsconfig.json └── yarn.lock /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: npm 5 | directory: '/' 6 | schedule: 7 | interval: daily 8 | open-pull-requests-limit: 10 -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ['12.x', '14.x'] 11 | os: [ubuntu-latest, windows-latest, macOS-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Lint 26 | run: yarn lint 27 | 28 | - name: Test 29 | run: yarn test --ci --coverage --maxWorkers=2 30 | 31 | - name: Build 32 | run: yarn build 33 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: size 2 | on: [pull_request] 3 | jobs: 4 | size: 5 | runs-on: ubuntu-latest 6 | env: 7 | CI_JOB_NUMBER: 1 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: andresz1/size-limit-action@v1 11 | with: 12 | github_token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Alireza Zamani 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Better Validation Pipe 2 | 3 | This pipe extends Nest's [built-in validation pipe](https://docs.nestjs.com/techniques/validation#using-the-built-in-validationpipe) and makes it a bit more descriptive. 4 | 5 | ## Introduction 6 | 7 | The default validation pipe is great, but error it returns is just an array of errors: 8 | 9 | ```json 10 | { 11 | "statusCode": 400, 12 | "error": "Bad Request", 13 | "message": ["email must be an email", "phone should not be empty"] 14 | } 15 | ``` 16 | 17 | This package changes the `message` to be an object with field names as keys: 18 | 19 | ```json 20 | { 21 | "statusCode": 400, 22 | "error": "Bad Request", 23 | "message": [ 24 | { 25 | "field": "email", 26 | "errors": ["email must be an email"] 27 | }, 28 | { 29 | "field": "phone", 30 | "errors": ["phone should not be empty"] 31 | } 32 | ] 33 | } 34 | ``` 35 | 36 | It also works with [nested validations](https://github.com/typestack/class-validator#validating-nested-objects): 37 | 38 | ```json 39 | { 40 | "field": "nestedObject.name", 41 | "errors": ["name should not be empty"] 42 | } 43 | ``` 44 | 45 | So then, on your frontend, you can show each error next to its relavant field, instead of showing all of them at the end of your form 46 | 47 | ## Installation 48 | 49 | On Yarn: 50 | 51 | ```shell 52 | yarn add nestjs-better-validation 53 | ``` 54 | 55 | On NPM: 56 | 57 | ```shell 58 | npm install nestjs-better-validation 59 | ``` 60 | 61 | ## Usage 62 | 63 | Just use it as you would normally use [Nest's built-in validation pipe](https://docs.nestjs.com/techniques/validation#using-the-built-in-validationpipe). You can also pass options to it, just like you would with the built-in one. 64 | 65 | ## Motivation 66 | 67 | This behavior is achievable by passing a custom `exceptionFactory` to the original pipe, but I found myself writing the same exception factory for each one of my projects, so I made this package to do the job. 68 | 69 | ## GraphQL Validation Filter 70 | 71 | This filter is just what I personally use for my GraphQL responses. it catches the validation exceptions and returns them as the following object: 72 | 73 | ```ts 74 | @ObjectType() 75 | export class UserError { 76 | @Field(() => [String], { nullable: true }) 77 | field: string[]; 78 | 79 | @Field(() => String) 80 | message: string; 81 | } 82 | ``` 83 | 84 | Additionally, your returned objects should contain a `userErrors` field, for example: 85 | 86 | ```ts 87 | @ObjectType() 88 | export class PostCreatePayload { 89 | @Field(() => Post) 90 | post: Post; 91 | 92 | @Field(() => [UserError]) 93 | userErrors: UserError[]; 94 | } 95 | ``` 96 | 97 | To use the filter, add it filter to your main.ts file: 98 | 99 | ```ts 100 | import { GraphqlValidationFilter } from 'nestjs-better-validation'; 101 | 102 | // ... 103 | app.useGlobalFilters(new GraphqlValidationFilter()); 104 | ``` 105 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.4.4", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": [ 7 | "dist", 8 | "src" 9 | ], 10 | "engines": { 11 | "node": ">=10" 12 | }, 13 | "homepage": "https://github.com/overnested", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/overnested/nestjs-better-validation" 17 | }, 18 | "scripts": { 19 | "start": "tsdx watch", 20 | "build": "tsdx build", 21 | "test": "tsdx test", 22 | "lint": "tsdx lint", 23 | "prepare": "tsdx build", 24 | "size": "size-limit", 25 | "analyze": "size-limit --why" 26 | }, 27 | "peerDependencies": {}, 28 | "husky": { 29 | "hooks": { 30 | "pre-commit": "tsdx lint" 31 | } 32 | }, 33 | "prettier": { 34 | "printWidth": 80, 35 | "semi": true, 36 | "singleQuote": true, 37 | "trailingComma": "es5" 38 | }, 39 | "name": "nestjs-better-validation", 40 | "author": { 41 | "email": "alirezazamani2922@gmail.com", 42 | "name": "Alireza Zamani" 43 | }, 44 | "module": "dist/nestjs-better-validation.esm.js", 45 | "size-limit": [ 46 | { 47 | "path": "dist/nestjs-better-validation.cjs.production.min.js", 48 | "limit": "10 KB" 49 | }, 50 | { 51 | "path": "dist/nestjs-better-validation.esm.js", 52 | "limit": "10 KB" 53 | } 54 | ], 55 | "babel": { 56 | "presets": [ 57 | [ 58 | "@babel/preset-env", 59 | { 60 | "targets": { 61 | "esmodules": true 62 | } 63 | } 64 | ] 65 | ] 66 | }, 67 | "devDependencies": { 68 | "@size-limit/preset-small-lib": "^6.0.4", 69 | "husky": "^8.0.1", 70 | "size-limit": "^6.0.4", 71 | "tsdx": "^0.14.1", 72 | "tslib": "^2.3.1", 73 | "typescript": "^4.8.2" 74 | }, 75 | "dependencies": { 76 | "@nestjs/common": "^9.0.0", 77 | "@nestjs/graphql": "^9.1.1", 78 | "class-transformer": "^0.4.0", 79 | "class-validator": "^0.13.1", 80 | "reflect-metadata": "^0.1.13" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/example-errors.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from '@nestjs/common'; 2 | 3 | export const exampleErrors: ValidationError[] = [ 4 | { 5 | target: { 6 | description: '', 7 | email: '', 8 | handle: '', 9 | nestedObject: { name: '', phone: '' }, 10 | title: '', 11 | }, 12 | value: '', 13 | property: 'title', 14 | children: [], 15 | constraints: { 16 | minLength: 'title must be longer than or equal to 10 characters', 17 | }, 18 | }, 19 | { 20 | target: { 21 | description: '', 22 | email: '', 23 | handle: '', 24 | nestedObject: { name: '', phone: '' }, 25 | title: '', 26 | }, 27 | value: '', 28 | property: 'description', 29 | children: [], 30 | constraints: { isNotEmpty: 'description should not be empty' }, 31 | }, 32 | { 33 | target: { 34 | description: '', 35 | email: '', 36 | handle: '', 37 | nestedObject: { name: '', phone: '' }, 38 | title: '', 39 | }, 40 | value: '', 41 | property: 'handle', 42 | children: [], 43 | constraints: { isNotEmpty: 'handle should not be empty' }, 44 | }, 45 | { 46 | target: { 47 | description: '', 48 | email: '', 49 | handle: '', 50 | nestedObject: { name: '', phone: '' }, 51 | title: '', 52 | }, 53 | value: '', 54 | property: 'email', 55 | children: [], 56 | constraints: { isEmail: 'email must be an email' }, 57 | }, 58 | { 59 | target: { 60 | description: '', 61 | email: '', 62 | handle: '', 63 | nestedObject: { name: '', phone: '' }, 64 | title: '', 65 | }, 66 | value: { name: '', phone: '' }, 67 | property: 'nestedObject', 68 | children: [ 69 | { 70 | target: { name: '', phone: '' }, 71 | value: '', 72 | property: 'name', 73 | children: [], 74 | constraints: { isNotEmpty: 'name should not be empty' }, 75 | }, 76 | { 77 | target: { name: '', phone: '' }, 78 | value: '', 79 | property: 'phone', 80 | children: [], 81 | constraints: { 82 | isPhoneNumber: 'phone must be a valid phone number', 83 | isNotEmpty: 'phone should not be empty', 84 | }, 85 | }, 86 | ], 87 | }, 88 | ]; 89 | -------------------------------------------------------------------------------- /src/gql-validation.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, BadRequestException } from '@nestjs/common'; 2 | import { GqlContextType, GqlExceptionFilter } from '@nestjs/graphql'; 3 | 4 | @Catch(BadRequestException) 5 | export class GraphqlValidationFilter implements GqlExceptionFilter { 6 | catch(exception: BadRequestException, host: ArgumentsHost) { 7 | if (host.getType() === 'graphql') { 8 | // const gqlHost = GqlArgumentsHost.create(host); 9 | const response = exception.getResponse() as any; 10 | 11 | if ( 12 | Array.isArray(response.message) && 13 | Array.isArray(response.message[0].errors) && 14 | typeof response.message[0].field === 'string' 15 | ) { 16 | return { 17 | userErrors: response.message.map((error: any) => ({ 18 | field: error.field.split('.'), 19 | message: error.errors[0], 20 | })), 21 | }; 22 | } 23 | } 24 | return exception; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './validation.pipe'; 2 | export * from './gql-validation.filter'; 3 | -------------------------------------------------------------------------------- /src/validation.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from './validation.pipe'; 2 | 3 | describe('Validation pipe', () => { 4 | it('is defined', () => { 5 | expect(ValidationPipe).toBeDefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ValidationError, 3 | ValidationPipe as OriginalValidationPipe, 4 | } from '@nestjs/common'; 5 | import { iterate } from 'iterare'; 6 | 7 | export class ValidationPipe extends OriginalValidationPipe { 8 | protected flattenValidationErrors(validationErrors: ValidationError[]) { 9 | return (iterate(validationErrors) 10 | .map(error => this.mapChildrenToValidationErrors(error)) 11 | .flatten() 12 | .filter(item => !!item.constraints) 13 | .map(item => ({ 14 | errors: Object.values(item.constraints || {}), 15 | field: (item as any).field || item.property, 16 | })) 17 | .filter(errorObject => errorObject.errors.length > 0) 18 | .flatten() 19 | .toArray() as unknown) as string[]; 20 | } 21 | 22 | protected prependConstraintsWithParentProp( 23 | parentPath: string, 24 | error: ValidationError 25 | ): ValidationError { 26 | return ({ 27 | field: `${parentPath}.${error.property}`, 28 | ...super.prependConstraintsWithParentProp(parentPath, error), 29 | } as unknown) as ValidationError; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "experimentalDecorators": true, 6 | "target": "ES6", 7 | "module": "esnext", 8 | "lib": ["dom", "esnext"], 9 | "importHelpers": true, 10 | // output .d.ts declaration files for consumers 11 | "declaration": true, 12 | // output .js.map sourcemap files for consumers 13 | "sourceMap": true, 14 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 15 | "rootDir": "./src", 16 | // stricter type-checking for stronger correctness. Recommended by TS 17 | "strict": true, 18 | // linter checks for common issues 19 | "noImplicitReturns": true, 20 | "noFallthroughCasesInSwitch": true, 21 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | // use Node's module resolution algorithm, instead of the legacy TS one 25 | "moduleResolution": "node", 26 | // transpile JSX to React.createElement 27 | "jsx": "react", 28 | // interop between ESM and CJS modules. Recommended by TS 29 | "esModuleInterop": true, 30 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 31 | "skipLibCheck": true, 32 | // error out if import and file system have a casing mismatch. Recommended by TS 33 | "forceConsistentCasingInFileNames": true, 34 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 35 | "noEmit": true 36 | } 37 | } 38 | --------------------------------------------------------------------------------