├── .github └── workflows │ ├── main.yml │ └── size.yml ├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── src ├── constants.ts ├── index.test.ts ├── index.ts ├── lib │ ├── filter.ts │ ├── suggestions.ts │ └── utils.ts └── types │ └── ValidationError.ts ├── tsconfig.json └── yarn.lock /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | name: Build, lint, and test 6 | 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout repo 10 | uses: actions/checkout@v2 11 | 12 | - name: Setup Node 13 | uses: actions/setup-node@v1 14 | with: 15 | node-version: '14.x' 16 | 17 | - name: Install deps and build (with cache) 18 | uses: bahmutov/npm-install@v1 19 | 20 | - name: Lint 21 | run: yarn lint 22 | 23 | - name: Test 24 | run: yarn test --ci --coverage --maxWorkers=2 25 | 26 | - name: Build 27 | run: yarn build -------------------------------------------------------------------------------- /.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@v2 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 | 6 | # use yarn.lock instead 7 | package-lock.json 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Apideck 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm (scoped)](https://img.shields.io/npm/v/@apideck/better-ajv-errors?color=brightgreen)](https://npmjs.com/@apideck/better-ajv-errors) [![npm](https://img.shields.io/npm/dm/@apideck/better-ajv-errors)](https://npmjs.com/@apideck/better-ajv-errors) [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/apideck-libraries/better-ajv-errors/CI)](https://github.com/apideck-libraries/better-ajv-errors/actions/workflows/main.yml?query=branch%3Amain++) 2 | 3 | # @apideck/better-ajv-errors 👮‍♀️ 4 | 5 | > Human-friendly JSON Schema validation for APIs 6 | 7 | 8 | - Readable and helpful [ajv](https://github.com/ajv-validator/ajv) errors 9 | - API-friendly format 10 | - Suggestions for spelling mistakes 11 | - Minimal footprint: 1.56 kB (gzip + minified) 12 | 13 | ![better-ajv-errors output Example](https://user-images.githubusercontent.com/8850410/118274790-e0529e80-b4c5-11eb-8188-9097c8064c61.png) 14 | 15 | ## Install 16 | 17 | ```bash 18 | $ yarn add @apideck/better-ajv-errors 19 | ``` 20 | 21 | or 22 | 23 | ```bash 24 | $ npm i @apideck/better-ajv-errors 25 | ``` 26 | 27 | Also make sure that you've installed [ajv](https://www.npmjs.com/package/ajv) at version 8 or higher. 28 | 29 | ## Usage 30 | 31 | After validating some data with ajv, pass the errors to `betterAjvErrors` 32 | 33 | ```ts 34 | import Ajv from 'ajv'; 35 | import { betterAjvErrors } from '@apideck/better-ajv-errors'; 36 | 37 | // Without allErrors: true, ajv will only return the first error 38 | const ajv = new Ajv({ allErrors: true }); 39 | 40 | const valid = ajv.validate(schema, data); 41 | 42 | if (!valid) { 43 | const betterErrors = betterAjvErrors({ schema, data, errors: ajv.errors }); 44 | } 45 | ``` 46 | 47 | ## API 48 | 49 | ### betterAjvErrors 50 | 51 | Function that formats ajv validation errors in a human-friendly format. 52 | 53 | #### Parameters 54 | 55 | - `options: BetterAjvErrorsOptions` 56 | - `errors: ErrorObject[] | null | undefined` Your ajv errors, you will find these in the `errors` property of your ajv instance (`ErrorObject` is a type from the ajv package). 57 | - `data: Object` The data you passed to ajv to be validated. 58 | - `schema: JSONSchema` The schema you passed to ajv to validate against. 59 | - `basePath?: string` An optional base path to prefix paths returned by `betterAjvErrors`. For example, in APIs, it could be useful to use `'{requestBody}'` or `'{queryParemeters}'` as a basePath. This will make it clear to users where exactly the error occurred. 60 | 61 | #### Return Value 62 | 63 | - `ValidationError[]` Array of formatted errors (properties of `ValidationError` below) 64 | - `message: string` Formatted error message 65 | - `suggestion?: string` Optional suggestion based on provided data and schema 66 | - `path: string` Object path where the error occurred (example: `.foo.bar.0.quz`) 67 | - `context: { errorType: DefinedError['keyword']; [additionalContext: string]: unknown }` `errorType` is `error.keyword` proxied from `ajv`. `errorType` can be used as a key for i18n if needed. There might be additional properties on context, based on the type of error. 68 | 69 | ## Related 70 | 71 | - [atlassian/better-ajv-errors](https://github.com/atlassian/better-ajv-errors) was the inspiration for this library. Atlassian's library is more focused on CLI errors, this library is focused on developer-friendly API error messages. 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@apideck/better-ajv-errors", 3 | "description": "Human-friendly JSON Schema validation for APIs", 4 | "version": "0.3.6", 5 | "author": "Apideck (https://apideck.com/)", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/apideck-libraries/better-ajv-errors" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/apideck-libraries/better-ajv-errors/issues" 13 | }, 14 | "contributors": [ 15 | "Elias Meire " 16 | ], 17 | "main": "dist/index.js", 18 | "module": "dist/better-ajv-errors.esm.js", 19 | "typings": "dist/index.d.ts", 20 | "files": [ 21 | "dist", 22 | "src" 23 | ], 24 | "engines": { 25 | "node": ">=10" 26 | }, 27 | "scripts": { 28 | "start": "tsdx watch", 29 | "build": "tsdx build", 30 | "test": "tsdx test", 31 | "lint": "tsdx lint", 32 | "prepare": "tsdx build", 33 | "size": "size-limit", 34 | "release": "np --no-publish && npm publish --access public --registry https://registry.npmjs.org", 35 | "analyze": "size-limit --why" 36 | }, 37 | "husky": { 38 | "hooks": { 39 | "pre-commit": "tsdx lint" 40 | } 41 | }, 42 | "prettier": { 43 | "printWidth": 120, 44 | "singleQuote": true, 45 | "trailingComma": "es5" 46 | }, 47 | "size-limit": [ 48 | { 49 | "path": "dist/better-ajv-errors.cjs.production.min.js", 50 | "limit": "2 KB" 51 | }, 52 | { 53 | "path": "dist/better-ajv-errors.esm.js", 54 | "limit": "2.5 KB" 55 | } 56 | ], 57 | "devDependencies": { 58 | "@size-limit/preset-small-lib": "^7.0.8", 59 | "ajv": "^8.11.0", 60 | "eslint-plugin-prettier": "^4.0.0", 61 | "husky": "^8.0.1", 62 | "np": "^7.6.1", 63 | "size-limit": "^7.0.8", 64 | "tsdx": "^0.14.1", 65 | "json-schema": "^0.4.0", 66 | "tslib": "^2.4.0", 67 | "typescript": "^4.7.2" 68 | }, 69 | "peerDependencies": { 70 | "ajv": ">=8" 71 | }, 72 | "dependencies": { 73 | "jsonpointer": "^5.0.1", 74 | "leven": "^3.1.0" 75 | }, 76 | "resolutions": { 77 | "prettier": "^2.3.0" 78 | }, 79 | "keywords": [ 80 | "apideck", 81 | "ajv", 82 | "json", 83 | "schema", 84 | "json-schema", 85 | "errors", 86 | "human" 87 | ] 88 | } 89 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { DefinedError } from 'ajv'; 2 | 3 | export const AJV_ERROR_KEYWORD_WEIGHT_MAP: Partial> = { 4 | enum: 1, 5 | type: 0, 6 | }; 7 | 8 | export const QUOTES_REGEX = /"/g; 9 | export const NOT_REGEX = /NOT/g; 10 | export const SLASH_REGEX = /\//g; 11 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv'; 2 | import { JSONSchema6 } from 'json-schema'; 3 | import { betterAjvErrors } from './index'; 4 | 5 | describe('betterAjvErrors', () => { 6 | let ajv: Ajv; 7 | let schema: JSONSchema6; 8 | let data: Record; 9 | 10 | beforeEach(() => { 11 | ajv = new Ajv({ allErrors: true }); 12 | schema = { 13 | type: 'object', 14 | required: ['str'], 15 | properties: { 16 | str: { 17 | type: 'string', 18 | }, 19 | enum: { 20 | type: 'string', 21 | enum: ['one', 'two'], 22 | }, 23 | bounds: { 24 | type: 'number', 25 | minimum: 2, 26 | maximum: 4, 27 | }, 28 | nested: { 29 | type: 'object', 30 | required: ['deepReq'], 31 | properties: { 32 | deepReq: { 33 | type: 'boolean', 34 | }, 35 | deep: { 36 | type: 'string', 37 | }, 38 | }, 39 | additionalProperties: false, 40 | }, 41 | }, 42 | additionalProperties: false, 43 | }; 44 | }); 45 | 46 | describe('combined schemas', () => { 47 | it('should handle type errors', () => { 48 | data = { 49 | str: 123, 50 | }; 51 | const combinedSchema = {type: 'boolean'}; 52 | const validateCombined = ajv.addSchema(combinedSchema).compile(schema); 53 | validateCombined(data); 54 | const betterErrors = betterAjvErrors({ data, schema, errors: validateCombined.errors }); 55 | expect(betterErrors).toEqual([ 56 | { 57 | context: { 58 | errorType: 'type', 59 | }, 60 | message: "'str' property type must be string", 61 | path: '{base}.str', 62 | }, 63 | ]); 64 | }); 65 | }); 66 | describe('additionalProperties', () => { 67 | it('should handle additionalProperties=false', () => { 68 | data = { 69 | str: 'str', 70 | foo: 'bar', 71 | }; 72 | ajv.validate(schema, data); 73 | const errors = betterAjvErrors({ data, schema, errors: ajv.errors }); 74 | expect(errors).toEqual([ 75 | { 76 | context: { 77 | errorType: 'additionalProperties', 78 | }, 79 | message: "'foo' property is not expected to be here", 80 | path: '{base}', 81 | }, 82 | ]); 83 | }); 84 | 85 | it('should handle additionalProperties=true', () => { 86 | data = { 87 | str: 'str', 88 | foo: 'bar', 89 | }; 90 | schema.additionalProperties = true; 91 | ajv.validate(schema, data); 92 | const errors = betterAjvErrors({ data, schema, errors: ajv.errors }); 93 | expect(errors).toEqual([]); 94 | }); 95 | 96 | it('should give suggestions when relevant', () => { 97 | data = { 98 | str: 'str', 99 | bonds: 'bar', 100 | }; 101 | ajv.validate(schema, data); 102 | const errors = betterAjvErrors({ data, schema, errors: ajv.errors }); 103 | expect(errors).toEqual([ 104 | { 105 | context: { 106 | errorType: 'additionalProperties', 107 | }, 108 | message: "'bonds' property is not expected to be here", 109 | path: '{base}', 110 | suggestion: "Did you mean property 'bounds'?", 111 | }, 112 | ]); 113 | }); 114 | 115 | it('should handle object schemas without properties', () => { 116 | data = { 117 | empty: { foo: 1 }, 118 | }; 119 | schema = { 120 | type: 'object', 121 | properties: { 122 | empty: { 123 | type: 'object', 124 | additionalProperties: false, 125 | }, 126 | }, 127 | }; 128 | ajv.validate(schema, data); 129 | const errors = betterAjvErrors({ data, schema, errors: ajv.errors }); 130 | expect(errors).toEqual([ 131 | { 132 | context: { 133 | errorType: 'additionalProperties', 134 | }, 135 | message: "'foo' property is not expected to be here", 136 | path: '{base}.empty', 137 | }, 138 | ]); 139 | }); 140 | }); 141 | 142 | describe('required', () => { 143 | it('should handle required properties', () => { 144 | data = { 145 | nested: {}, 146 | }; 147 | ajv.validate(schema, data); 148 | const errors = betterAjvErrors({ data, schema, errors: ajv.errors }); 149 | expect(errors).toEqual([ 150 | { 151 | context: { 152 | errorType: 'required', 153 | }, 154 | message: "{base} must have required property 'str'", 155 | path: '{base}', 156 | }, 157 | { 158 | context: { 159 | errorType: 'required', 160 | }, 161 | message: "{base}.nested must have required property 'deepReq'", 162 | path: '{base}.nested', 163 | }, 164 | ]); 165 | }); 166 | 167 | it('should handle multiple required properties', () => { 168 | schema = { 169 | type: 'object', 170 | required: ['req1', 'req2'], 171 | properties: { 172 | req1: { 173 | type: 'string', 174 | }, 175 | req2: { 176 | type: 'string', 177 | }, 178 | }, 179 | }; 180 | data = {}; 181 | ajv.validate(schema, data); 182 | const errors = betterAjvErrors({ data, schema, errors: ajv.errors }); 183 | expect(errors).toEqual([ 184 | { 185 | context: { 186 | errorType: 'required', 187 | }, 188 | message: "{base} must have required property 'req1'", 189 | path: '{base}', 190 | }, 191 | { 192 | context: { 193 | errorType: 'required', 194 | }, 195 | message: "{base} must have required property 'req2'", 196 | path: '{base}', 197 | }, 198 | ]); 199 | }); 200 | }); 201 | 202 | describe('type', () => { 203 | it('should handle type errors', () => { 204 | data = { 205 | str: 123, 206 | }; 207 | ajv.validate(schema, data); 208 | const errors = betterAjvErrors({ data, schema, errors: ajv.errors }); 209 | expect(errors).toEqual([ 210 | { 211 | context: { 212 | errorType: 'type', 213 | }, 214 | message: "'str' property type must be string", 215 | path: '{base}.str', 216 | }, 217 | ]); 218 | }); 219 | }); 220 | 221 | describe('minimum/maximum', () => { 222 | it('should handle minimum/maximum errors', () => { 223 | data = { 224 | str: 'str', 225 | bounds: 123, 226 | }; 227 | ajv.validate(schema, data); 228 | const errors = betterAjvErrors({ data, schema, errors: ajv.errors }); 229 | expect(errors).toEqual([ 230 | { 231 | context: { 232 | errorType: 'maximum', 233 | }, 234 | message: "property 'bounds' must be <= 4", 235 | path: '{base}.bounds', 236 | }, 237 | ]); 238 | }); 239 | }); 240 | 241 | describe('enum', () => { 242 | it('should handle enum errors', () => { 243 | data = { 244 | str: 'str', 245 | enum: 'zzzz', 246 | }; 247 | ajv.validate(schema, data); 248 | const errors = betterAjvErrors({ data, schema, errors: ajv.errors }); 249 | expect(errors).toEqual([ 250 | { 251 | context: { 252 | errorType: 'enum', 253 | allowedValues: ['one', 'two'], 254 | }, 255 | message: "'enum' property must be equal to one of the allowed values", 256 | path: '{base}.enum', 257 | }, 258 | ]); 259 | }); 260 | 261 | it('should provide suggestions when relevant', () => { 262 | data = { 263 | str: 'str', 264 | enum: 'pne', 265 | }; 266 | ajv.validate(schema, data); 267 | const errors = betterAjvErrors({ data, schema, errors: ajv.errors }); 268 | expect(errors).toEqual([ 269 | { 270 | context: { 271 | errorType: 'enum', 272 | allowedValues: ['one', 'two'], 273 | }, 274 | message: "'enum' property must be equal to one of the allowed values", 275 | path: '{base}.enum', 276 | suggestion: "Did you mean 'one'?", 277 | }, 278 | ]); 279 | }); 280 | 281 | it('should not crash on null value', () => { 282 | data = { 283 | type: null, 284 | }; 285 | schema = { 286 | type: 'object', 287 | properties: { 288 | type: { 289 | type: 'string', 290 | enum: ['primary', 'secondary'], 291 | }, 292 | }, 293 | }; 294 | ajv.validate(schema, data); 295 | const errors = betterAjvErrors({ data, schema, errors: ajv.errors }); 296 | expect(errors).toEqual([ 297 | { 298 | context: { 299 | allowedValues: ['primary', 'secondary'], 300 | errorType: 'enum', 301 | }, 302 | message: "'type' property must be equal to one of the allowed values", 303 | path: '{base}.type', 304 | }, 305 | ]); 306 | }); 307 | }); 308 | 309 | it('should handle array paths', () => { 310 | data = { 311 | custom: [{ foo: 'bar' }, { aaa: 'zzz' }], 312 | }; 313 | schema = { 314 | type: 'object', 315 | properties: { 316 | custom: { 317 | type: 'array', 318 | items: { 319 | type: 'object', 320 | additionalProperties: false, 321 | properties: { 322 | id: { 323 | type: 'string', 324 | }, 325 | title: { 326 | type: 'string', 327 | }, 328 | }, 329 | }, 330 | }, 331 | }, 332 | }; 333 | ajv.validate(schema, data); 334 | const errors = betterAjvErrors({ data, schema, errors: ajv.errors }); 335 | expect(errors).toEqual([ 336 | { 337 | context: { 338 | errorType: 'additionalProperties', 339 | }, 340 | message: "'foo' property is not expected to be here", 341 | path: '{base}.custom.0', 342 | }, 343 | { 344 | context: { 345 | errorType: 'additionalProperties', 346 | }, 347 | message: "'aaa' property is not expected to be here", 348 | path: '{base}.custom.1', 349 | }, 350 | ]); 351 | }); 352 | 353 | it('should handle file $refs', () => { 354 | data = { 355 | child: [{ foo: 'bar' }, { aaa: 'zzz' }], 356 | }; 357 | schema = { 358 | $id: 'http://example.com/schemas/Main.json', 359 | type: 'object', 360 | properties: { 361 | child: { 362 | type: 'array', 363 | items: { 364 | $ref: './Child.json', 365 | }, 366 | }, 367 | }, 368 | }; 369 | ajv.addSchema({ 370 | $id: 'http://example.com/schemas/Child.json', 371 | additionalProperties: false, 372 | type: 'object', 373 | properties: { 374 | id: { 375 | type: 'string', 376 | }, 377 | }, 378 | }); 379 | ajv.validate(schema, data); 380 | const errors = betterAjvErrors({ data, schema, errors: ajv.errors }); 381 | expect(errors).toEqual([ 382 | { 383 | context: { 384 | errorType: 'additionalProperties', 385 | }, 386 | message: "'foo' property is not expected to be here", 387 | path: '{base}.child.0', 388 | }, 389 | { 390 | context: { 391 | errorType: 'additionalProperties', 392 | }, 393 | message: "'aaa' property is not expected to be here", 394 | path: '{base}.child.1', 395 | }, 396 | ]); 397 | }); 398 | 399 | it('should handle number enums', () => { 400 | data = { 401 | isLive: 2, 402 | }; 403 | schema = { 404 | type: 'object', 405 | properties: { 406 | isLive: { 407 | type: 'integer', 408 | enum: [0, 1], 409 | }, 410 | }, 411 | }; 412 | ajv.validate(schema, data); 413 | const errors = betterAjvErrors({ data, schema, errors: ajv.errors }); 414 | expect(errors).toEqual([ 415 | { 416 | context: { 417 | allowedValues: [0, 1], 418 | errorType: 'enum', 419 | }, 420 | message: "'isLive' property must be equal to one of the allowed values", 421 | path: '{base}.isLive', 422 | }, 423 | ]); 424 | }); 425 | 426 | describe('const', () => { 427 | it('should handle const errors', () => { 428 | data = { 429 | const: 2, 430 | }; 431 | schema = { 432 | type: 'object', 433 | properties: { 434 | const: { 435 | type: 'integer', 436 | const: 42, 437 | }, 438 | }, 439 | }; 440 | ajv.validate(schema, data); 441 | const errors = betterAjvErrors({ data, schema, errors: ajv.errors }); 442 | expect(errors).toEqual([ 443 | { 444 | context: { 445 | allowedValue: 42, 446 | errorType: 'const', 447 | }, 448 | message: "'const' property must be equal to the allowed value", 449 | path: '{base}.const', 450 | }, 451 | ]); 452 | }); 453 | }); 454 | }); 455 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { DefinedError, ErrorObject } from 'ajv'; 2 | import { ValidationError } from './types/ValidationError'; 3 | import { filterSingleErrorPerProperty } from './lib/filter'; 4 | import { getSuggestion } from './lib/suggestions'; 5 | import { cleanAjvMessage, getLastSegment, pointerToDotNotation, safeJsonPointer } from './lib/utils'; 6 | 7 | export interface BetterAjvErrorsOptions { 8 | errors: ErrorObject[] | null | undefined; 9 | data: any; 10 | schema: S; 11 | basePath?: string; 12 | } 13 | 14 | export const betterAjvErrors = ({ 15 | errors, 16 | data, 17 | schema, 18 | basePath = '{base}', 19 | }: BetterAjvErrorsOptions): ValidationError[] => { 20 | if (!Array.isArray(errors) || errors.length === 0) { 21 | return []; 22 | } 23 | 24 | const definedErrors = filterSingleErrorPerProperty(errors as DefinedError[]); 25 | 26 | return definedErrors.map((error) => { 27 | const path = pointerToDotNotation(basePath + error.instancePath); 28 | const prop = getLastSegment(error.instancePath); 29 | const defaultContext = { 30 | errorType: error.keyword, 31 | }; 32 | const defaultMessage = `${prop ? `property '${prop}'` : path} ${cleanAjvMessage(error.message as string)}`; 33 | 34 | let validationError: ValidationError; 35 | 36 | switch (error.keyword) { 37 | case 'additionalProperties': { 38 | const additionalProp = error.params.additionalProperty; 39 | const suggestionPointer = error.schemaPath.replace('#', '').replace('/additionalProperties', ''); 40 | const { properties } = safeJsonPointer({ 41 | object: schema, 42 | pnter: suggestionPointer, 43 | fallback: { properties: {} }, 44 | }); 45 | validationError = { 46 | message: `'${additionalProp}' property is not expected to be here`, 47 | suggestion: getSuggestion({ 48 | value: additionalProp, 49 | suggestions: Object.keys(properties ?? {}), 50 | format: (suggestion) => `Did you mean property '${suggestion}'?`, 51 | }), 52 | path, 53 | context: defaultContext, 54 | }; 55 | break; 56 | } 57 | case 'enum': { 58 | const suggestions = error.params.allowedValues.map((value) => value.toString()); 59 | const prop = getLastSegment(error.instancePath); 60 | const value = safeJsonPointer({ object: data, pnter: error.instancePath, fallback: '' }); 61 | validationError = { 62 | message: `'${prop}' property must be equal to one of the allowed values`, 63 | suggestion: getSuggestion({ 64 | value, 65 | suggestions, 66 | }), 67 | path, 68 | context: { 69 | ...defaultContext, 70 | allowedValues: error.params.allowedValues, 71 | }, 72 | }; 73 | break; 74 | } 75 | case 'type': { 76 | const prop = getLastSegment(error.instancePath); 77 | const type = error.params.type; 78 | validationError = { 79 | message: `'${prop}' property type must be ${type}`, 80 | path, 81 | context: defaultContext, 82 | }; 83 | break; 84 | } 85 | case 'required': { 86 | validationError = { 87 | message: `${path} must have required property '${error.params.missingProperty}'`, 88 | path, 89 | context: defaultContext, 90 | }; 91 | break; 92 | } 93 | case 'const': { 94 | return { 95 | message: `'${prop}' property must be equal to the allowed value`, 96 | path, 97 | context: { 98 | ...defaultContext, 99 | allowedValue: error.params.allowedValue, 100 | }, 101 | }; 102 | } 103 | 104 | default: 105 | return { message: defaultMessage, path, context: defaultContext }; 106 | } 107 | 108 | // Remove empty properties 109 | const errorEntries = Object.entries(validationError); 110 | for (const [key, value] of errorEntries as [keyof ValidationError, unknown][]) { 111 | if (value === null || value === undefined || value === '') { 112 | delete validationError[key]; 113 | } 114 | } 115 | 116 | return validationError; 117 | }); 118 | }; 119 | 120 | export { ValidationError }; 121 | -------------------------------------------------------------------------------- /src/lib/filter.ts: -------------------------------------------------------------------------------- 1 | import { DefinedError } from 'ajv'; 2 | import { AJV_ERROR_KEYWORD_WEIGHT_MAP } from '../constants'; 3 | 4 | export const filterSingleErrorPerProperty = (errors: DefinedError[]): DefinedError[] => { 5 | const errorsPerProperty = errors.reduce>((acc, error) => { 6 | const prop = 7 | error.instancePath + ((error.params as any)?.additionalProperty ?? (error.params as any)?.missingProperty ?? ''); 8 | const existingError = acc[prop]; 9 | if (!existingError) { 10 | acc[prop] = error; 11 | return acc; 12 | } 13 | const weight = AJV_ERROR_KEYWORD_WEIGHT_MAP[error.keyword] ?? 0; 14 | const existingWeight = AJV_ERROR_KEYWORD_WEIGHT_MAP[existingError.keyword] ?? 0; 15 | 16 | if (weight > existingWeight) { 17 | acc[prop] = error; 18 | } 19 | return acc; 20 | }, {}); 21 | 22 | return Object.values(errorsPerProperty); 23 | }; 24 | -------------------------------------------------------------------------------- /src/lib/suggestions.ts: -------------------------------------------------------------------------------- 1 | import leven from 'leven'; 2 | 3 | export const getSuggestion = ({ 4 | value, 5 | suggestions, 6 | format = (suggestion) => `Did you mean '${suggestion}'?`, 7 | }: { 8 | value: string | null; 9 | suggestions: string[]; 10 | format?: (suggestion: string) => string; 11 | }): string => { 12 | if (!value) return ''; 13 | const bestSuggestion = suggestions.reduce( 14 | (best, current) => { 15 | const distance = leven(value, current); 16 | if (best.distance > distance) { 17 | return { value: current, distance }; 18 | } 19 | 20 | return best; 21 | }, 22 | { 23 | distance: Infinity, 24 | value: '', 25 | } 26 | ); 27 | 28 | return bestSuggestion.distance < value.length ? format(bestSuggestion.value) : ''; 29 | }; 30 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { NOT_REGEX, QUOTES_REGEX, SLASH_REGEX } from '../constants'; 2 | import pointer from 'jsonpointer'; 3 | 4 | export const pointerToDotNotation = (pointer: string): string => { 5 | return pointer.replace(SLASH_REGEX, '.'); 6 | }; 7 | 8 | export const cleanAjvMessage = (message: string): string => { 9 | return message.replace(QUOTES_REGEX, "'").replace(NOT_REGEX, 'not'); 10 | }; 11 | 12 | export const getLastSegment = (path: string): string => { 13 | const segments = path.split('/'); 14 | return segments.pop() as string; 15 | }; 16 | 17 | export const safeJsonPointer = ({ object, pnter, fallback }: { object: any; pnter: string; fallback: T }): T => { 18 | try { 19 | return pointer.get(object, pnter); 20 | } catch (err) { 21 | return fallback; 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /src/types/ValidationError.ts: -------------------------------------------------------------------------------- 1 | import { DefinedError } from 'ajv'; 2 | 3 | export interface ValidationError { 4 | message: string; 5 | path: string; 6 | suggestion?: string; 7 | context: { 8 | errorType: DefinedError['keyword']; 9 | [additionalContext: string]: unknown; 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | // output .d.ts declaration files for consumers 9 | "declaration": true, 10 | // output .js.map sourcemap files for consumers 11 | "sourceMap": true, 12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 13 | "rootDir": "./src", 14 | "baseUrl": "src", 15 | // stricter type-checking for stronger correctness. Recommended by TS 16 | "strict": true, 17 | // linter checks for common issues 18 | "noImplicitReturns": true, 19 | "noFallthroughCasesInSwitch": true, 20 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | // use Node's module resolution algorithm, instead of the legacy TS one 24 | "moduleResolution": "node", 25 | // transpile JSX to React.createElement 26 | "jsx": "react", 27 | // interop between ESM and CJS modules. Recommended by TS 28 | "esModuleInterop": true, 29 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 30 | "skipLibCheck": true, 31 | // error out if import and file system have a casing mismatch. Recommended by TS 32 | "forceConsistentCasingInFileNames": true, 33 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 34 | "noEmit": true 35 | } 36 | } 37 | --------------------------------------------------------------------------------