├── .changeset ├── README.md ├── config.json └── create-dependabot-dependabot.js ├── .envrc ├── .flowconfig ├── .github └── workflows │ ├── codeql-analysis.yml │ ├── dependabot.yml │ ├── main.yml │ └── release.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmrc ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── _config.yml ├── build.js ├── eslint.config.js ├── flake.lock ├── flake.nix ├── media ├── logo.png └── screenshot.svg ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── renovate.json ├── screenshot.js ├── src ├── __fixtures__ │ └── default │ │ ├── data.json │ │ └── schema.json ├── __tests__ │ ├── __snapshots__ │ │ └── index.js.snap │ ├── helpers │ │ ├── create-error-instances.js │ │ ├── filter-redundant-errors.js │ │ └── make-tree.js │ ├── index.js │ └── utils.js ├── helpers.js ├── index.js ├── json │ ├── __fixtures__ │ │ ├── scenario-1.json │ │ ├── scenario-2.json │ │ ├── scenario-3.json │ │ ├── scenario-4.json │ │ └── scenario-5.json │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── index.js.snap │ │ └── index.js │ ├── get-decorated-data-path.js │ ├── get-meta-from-path.js │ ├── index.js │ └── utils.js ├── test-helpers.js ├── types.js ├── utils.js └── validation-errors │ ├── __fixtures__ │ ├── additionalProperties │ │ ├── data.json │ │ └── schema.json │ ├── default │ │ ├── data.json │ │ └── schema.json │ ├── enum-string │ │ ├── data.json │ │ └── schema.json │ ├── enum │ │ ├── data.json │ │ └── schema.json │ └── required │ │ ├── data.json │ │ └── schema.json │ ├── __tests__ │ ├── __snapshots__ │ │ ├── enum.js.snap │ │ ├── main.js.snap │ │ └── required.js.snap │ ├── enum.js │ ├── main.js │ └── required.js │ ├── additional-prop.js │ ├── base.js │ ├── default.js │ ├── enum.js │ ├── index.js │ └── required.js ├── typings.d.ts ├── typings.test-d.ts └── vitest.config.ts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.6.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "linked": [], 6 | "access": "public", 7 | "baseBranch": "main", 8 | "updateInternalDependencies": "patch", 9 | "ignore": [] 10 | } 11 | -------------------------------------------------------------------------------- /.changeset/create-dependabot-dependabot.js: -------------------------------------------------------------------------------- 1 | const process = require('process'); 2 | const write = require('@changesets/write').default; 3 | 4 | const [, , summary, packageName] = process.argv; 5 | 6 | if (!summary || !packageName) { 7 | console.error( 8 | 'node .changeset/create-dependabot-dependabot.js "" ""' 9 | ); 10 | process.exit(1); 11 | } 12 | 13 | const changeset = { 14 | summary, 15 | releases: [{ name: packageName, type: 'patch' }], 16 | }; 17 | 18 | const cwd = process.cwd(); 19 | 20 | write(changeset, cwd).then(uniqueId => 21 | console.log(`.changeset/${uniqueId}.md`) 22 | ); 23 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | if has nix; then 2 | use flake 3 | fi 4 | # vim: ft=bash 5 | # shellcheck shell=bash 6 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | 5 | [libs] 6 | 7 | [lints] 8 | 9 | [options] 10 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ main ] 9 | schedule: 10 | - cron: '23 6 * * 1' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | language: [ 'javascript' ] 25 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 26 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | 32 | # Initializes the CodeQL tools for scanning. 33 | - name: Initialize CodeQL 34 | uses: github/codeql-action/init@v3 35 | with: 36 | languages: ${{ matrix.language }} 37 | # If you wish to specify custom queries, you can do so here or in a config file. 38 | # By default, queries listed here will override any specified in a config file. 39 | # Prefix the list here with "+" to use these queries and those in the config file. 40 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 41 | 42 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 43 | # If this step fails, then you should remove it and run the build manually (see below) 44 | - name: Autobuild 45 | uses: github/codeql-action/autobuild@v3 46 | 47 | # ℹ️ Command-line programs to run using the OS shell. 48 | # 📚 https://git.io/JvXDl 49 | 50 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 51 | # and modify them (or add more) to build your code if your project 52 | # uses a compiled language 53 | 54 | #- run: | 55 | # make bootstrap 56 | # make release 57 | 58 | - name: Perform CodeQL Analysis 59 | uses: github/codeql-action/analyze@v3 60 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot 2 | on: 3 | push: 4 | branches: 5 | - 'dependabot/**' 6 | 7 | jobs: 8 | ci-image: 9 | name: Changeset 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 1 15 | ref: ${{ github.event.pull_request.head.ref }} 16 | 17 | - name: Configure git 18 | run: | 19 | git config --local user.email "$(git log --pretty='%ae' -1)" 20 | git config --local user.name "Dependabot[bot]" 21 | git checkout ${{ github.event.pull_request.head.ref }} 22 | 23 | - uses: pnpm/action-setup@v4 24 | name: Install pnpm 25 | with: 26 | version: 10 27 | run_install: false 28 | 29 | - name: Use Node.js 20.x 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: 20.x 33 | cache: 'pnpm' 34 | 35 | - name: Install Dependencies 36 | run: pnpm install 37 | 38 | - name: Create a changeset 39 | run: | 40 | # Parse the dependency 41 | commit_message=$(git show -s --format=%B ${{ github.event.pull_request.head.sha }} | head -1) 42 | dependency_name=$(sed -nE 's/Bump (.*) from.*/\1/p' <(echo $commit_message)) 43 | changeset_file_path=$(npm run dependabot:changeset $commit_message $dependency_name) 44 | 45 | - name: Commit 46 | run: | 47 | git add $changeset_file_path 48 | git commit -m "[dependabot skip] Add changeset" 49 | 50 | - name: Push changes back to branch 51 | run: | 52 | git push origin ${{ github.event.pull_request.head.ref }} 53 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | test: 9 | name: Test 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | matrix: 14 | node-version: [18.x, 20.x] 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - uses: pnpm/action-setup@v4 21 | name: Install pnpm 22 | with: 23 | version: 10 24 | run_install: false 25 | 26 | - name: Use Node.js ${{ matrix.node-version }} 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | cache: 'pnpm' 31 | 32 | - name: Install dependencies 33 | run: pnpm install 34 | 35 | - name: Run Tests 36 | run: pnpm run test-ci 37 | 38 | - name: Upload coverage to Codecov 39 | if: matrix.node-version == '18.x' 40 | uses: codecov/codecov-action@v5 41 | with: 42 | token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos 43 | fail_ci_if_error: true # optional (default = false) 44 | lint: 45 | name: Lint 46 | runs-on: ubuntu-latest 47 | steps: 48 | - uses: actions/checkout@v4 49 | 50 | - uses: pnpm/action-setup@v4 51 | name: Install pnpm 52 | with: 53 | version: 10 54 | run_install: false 55 | 56 | - name: Use Node.js 20.x 57 | uses: actions/setup-node@v4 58 | with: 59 | node-version: 20.x 60 | cache: 'pnpm' 61 | 62 | - name: Install Dependencies 63 | run: pnpm install 64 | 65 | - name: Run Lint 66 | run: pnpm run lint 67 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - '.changeset/**' 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Repo 16 | uses: actions/checkout@v4 17 | with: 18 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits 19 | fetch-depth: 0 20 | 21 | - uses: pnpm/action-setup@v4 22 | name: Install pnpm 23 | with: 24 | version: 10 25 | run_install: false 26 | 27 | - name: Setup Node.js 18.x 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: 18.x 31 | cache: 'pnpm' 32 | 33 | - name: Install Dependencies 34 | run: pnpm install 35 | 36 | - name: Create Release Pull Request or Publish to npm 37 | if: github.repository == 'atlassian/better-ajv-errors' 38 | id: changesets 39 | uses: changesets/action@master 40 | with: 41 | # This expects you to have a script called release which does a build for your packages and calls changeset publish 42 | publish: pnpm run release 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /lib/ 2 | /node_modules/ 3 | /coverage/ 4 | *.log 5 | .vscode 6 | .env 7 | .direnv 8 | yarn.lock 9 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | git-format-staged -f 'prettier --ignore-unknown --stdin --stdin-filepath "{}"' . 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "arrowParens":"avoid" 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # better-ajv-errors 2 | 3 | ## 2.0.2 4 | 5 | ### Patch Changes 6 | 7 | - 83348c8: Add missing `lib` in the published package 8 | 9 | ## 2.0.1 10 | 11 | ### Patch Changes 12 | 13 | - 7915d20: Downgrade minimum supported Node version from `>= 22.16.0` to `>= 18.20.6` 14 | 15 | ## 2.0.0 16 | 17 | ### Major Changes 18 | 19 | - 5ea0600: Minimum supported Node version bumped from `>= 12.13.0` to `>= 22.16.0` 20 | - 6bd1a6e: Remove Jest and Bump dependencies 21 | 22 | ## 1.2.0 23 | 24 | ### Minor Changes 25 | 26 | - 3918d58: Add integration with ajv-errors 27 | 28 | ### Patch Changes 29 | 30 | - 6120105: Remove for...in loop to prevent possible enumeration errors 31 | 32 | ## 1.1.2 33 | 34 | ### Patch Changes 35 | 36 | - a1cafc8: :wrench: Fix esm build 37 | 38 | ## 1.1.1 39 | 40 | ### Patch Changes 41 | 42 | - 7c83bf6: :bug: Fix cli return type 43 | 44 | ## 1.1.0 45 | 46 | ### Minor Changes 47 | 48 | - ade58e0: :package: Swap `json-to-ast` with `momoa` 49 | 50 | | | `json-to-ast` | `momoa` | 51 | | ---------------------- | --------------: | --------------: | 52 | | **Small JSON** `23B` | 254,556 ops/sec | 329,012 ops/sec | 53 | | **Medium JSON** `55KB` | 226 ops/sec | 246 ops/sec | 54 | | **Large JSON** `25MB` | 0.19 ops/sec | 0.29 ops/sec | 55 | 56 | ### Patch Changes 57 | 58 | - abee681: :package: Restrict `leven` version to < 4 59 | 60 | `leven@4` only ships `esm` module which is not compatible with this library. 61 | 62 | ## 1.0.0 63 | 64 | ### Major Changes 65 | 66 | - 146a859: :package: better-ajv-errors v1 67 | 68 | ### Breaking Changes 69 | 70 | - Dropped support for Node.js `< 12.13.0` 71 | - Default import in CommonJS format no longer supported 72 | 73 | **:no_entry_sign: Wrong** 74 | 75 | ```js 76 | const betterAjvErrors = require('better-ajv-errors'); 77 | ``` 78 | 79 | **:white_check_mark: Correct** 80 | 81 | ```js 82 | const betterAjvErrors = require('better-ajv-errors').default; 83 | // Or 84 | const { default: betterAjvErrors } = require('better-ajv-errors'); 85 | ``` 86 | 87 | ### Other Changes 88 | 89 | - Added ESM support 90 | - Moved from `babel` to `esbuild` _(99% faster build: from `2170ms` to `20ms`)_ 91 | - https://github.com/atlassian/better-ajv-errors/pull/101#issuecomment-963129931 92 | - Bumped all `dependencies` & `devDependencies` 93 | 94 | - ad60e6b: :nail_care: Improve typings and add test 95 | 96 | ### Breaking Changes 97 | 98 | - New TypeScript types are not fully backward compatible 99 | 100 | ### Patch Changes 101 | 102 | - 768ce0f: Bump ws from 5.2.2 to 5.2.3 103 | - dc45eb7: Bump tar from 4.4.10 to 4.4.19 104 | - 5ef7b1e: Bump path-parse from 1.0.6 to 1.0.7 105 | - 3ef2bbc: Bump tmpl from 1.0.4 to 1.0.5 106 | - 46b57d3: Bump color-string from 1.5.3 to 1.6.0 107 | - d568784: Bump lodash from 4.17.10 to 4.17.21 108 | - e71f114: Bump browserslist from 4.7.0 to 4.17.6 109 | 110 | ## 0.8.2 111 | 112 | ### Patch Changes 113 | 114 | - 2513443: :fire_engine: Bump `jsonpointer` - CVE-2021-23807 115 | 116 | ## 0.8.1 117 | 118 | ### Patch Changes 119 | 120 | - 25cf308: :fire_engine: Bump `jsonpointer` - CVE-2021-23807 121 | 122 | ## 0.8.0 123 | 124 | ### Minor Changes 125 | 126 | - 8846dda: ajv 8 support 127 | 128 | ## 0.7.0 129 | 130 | ### Minor Changes 131 | 132 | - 4e6e4c7: Support json option to get accurate line/column listings 133 | 134 | ## 0.6.7 135 | 136 | ### Patch Changes 137 | 138 | - 234c01d: Handle primitive values in EnumValidationError 139 | 140 | ## 0.6.6 141 | 142 | ### Patch Changes 143 | 144 | - 84517c3: Fix a bug where enum error shows duplicate allowed values 145 | 146 | ## 0.6.5 147 | 148 | ### Patch Changes 149 | 150 | - f2e0424: Fix a bug where nested errors were ignored when top level had enum errors 151 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Atlassian Pty Ltd 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | better-ajv-errors 3 |
4 |

5 | 6 | > JSON Schema validation for Human 👨‍🎤 7 | 8 | Main goal of this library is to provide relevant error messages like the following: 9 | 10 |

11 | 12 |

13 | 14 | ## Installation 15 | 16 | ```bash 17 | $ npm i better-ajv-errors 18 | $ # Or 19 | $ yarn add better-ajv-errors 20 | ``` 21 | 22 | Also make sure that you installed [ajv](https://www.npmjs.com/package/ajv) package to validate data against JSON schemas. 23 | 24 | ## Usage 25 | 26 | First, you need to validate your payload with `ajv`. If it's invalid then you can pass `validate.errors` object into `better-ajv-errors`. 27 | 28 | ```js 29 | import Ajv from 'ajv'; 30 | import betterAjvErrors from 'better-ajv-errors'; 31 | // const Ajv = require('ajv'); 32 | // const betterAjvErrors = require('better-ajv-errors').default; 33 | // Or 34 | // const { default: betterAjvErrors } = require('better-ajv-errors'); 35 | 36 | // You need to pass `{ jsonPointers: true }` for older versions of ajv 37 | const ajv = new Ajv(); 38 | 39 | // Load schema and data 40 | const schema = ...; 41 | const data = ...; 42 | 43 | const validate = ajv.compile(schema); 44 | const valid = validate(data); 45 | 46 | if (!valid) { 47 | const output = betterAjvErrors(schema, data, validate.errors); 48 | console.log(output); 49 | } 50 | ``` 51 | 52 | ## API 53 | 54 | ### betterAjvErrors(schema, data, errors, [options]) 55 | 56 | Returns formatted validation error to **print** in `console`. See [`options.format`](#format) for further details. 57 | 58 | #### schema 59 | 60 | Type: `Object` 61 | 62 | The JSON Schema you used for validation with `ajv` 63 | 64 | #### data 65 | 66 | Type: `Object` 67 | 68 | The JSON payload you validate against using `ajv` 69 | 70 | #### errors 71 | 72 | Type: `Array` 73 | 74 | Array of [ajv validation errors](https://github.com/epoberezkin/ajv#validation-errors) 75 | 76 | #### options 77 | 78 | Type: `Object` 79 | 80 | ##### format 81 | 82 | Type: `string` 83 | Default: `cli` 84 | Values: `cli` `js` 85 | 86 | Use default `cli` output format if you want to **print** beautiful validation errors like following: 87 | 88 | 89 | 90 | Or, use `js` if you are planning to use this with some API. Your output will look like following: 91 | 92 | ```javascript 93 | [ 94 | { 95 | start: { line: 6, column: 15, offset: 70 }, 96 | end: { line: 6, column: 26, offset: 81 }, 97 | error: 98 | '/content/0/type should be equal to one of the allowed values: panel, paragraph, ...', 99 | suggestion: 'Did you mean paragraph?', 100 | }, 101 | ]; 102 | ``` 103 | 104 | ##### indent 105 | 106 | Type: `number` `null` 107 | Default: `null` 108 | 109 | If you have an unindented JSON payload and you want the error output indented. 110 | 111 | This option have no effect when using the `json` option. 112 | 113 | ##### json 114 | 115 | Type: `string` `null` 116 | Default: `null` 117 | 118 | Raw JSON payload used when formatting codeframe. 119 | Gives accurate line and column listings. 120 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-hacker -------------------------------------------------------------------------------- /build.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const process = require('process'); 4 | 5 | const isCI = require('is-ci'); 6 | const fg = require('fast-glob'); 7 | const esbuild = require('esbuild'); 8 | 9 | const isEsmBuild = process.argv[2] !== '--cjs'; 10 | 11 | const config = { 12 | cjs: { 13 | format: 'cjs', 14 | platform: 'node', 15 | outdir: './lib/cjs', 16 | }, 17 | esm: { 18 | format: 'esm', 19 | outdir: './lib/esm', 20 | outExtension: { 21 | '.js': '.mjs', 22 | }, 23 | bundle: true, 24 | plugins: [ 25 | { 26 | name: 'add-mjs', 27 | setup(build) { 28 | build.onResolve({ filter: /.*/ }, args => { 29 | if (args.kind === 'entry-point') return; 30 | let path = args.path; 31 | if (path.startsWith('.') && !path.endsWith('.mjs')) path += '.mjs'; 32 | return { path, external: true }; 33 | }); 34 | }, 35 | }, 36 | ], 37 | }, 38 | }; 39 | 40 | fg('src/**/*.js', { 41 | ignore: ['**/__tests__', '**/__fixtures__'], 42 | }) 43 | .then(entryPoints => 44 | esbuild.build({ 45 | ...(isEsmBuild ? config.esm : config.cjs), 46 | entryPoints, 47 | sourcemap: true, 48 | logLevel: isCI ? 'silent' : 'info', 49 | target: 'node12.13', 50 | }) 51 | ) 52 | .catch(_ => process.exit(1)); 53 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require("eslint/config"); 2 | 3 | const globals = require("globals"); 4 | const vitest = require("@vitest/eslint-plugin"); 5 | const js = require("@eslint/js"); 6 | 7 | const { FlatCompat } = require("@eslint/eslintrc"); 8 | 9 | const compat = new FlatCompat({ 10 | baseDirectory: __dirname, 11 | recommendedConfig: js.configs.recommended, 12 | allConfig: js.configs.all 13 | }); 14 | 15 | module.exports = defineConfig([{ 16 | files: ["src/**/*.js"], 17 | languageOptions: { 18 | globals: { 19 | ...globals.node, 20 | }, 21 | 22 | ecmaVersion: 2018, 23 | sourceType: "module", 24 | parserOptions: {}, 25 | }, 26 | 27 | // extends: compat.extends("eslint:recommended", "plugin:prettier/recommended"), 28 | extends: compat.extends("eslint:recommended"), 29 | 30 | plugins: { 31 | vitest, 32 | }, 33 | 34 | rules: { 35 | "no-unused-vars": [2, { 36 | args: "all", 37 | argsIgnorePattern: "^_", 38 | }], 39 | ...vitest.configs.recommended.rules, 40 | }, 41 | 42 | ignores: [ 43 | "flow-typed/", 44 | "lib/", 45 | "node_modules/", 46 | "dist/", 47 | ], 48 | }]); 49 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1748406211, 24 | "narHash": "sha256-B3BsCRbc+x/d0WiG1f+qfSLUy+oiIfih54kalWBi+/M=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "3d1f29646e4b57ed468d60f9d286cde23a8d1707", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixpkgs-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "A Nix-flake-based JavaScript development environment"; 3 | inputs = { 4 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 5 | flake-utils.url = "github:numtide/flake-utils"; 6 | }; 7 | 8 | outputs = { self, nixpkgs, flake-utils }: 9 | flake-utils.lib.eachDefaultSystem ( system: 10 | let 11 | pkgs = nixpkgs.legacyPackages.${system}; 12 | in 13 | { 14 | devShells.default = pkgs.mkShell { 15 | packages = with pkgs; [ 16 | pnpm 17 | ]; 18 | }; 19 | 20 | formatter = pkgs.nixfmt-rfc-style; 21 | } 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /media/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/better-ajv-errors/ba5edc30d4b3bf5306891cd81e2e604eef964a81/media/logo.png -------------------------------------------------------------------------------- /media/screenshot.svg: -------------------------------------------------------------------------------- 1 | ENUMmustbeequaltooneoftheallowedvalues(paragraph,codeBlock,blockquote)4|"content":[5|{>6|"type":"paragarph"|^^^^^^^^^^^👈🏽Didyoumeanparagraphhere?7|}8|]9|} -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "better-ajv-errors", 3 | "version": "2.0.2", 4 | "description": "JSON Schema validation for Human", 5 | "repository": "atlassian/better-ajv-errors", 6 | "main": "./lib/cjs/index.js", 7 | "exports": { 8 | ".": { 9 | "require": "./lib/cjs/index.js", 10 | "default": "./lib/esm/index.mjs" 11 | } 12 | }, 13 | "module": "./lib/esm/index.mjs", 14 | "engines": { 15 | "node": ">= 18.20.6" 16 | }, 17 | "keywords": [ 18 | "json-schema", 19 | "ajv", 20 | "ajv-errors" 21 | ], 22 | "author": "Rifat Nabi ", 23 | "maintainers": [ 24 | "Rifat Nabi ", 25 | "Dmitrii Sorin ", 26 | "Tong Li" 27 | ], 28 | "license": "Apache-2.0", 29 | "types": "./typings.d.ts", 30 | "files": [ 31 | "lib", 32 | "typings.d.ts" 33 | ], 34 | "scripts": { 35 | "prebuild": "rm -rf lib", 36 | "build": "npm run build:cjs && npm run build:esm", 37 | "build:cjs": "node build.js --cjs", 38 | "build:esm": "node build.js", 39 | "prerelease": "npm run build", 40 | "release": "changeset publish", 41 | "format": "prettier --write './src/**/*.js' './.changeset/*.json'", 42 | "lint": "eslint .", 43 | "test": "vitest", 44 | "test-ci": "vitest --coverage", 45 | "prescreenshot": "npm run build:cjs", 46 | "screenshot": "svg-term --command='node screenshot' --out=./media/screenshot.svg --padding=5 --width=80 --height=13 --at=1000 --no-cursor --term iterm2 --profile='deep' --window", 47 | "prepare": "is-ci || husky install", 48 | "dependabot:changeset": "node ./.changeset/create-dependabot-dependabot.js" 49 | }, 50 | "dependencies": { 51 | "@babel/code-frame": "^7.27.1", 52 | "@humanwhocodes/momoa": "^2.0.4", 53 | "chalk": "^4.1.2", 54 | "jsonpointer": "^5.0.1", 55 | "leven": "^3.1.0 < 4" 56 | }, 57 | "devDependencies": { 58 | "@changesets/cli": "^2.29.4", 59 | "@changesets/write": "^0.4.0", 60 | "@eslint/eslintrc": "^3.3.1", 61 | "@eslint/js": "^9.27.0", 62 | "@vitest/coverage-v8": "^3.1.4", 63 | "@vitest/ui": "3.2.1", 64 | "ajv": "^8.17.1", 65 | "esbuild": "^0.25.5", 66 | "eslint": "^9.27.0", 67 | "@vitest/eslint-plugin": "^1.0.1", 68 | "fast-glob": "^3.3.3", 69 | "flow-bin": "^0.272.1", 70 | "git-format-staged": "^3.1.1", 71 | "globals": "^16.2.0", 72 | "husky": "^9.0.0", 73 | "is-ci": "^4.1.0", 74 | "jest-fixtures": "^0.6.0", 75 | "prettier": "^3.5.3", 76 | "svg-term-cli": "^2.1.1", 77 | "tsd": "^0.32.0", 78 | "vitest": "^3.1.4" 79 | }, 80 | "peerDependencies": { 81 | "ajv": "4.11.8 - 8" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | overrides: 2 | xmldom@<0.5.0: '>=0.5.0' 3 | node-fetch@<2.6.7: '>=2.6.7' 4 | trim-newlines@<3.0.1: '>=3.0.1' 5 | plist@<3.0.5: '>=3.0.5' 6 | nth-check@<2.0.1: '>=2.0.1' 7 | semver@>=7.0.0 <7.5.2: '>=7.5.2' 8 | semver@<5.7.2: '>=5.7.2' 9 | '@babel/runtime@<7.26.10': '>=7.26.10' 10 | cross-spawn@<6.0.6: '>=6.0.6' 11 | cross-spawn@>=7.0.0 <7.0.5: '>=7.0.5' 12 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "rangeStrategy": "replace", 6 | "packageRules": [ 7 | { 8 | "matchPackagePatterns": ["chalk", "leven"], 9 | "enabled": false 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /screenshot.js: -------------------------------------------------------------------------------- 1 | // iTerm2 Theme: https://raw.githubusercontent.com/mbadolato/iTerm2-Color-Schemes/master/schemes/deep.itermcolors 2 | const Ajv = require('ajv'); 3 | 4 | const schema = require('./src/__fixtures__/default/schema.json'); 5 | const data = require('./src/__fixtures__/default/data.json'); 6 | 7 | const betterAjvErrors = require('.').default; 8 | 9 | // options can be passed, e.g. {allErrors: true} 10 | // const ajv = new Ajv({ allErrors: true, async: 'es7' }); 11 | const ajv = new Ajv(); 12 | 13 | const validate = ajv.compile(schema); 14 | const valid = validate(data); 15 | 16 | const output = betterAjvErrors(schema, data, validate.errors, { 17 | indent: 2, 18 | // format: 'js', 19 | }); 20 | 21 | if (!valid) { 22 | console.log(output); 23 | } 24 | -------------------------------------------------------------------------------- /src/__fixtures__/default/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "doc", 3 | "version": 1, 4 | "content": [{ "type": "paragarph" }] 5 | } 6 | -------------------------------------------------------------------------------- /src/__fixtures__/default/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$ref": "#/definitions/doc", 3 | "definitions": { 4 | "paragraph": { 5 | "type": "object", 6 | "properties": { 7 | "type": { 8 | "enum": ["paragraph"] 9 | } 10 | } 11 | }, 12 | "codeBlock": { 13 | "type": "object", 14 | "properties": { 15 | "type": { 16 | "enum": ["codeBlock"] 17 | } 18 | } 19 | }, 20 | "blockquote": { 21 | "type": "object", 22 | "properties": { 23 | "type": { 24 | "enum": ["blockquote"] 25 | } 26 | } 27 | }, 28 | "doc": { 29 | "type": "object", 30 | "properties": { 31 | "version": { 32 | "enum": [1] 33 | }, 34 | "type": { 35 | "enum": ["doc"] 36 | }, 37 | "content": { 38 | "type": "array", 39 | "items": { 40 | "anyOf": [ 41 | { 42 | "$ref": "#/definitions/paragraph" 43 | }, 44 | { 45 | "$ref": "#/definitions/codeBlock" 46 | }, 47 | { 48 | "$ref": "#/definitions/blockquote" 49 | } 50 | ] 51 | } 52 | } 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/index.js.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Main > should output error with codeframe 1`] = ` 4 | "ENUM must be equal to one of the allowed values 5 | (paragraph, codeBlock, blockquote) 6 | 7 |   2 | "type": "doc", 8 |  3 | "version": 1, 9 | > 4 | "content": [{ "type": "paragarph" }] 10 |  | ^^^^^^^^^^^ 👈🏽 Did you mean paragraph here? 11 |  5 | } 12 |  6 |" 13 | `; 14 | 15 | exports[`Main > should output error with reconstructed codeframe 1`] = ` 16 | "ENUM must be equal to one of the allowed values 17 | (paragraph, codeBlock, blockquote) 18 | 19 |   4 | "content": [ 20 |  5 | { 21 | > 6 | "type": "paragarph" 22 |  | ^^^^^^^^^^^ 👈🏽 Did you mean paragraph here? 23 |  7 | } 24 |  8 | ] 25 |  9 | }" 26 | `; 27 | -------------------------------------------------------------------------------- /src/__tests__/helpers/create-error-instances.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { createErrorInstances } from '../../helpers'; 3 | 4 | describe('createErrorInstances', () => { 5 | it('should not show duplicate values under allowed values', () => { 6 | const errors = createErrorInstances( 7 | { 8 | children: {}, 9 | errors: [ 10 | { 11 | keyword: 'enum', 12 | params: { 13 | allowedValues: ['one', 'two', 'one'], 14 | }, 15 | }, 16 | { 17 | keyword: 'enum', 18 | params: { 19 | allowedValues: ['two', 'three', 'four'], 20 | }, 21 | }, 22 | ], 23 | }, 24 | {} 25 | ); 26 | 27 | expect(errors).toMatchInlineSnapshot(` 28 | [ 29 | EnumValidationError { 30 | "data": undefined, 31 | "jsonAst": undefined, 32 | "jsonRaw": undefined, 33 | "options": { 34 | "keyword": "enum", 35 | "params": { 36 | "allowedValues": [ 37 | "one", 38 | "two", 39 | "three", 40 | "four", 41 | ], 42 | }, 43 | }, 44 | "schema": undefined, 45 | }, 46 | ] 47 | `); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/__tests__/helpers/filter-redundant-errors.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { filterRedundantErrors } from '../../helpers'; 3 | 4 | describe('filterRedundantErrors', () => { 5 | it('should prioritize required', async () => { 6 | const tree = { 7 | children: { 8 | a: { 9 | children: { 10 | b: { 11 | children: {}, 12 | errors: [ 13 | { 14 | keyword: 'required', 15 | }, 16 | ], 17 | }, 18 | }, 19 | errors: [ 20 | { 21 | keyword: 'required', 22 | }, 23 | { 24 | keyword: 'anyOf', 25 | }, 26 | { 27 | keyword: 'enum', 28 | }, 29 | ], 30 | }, 31 | }, 32 | }; 33 | filterRedundantErrors(tree); 34 | expect(tree).toMatchInlineSnapshot(` 35 | { 36 | "children": { 37 | "a": { 38 | "children": {}, 39 | "errors": [ 40 | { 41 | "keyword": "required", 42 | }, 43 | ], 44 | }, 45 | }, 46 | } 47 | `); 48 | }); 49 | 50 | it('should handle anyOf', async () => { 51 | const tree = { 52 | children: { 53 | a: { 54 | children: { 55 | b: { 56 | children: {}, 57 | errors: [ 58 | { 59 | keyword: 'required', 60 | }, 61 | ], 62 | }, 63 | }, 64 | errors: [ 65 | { 66 | keyword: 'anyOf', 67 | }, 68 | { 69 | keyword: 'enum', 70 | }, 71 | ], 72 | }, 73 | }, 74 | }; 75 | filterRedundantErrors(tree); 76 | expect(tree).toMatchInlineSnapshot(` 77 | { 78 | "children": { 79 | "a": { 80 | "children": { 81 | "b": { 82 | "children": {}, 83 | "errors": [ 84 | { 85 | "keyword": "required", 86 | }, 87 | ], 88 | }, 89 | }, 90 | }, 91 | }, 92 | } 93 | `); 94 | }); 95 | 96 | it('should handle enum', async () => { 97 | const tree = { 98 | children: { 99 | a: { 100 | children: { 101 | b: { 102 | children: {}, 103 | errors: [ 104 | { 105 | keyword: 'enum', 106 | }, 107 | { 108 | keyword: 'enum', 109 | }, 110 | ], 111 | }, 112 | }, 113 | errors: [ 114 | { 115 | keyword: 'anyOf', 116 | }, 117 | { 118 | keyword: 'additionalProperty', 119 | }, 120 | ], 121 | }, 122 | }, 123 | }; 124 | filterRedundantErrors(tree); 125 | expect(tree).toMatchInlineSnapshot(` 126 | { 127 | "children": { 128 | "a": { 129 | "children": { 130 | "b": { 131 | "children": {}, 132 | "errors": [ 133 | { 134 | "keyword": "enum", 135 | }, 136 | { 137 | "keyword": "enum", 138 | }, 139 | ], 140 | }, 141 | }, 142 | }, 143 | }, 144 | } 145 | `); 146 | }); 147 | 148 | it('should handle enum - sibling', async () => { 149 | const tree = { 150 | children: { 151 | a1: { 152 | children: {}, 153 | errors: [ 154 | { 155 | keyword: 'enum', 156 | }, 157 | { 158 | keyword: 'enum', 159 | }, 160 | ], 161 | }, 162 | a2: { 163 | children: {}, 164 | errors: [ 165 | { 166 | keyword: 'additionalProperty', 167 | }, 168 | ], 169 | }, 170 | }, 171 | }; 172 | filterRedundantErrors(tree); 173 | expect(tree).toMatchInlineSnapshot(` 174 | { 175 | "children": { 176 | "a2": { 177 | "children": {}, 178 | "errors": [ 179 | { 180 | "keyword": "additionalProperty", 181 | }, 182 | ], 183 | }, 184 | }, 185 | } 186 | `); 187 | }); 188 | 189 | it('should handle enum - sibling with nested error', async () => { 190 | const tree = { 191 | children: { 192 | a1: { 193 | children: { 194 | b1: { 195 | children: {}, 196 | errors: [ 197 | { 198 | keyword: 'additionalProperty', 199 | }, 200 | ], 201 | }, 202 | }, 203 | errors: [], 204 | }, 205 | a2: { 206 | children: {}, 207 | errors: [ 208 | { 209 | keyword: 'enum', 210 | }, 211 | { 212 | keyword: 'enum', 213 | }, 214 | ], 215 | }, 216 | }, 217 | }; 218 | filterRedundantErrors(tree); 219 | expect(tree).toMatchInlineSnapshot(` 220 | { 221 | "children": { 222 | "a1": { 223 | "children": { 224 | "b1": { 225 | "children": {}, 226 | "errors": [ 227 | { 228 | "keyword": "additionalProperty", 229 | }, 230 | ], 231 | }, 232 | }, 233 | "errors": [], 234 | }, 235 | }, 236 | } 237 | `); 238 | }); 239 | 240 | it('should not remove anyOf errors if there are no children', async () => { 241 | const tree = { 242 | children: { 243 | '/object': { 244 | children: { 245 | '/type': { 246 | children: {}, 247 | errors: [ 248 | { 249 | keyword: 'type', 250 | }, 251 | { 252 | keyword: 'type', 253 | }, 254 | { 255 | keyword: 'anyOf', 256 | }, 257 | ], 258 | }, 259 | }, 260 | errors: [], 261 | }, 262 | }, 263 | }; 264 | 265 | filterRedundantErrors(tree); 266 | expect(tree).toMatchInlineSnapshot(` 267 | { 268 | "children": { 269 | "/object": { 270 | "children": { 271 | "/type": { 272 | "children": {}, 273 | "errors": [ 274 | { 275 | "keyword": "type", 276 | }, 277 | { 278 | "keyword": "type", 279 | }, 280 | { 281 | "keyword": "anyOf", 282 | }, 283 | ], 284 | }, 285 | }, 286 | "errors": [], 287 | }, 288 | }, 289 | } 290 | `); 291 | }); 292 | }); 293 | -------------------------------------------------------------------------------- /src/__tests__/helpers/make-tree.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { makeTree } from '../../helpers'; 3 | 4 | describe('makeTree', () => { 5 | it('works on empty array', async () => { 6 | expect(makeTree([])).toMatchInlineSnapshot(` 7 | { 8 | "children": {}, 9 | } 10 | `); 11 | }); 12 | 13 | it('works on root dataPath', async () => { 14 | expect(makeTree([{ dataPath: '' }])).toMatchInlineSnapshot(` 15 | { 16 | "children": { 17 | "": { 18 | "children": {}, 19 | "errors": [ 20 | { 21 | "dataPath": "", 22 | }, 23 | ], 24 | }, 25 | }, 26 | } 27 | `); 28 | }); 29 | 30 | it('works on nested dataPath', async () => { 31 | expect(makeTree([{ dataPath: '/root/child' }])).toMatchInlineSnapshot(` 32 | { 33 | "children": { 34 | "/root": { 35 | "children": { 36 | "/child": { 37 | "children": {}, 38 | "errors": [ 39 | { 40 | "dataPath": "/root/child", 41 | }, 42 | ], 43 | }, 44 | }, 45 | "errors": [], 46 | }, 47 | }, 48 | } 49 | `); 50 | }); 51 | 52 | it('works on array dataPath', async () => { 53 | expect( 54 | makeTree([{ dataPath: '/root/child/0' }, { dataPath: '/root/child/1' }]) 55 | ).toMatchInlineSnapshot(` 56 | { 57 | "children": { 58 | "/root": { 59 | "children": { 60 | "/child/0": { 61 | "children": {}, 62 | "errors": [ 63 | { 64 | "dataPath": "/root/child/0", 65 | }, 66 | ], 67 | }, 68 | "/child/1": { 69 | "children": {}, 70 | "errors": [ 71 | { 72 | "dataPath": "/root/child/1", 73 | }, 74 | ], 75 | }, 76 | }, 77 | "errors": [], 78 | }, 79 | }, 80 | } 81 | `); 82 | }); 83 | 84 | it('works on array item dataPath', async () => { 85 | expect( 86 | makeTree([ 87 | { dataPath: '/root/child/0/grand-child' }, 88 | { dataPath: '/root/child/1/grand-child' }, 89 | ]) 90 | ).toMatchInlineSnapshot(` 91 | { 92 | "children": { 93 | "/root": { 94 | "children": { 95 | "/child/0": { 96 | "children": { 97 | "/grand-child": { 98 | "children": {}, 99 | "errors": [ 100 | { 101 | "dataPath": "/root/child/0/grand-child", 102 | }, 103 | ], 104 | }, 105 | }, 106 | "errors": [], 107 | }, 108 | "/child/1": { 109 | "children": { 110 | "/grand-child": { 111 | "children": {}, 112 | "errors": [ 113 | { 114 | "dataPath": "/root/child/1/grand-child", 115 | }, 116 | ], 117 | }, 118 | }, 119 | "errors": [], 120 | }, 121 | }, 122 | "errors": [], 123 | }, 124 | }, 125 | } 126 | `); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /src/__tests__/index.js: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv'; 2 | import { describe, it, expect } from 'vitest'; 3 | import { getSchemaAndData } from '../test-helpers'; 4 | import betterAjvErrors from '../'; 5 | 6 | describe('Main', () => { 7 | it('should output error with reconstructed codeframe', async () => { 8 | const [schema, data] = await getSchemaAndData('default', __dirname); 9 | const ajv = new Ajv(); 10 | const validate = ajv.compile(schema); 11 | const valid = validate(data); 12 | expect(valid).toBeFalsy(); 13 | 14 | const res = betterAjvErrors(schema, data, validate.errors, { 15 | format: 'cli', 16 | indent: 2, 17 | }); 18 | expect(res).toMatchSnapshot(); 19 | }); 20 | 21 | it('should output error with codeframe', async () => { 22 | const [schema, data, json] = await getSchemaAndData('default', __dirname); 23 | const ajv = new Ajv(); 24 | const validate = ajv.compile(schema); 25 | const valid = validate(data); 26 | expect(valid).toBeFalsy(); 27 | 28 | const res = betterAjvErrors(schema, data, validate.errors, { 29 | format: 'cli', 30 | json, 31 | }); 32 | expect(res).toMatchSnapshot(); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/__tests__/utils.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { getErrors } from '../utils.js'; 3 | 4 | describe('utils', () => { 5 | it('getErrors remaps ajv-errors custom messages', async () => { 6 | const node = { 7 | children: {}, 8 | errors: [ 9 | { 10 | keyword: 'errorMessage', 11 | dataPath: '/nested', 12 | schemaPath: '#/errorMessage', 13 | params: { 14 | errors: [ 15 | { 16 | keyword: 'additionalProperties', 17 | dataPath: '/api/AbortController', 18 | schemaPath: '#/additionalProperties', 19 | params: { additionalProperty: 'AbortController$@*)$' }, 20 | message: 'should NOT have additional properties', 21 | }, 22 | ], 23 | }, 24 | message: 'Hello world!', 25 | }, 26 | ], 27 | }; 28 | 29 | const errors = getErrors(node); 30 | expect(errors).toStrictEqual([ 31 | { 32 | keyword: 'additionalProperties', 33 | dataPath: '/api/AbortController', 34 | schemaPath: '#/additionalProperties', 35 | params: { additionalProperty: 'AbortController$@*)$' }, 36 | message: 'Hello world!', 37 | }, 38 | ]); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | import { 2 | getChildren, 3 | getErrors, 4 | getSiblings, 5 | isAnyOfError, 6 | isEnumError, 7 | isRequiredError, 8 | concatAll, 9 | notUndefined, 10 | } from './utils'; 11 | import { 12 | AdditionalPropValidationError, 13 | RequiredValidationError, 14 | EnumValidationError, 15 | DefaultValidationError, 16 | } from './validation-errors/index'; 17 | 18 | const JSON_POINTERS_REGEX = /\/[\w_-]+(\/\d+)?/g; 19 | 20 | // Make a tree of errors from ajv errors array 21 | export function makeTree(ajvErrors = []) { 22 | const root = { children: {} }; 23 | ajvErrors.forEach(ajvError => { 24 | const instancePath = 25 | typeof ajvError.instancePath !== 'undefined' 26 | ? ajvError.instancePath 27 | : ajvError.dataPath; 28 | 29 | // `dataPath === ''` is root 30 | const paths = 31 | instancePath === '' ? [''] : instancePath.match(JSON_POINTERS_REGEX); 32 | paths && 33 | paths.reduce((obj, path, i) => { 34 | obj.children[path] = obj.children[path] || { children: {}, errors: [] }; 35 | if (i === paths.length - 1) { 36 | obj.children[path].errors.push(ajvError); 37 | } 38 | return obj.children[path]; 39 | }, root); 40 | }); 41 | return root; 42 | } 43 | 44 | export function filterRedundantErrors(root, parent, key) { 45 | /** 46 | * If there is a `required` error then we can just skip everythig else. 47 | * And, also `required` should have more priority than `anyOf`. @see #8 48 | */ 49 | getErrors(root).forEach(error => { 50 | if (isRequiredError(error)) { 51 | root.errors = [error]; 52 | root.children = {}; 53 | } 54 | }); 55 | 56 | /** 57 | * If there is an `anyOf` error that means we have more meaningful errors 58 | * inside children. So we will just remove all errors from this level. 59 | * 60 | * If there are no children, then we don't delete the errors since we should 61 | * have at least one error to report. 62 | */ 63 | if (getErrors(root).some(isAnyOfError)) { 64 | if (Object.keys(root.children).length > 0) { 65 | delete root.errors; 66 | } 67 | } 68 | 69 | /** 70 | * If all errors are `enum` and siblings have any error then we can safely 71 | * ignore the node. 72 | * 73 | * **CAUTION** 74 | * Need explicit `root.errors` check because `[].every(fn) === true` 75 | * https://en.wikipedia.org/wiki/Vacuous_truth#Vacuous_truths_in_mathematics 76 | */ 77 | if (root.errors && root.errors.length && getErrors(root).every(isEnumError)) { 78 | if ( 79 | getSiblings(parent)(root) 80 | // Remove any reference which becomes `undefined` later 81 | .filter(notUndefined) 82 | .some(getErrors) 83 | ) { 84 | delete parent.children[key]; 85 | } 86 | } 87 | 88 | Object.entries(root.children).forEach(([key, child]) => 89 | filterRedundantErrors(child, root, key) 90 | ); 91 | } 92 | 93 | export function createErrorInstances(root, options) { 94 | const errors = getErrors(root); 95 | if (errors.length && errors.every(isEnumError)) { 96 | const uniqueValues = new Set( 97 | concatAll([])(errors.map(e => e.params.allowedValues)) 98 | ); 99 | const allowedValues = [...uniqueValues]; 100 | const error = errors[0]; 101 | return [ 102 | new EnumValidationError( 103 | { 104 | ...error, 105 | params: { allowedValues }, 106 | }, 107 | options 108 | ), 109 | ]; 110 | } else { 111 | return concatAll( 112 | errors.reduce((ret, error) => { 113 | switch (error.keyword) { 114 | case 'additionalProperties': 115 | return ret.concat( 116 | new AdditionalPropValidationError(error, options) 117 | ); 118 | case 'required': 119 | return ret.concat(new RequiredValidationError(error, options)); 120 | default: 121 | return ret.concat(new DefaultValidationError(error, options)); 122 | } 123 | }, []) 124 | )(getChildren(root).map(child => createErrorInstances(child, options))); 125 | } 126 | } 127 | 128 | export default (ajvErrors, options) => { 129 | const tree = makeTree(ajvErrors || []); 130 | filterRedundantErrors(tree); 131 | return createErrorInstances(tree, options); 132 | }; 133 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { parse } from '@humanwhocodes/momoa'; 2 | import prettify from './helpers'; 3 | 4 | export default (schema, data, errors, options = {}) => { 5 | const { format = 'cli', indent = null, json = null } = options; 6 | 7 | const jsonRaw = json || JSON.stringify(data, null, indent); 8 | const jsonAst = parse(jsonRaw); 9 | 10 | const customErrorToText = error => error.print().join('\n'); 11 | const customErrorToStructure = error => error.getError(); 12 | const customErrors = prettify(errors, { 13 | data, 14 | schema, 15 | jsonAst, 16 | jsonRaw, 17 | }); 18 | 19 | if (format === 'cli') { 20 | return customErrors.map(customErrorToText).join('\n\n'); 21 | } else { 22 | return customErrors.map(customErrorToStructure); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/json/__fixtures__/scenario-1.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "bar" 3 | } 4 | -------------------------------------------------------------------------------- /src/json/__fixtures__/scenario-2.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "bar", 3 | "value": 20 4 | } 5 | -------------------------------------------------------------------------------- /src/json/__fixtures__/scenario-3.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "bar", 3 | "meta": { 4 | "isMeta": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/json/__fixtures__/scenario-4.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "bar", 3 | "arr": [ 4 | 1, 5 | { 6 | "foo": "bar" 7 | }, 8 | 3, 9 | {} 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/json/__fixtures__/scenario-5.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": { 3 | "/some/path": { 4 | "value": 1 5 | }, 6 | "~some~path": { 7 | "value": 2 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/json/__tests__/__snapshots__/index.js.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`JSON > can work on JSON with Array 1`] = ` 4 | { 5 | "loc": { 6 | "end": { 7 | "column": 19, 8 | "line": 6, 9 | "offset": 60, 10 | }, 11 | "start": { 12 | "column": 14, 13 | "line": 6, 14 | "offset": 55, 15 | }, 16 | }, 17 | "type": "String", 18 | "value": "bar", 19 | } 20 | `; 21 | 22 | exports[`JSON > can work on JSON with Array 2`] = ` 23 | { 24 | "loc": { 25 | "end": { 26 | "column": 12, 27 | "line": 6, 28 | "offset": 53, 29 | }, 30 | "start": { 31 | "column": 7, 32 | "line": 6, 33 | "offset": 48, 34 | }, 35 | }, 36 | "type": "String", 37 | "value": "foo", 38 | } 39 | `; 40 | 41 | exports[`JSON > can work on JSON with Array with empty children 1`] = `"/arr/4"`; 42 | 43 | exports[`JSON > can work on JSON with a key named meta 1`] = ` 44 | { 45 | "loc": { 46 | "end": { 47 | "column": 19, 48 | "line": 4, 49 | "offset": 48, 50 | }, 51 | "start": { 52 | "column": 15, 53 | "line": 4, 54 | "offset": 44, 55 | }, 56 | }, 57 | "type": "Boolean", 58 | "value": true, 59 | } 60 | `; 61 | 62 | exports[`JSON > can work on JSON with a key named meta 2`] = ` 63 | { 64 | "loc": { 65 | "end": { 66 | "column": 13, 67 | "line": 4, 68 | "offset": 42, 69 | }, 70 | "start": { 71 | "column": 5, 72 | "line": 4, 73 | "offset": 34, 74 | }, 75 | }, 76 | "type": "String", 77 | "value": "isMeta", 78 | } 79 | `; 80 | 81 | exports[`JSON > can work on JSON with a key named value 1`] = ` 82 | { 83 | "loc": { 84 | "end": { 85 | "column": 14, 86 | "line": 3, 87 | "offset": 31, 88 | }, 89 | "start": { 90 | "column": 12, 91 | "line": 3, 92 | "offset": 29, 93 | }, 94 | }, 95 | "type": "Number", 96 | "value": 20, 97 | } 98 | `; 99 | 100 | exports[`JSON > can work on JSON with a key named value 2`] = ` 101 | { 102 | "loc": { 103 | "end": { 104 | "column": 10, 105 | "line": 3, 106 | "offset": 27, 107 | }, 108 | "start": { 109 | "column": 3, 110 | "line": 3, 111 | "offset": 20, 112 | }, 113 | }, 114 | "type": "String", 115 | "value": "value", 116 | } 117 | `; 118 | 119 | exports[`JSON > can work on simple JSON 1`] = ` 120 | { 121 | "loc": { 122 | "end": { 123 | "column": 15, 124 | "line": 2, 125 | "offset": 16, 126 | }, 127 | "start": { 128 | "column": 10, 129 | "line": 2, 130 | "offset": 11, 131 | }, 132 | }, 133 | "type": "String", 134 | "value": "bar", 135 | } 136 | `; 137 | 138 | exports[`JSON > can work on simple JSON 2`] = ` 139 | { 140 | "loc": { 141 | "end": { 142 | "column": 8, 143 | "line": 2, 144 | "offset": 9, 145 | }, 146 | "start": { 147 | "column": 3, 148 | "line": 2, 149 | "offset": 4, 150 | }, 151 | }, 152 | "type": "String", 153 | "value": "foo", 154 | } 155 | `; 156 | 157 | exports[`JSON > can work with unescaped JSON pointers with ~0 1`] = ` 158 | { 159 | "loc": { 160 | "end": { 161 | "column": 17, 162 | "line": 7, 163 | "offset": 93, 164 | }, 165 | "start": { 166 | "column": 16, 167 | "line": 7, 168 | "offset": 92, 169 | }, 170 | }, 171 | "type": "Number", 172 | "value": 2, 173 | } 174 | `; 175 | 176 | exports[`JSON > can work with unescaped JSON pointers with ~1 1`] = ` 177 | { 178 | "loc": { 179 | "end": { 180 | "column": 17, 181 | "line": 4, 182 | "offset": 49, 183 | }, 184 | "start": { 185 | "column": 16, 186 | "line": 4, 187 | "offset": 48, 188 | }, 189 | }, 190 | "type": "Number", 191 | "value": 1, 192 | } 193 | `; 194 | 195 | exports[`JSON > should not throw error when children is array 1`] = `"/arr/3"`; 196 | -------------------------------------------------------------------------------- /src/json/__tests__/index.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { readFileSync } from 'fs'; 3 | const { parse } = require('@humanwhocodes/momoa'); 4 | import { getFixturePath } from 'jest-fixtures'; 5 | import { getMetaFromPath, getDecoratedDataPath } from '../'; 6 | 7 | async function loadScenario(n) { 8 | const fixturePath = await getFixturePath(__dirname, `scenario-${n}.json`); 9 | return parse(readFileSync(fixturePath, 'utf8')); 10 | } 11 | 12 | describe('JSON', () => { 13 | it('can work on simple JSON', async () => { 14 | const jsonAst = await loadScenario(1); 15 | expect(getMetaFromPath(jsonAst, '/foo')).toMatchSnapshot(); 16 | expect(getMetaFromPath(jsonAst, '/foo', true)).toMatchSnapshot(); 17 | }); 18 | 19 | it('can work on JSON with a key named value', async () => { 20 | const jsonAst = await loadScenario(2); 21 | expect(getMetaFromPath(jsonAst, '/value')).toMatchSnapshot(); 22 | expect(getMetaFromPath(jsonAst, '/value', true)).toMatchSnapshot(); 23 | }); 24 | 25 | it('can work on JSON with a key named meta', async () => { 26 | const jsonAst = await loadScenario(3); 27 | expect(getMetaFromPath(jsonAst, '/meta/isMeta')).toMatchSnapshot(); 28 | expect(getMetaFromPath(jsonAst, '/meta/isMeta', true)).toMatchSnapshot(); 29 | }); 30 | 31 | it('can work on JSON with Array', async () => { 32 | const jsonAst = await loadScenario(4); 33 | expect(getMetaFromPath(jsonAst, '/arr/1/foo')).toMatchSnapshot(); 34 | expect(getMetaFromPath(jsonAst, '/arr/1/foo', true)).toMatchSnapshot(); 35 | }); 36 | 37 | it('can work on JSON with Array with empty children', async () => { 38 | const jsonAst = await loadScenario(4); 39 | expect(getDecoratedDataPath(jsonAst, '/arr/4')).toMatchSnapshot(); 40 | }); 41 | 42 | it('should not throw error when children is array', async () => { 43 | const rawJsonWithArrayItem = JSON.stringify({ 44 | foo: 'bar', 45 | arr: [ 46 | 1, 47 | { 48 | foo: 'bar', 49 | }, 50 | 3, 51 | ['anArray'], 52 | ], 53 | }); 54 | const jsonAst = parse(rawJsonWithArrayItem); 55 | expect(getDecoratedDataPath(jsonAst, '/arr/3')).toMatchSnapshot(); 56 | }); 57 | 58 | it('can work with unescaped JSON pointers with ~1', async () => { 59 | const jsonAst = await loadScenario(5); 60 | expect( 61 | getMetaFromPath(jsonAst, '/foo/~1some~1path/value') 62 | ).toMatchSnapshot(); 63 | }); 64 | 65 | it('can work with unescaped JSON pointers with ~0', async () => { 66 | const jsonAst = await loadScenario(5); 67 | expect( 68 | getMetaFromPath(jsonAst, '/foo/~0some~0path/value') 69 | ).toMatchSnapshot(); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/json/get-decorated-data-path.js: -------------------------------------------------------------------------------- 1 | import { getPointers } from './utils'; 2 | 3 | export default function getDecoratedDataPath(jsonAst, dataPath) { 4 | let decoratedPath = ''; 5 | getPointers(dataPath).reduce((obj, pointer) => { 6 | switch (obj.type) { 7 | case 'Object': { 8 | decoratedPath += `/${pointer}`; 9 | const filtered = obj.members.filter( 10 | child => child.name.value === pointer 11 | ); 12 | if (filtered.length !== 1) { 13 | throw new Error(`Couldn't find property ${pointer} of ${dataPath}`); 14 | } 15 | return filtered[0].value; 16 | } 17 | case 'Array': { 18 | decoratedPath += `/${pointer}${getTypeName(obj.elements[pointer])}`; 19 | return obj.elements[pointer]; 20 | } 21 | default: 22 | console.log(obj); 23 | } 24 | }, jsonAst.body); 25 | return decoratedPath; 26 | } 27 | 28 | function getTypeName(obj) { 29 | if (!obj || !obj.elements) { 30 | return ''; 31 | } 32 | const type = obj.elements.filter( 33 | child => child && child.name && child.name.value === 'type' 34 | ); 35 | 36 | if (!type.length) { 37 | return ''; 38 | } 39 | 40 | return (type[0].value && `:${type[0].value.value}`) || ''; 41 | } 42 | -------------------------------------------------------------------------------- /src/json/get-meta-from-path.js: -------------------------------------------------------------------------------- 1 | import { getPointers } from './utils'; 2 | 3 | export default function getMetaFromPath( 4 | jsonAst, 5 | dataPath, 6 | includeIdentifierLocation 7 | ) { 8 | const pointers = getPointers(dataPath); 9 | const lastPointerIndex = pointers.length - 1; 10 | return pointers.reduce((obj, pointer, idx) => { 11 | switch (obj.type) { 12 | case 'Object': { 13 | const filtered = obj.members.filter( 14 | child => child.name.value === pointer 15 | ); 16 | if (filtered.length !== 1) { 17 | throw new Error(`Couldn't find property ${pointer} of ${dataPath}`); 18 | } 19 | 20 | const { name, value } = filtered[0]; 21 | return includeIdentifierLocation && idx === lastPointerIndex 22 | ? name 23 | : value; 24 | } 25 | case 'Array': 26 | return obj.elements[pointer]; 27 | default: 28 | console.log(obj); 29 | } 30 | }, jsonAst.body); 31 | } 32 | -------------------------------------------------------------------------------- /src/json/index.js: -------------------------------------------------------------------------------- 1 | export { default as getMetaFromPath } from './get-meta-from-path'; 2 | export { default as getDecoratedDataPath } from './get-decorated-data-path'; 3 | -------------------------------------------------------------------------------- /src/json/utils.js: -------------------------------------------------------------------------------- 1 | // TODO: Better error handling 2 | export const getPointers = dataPath => { 3 | return dataPath 4 | .split('/') 5 | .slice(1) 6 | .map(pointer => pointer.split('~1').join('/').split('~0').join('~')); 7 | }; 8 | -------------------------------------------------------------------------------- /src/test-helpers.js: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import { getFixturePath } from 'jest-fixtures'; 3 | 4 | export async function getSchemaAndData(name, dirPath) { 5 | const schemaPath = await getFixturePath(dirPath, name, 'schema.json'); 6 | const schema = JSON.parse(readFileSync(schemaPath, 'utf8')); 7 | const dataPath = await getFixturePath(dirPath, name, 'data.json'); 8 | const json = readFileSync(dataPath, 'utf8'); 9 | const data = JSON.parse(json); 10 | 11 | return [schema, data, json]; 12 | } 13 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | /*:: 4 | export interface Error { 5 | keyword: string 6 | }; 7 | 8 | export interface Node { 9 | children: {| [key: string]: Node |}, 10 | errors: Array 11 | }; 12 | 13 | */ 14 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | /*:: 4 | import type { Error, Node } from './types'; 5 | */ 6 | 7 | // Basic 8 | const eq = x => y => x === y; 9 | const not = fn => x => !fn(x); 10 | 11 | // https://github.com/facebook/flow/issues/2221 12 | const getValues = /*::*/ ( 13 | o /*: Obj*/ 14 | ) /*: $ReadOnlyArray<$Values>*/ => Object.values(o); 15 | 16 | export const notUndefined = (x /*: mixed*/) => x !== undefined; 17 | 18 | // Error 19 | const isXError = x => (error /*: Error */) => error.keyword === x; 20 | export const isRequiredError = isXError('required'); 21 | export const isAnyOfError = isXError('anyOf'); 22 | export const isEnumError = isXError('enum'); 23 | export const getErrors = (node /*: Node*/) => 24 | node && node.errors 25 | ? node.errors.map(e => 26 | e.keyword === 'errorMessage' 27 | ? { ...e.params.errors[0], message: e.message } 28 | : e 29 | ) 30 | : []; 31 | 32 | // Node 33 | export const getChildren = (node /*: Node*/) /*: $ReadOnlyArray*/ => 34 | (node && getValues(node.children)) || []; 35 | 36 | export const getSiblings = 37 | (parent /*: Node*/) => (node /*: Node*/) /*: $ReadOnlyArray*/ => 38 | getChildren(parent).filter(not(eq(node))); 39 | 40 | export const concatAll = 41 | /*::*/ 42 | 43 | 44 | (xs /*: $ReadOnlyArray*/) => 45 | (ys /*: $ReadOnlyArray*/) /*: $ReadOnlyArray*/ => 46 | ys.reduce((zs, z) => zs.concat(z), xs); 47 | -------------------------------------------------------------------------------- /src/validation-errors/__fixtures__/additionalProperties/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "bar", 3 | "bar": 2, 4 | "baz": [] 5 | } 6 | -------------------------------------------------------------------------------- /src/validation-errors/__fixtures__/additionalProperties/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "foo": { "type": "string" }, 5 | "bar": { "type": "number" } 6 | }, 7 | "required": [ 8 | "foo", 9 | "bar" 10 | ], 11 | "additionalProperties": false 12 | } 13 | -------------------------------------------------------------------------------- /src/validation-errors/__fixtures__/default/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": [{ 3 | "xxx": "diesel" 4 | }] 5 | } -------------------------------------------------------------------------------- /src/validation-errors/__fixtures__/default/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "id": { 5 | "type": "number" 6 | } 7 | }, 8 | "required": [ 9 | "id" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/validation-errors/__fixtures__/enum-string/data.json: -------------------------------------------------------------------------------- 1 | "baz" 2 | -------------------------------------------------------------------------------- /src/validation-errors/__fixtures__/enum-string/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "string", 3 | "enum": ["foo", "bar"] 4 | } 5 | -------------------------------------------------------------------------------- /src/validation-errors/__fixtures__/enum/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "baz" 3 | } -------------------------------------------------------------------------------- /src/validation-errors/__fixtures__/enum/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "id": { 5 | "enum": [ 6 | "foo", 7 | "bar" 8 | ] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/validation-errors/__fixtures__/required/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "nested": {} 3 | } 4 | -------------------------------------------------------------------------------- /src/validation-errors/__fixtures__/required/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "nested": { 5 | "type": "object", 6 | "properties": { 7 | "id": { 8 | "type": "number" 9 | } 10 | }, 11 | "required": ["id"] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/validation-errors/__tests__/__snapshots__/enum.js.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Enum > when value is a primitive > prints correctly for empty value 1`] = ` 4 | [ 5 | "ENUM should be equal to one of the allowed values", 6 | "(foo, bar) 7 | ", 8 | "> 1 | "baz" 9 |  | ^^^^^ 👈🏽 Did you mean bar here?", 10 | ] 11 | `; 12 | 13 | exports[`Enum > when value is a primitive > prints correctly for enum prop 1`] = ` 14 | [ 15 | "ENUM should be equal to one of the allowed values", 16 | "(foo, bar) 17 | ", 18 | "> 1 | "baz" 19 |  | ^^^^^ 👈🏽 Did you mean bar here?", 20 | ] 21 | `; 22 | 23 | exports[`Enum > when value is a primitive > prints correctly for no levenshtein match 1`] = ` 24 | [ 25 | "ENUM should be equal to one of the allowed values", 26 | "(one, two) 27 | ", 28 | "> 1 | "baz" 29 |  | ^^^^^ 👈🏽 Unexpected value, should be equal to one of the allowed values", 30 | ] 31 | `; 32 | 33 | exports[`Enum > when value is an object > prints correctly for empty value 1`] = ` 34 | [ 35 | "ENUM should be equal to one of the allowed values", 36 | "(foo, bar) 37 | ", 38 | "  1 | { 39 | > 2 | "id": "baz" 40 |  | ^^^^^ 👈🏽 Did you mean bar here? 41 |  3 | }", 42 | ] 43 | `; 44 | 45 | exports[`Enum > when value is an object > prints correctly for enum prop 1`] = ` 46 | [ 47 | "ENUM should be equal to one of the allowed values", 48 | "(foo, bar) 49 | ", 50 | "  1 | { 51 | > 2 | "id": "baz" 52 |  | ^^^^^ 👈🏽 Did you mean bar here? 53 |  3 | }", 54 | ] 55 | `; 56 | 57 | exports[`Enum > when value is an object > prints correctly for no levenshtein match 1`] = ` 58 | [ 59 | "ENUM should be equal to one of the allowed values", 60 | "(one, two) 61 | ", 62 | "  1 | { 63 | > 2 | "id": "baz" 64 |  | ^^^^^ 👈🏽 Unexpected value, should be equal to one of the allowed values 65 |  3 | }", 66 | ] 67 | `; 68 | -------------------------------------------------------------------------------- /src/validation-errors/__tests__/__snapshots__/main.js.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Main > should support js output format for additionalProperties errors 1`] = ` 4 | [ 5 | { 6 | "end": { 7 | "column": 27, 8 | "line": 1, 9 | "offset": 26, 10 | }, 11 | "error": " Property baz is not expected to be here", 12 | "path": "", 13 | "start": { 14 | "column": 22, 15 | "line": 1, 16 | "offset": 21, 17 | }, 18 | }, 19 | ] 20 | `; 21 | 22 | exports[`Main > should support js output format for default errors 1`] = ` 23 | [ 24 | { 25 | "end": { 26 | "column": 25, 27 | "line": 1, 28 | "offset": 24, 29 | }, 30 | "error": "/id: type must be number", 31 | "path": "/id", 32 | "start": { 33 | "column": 7, 34 | "line": 1, 35 | "offset": 6, 36 | }, 37 | }, 38 | ] 39 | `; 40 | 41 | exports[`Main > should support js output format for enum errors 1`] = ` 42 | [ 43 | { 44 | "end": { 45 | "column": 12, 46 | "line": 1, 47 | "offset": 11, 48 | }, 49 | "error": "/id must be equal to one of the allowed values: foo, bar", 50 | "path": "/id", 51 | "start": { 52 | "column": 7, 53 | "line": 1, 54 | "offset": 6, 55 | }, 56 | "suggestion": "Did you mean bar?", 57 | }, 58 | ] 59 | `; 60 | 61 | exports[`Main > should support js output format for required errors 1`] = ` 62 | [ 63 | { 64 | "error": "/nested must have required property 'id'", 65 | "path": "/nested", 66 | "start": { 67 | "column": 11, 68 | "line": 1, 69 | "offset": 10, 70 | }, 71 | }, 72 | ] 73 | `; 74 | -------------------------------------------------------------------------------- /src/validation-errors/__tests__/__snapshots__/required.js.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Required > prints correctly for missing required prop 1`] = ` 4 | [ 5 | "REQUIRED should have required property 'id' 6 | ", 7 | "  1 | { 8 | > 2 | "nested": {} 9 |  | ^ ☹️ id is missing here! 10 |  3 | }", 11 | ] 12 | `; 13 | -------------------------------------------------------------------------------- /src/validation-errors/__tests__/enum.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll } from 'vitest'; 2 | const { parse } = require('@humanwhocodes/momoa'); 3 | import { getSchemaAndData } from '../../test-helpers'; 4 | import EnumValidationError from '../enum'; 5 | 6 | describe('Enum', () => { 7 | describe('when value is an object', () => { 8 | let schema, data, jsonRaw, jsonAst; 9 | beforeAll(async () => { 10 | [schema, data] = await getSchemaAndData('enum', __dirname); 11 | jsonRaw = JSON.stringify(data, null, 2); 12 | jsonAst = parse(jsonRaw); 13 | }); 14 | 15 | it('prints correctly for enum prop', () => { 16 | const error = new EnumValidationError( 17 | { 18 | keyword: 'enum', 19 | dataPath: '/id', 20 | schemaPath: '#/enum', 21 | params: { allowedValues: ['foo', 'bar'] }, 22 | message: `should be equal to one of the allowed values`, 23 | }, 24 | { data, schema, jsonRaw, jsonAst } 25 | ); 26 | 27 | expect(error.print()).toMatchSnapshot(); 28 | }); 29 | 30 | it('prints correctly for no levenshtein match', () => { 31 | const error = new EnumValidationError( 32 | { 33 | keyword: 'enum', 34 | dataPath: '/id', 35 | schemaPath: '#/enum', 36 | params: { allowedValues: ['one', 'two'] }, 37 | message: `should be equal to one of the allowed values`, 38 | }, 39 | { data, schema, jsonRaw, jsonAst } 40 | ); 41 | 42 | expect(error.print()).toMatchSnapshot(); 43 | }); 44 | 45 | it('prints correctly for empty value', () => { 46 | const error = new EnumValidationError( 47 | { 48 | keyword: 'enum', 49 | dataPath: '/id', 50 | schemaPath: '#/enum', 51 | params: { allowedValues: ['foo', 'bar'] }, 52 | message: `should be equal to one of the allowed values`, 53 | }, 54 | { data, schema, jsonRaw, jsonAst } 55 | ); 56 | 57 | expect(error.print(schema, { id: '' })).toMatchSnapshot(); 58 | }); 59 | }); 60 | 61 | describe('when value is a primitive', () => { 62 | let schema, data, jsonRaw, jsonAst; 63 | beforeAll(async () => { 64 | [schema, data] = await getSchemaAndData('enum-string', __dirname); 65 | jsonRaw = JSON.stringify(data, null, 2); 66 | jsonAst = parse(jsonRaw); 67 | }); 68 | 69 | it('prints correctly for enum prop', () => { 70 | const error = new EnumValidationError( 71 | { 72 | keyword: 'enum', 73 | dataPath: '', 74 | schemaPath: '#/enum', 75 | params: { 76 | allowedValues: ['foo', 'bar'], 77 | }, 78 | message: 'should be equal to one of the allowed values', 79 | }, 80 | { data, schema, jsonRaw, jsonAst } 81 | ); 82 | 83 | expect(error.print()).toMatchSnapshot(); 84 | }); 85 | 86 | it('prints correctly for no levenshtein match', () => { 87 | const error = new EnumValidationError( 88 | { 89 | keyword: 'enum', 90 | dataPath: '', 91 | schemaPath: '#/enum', 92 | params: { 93 | allowedValues: ['one', 'two'], 94 | }, 95 | message: 'should be equal to one of the allowed values', 96 | }, 97 | { data, schema, jsonRaw, jsonAst } 98 | ); 99 | 100 | expect(error.print()).toMatchSnapshot(); 101 | }); 102 | 103 | it('prints correctly for empty value', () => { 104 | const error = new EnumValidationError( 105 | { 106 | keyword: 'enum', 107 | dataPath: '', 108 | schemaPath: '#/enum', 109 | params: { 110 | allowedValues: ['foo', 'bar'], 111 | }, 112 | message: 'should be equal to one of the allowed values', 113 | }, 114 | { data, schema, jsonRaw, jsonAst } 115 | ); 116 | 117 | expect(error.print(schema, '')).toMatchSnapshot(); 118 | }); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /src/validation-errors/__tests__/main.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import Ajv from 'ajv'; 3 | import betterAjvErrors from '../../'; 4 | import { getSchemaAndData } from '../../test-helpers'; 5 | 6 | describe('Main', () => { 7 | it('should support js output format for default errors', async () => { 8 | const [schema, data] = await getSchemaAndData('default', __dirname); 9 | 10 | const ajv = new Ajv(); 11 | const validate = ajv.compile(schema); 12 | const valid = validate(data); 13 | expect(valid).toBeFalsy(); 14 | 15 | const res = betterAjvErrors(schema, data, validate.errors, { 16 | format: 'js', 17 | }); 18 | expect(res).toMatchSnapshot(); 19 | }); 20 | 21 | it('should support js output format for required errors', async () => { 22 | const [schema, data] = await getSchemaAndData('required', __dirname); 23 | 24 | const ajv = new Ajv(); 25 | const validate = ajv.compile(schema); 26 | const valid = validate(data); 27 | expect(valid).toBeFalsy(); 28 | 29 | const res = betterAjvErrors(schema, data, validate.errors, { 30 | format: 'js', 31 | }); 32 | expect(res).toMatchSnapshot(); 33 | }); 34 | 35 | it('should support js output format for additionalProperties errors', async () => { 36 | const [schema, data] = await getSchemaAndData( 37 | 'additionalProperties', 38 | __dirname 39 | ); 40 | 41 | const ajv = new Ajv(); 42 | const validate = ajv.compile(schema); 43 | const valid = validate(data); 44 | expect(valid).toBeFalsy(); 45 | 46 | const res = betterAjvErrors(schema, data, validate.errors, { 47 | format: 'js', 48 | }); 49 | expect(res).toMatchSnapshot(); 50 | }); 51 | 52 | it('should support js output format for enum errors', async () => { 53 | const [schema, data] = await getSchemaAndData('enum', __dirname); 54 | 55 | const ajv = new Ajv(); 56 | const validate = ajv.compile(schema); 57 | const valid = validate(data); 58 | expect(valid).toBeFalsy(); 59 | 60 | const res = betterAjvErrors(schema, data, validate.errors, { 61 | format: 'js', 62 | }); 63 | expect(res).toMatchSnapshot(); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/validation-errors/__tests__/required.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | const { parse } = require('@humanwhocodes/momoa'); 3 | import { getSchemaAndData } from '../../test-helpers'; 4 | import RequiredValidationError from '../required'; 5 | 6 | describe('Required', () => { 7 | it('prints correctly for missing required prop', async () => { 8 | const [schema, data] = await getSchemaAndData('required', __dirname); 9 | const jsonRaw = JSON.stringify(data, null, 2); 10 | const jsonAst = parse(jsonRaw); 11 | 12 | const error = new RequiredValidationError( 13 | { 14 | keyword: 'required', 15 | dataPath: '/nested', 16 | schemaPath: '#/required', 17 | params: { missingProperty: 'id' }, 18 | message: `should have required property 'id'`, 19 | }, 20 | { data, schema, jsonRaw, jsonAst } 21 | ); 22 | 23 | expect(error.print()).toMatchSnapshot(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/validation-errors/additional-prop.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import BaseValidationError from './base'; 3 | 4 | export default class AdditionalPropValidationError extends BaseValidationError { 5 | constructor(...args) { 6 | super(...args); 7 | this.options.isIdentifierLocation = true; 8 | } 9 | 10 | print() { 11 | const { message, params } = this.options; 12 | const output = [chalk`{red {bold ADDTIONAL PROPERTY} ${message}}\n`]; 13 | 14 | return output.concat( 15 | this.getCodeFrame( 16 | chalk`😲 {magentaBright ${params.additionalProperty}} is not expected to be here!`, 17 | `${this.instancePath}/${params.additionalProperty}` 18 | ) 19 | ); 20 | } 21 | 22 | getError() { 23 | const { params } = this.options; 24 | 25 | return { 26 | ...this.getLocation(`${this.instancePath}/${params.additionalProperty}`), 27 | error: `${this.getDecoratedPath()} Property ${ 28 | params.additionalProperty 29 | } is not expected to be here`, 30 | path: this.instancePath, 31 | }; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/validation-errors/base.js: -------------------------------------------------------------------------------- 1 | import { codeFrameColumns } from '@babel/code-frame'; 2 | import { getMetaFromPath, getDecoratedDataPath } from '../json/index'; 3 | 4 | export default class BaseValidationError { 5 | constructor( 6 | options = { isIdentifierLocation: false }, 7 | { data, schema, jsonAst, jsonRaw } 8 | ) { 9 | this.options = options; 10 | this.data = data; 11 | this.schema = schema; 12 | this.jsonAst = jsonAst; 13 | this.jsonRaw = jsonRaw; 14 | } 15 | 16 | getLocation(dataPath = this.instancePath) { 17 | const { isIdentifierLocation, isSkipEndLocation } = this.options; 18 | const { loc } = getMetaFromPath( 19 | this.jsonAst, 20 | dataPath, 21 | isIdentifierLocation 22 | ); 23 | return { 24 | start: loc.start, 25 | end: isSkipEndLocation ? undefined : loc.end, 26 | }; 27 | } 28 | 29 | getDecoratedPath(dataPath = this.instancePath) { 30 | const decoratedPath = getDecoratedDataPath(this.jsonAst, dataPath); 31 | return decoratedPath; 32 | } 33 | 34 | getCodeFrame(message, dataPath = this.instancePath) { 35 | return codeFrameColumns(this.jsonRaw, this.getLocation(dataPath), { 36 | highlightCode: true, 37 | message, 38 | }); 39 | } 40 | 41 | /** 42 | * @return {string} 43 | */ 44 | get instancePath() { 45 | return typeof this.options.instancePath !== 'undefined' 46 | ? this.options.instancePath 47 | : this.options.dataPath; 48 | } 49 | 50 | print() { 51 | throw new Error( 52 | `Implement the 'print' method inside ${this.constructor.name}!` 53 | ); 54 | } 55 | 56 | getError() { 57 | throw new Error( 58 | `Implement the 'getError' method inside ${this.constructor.name}!` 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/validation-errors/default.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import BaseValidationError from './base'; 3 | 4 | export default class DefaultValidationError extends BaseValidationError { 5 | print() { 6 | const { keyword, message } = this.options; 7 | const output = [chalk`{red {bold ${keyword.toUpperCase()}} ${message}}\n`]; 8 | 9 | return output.concat( 10 | this.getCodeFrame(chalk`👈🏽 {magentaBright ${keyword}} ${message}`) 11 | ); 12 | } 13 | 14 | getError() { 15 | const { keyword, message } = this.options; 16 | 17 | return { 18 | ...this.getLocation(), 19 | error: `${this.getDecoratedPath()}: ${keyword} ${message}`, 20 | path: this.instancePath, 21 | }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/validation-errors/enum.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import leven from 'leven'; 3 | import pointer from 'jsonpointer'; 4 | import BaseValidationError from './base'; 5 | 6 | export default class EnumValidationError extends BaseValidationError { 7 | print() { 8 | const { 9 | message, 10 | params: { allowedValues }, 11 | } = this.options; 12 | const bestMatch = this.findBestMatch(); 13 | 14 | const output = [ 15 | chalk`{red {bold ENUM} ${message}}`, 16 | chalk`{red (${allowedValues.join(', ')})}\n`, 17 | ]; 18 | 19 | return output.concat( 20 | this.getCodeFrame( 21 | bestMatch !== null 22 | ? chalk`👈🏽 Did you mean {magentaBright ${bestMatch}} here?` 23 | : chalk`👈🏽 Unexpected value, should be equal to one of the allowed values` 24 | ) 25 | ); 26 | } 27 | 28 | getError() { 29 | const { message, params } = this.options; 30 | const bestMatch = this.findBestMatch(); 31 | const allowedValues = params.allowedValues.join(', '); 32 | 33 | const output = { 34 | ...this.getLocation(), 35 | error: `${this.getDecoratedPath()} ${message}: ${allowedValues}`, 36 | path: this.instancePath, 37 | }; 38 | 39 | if (bestMatch !== null) { 40 | output.suggestion = `Did you mean ${bestMatch}?`; 41 | } 42 | 43 | return output; 44 | } 45 | 46 | findBestMatch() { 47 | const { 48 | params: { allowedValues }, 49 | } = this.options; 50 | 51 | const currentValue = 52 | this.instancePath === '' 53 | ? this.data 54 | : pointer.get(this.data, this.instancePath); 55 | 56 | if (!currentValue) { 57 | return null; 58 | } 59 | 60 | const bestMatch = allowedValues 61 | .map(value => ({ 62 | value, 63 | weight: leven(value, currentValue.toString()), 64 | })) 65 | .sort((x, y) => 66 | x.weight > y.weight ? 1 : x.weight < y.weight ? -1 : 0 67 | )[0]; 68 | 69 | return allowedValues.length === 1 || 70 | bestMatch.weight < bestMatch.value.length 71 | ? bestMatch.value 72 | : null; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/validation-errors/index.js: -------------------------------------------------------------------------------- 1 | export { default as RequiredValidationError } from './required'; 2 | export { default as AdditionalPropValidationError } from './additional-prop'; 3 | export { default as EnumValidationError } from './enum'; 4 | export { default as DefaultValidationError } from './default'; 5 | -------------------------------------------------------------------------------- /src/validation-errors/required.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import BaseValidationError from './base'; 3 | 4 | export default class RequiredValidationError extends BaseValidationError { 5 | getLocation(dataPath = this.instancePath) { 6 | const { start } = super.getLocation(dataPath); 7 | return { start }; 8 | } 9 | 10 | print() { 11 | const { message, params } = this.options; 12 | const output = [chalk`{red {bold REQUIRED} ${message}}\n`]; 13 | 14 | return output.concat( 15 | this.getCodeFrame( 16 | chalk`☹️ {magentaBright ${params.missingProperty}} is missing here!` 17 | ) 18 | ); 19 | } 20 | 21 | getError() { 22 | const { message } = this.options; 23 | 24 | return { 25 | ...this.getLocation(), 26 | error: `${this.getDecoratedPath()} ${message}`, 27 | path: this.instancePath, 28 | }; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | import type { ErrorObject } from 'ajv'; 2 | 3 | export interface IOutputError { 4 | start: { line: number; column: number; offset: number }; 5 | // Optional for required 6 | end?: { line: number; column: number; offset: number }; 7 | error: string; 8 | suggestion?: string; 9 | } 10 | 11 | export interface IInputOptions { 12 | format?: 'cli' | 'js'; 13 | indent?: number | null; 14 | 15 | /** Raw JSON used when highlighting error location */ 16 | json?: string | null; 17 | } 18 | 19 | export default function ( 20 | schema: S, 21 | data: T, 22 | errors: Array, 23 | options?: Options 24 | ): Options extends { format: 'js' } ? Array : string; 25 | -------------------------------------------------------------------------------- /typings.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from 'tsd'; 2 | import betterAjvErrors, { IOutputError } from '.'; 3 | 4 | expectType(betterAjvErrors(true, false, [])); 5 | expectType(betterAjvErrors(true, false, [], { format: 'cli' })); 6 | 7 | expectType>( 8 | betterAjvErrors('abc', 'xyz', [], { format: 'js' }) 9 | ); 10 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ['src/**/__tests__/**/*.js'], 6 | env: { 7 | CI: 'true', 8 | }, 9 | }, 10 | }); 11 | --------------------------------------------------------------------------------