├── .eslintrc.js ├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ └── publish.yml ├── .gitignore ├── .prettierignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── spec ├── .eslintrc.js ├── index.spec.ts ├── object.spec.ts ├── options.spec.ts ├── string.spec.ts └── tsconfig.json ├── src └── index.ts └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const jsConfig = require("@ajv-validator/config/.eslintrc_js") 2 | const tsConfig = require("@ajv-validator/config/.eslintrc") 3 | 4 | module.exports = { 5 | env: { 6 | es6: true, 7 | node: true, 8 | }, 9 | overrides: [ 10 | jsConfig, 11 | { 12 | ...tsConfig, 13 | files: ["*.ts"], 14 | rules: { 15 | ...tsConfig.rules, 16 | complexity: ["error", 18], 17 | "@typescript-eslint/no-explicit-any": "off", 18 | "@typescript-eslint/no-unnecessary-condition": "warn", 19 | "@typescript-eslint/no-unsafe-assignment": "off", 20 | "@typescript-eslint/no-unsafe-member-access": "off", 21 | "@typescript-eslint/restrict-template-expressions": "off", 22 | }, 23 | }, 24 | ], 25 | } 26 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: epoberezkin 2 | tidelift: "npm/ajv-errors" 3 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: ["*"] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [10.x, 12.x, 14.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - run: npm install 24 | - run: npm test 25 | - name: Coveralls 26 | uses: coverallsapp/github-action@master 27 | with: 28 | github-token: ${{ secrets.GITHUB_TOKEN }} 29 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish-npm: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: 14 15 | registry-url: https://registry.npmjs.org/ 16 | - run: npm install 17 | - run: npm test 18 | - name: Publish beta version to npm 19 | if: "github.event.release.prerelease" 20 | run: npm publish --tag beta 21 | env: 22 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 23 | - name: Publish to npm 24 | if: "!github.event.release.prerelease" 25 | run: npm publish 26 | env: 27 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # Compiled templates 40 | lib/dotjs/*.js 41 | 42 | .DS_Store 43 | 44 | package-lock.json 45 | 46 | dist 47 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at ajv.validator@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Evgeny Poberezkin 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 | # ajv-errors 2 | 3 | Custom error messages in JSON-Schema for Ajv validator 4 | 5 | [![build](https://github.com/ajv-validator/ajv-errors/workflows/build/badge.svg)](https://github.com/ajv-validator/ajv-errors/actions?query=workflow%3Abuild) 6 | [![npm](https://img.shields.io/npm/v/ajv-errors.svg)](https://www.npmjs.com/package/ajv-errors) 7 | [![coverage](https://coveralls.io/repos/github/ajv-validator/ajv-errors/badge.svg?branch=master)](https://coveralls.io/github/ajv-validator/ajv-errors?branch=master) 8 | [![gitter](https://img.shields.io/gitter/room/ajv-validator/ajv.svg)](https://gitter.im/ajv-validator/ajv) 9 | 10 | **Please note** 11 | 12 | ajv-errors v3 supports [ajv v8](https://github.com/ajv-validator/ajv). 13 | 14 | If you are using ajv v6, you should use [ajv-errors v1](https://github.com/ajv-validator/ajv-errors/tree/v1) 15 | 16 | ## Contents 17 | 18 | - [Install](#install) 19 | - [Usage](#usage) 20 | - [Single message](#single-message) 21 | - [Messages for keywords](#messages-for-keywords) 22 | - [Messages for properties and items](#messages-for-properties-and-items) 23 | - [Default message](#default-message) 24 | - [Templates](#templates) 25 | - [Options](#options) 26 | - [Supporters, Enterprise support, Security contact](#supporters) 27 | - [License](#license) 28 | 29 | ## Install 30 | 31 | ``` 32 | npm install ajv-errors 33 | ``` 34 | 35 | ## Usage 36 | 37 | Add the keyword `errorMessages` to Ajv instance: 38 | 39 | ```javascript 40 | const Ajv = require("ajv").default 41 | const ajv = new Ajv({allErrors: true}) 42 | // Ajv option allErrors is required 43 | require("ajv-errors")(ajv /*, {singleError: true} */) 44 | ``` 45 | 46 | See [Options](#options) below. 47 | 48 | ### Single message 49 | 50 | Replace all errors in the current schema and subschemas with a single message: 51 | 52 | ```javascript 53 | const schema = { 54 | type: "object", 55 | required: ["foo"], 56 | properties: { 57 | foo: {type: "integer"}, 58 | }, 59 | additionalProperties: false, 60 | errorMessage: "should be an object with an integer property foo only", 61 | } 62 | 63 | const validate = ajv.compile(schema) 64 | console.log(validate({foo: "a", bar: 2})) // false 65 | console.log(validate.errors) // processed errors 66 | ``` 67 | 68 | Processed errors: 69 | 70 | ```json5 71 | [ 72 | { 73 | keyword: "errorMessage", 74 | message: "should be an object with an integer property foo only", 75 | // ... 76 | params: { 77 | errors: [ 78 | {keyword: "additionalProperties", instancePath: "" /* , ... */}, 79 | {keyword: "type", instancePath: ".foo" /* , ... */}, 80 | ], 81 | }, 82 | }, 83 | ] 84 | ``` 85 | 86 | ### Messages for keywords 87 | 88 | Replace errors for certain keywords in the current schema only: 89 | 90 | ```javascript 91 | const schema = { 92 | type: "object", 93 | required: ["foo"], 94 | properties: { 95 | foo: {type: "integer"}, 96 | }, 97 | additionalProperties: false, 98 | errorMessage: { 99 | type: "should be an object", // will not replace internal "type" error for the property "foo" 100 | required: "should have property foo", 101 | additionalProperties: "should not have properties other than foo", 102 | }, 103 | } 104 | 105 | const validate = ajv.compile(schema) 106 | console.log(validate({foo: "a", bar: 2})) // false 107 | console.log(validate.errors) // processed errors 108 | ``` 109 | 110 | Processed errors: 111 | 112 | ```json5 113 | [ 114 | { 115 | // original error 116 | keyword: type, 117 | instancePath: "/foo", 118 | // ... 119 | message: "should be integer", 120 | }, 121 | { 122 | // generated error 123 | keyword: "errorMessage", 124 | message: "should not have properties other than foo", 125 | // ... 126 | params: { 127 | errors: [{keyword: "additionalProperties" /* , ... */}], 128 | }, 129 | }, 130 | ] 131 | ``` 132 | 133 | For keywords "required" and "dependencies" it is possible to specify different messages for different properties: 134 | 135 | ```javascript 136 | const schema = { 137 | type: "object", 138 | required: ["foo", "bar"], 139 | properties: { 140 | foo: {type: "integer"}, 141 | bar: {type: "string"}, 142 | }, 143 | errorMessage: { 144 | type: "should be an object", // will not replace internal "type" error for the property "foo" 145 | required: { 146 | foo: 'should have an integer property "foo"', 147 | bar: 'should have a string property "bar"', 148 | }, 149 | }, 150 | } 151 | ``` 152 | 153 | ### Messages for properties and items 154 | 155 | Replace errors for properties / items (and deeper), regardless where in schema they were created: 156 | 157 | ```javascript 158 | const schema = { 159 | type: "object", 160 | required: ["foo", "bar"], 161 | allOf: [ 162 | { 163 | properties: { 164 | foo: {type: "integer", minimum: 2}, 165 | bar: {type: "string", minLength: 2}, 166 | }, 167 | additionalProperties: false, 168 | }, 169 | ], 170 | errorMessage: { 171 | properties: { 172 | foo: "data.foo should be integer >= 2", 173 | bar: "data.bar should be string with length >= 2", 174 | }, 175 | }, 176 | } 177 | 178 | const validate = ajv.compile(schema) 179 | console.log(validate({foo: 1, bar: "a"})) // false 180 | console.log(validate.errors) // processed errors 181 | ``` 182 | 183 | Processed errors: 184 | 185 | ```json5 186 | [ 187 | { 188 | keyword: "errorMessage", 189 | message: "data.foo should be integer >= 2", 190 | instancePath: "/foo", 191 | // ... 192 | params: { 193 | errors: [{keyword: "minimum" /* , ... */}], 194 | }, 195 | }, 196 | { 197 | keyword: "errorMessage", 198 | message: "data.bar should be string with length >= 2", 199 | instancePath: "/bar", 200 | // ... 201 | params: { 202 | errors: [{keyword: "minLength" /* , ... */}], 203 | }, 204 | }, 205 | ] 206 | ``` 207 | 208 | ### Default message 209 | 210 | When the value of keyword `errorMessage` is an object you can specify a message that will be used if any error appears that is not specified by keywords/properties/items using `_` property: 211 | 212 | ```javascript 213 | const schema = { 214 | type: "object", 215 | required: ["foo", "bar"], 216 | allOf: [ 217 | { 218 | properties: { 219 | foo: {type: "integer", minimum: 2}, 220 | bar: {type: "string", minLength: 2}, 221 | }, 222 | additionalProperties: false, 223 | }, 224 | ], 225 | errorMessage: { 226 | type: "data should be an object", 227 | properties: { 228 | foo: "data.foo should be integer >= 2", 229 | bar: "data.bar should be string with length >= 2", 230 | }, 231 | _: 'data should have properties "foo" and "bar" only', 232 | }, 233 | } 234 | 235 | const validate = ajv.compile(schema) 236 | console.log(validate({})) // false 237 | console.log(validate.errors) // processed errors 238 | ``` 239 | 240 | Processed errors: 241 | 242 | ```json5 243 | [ 244 | { 245 | keyword: "errorMessage", 246 | message: 'data should be an object with properties "foo" and "bar" only', 247 | instancePath: "", 248 | // ... 249 | params: { 250 | errors: [{keyword: "required" /* , ... */}, {keyword: "required" /* , ... */}], 251 | }, 252 | }, 253 | ] 254 | ``` 255 | 256 | The message in property `_` of `errorMessage` replaces the same errors that would have been replaced if `errorMessage` were a string. 257 | 258 | ## Templates 259 | 260 | Custom error messages used in `errorMessage` keyword can be templates using [JSON-pointers](https://tools.ietf.org/html/rfc6901) or [relative JSON-pointers](http://tools.ietf.org/html/draft-luff-relative-json-pointer-00) to data being validated, in which case the value will be interpolated. Also see [examples](https://gist.github.com/geraintluff/5911303) of relative JSON-pointers. 261 | 262 | The syntax to interpolate a value is `${}`. 263 | 264 | The values used in messages will be JSON-stringified: 265 | 266 | - to differentiate between `false` and `"false"`, etc. 267 | - to support structured values. 268 | 269 | Example: 270 | 271 | ```javascript 272 | const schema = { 273 | type: "object", 274 | properties: { 275 | size: { 276 | type: "number", 277 | minimum: 4, 278 | }, 279 | }, 280 | errorMessage: { 281 | properties: { 282 | size: "size should be a number bigger or equal to 4, current value is ${/size}", 283 | }, 284 | }, 285 | } 286 | ``` 287 | 288 | #### Using property names in error messages 289 | 290 | Property names can be used in error messages with the relative JSON-pointer (e.g. `0#`). 291 | 292 | Example: 293 | ```javascript 294 | const schema = { 295 | type: "object", 296 | properties: { 297 | size: { 298 | type: "number", 299 | }, 300 | }, 301 | additionalProperties: { 302 | not: true, 303 | errorMessage: “extra property is ${0#}” 304 | } 305 | } 306 | ``` 307 | 308 | ## Options 309 | 310 | Defaults: 311 | 312 | ```json5 313 | { 314 | keepErrors: false, 315 | singleError: false, 316 | } 317 | ``` 318 | 319 | - _keepErrors_: keep original errors. Default is to remove matched errors (they will still be available in `params.errors` property of generated error). If an error was matched and included in the error generated by `errorMessage` keyword it will have property `emUsed: true`. 320 | - _singleError_: create one error for all keywords used in `errorMessage` keyword (error messages defined for properties and items are not merged because they have different instancePaths). Multiple error messages are concatenated. Option values: 321 | - `false` (default): create multiple errors, one for each message 322 | - `true`: create single error, messages are concatenated using `"; "` 323 | - non-empty string: this string is used as a separator to concatenate messages 324 | 325 | ## Supporters 326 | 327 | [Roger Kepler](https://www.linkedin.com/in/rogerkepler/) 328 | 329 | ## Enterprise support 330 | 331 | ajv-errors package is a part of [Tidelift enterprise subscription](https://tidelift.com/subscription/pkg/npm-ajv-errors?utm_source=npm-ajv-errors&utm_medium=referral&utm_campaign=enterprise&utm_term=repo) - it provides a centralised commercial support to open-source software users, in addition to the support provided by software maintainers. 332 | 333 | ## Security contact 334 | 335 | To report a security vulnerability, please use the 336 | [Tidelift security contact](https://tidelift.com/security). 337 | Tidelift will coordinate the fix and disclosure. Please do NOT report security vulnerability via GitHub issues. 338 | 339 | ## License 340 | 341 | [MIT](https://github.com/epoberezkin/ajv-errors/blob/master/LICENSE) 342 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | collectCoverageFrom: ["dist/**/*.js"], 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ajv-errors", 3 | "version": "3.0.0", 4 | "description": "Custom error messages in JSON Schemas for Ajv validator", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "src", 9 | "dist" 10 | ], 11 | "scripts": { 12 | "build": "rm -rf dist && tsc", 13 | "eslint": "eslint \"src/**/*.*s\" \"spec/**/*.*s\"", 14 | "prettier:write": "prettier --write \"./**/*.{json,ts,js}\"", 15 | "prettier:check": "prettier --list-different \"./**/*.{json,ts,js}\"", 16 | "test-spec": "jest \"spec/*.ts\"", 17 | "test-cov": "jest \"spec/*.ts\" --coverage", 18 | "test": "npm run prettier:check && npm run eslint && npm run build && npm run test-cov", 19 | "prepublish": "npm run build" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/epoberezkin/ajv-errors.git" 24 | }, 25 | "keywords": [ 26 | "ajv", 27 | "json-schema", 28 | "validator", 29 | "error", 30 | "messages" 31 | ], 32 | "author": "", 33 | "license": "MIT", 34 | "bugs": { 35 | "url": "https://github.com/epoberezkin/ajv-errors/issues" 36 | }, 37 | "homepage": "https://github.com/epoberezkin/ajv-errors#readme", 38 | "peerDependencies": { 39 | "ajv": "^8.0.1" 40 | }, 41 | "devDependencies": { 42 | "@ajv-validator/config": "^0.3.0", 43 | "@types/jest": "^26.0.15", 44 | "@types/node": "^14.14.7", 45 | "@typescript-eslint/eslint-plugin": "^4.7.0", 46 | "@typescript-eslint/parser": "^4.7.0", 47 | "ajv": "^8.0.1", 48 | "eslint": "^7.2.0", 49 | "eslint-config-prettier": "^7.0.0", 50 | "husky": "^5.1.3", 51 | "jest": "^26.6.3", 52 | "lint-staged": "^10.5.1", 53 | "prettier": "^2.1.2", 54 | "ts-jest": "^26.4.4", 55 | "typescript": "^4.0.5" 56 | }, 57 | "prettier": "@ajv-validator/config/prettierrc.json", 58 | "husky": { 59 | "hooks": { 60 | "pre-commit": "lint-staged && npm test" 61 | } 62 | }, 63 | "lint-staged": { 64 | "*.{json,yaml,js,ts}": "prettier --write" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /spec/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | globals: { 3 | it: false, 4 | describe: false, 5 | beforeEach: false, 6 | }, 7 | overrides: [ 8 | { 9 | files: ["*.ts"], 10 | parserOptions: { 11 | project: ["./spec/tsconfig.json"], 12 | }, 13 | rules: { 14 | "@typescript-eslint/no-empty-function": "off", 15 | "@typescript-eslint/no-extraneous-class": "off", 16 | "@typescript-eslint/no-var-requires": "off", 17 | "@typescript-eslint/no-unsafe-call": "off", 18 | }, 19 | }, 20 | ], 21 | rules: { 22 | "no-console": "off", 23 | "no-new-wrappers": "off", 24 | "no-invalid-this": "off", 25 | "no-template-curly-in-string": "off", 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /spec/index.spec.ts: -------------------------------------------------------------------------------- 1 | import ajvErrors from ".." 2 | import Ajv from "ajv" 3 | import assert = require("assert") 4 | 5 | describe("ajv-errors", () => { 6 | it("should return ajv instance", () => { 7 | const ajv = new Ajv({allErrors: true}) 8 | assert.strictEqual(ajvErrors(ajv), ajv) 9 | }) 10 | 11 | it("should throw if option allErrors is not set", () => assert.throws(() => ajvErrors(new Ajv()))) 12 | 13 | it("should throw if option jsPropertySyntax is set", () => { 14 | const ajv = new Ajv({allErrors: true, jsPropertySyntax: true, logger: false}) 15 | assert.throws(() => ajvErrors(ajv)) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /spec/object.spec.ts: -------------------------------------------------------------------------------- 1 | import ajvErrors from ".." 2 | import Ajv, {SchemaObject, ErrorObject, ValidateFunction} from "ajv" 3 | import AjvPack from "ajv/dist/standalone/instance" 4 | import assert = require("assert") 5 | 6 | function _ajv(verbose?: boolean): Ajv { 7 | return ajvErrors(new Ajv({allErrors: true, verbose, code: {source: true}})) 8 | } 9 | 10 | describe("errorMessage value is an object", () => { 11 | let ajvs: (Ajv | AjvPack)[] 12 | 13 | beforeEach(() => { 14 | ajvs = [_ajv(), _ajv(true), new AjvPack(_ajv()), new AjvPack(_ajv(true))] 15 | }) 16 | 17 | describe("keywords", () => { 18 | it("should replace keyword errors with custom error messages", () => { 19 | const schema: SchemaObject = { 20 | type: "object", 21 | required: ["foo"], 22 | properties: { 23 | foo: {type: "integer"}, 24 | }, 25 | additionalProperties: false, 26 | errorMessage: { 27 | type: "should be an object", 28 | required: "should have property foo", 29 | additionalProperties: "should not have properties other than foo", 30 | }, 31 | } 32 | 33 | ajvs.forEach((ajv) => { 34 | const validate = ajv.compile(schema) 35 | assert.strictEqual(validate({foo: 1}), true) 36 | testInvalid({}, [["required"]]) 37 | testInvalid({bar: 2}, [["required"], ["additionalProperties"]]) 38 | testInvalid({foo: 1, bar: 2}, [["additionalProperties"]]) 39 | testInvalid({foo: "a"}, ["type"]) 40 | testInvalid({foo: "a", bar: 2}, ["type", ["additionalProperties"]]) 41 | testInvalid(1, [["type"]]) 42 | 43 | function testInvalid(data: any, expectedErrors: (string | string[])[]): void { 44 | assert.strictEqual(validate(data), false) 45 | assert.strictEqual(validate.errors?.length, expectedErrors.length) 46 | validate.errors.forEach((err, i) => { 47 | const expectedErr = expectedErrors[i] 48 | if (Array.isArray(expectedErr)) { 49 | // errorMessage error 50 | assert.strictEqual(err.keyword, "errorMessage") 51 | assert.strictEqual(err.message, schema.errorMessage[err.params.errors[0].keyword]) 52 | assert.strictEqual(err.instancePath, "") 53 | assert.strictEqual(err.schemaPath, "#/errorMessage") 54 | const replacedKeywords = err.params.errors.map((e: ErrorObject) => e.keyword) 55 | assert.deepStrictEqual( 56 | Array.from(replacedKeywords.sort()), 57 | Array.from(expectedErr.sort()) 58 | ) 59 | } else { 60 | // original error 61 | assert.strictEqual(err.keyword, expectedErr) 62 | } 63 | }) 64 | } 65 | }) 66 | }) 67 | 68 | describe("keyword errors with interpolated error messages", () => { 69 | let schema: SchemaObject, validate: ValidateFunction 70 | 71 | it("should replace errors", () => { 72 | schema = { 73 | type: "object", 74 | properties: { 75 | foo: { 76 | type: "number", 77 | minimum: 5, 78 | maximum: 10, 79 | errorMessage: { 80 | type: "property ${0#} should be number, it is ${0}", 81 | minimum: "property ${0#} should be >= 5, it is ${0}", 82 | maximum: "property foo should be <= 10", 83 | }, 84 | }, 85 | }, 86 | } 87 | 88 | ajvs.forEach((ajv) => { 89 | validate = ajv.compile(schema) 90 | assert.strictEqual(validate({foo: 7}), true) 91 | testInvalid({foo: "a"}, [["type"]]) 92 | testInvalid({foo: ["a"]}, [["type"]]) 93 | testInvalid({foo: 4.5}, [["minimum"]]) 94 | testInvalid({foo: 10.5}, [["maximum"]]) 95 | }) 96 | }) 97 | 98 | it("should replace keyword errors with interpolated error messages with type integer", () => { 99 | schema = { 100 | type: "object", 101 | properties: { 102 | foo: { 103 | type: "integer", 104 | minimum: 5, 105 | maximum: 10, 106 | errorMessage: { 107 | type: "property ${0#} should be integer, it is ${0}", 108 | minimum: "property ${0#} should be >= 5, it is ${0}", 109 | maximum: "property foo should be <= 10", 110 | }, 111 | }, 112 | }, 113 | } 114 | 115 | ajvs.forEach((ajv) => { 116 | validate = ajv.compile(schema) 117 | assert.strictEqual(validate({foo: 7}), true) 118 | testInvalid({foo: "a"}, [["type"]]) 119 | testInvalid({foo: ["a"]}, [["type"]]) 120 | testInvalid({foo: 5.5}, [["type"]]) 121 | testInvalid({foo: 4.5}, [["type"], ["minimum"]]) 122 | testInvalid({foo: 4}, [["minimum"]]) 123 | testInvalid({foo: 11}, [["maximum"]]) 124 | }) 125 | }) 126 | 127 | function testInvalid(data: any, expectedErrors: (string | string[])[]): void { 128 | assert.strictEqual(validate(data), false) 129 | assert.strictEqual(validate.errors?.length, expectedErrors.length) 130 | validate.errors.forEach((err, i) => { 131 | const expectedErr = expectedErrors[i] 132 | if (Array.isArray(expectedErr)) { 133 | // errorMessage error 134 | assert.strictEqual(err.keyword, "errorMessage") 135 | const expectedMessage = schema.properties.foo.errorMessage[err.params.errors[0].keyword] 136 | .replace("${0#}", '"foo"') 137 | .replace("${0}", JSON.stringify(data.foo)) 138 | assert.strictEqual(err.message, expectedMessage) 139 | assert.strictEqual(err.instancePath, "/foo") 140 | assert.strictEqual(err.schemaPath, "#/properties/foo/errorMessage") 141 | const replacedKeywords = err.params.errors.map((e: ErrorObject) => e.keyword) 142 | assert.deepStrictEqual( 143 | Array.from(replacedKeywords.sort()), 144 | Array.from(expectedErr.sort()) 145 | ) 146 | } else { 147 | // original error 148 | assert.strictEqual(err.keyword, expectedErr) 149 | } 150 | }) 151 | } 152 | }) 153 | 154 | describe('"required" and "dependencies" keywords errors for specific properties', () => { 155 | let schema: SchemaObject, validate: ValidateFunction 156 | 157 | it("should replace required errors with messages", () => { 158 | schema = { 159 | type: "object", 160 | required: ["foo", "bar"], 161 | properties: { 162 | foo: {type: "integer"}, 163 | bar: {type: "string"}, 164 | }, 165 | errorMessage: { 166 | type: "should be an object", 167 | required: { 168 | foo: 'an integer property "foo" is required', 169 | bar: 'a string property "bar" is required', 170 | }, 171 | }, 172 | } 173 | 174 | ajvs.forEach((ajv) => { 175 | validate = ajv.compile(schema) 176 | assert.strictEqual(validate({foo: 1, bar: "a"}), true) 177 | testInvalid({}, [{required: "foo"}, {required: "bar"}]) 178 | testInvalid({foo: 1}, [{required: "bar"}]) 179 | testInvalid({foo: "a"}, ["type", {required: "bar"}]) 180 | testInvalid({bar: "a"}, [{required: "foo"}]) 181 | }) 182 | }) 183 | 184 | it("should replace required errors with messages only for specific properties", () => { 185 | schema = { 186 | type: "object", 187 | required: ["foo", "bar"], 188 | properties: { 189 | foo: {type: "integer"}, 190 | bar: {type: "string"}, 191 | }, 192 | errorMessage: { 193 | type: "should be an object", 194 | required: { 195 | foo: 'an integer property "foo" is required', 196 | }, 197 | }, 198 | } 199 | 200 | ajvs.forEach((ajv) => { 201 | validate = ajv.compile(schema) 202 | assert.strictEqual(validate({foo: 1, bar: "a"}), true) 203 | testInvalid({}, ["required", {required: "foo"}]) 204 | testInvalid({foo: 1}, ["required"]) 205 | testInvalid({foo: "a"}, ["required", "type"]) 206 | testInvalid({bar: "a"}, [{required: "foo"}]) 207 | }) 208 | }) 209 | 210 | it("should replace required and dependencies errors with messages", () => { 211 | schema = { 212 | type: "object", 213 | required: ["foo", "bar"], 214 | properties: { 215 | foo: {type: "integer"}, 216 | bar: {type: "string"}, 217 | }, 218 | dependencies: { 219 | foo: ["quux"], 220 | bar: ["boo"], 221 | }, 222 | errorMessage: { 223 | type: "should be an object", 224 | required: { 225 | foo: 'an integer property "foo" is required, "bar" is ${/bar}', 226 | bar: 'a string property "bar" is required, "foo" is ${/foo}', 227 | }, 228 | dependencies: { 229 | foo: '"quux" should be present when "foo" is present, "foo" is ${/foo}', 230 | bar: '"boo" should be present when "bar" is present, "bar" is ${/bar}', 231 | }, 232 | }, 233 | } 234 | 235 | ajvs.forEach((ajv) => { 236 | validate = ajv.compile(schema) 237 | assert.strictEqual(validate({foo: 1, bar: "a", quux: 2, boo: 3}), true) 238 | testInvalid({}, [{required: "foo"}, {required: "bar"}]) 239 | testInvalid({foo: 1}, [{required: "bar"}, {dependencies: "foo"}]) 240 | testInvalid({foo: "a"}, ["type", {required: "bar"}, {dependencies: "foo"}]) 241 | testInvalid({bar: "a"}, [{required: "foo"}, {dependencies: "bar"}]) 242 | testInvalid({foo: 1, bar: "a"}, [{dependencies: "foo"}, {dependencies: "bar"}]) 243 | testInvalid({foo: 1, bar: "a", quux: 2}, [{dependencies: "bar"}]) 244 | }) 245 | }) 246 | 247 | it("should replace required errors with interpolated messages", () => { 248 | schema = { 249 | type: "object", 250 | required: ["foo", "bar"], 251 | properties: { 252 | foo: {type: "integer"}, 253 | bar: {type: "string"}, 254 | }, 255 | errorMessage: { 256 | type: "should be an object", 257 | required: { 258 | foo: 'an integer property "foo" is required, "bar" is ${/bar}', 259 | bar: 'a string property "bar" is required, "foo" is ${/foo}', 260 | }, 261 | }, 262 | } 263 | 264 | ajvs.forEach((ajv) => { 265 | validate = ajv.compile(schema) 266 | assert.strictEqual(validate({foo: 1, bar: "a"}), true) 267 | testInvalid({}, [{required: "foo"}, {required: "bar"}]) 268 | testInvalid({foo: 1}, [{required: "bar"}]) 269 | testInvalid({foo: "a"}, ["type", {required: "bar"}]) 270 | testInvalid({bar: "a"}, [{required: "foo"}]) 271 | }) 272 | }) 273 | 274 | function testInvalid(data: any, expectedErrors: (string | {[K in string]?: string})[]): void { 275 | assert.strictEqual(validate(data), false) 276 | assert.strictEqual(validate.errors?.length, expectedErrors.length) 277 | validate.errors.forEach((err, i) => { 278 | const expectedErr = expectedErrors[i] 279 | if (typeof expectedErr == "object") { 280 | // errorMessage error 281 | const errKeyword = Object.keys(expectedErr)[0] 282 | const errProp = expectedErr[errKeyword] 283 | 284 | assert.strictEqual(err.keyword, "errorMessage") 285 | const expectedMessage = schema.errorMessage[errKeyword][errProp as string] 286 | .replace("${/foo}", JSON.stringify(data.foo)) 287 | .replace("${/bar}", JSON.stringify(data.bar)) 288 | assert.strictEqual(err.message, expectedMessage) 289 | assert.strictEqual(err.instancePath, "") 290 | assert.strictEqual(err.schemaPath, "#/errorMessage") 291 | const replacedKeywords = err.params.errors.map((e: ErrorObject) => e.keyword) 292 | assert.deepStrictEqual( 293 | Array.from(replacedKeywords), 294 | Array.from(Object.keys(expectedErr)) 295 | ) 296 | } else { 297 | // original error 298 | assert.strictEqual(err.keyword, expectedErr) 299 | } 300 | }) 301 | } 302 | }) 303 | }) 304 | 305 | describe("properties and items", () => { 306 | let schema: SchemaObject, validate: ValidateFunction 307 | 308 | describe("properties only", () => { 309 | beforeEach(() => { 310 | schema = { 311 | type: "object", 312 | required: ["foo", "bar"], 313 | properties: { 314 | foo: { 315 | type: "object", 316 | required: ["baz"], 317 | properties: { 318 | baz: {type: "integer", maximum: 2}, 319 | }, 320 | }, 321 | bar: { 322 | type: "array", 323 | items: {type: "string", maxLength: 3}, 324 | minItems: 1, 325 | }, 326 | }, 327 | additionalProperties: false, 328 | errorMessage: "will be replaced in each test", 329 | } 330 | }) 331 | 332 | it("should replace errors for properties with custom error messages", () => { 333 | schema.errorMessage = { 334 | properties: { 335 | foo: 'data.foo should be an object with the integer property "baz" <= 2', 336 | bar: "data.bar should be an array with at least one string item with length <= 3", 337 | }, 338 | } 339 | 340 | testProperties() 341 | }) 342 | 343 | it("should replace errors for properties with interpolated error messages", () => { 344 | schema.errorMessage = { 345 | properties: { 346 | foo: 347 | 'data.foo should be an object with the integer property "baz" <= 2, "baz" is ${/foo/baz}', 348 | bar: 349 | 'data.bar should be an array with at least one string item with length <= 3, "bar" is ${/bar}', 350 | }, 351 | } 352 | 353 | testProperties((str, data) => 354 | str 355 | .replace("${/foo/baz}", JSON.stringify(data.foo?.baz)) 356 | .replace("${/bar}", JSON.stringify(data.bar)) 357 | ) 358 | }) 359 | 360 | function testProperties(tmpl?: (s: string, data: any) => string): void { 361 | const validData = { 362 | foo: {baz: 1}, 363 | bar: ["abc"], 364 | } 365 | 366 | ajvs.forEach((ajv) => { 367 | validate = ajv.compile(schema) 368 | 369 | assert.strictEqual(validate(validData), true) 370 | testInvalid({}, ["required", "required"], tmpl) 371 | testInvalid({foo: 1}, ["required", ["type"]], tmpl) 372 | testInvalid({foo: 1, bar: 2}, [["type"], ["type"]], tmpl) 373 | testInvalid({foo: {baz: "a"}}, ["required", ["type"]], tmpl) 374 | testInvalid({foo: {baz: 3}}, ["required", ["maximum"]], tmpl) 375 | testInvalid({foo: {baz: 3}, bar: []}, [["maximum"], ["minItems"]], tmpl) 376 | testInvalid({foo: {baz: 3}, bar: [1]}, [["maximum"], ["type"]], tmpl) 377 | testInvalid({foo: {baz: 3}, bar: ["abcd"]}, [["maximum"], ["maxLength"]], tmpl) 378 | }) 379 | } 380 | }) 381 | 382 | describe("items only", () => { 383 | beforeEach(() => { 384 | schema = { 385 | type: "array", 386 | items: [ 387 | { 388 | type: "object", 389 | required: ["baz"], 390 | properties: { 391 | baz: {type: "integer", maximum: 2}, 392 | }, 393 | }, 394 | { 395 | type: "array", 396 | items: {type: "string", maxLength: 3}, 397 | minItems: 1, 398 | }, 399 | ], 400 | minItems: 2, 401 | additionalItems: false, 402 | errorMessage: "will be replaced in each test", 403 | } 404 | }) 405 | 406 | it("should replace errors for items with custom error messages", () => { 407 | schema.errorMessage = { 408 | items: [ 409 | 'data[0] should be an object with the integer property "baz" <= 2', 410 | "data[1] should be an array with at least one string item with length <= 3", 411 | ], 412 | } 413 | 414 | testItems() 415 | }) 416 | 417 | it("should replace errors for items with interpolated error messages", () => { 418 | schema.errorMessage = { 419 | items: [ 420 | 'data[0] should be an object with the integer property "baz" <= 2, data[0].baz is ${/0/baz}', 421 | "data[1] should be an array with at least one string item with length <= 3, data[1] is ${/1}", 422 | ], 423 | } 424 | 425 | testItems((str, data) => 426 | str 427 | .replace("${/0/baz}", JSON.stringify(data[0]?.baz)) 428 | .replace("${/1}", JSON.stringify(data[1])) 429 | ) 430 | }) 431 | 432 | function testItems(tmpl?: (s: string, data: any) => string): void { 433 | const validData = [{baz: 1}, ["abc"]] 434 | 435 | ajvs.forEach((ajv) => { 436 | validate = ajv.compile(schema) 437 | 438 | assert.strictEqual(validate(validData), true) 439 | testInvalid([], ["minItems"], tmpl) 440 | testInvalid([1], ["minItems", ["type"]], tmpl) 441 | testInvalid([1, 2], [["type"], ["type"]], tmpl) 442 | testInvalid([{baz: "a"}], ["minItems", ["type"]], tmpl) 443 | testInvalid([{baz: 3}], ["minItems", ["maximum"]], tmpl) 444 | testInvalid([{baz: 3}, []], [["maximum"], ["minItems"]], tmpl) 445 | testInvalid([{baz: 3}, [1]], [["maximum"], ["type"]], tmpl) 446 | testInvalid([{baz: 3}, ["abcd"]], [["maximum"], ["maxLength"]], tmpl) 447 | }) 448 | } 449 | }) 450 | 451 | describe("both properties and items", () => { 452 | beforeEach(() => { 453 | schema = { 454 | definitions: { 455 | foo: { 456 | type: "object", 457 | required: ["baz"], 458 | properties: { 459 | baz: {type: "integer", maximum: 2}, 460 | }, 461 | }, 462 | bar: { 463 | type: "array", 464 | items: {type: "string", maxLength: 3}, 465 | minItems: 1, 466 | }, 467 | }, 468 | anyOf: [ 469 | { 470 | type: "object", 471 | required: ["foo", "bar"], 472 | properties: { 473 | foo: {$ref: "#/definitions/foo"}, 474 | bar: {$ref: "#/definitions/bar"}, 475 | }, 476 | additionalProperties: false, 477 | }, 478 | { 479 | type: "array", 480 | items: [{$ref: "#/definitions/foo"}, {$ref: "#/definitions/bar"}], 481 | minItems: 2, 482 | additionalItems: false, 483 | }, 484 | ], 485 | errorMessage: "will be replaced in each test", 486 | } 487 | }) 488 | 489 | it("should replace errors for properties and items with custom error messages", () => { 490 | schema.errorMessage = { 491 | properties: { 492 | foo: 'data.foo should be an object with the integer property "baz" <= 2', 493 | bar: "data.bar should be an array with at least one string item with length <= 3", 494 | }, 495 | items: [ 496 | 'data[0] should be an object with the integer property "baz" <= 2', 497 | "data[1] should be an array with at least one string item with length <= 3", 498 | ], 499 | } 500 | 501 | testPropsAndItems() 502 | }) 503 | 504 | it("should replace errors for properties and items with interpolated error messages", () => { 505 | schema.errorMessage = { 506 | properties: { 507 | foo: 508 | 'data.foo should be an object with the integer property "baz" <= 2, "baz" is ${/foo/baz}', 509 | bar: 510 | 'data.bar should be an array with at least one string item with length <= 3, "bar" is ${/bar}', 511 | }, 512 | items: [ 513 | 'data[0] should be an object with the integer property "baz" <= 2, data[0].baz is ${/0/baz}', 514 | "data[1] should be an array with at least one string item with length <= 3, data[1] is ${/1}", 515 | ], 516 | } 517 | 518 | testPropsAndItems((str, data) => 519 | str 520 | .replace("${/foo/baz}", JSON.stringify(data.foo?.baz)) 521 | .replace("${/bar}", JSON.stringify(data.bar)) 522 | .replace("${/0/baz}", JSON.stringify(data[0]?.baz)) 523 | .replace("${/1}", JSON.stringify(data[1])) 524 | ) 525 | }) 526 | 527 | function testPropsAndItems(tmpl?: (s: string, data: any) => string): void { 528 | const validData1 = { 529 | foo: {baz: 1}, 530 | bar: ["abc"], 531 | } 532 | 533 | const validData2 = [{baz: 1}, ["abc"]] 534 | 535 | ajvs.forEach((ajv) => { 536 | validate = ajv.compile(schema) 537 | 538 | assert.strictEqual(validate(validData1), true) 539 | assert.strictEqual(validate(validData2), true) 540 | testInvalid({}, ["required", "required", "type", "anyOf"], tmpl) 541 | testInvalid({foo: 1}, ["required", "type", "anyOf", ["type"]], tmpl) 542 | testInvalid({foo: 1, bar: 2}, ["type", "anyOf", ["type"], ["type"]], tmpl) 543 | testInvalid({foo: {baz: "a"}}, ["required", "type", "anyOf", ["type"]], tmpl) 544 | testInvalid({foo: {baz: 3}}, ["required", "type", "anyOf", ["maximum"]], tmpl) 545 | testInvalid({foo: {baz: 3}, bar: []}, ["type", "anyOf", ["maximum"], ["minItems"]], tmpl) 546 | testInvalid({foo: {baz: 3}, bar: [1]}, ["type", "anyOf", ["maximum"], ["type"]], tmpl) 547 | testInvalid( 548 | {foo: {baz: 3}, bar: ["abcd"]}, 549 | ["type", "anyOf", ["maximum"], ["maxLength"]], 550 | tmpl 551 | ) 552 | 553 | testInvalid([], ["type", "minItems", "anyOf"], tmpl) 554 | testInvalid([1], ["type", "minItems", "anyOf", ["type"]], tmpl) 555 | testInvalid([1, 2], ["type", "anyOf", ["type"], ["type"]], tmpl) 556 | testInvalid([{baz: "a"}], ["type", "minItems", "anyOf", ["type"]], tmpl) 557 | testInvalid([{baz: 3}], ["type", "minItems", "anyOf", ["maximum"]], tmpl) 558 | testInvalid([{baz: 3}, []], ["type", "anyOf", ["maximum"], ["minItems"]], tmpl) 559 | testInvalid([{baz: 3}, [1]], ["type", "anyOf", ["maximum"], ["type"]], tmpl) 560 | testInvalid([{baz: 3}, ["abcd"]], ["type", "anyOf", ["maximum"], ["maxLength"]], tmpl) 561 | }) 562 | } 563 | }) 564 | 565 | function testInvalid( 566 | data: any, 567 | expectedErrors: (string | string[])[], 568 | interpolate?: (s: string, x: any) => string 569 | ): void { 570 | assert.strictEqual(validate(data), false) 571 | assert.strictEqual(validate.errors?.length, expectedErrors.length) 572 | validate.errors.forEach((err, i) => { 573 | const expectedErr = expectedErrors[i] 574 | if (Array.isArray(expectedErr)) { 575 | // errorMessage error 576 | assert.strictEqual(err.keyword, "errorMessage") 577 | const child = Array.isArray(data) ? "items" : "properties" 578 | let expectedMessage = schema.errorMessage[child][err.instancePath.slice(1)] 579 | if (interpolate) expectedMessage = interpolate(expectedMessage, data) 580 | assert.strictEqual(err.message, expectedMessage) 581 | assert((Array.isArray(data) ? /^\/(0|1)$/ : /^\/(foo|bar)$/).test(err.instancePath)) 582 | assert.strictEqual(err.schemaPath, "#/errorMessage") 583 | const replacedKeywords = err.params.errors.map((e: ErrorObject) => e.keyword) 584 | assert.deepStrictEqual( 585 | Array.from(replacedKeywords.sort()), 586 | Array.from(expectedErr.sort()) 587 | ) 588 | } else { 589 | // original error 590 | assert.strictEqual(err.keyword, expectedErr) 591 | } 592 | }) 593 | } 594 | }) 595 | 596 | describe("default message", () => { 597 | it("should replace all errors not replaced by keyword/properties/items messages", () => { 598 | const schema = { 599 | type: "object", 600 | required: ["foo", "bar"], 601 | properties: { 602 | foo: {type: "integer"}, 603 | bar: {type: "string"}, 604 | }, 605 | errorMessage: { 606 | properties: { 607 | foo: "data.foo should be integer", 608 | bar: "data.bar should be integer", 609 | }, 610 | required: "properties foo and bar are required", 611 | _: 'should be an object with properties "foo" (integer) and "bar" (string)', 612 | }, 613 | } 614 | 615 | ajvs.forEach((ajv) => { 616 | const validate = ajv.compile(schema) 617 | 618 | assert.strictEqual(validate({foo: 1, bar: "a"}), true) 619 | testInvalid({foo: 1}, [["required"]]) 620 | testInvalid({foo: "a"}, [["required"], ["type"]]) 621 | testInvalid(null, [["type"]]) 622 | 623 | function testInvalid(data: any, expectedErrors: (string | string[])[]): void { 624 | assert.strictEqual(validate(data), false) 625 | assert.strictEqual(validate.errors?.length, expectedErrors.length) 626 | validate.errors.forEach((err, i) => { 627 | const expectedErr = expectedErrors[i] 628 | if (Array.isArray(expectedErr)) { 629 | // errorMessage error 630 | assert.strictEqual(err.keyword, "errorMessage") 631 | assert.strictEqual(err.schemaPath, "#/errorMessage") 632 | const expectedMessage = err.instancePath 633 | ? schema.errorMessage.properties.foo 634 | : schema.errorMessage[expectedErr[0] === "required" ? "required" : "_"] 635 | assert.strictEqual(err.message, expectedMessage) 636 | const replacedKeywords = err.params.errors.map((e: ErrorObject) => e.keyword) 637 | assert.deepStrictEqual( 638 | Array.from(replacedKeywords.sort()), 639 | Array.from(expectedErr.sort()) 640 | ) 641 | } else { 642 | // original error 643 | assert.strictEqual(err.keyword, expectedErr) 644 | } 645 | }) 646 | } 647 | }) 648 | }) 649 | }) 650 | }) 651 | -------------------------------------------------------------------------------- /spec/options.spec.ts: -------------------------------------------------------------------------------- 1 | import ajvErrors from ".." 2 | import Ajv, {ErrorObject, SchemaObject, ValidateFunction} from "ajv" 3 | import assert = require("assert") 4 | 5 | describe("options", () => { 6 | let ajv: Ajv 7 | 8 | beforeEach(() => { 9 | ajv = new Ajv({allErrors: true}) 10 | }) 11 | 12 | describe("keepErrors = true", () => { 13 | beforeEach(() => ajvErrors(ajv, {keepErrors: true})) 14 | 15 | describe("errorMessage is a string", () => { 16 | it("should keep matched errors and mark them with {emUsed: true} property", () => { 17 | const schema = { 18 | type: "object", 19 | required: ["foo", "bar"], 20 | properties: { 21 | foo: { 22 | type: "object", 23 | properties: { 24 | baz: { 25 | type: "integer", 26 | }, 27 | }, 28 | errorMessage: "should be an object with an integer property baz", 29 | }, 30 | bar: { 31 | type: "integer", 32 | }, 33 | }, 34 | } 35 | 36 | const validate = ajv.compile(schema) 37 | assert.strictEqual(validate({foo: {baz: 1}, bar: 2}), true) 38 | assert.strictEqual(validate({foo: 1}), false) 39 | 40 | assertErrors(validate, [ 41 | { 42 | keyword: "required", 43 | instancePath: "", 44 | }, 45 | { 46 | keyword: "type", 47 | instancePath: "/foo", 48 | emUsed: true, 49 | }, 50 | { 51 | keyword: "errorMessage", 52 | message: "should be an object with an integer property baz", 53 | instancePath: "/foo", 54 | errors: ["type"], 55 | }, 56 | ]) 57 | }) 58 | }) 59 | 60 | describe("errorMessage is an object with keywords", () => { 61 | it("should keep matched errors and mark them with {emUsed: true} property", () => { 62 | const schema = { 63 | type: "number", 64 | minimum: 2, 65 | maximum: 10, 66 | multipleOf: 2, 67 | errorMessage: { 68 | type: "should be number", 69 | minimum: "should be >= 2", 70 | maximum: "should be <= 10", 71 | }, 72 | } 73 | 74 | const validate = ajv.compile(schema) 75 | assert.strictEqual(validate(4), true) 76 | assert.strictEqual(validate(11), false) 77 | 78 | assertErrors(validate, [ 79 | { 80 | keyword: "maximum", 81 | instancePath: "", 82 | emUsed: true, 83 | }, 84 | { 85 | keyword: "multipleOf", 86 | instancePath: "", 87 | }, 88 | { 89 | keyword: "errorMessage", 90 | message: "should be <= 10", 91 | instancePath: "", 92 | errors: ["maximum"], 93 | }, 94 | ]) 95 | }) 96 | }) 97 | 98 | describe('errorMessage is an object with "required" keyword with properties', () => { 99 | it("should keep matched errors and mark them with {emUsed: true} property", () => { 100 | const schema = { 101 | type: "object", 102 | required: ["foo", "bar"], 103 | errorMessage: { 104 | type: "should be object", 105 | required: { 106 | foo: "should have property foo", 107 | bar: "should have property bar", 108 | }, 109 | }, 110 | } 111 | 112 | const validate = ajv.compile(schema) 113 | assert.strictEqual(validate({foo: 1, bar: 2}), true) 114 | assert.strictEqual(validate({}), false) 115 | 116 | assertErrors(validate, [ 117 | { 118 | keyword: "required", 119 | instancePath: "", 120 | emUsed: true, 121 | }, 122 | { 123 | keyword: "required", 124 | instancePath: "", 125 | emUsed: true, 126 | }, 127 | { 128 | keyword: "errorMessage", 129 | message: "should have property foo", 130 | instancePath: "", 131 | errors: ["required"], 132 | }, 133 | { 134 | keyword: "errorMessage", 135 | message: "should have property bar", 136 | instancePath: "", 137 | errors: ["required"], 138 | }, 139 | ]) 140 | }) 141 | }) 142 | 143 | describe("errorMessage is an object with properties/items", () => { 144 | it("should keep matched errors and mark them with {emUsed: true} property", () => { 145 | const schema = { 146 | type: "object", 147 | properties: { 148 | foo: {type: "number"}, 149 | bar: {type: "string"}, 150 | }, 151 | errorMessage: { 152 | properties: { 153 | foo: "foo should be a number", 154 | }, 155 | }, 156 | } 157 | 158 | const validate = ajv.compile(schema) 159 | assert.strictEqual(validate({foo: 1, bar: "a"}), true) 160 | assert.strictEqual(validate({foo: "a", bar: 1}), false) 161 | 162 | assertErrors(validate, [ 163 | { 164 | keyword: "type", 165 | instancePath: "/foo", 166 | emUsed: true, 167 | }, 168 | { 169 | keyword: "type", 170 | instancePath: "/bar", 171 | }, 172 | { 173 | keyword: "errorMessage", 174 | message: "foo should be a number", 175 | instancePath: "/foo", 176 | errors: ["type"], 177 | }, 178 | ]) 179 | }) 180 | }) 181 | }) 182 | 183 | describe("singleError", () => { 184 | describe("= true", () => { 185 | it("should generate a single error for all keywords", () => { 186 | ajvErrors(ajv, {singleError: true}) 187 | testSingleErrors("; ") 188 | }) 189 | }) 190 | 191 | describe("= separator", () => { 192 | it("should generate a single error for all keywords using separator", () => { 193 | ajvErrors(ajv, {singleError: "\n"}) 194 | testSingleErrors("\n") 195 | }) 196 | }) 197 | 198 | function testSingleErrors(separator: string): void { 199 | const schema: SchemaObject = { 200 | type: "number", 201 | minimum: 2, 202 | maximum: 10, 203 | multipleOf: 2, 204 | errorMessage: { 205 | type: "should be number", 206 | minimum: "should be >= 2", 207 | maximum: "should be <= 10", 208 | multipleOf: "should be multipleOf 2", 209 | }, 210 | } 211 | 212 | const validate = ajv.compile(schema) 213 | assert.strictEqual(validate(4), true) 214 | assert.strictEqual(validate(11), false) 215 | 216 | const expectedKeywords = ["maximum", "multipleOf"] 217 | const expectedMessage = expectedKeywords 218 | .map((keyword) => schema.errorMessage?.[keyword] as string) 219 | .join(separator) 220 | 221 | assertErrors(validate, [ 222 | { 223 | keyword: "errorMessage", 224 | message: expectedMessage, 225 | instancePath: "", 226 | errors: expectedKeywords, 227 | }, 228 | ]) 229 | } 230 | }) 231 | 232 | function assertErrors( 233 | validate: ValidateFunction, 234 | expectedErrors: Partial[] 235 | ): void { 236 | const {errors} = validate 237 | assert.strictEqual(errors?.length, expectedErrors.length) 238 | 239 | expectedErrors.forEach((expectedErr, i) => { 240 | const err = errors[i] as ErrorObject & {emUsed: boolean} 241 | assert.strictEqual(err.keyword, expectedErr.keyword) 242 | assert.strictEqual(err.instancePath, expectedErr.instancePath) 243 | assert.strictEqual(err.emUsed, expectedErr.emUsed) 244 | if (expectedErr.keyword === "errorMessage") { 245 | assert.strictEqual(err.params.errors.length, expectedErr.errors?.length) 246 | expectedErr.errors?.forEach((matchedKeyword: string, j: number) => 247 | assert.strictEqual(err.params.errors[j].keyword, matchedKeyword) 248 | ) 249 | } 250 | }) 251 | } 252 | }) 253 | -------------------------------------------------------------------------------- /spec/string.spec.ts: -------------------------------------------------------------------------------- 1 | import ajvErrors from ".." 2 | import Ajv, {ErrorObject} from "ajv" 3 | import AjvPack from "ajv/dist/standalone/instance" 4 | import assert = require("assert") 5 | 6 | function _ajv(verbose?: boolean): Ajv { 7 | return ajvErrors(new Ajv({allErrors: true, verbose, code: {source: true}})) 8 | } 9 | 10 | describe.only("errorMessage value is a string", () => { 11 | it("should replace all errors with custom error message", () => { 12 | const ajvs = [_ajv(), _ajv(true), new AjvPack(_ajv()), new AjvPack(_ajv(true))] 13 | 14 | const schema = { 15 | type: "object", 16 | required: ["foo"], 17 | properties: { 18 | foo: {type: "integer"}, 19 | }, 20 | additionalProperties: false, 21 | errorMessage: "should be an object with an integer property foo only", 22 | } 23 | 24 | ajvs.forEach((ajv) => { 25 | const validate = ajv.compile(schema) 26 | assert.strictEqual(validate({foo: 1}), true) 27 | testInvalid({}, ["required"]) 28 | testInvalid({bar: 2}, ["required", "additionalProperties"]) 29 | testInvalid({foo: 1, bar: 2}, ["additionalProperties"]) 30 | testInvalid({foo: "a"}, ["type"]) 31 | testInvalid({foo: "a", bar: 2}, ["type", "additionalProperties"]) 32 | testInvalid(1, ["type"]) 33 | 34 | function testInvalid(data: any, expectedReplacedKeywords: string[]): void { 35 | assert.strictEqual(validate(data), false) 36 | assert.strictEqual(validate.errors?.length, 1) 37 | const err = validate.errors[0] 38 | assert.strictEqual(err.keyword, "errorMessage") 39 | assert.strictEqual(err.message, schema.errorMessage) 40 | assert.strictEqual(err.instancePath, "") 41 | assert.strictEqual(err.schemaPath, "#/errorMessage") 42 | const replacedKeywords = err.params.errors.map((e: ErrorObject) => { 43 | return e.keyword 44 | }) 45 | assert.deepStrictEqual( 46 | Array.from(replacedKeywords.sort()), 47 | Array.from(expectedReplacedKeywords.sort()) 48 | ) 49 | } 50 | }) 51 | }) 52 | 53 | it("should replace all errors with interpolated error message", () => { 54 | const ajvs = [ 55 | ajvErrors(new Ajv({allErrors: true})), 56 | ajvErrors(new Ajv({allErrors: true, verbose: true})), 57 | ] 58 | 59 | const errorMessages = [ 60 | 'properties "foo" = ${/foo}, "bar" = ${/bar}, should be integer', 61 | '${/foo}, ${/bar} are the values of properties "foo", "bar", should be integer', 62 | 'properties "foo", "bar" should be integer, they are ${/foo}, ${/bar}', 63 | ] 64 | 65 | let schema = { 66 | type: "object", 67 | properties: { 68 | foo: {type: "integer"}, 69 | bar: {type: "integer"}, 70 | }, 71 | errorMessage: "will be replaced with one of the messages above", 72 | } 73 | 74 | errorMessages.forEach((message) => { 75 | ajvs.forEach((ajv) => { 76 | schema = JSON.parse(JSON.stringify(schema)) 77 | schema.errorMessage = message 78 | const validate = ajv.compile(schema) 79 | assert.strictEqual(validate({foo: 1}), true) 80 | testInvalid({foo: 1.2, bar: 2.3}, ["type", "type"]) 81 | testInvalid({foo: "a", bar: "b"}, ["type", "type"]) 82 | testInvalid({foo: false, bar: true}, ["type", "type"]) 83 | 84 | function testInvalid(data: any, expectedReplacedKeywords: string[]): void { 85 | assert.strictEqual(validate(data), false) 86 | assert.strictEqual(validate.errors?.length, 1) 87 | const err = validate.errors[0] 88 | assert.strictEqual(err.keyword, "errorMessage") 89 | const expectedMessage = schema.errorMessage 90 | .replace("${/foo}", JSON.stringify(data.foo)) 91 | .replace("${/bar}", JSON.stringify(data.bar)) 92 | assert.strictEqual(err.message, expectedMessage) 93 | assert.strictEqual(err.instancePath, "") 94 | assert.strictEqual(err.schemaPath, "#/errorMessage") 95 | const replacedKeywords = err.params.errors.map((e: ErrorObject) => e.keyword) 96 | assert.deepStrictEqual(replacedKeywords.sort(), expectedReplacedKeywords.sort()) 97 | } 98 | }) 99 | }) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /spec/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "..", 3 | "include": ["."], 4 | "compilerOptions": { 5 | "types": ["node", "jest"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type {Plugin, CodeKeywordDefinition, KeywordCxt, ErrorObject, Code} from "ajv" 2 | import Ajv, {_, str, stringify, Name} from "ajv" 3 | import {and, or, not, strConcat} from "ajv/dist/compile/codegen" 4 | import {safeStringify, _Code} from "ajv/dist/compile/codegen/code" 5 | import {getData} from "ajv/dist/compile/validate" 6 | import {reportError} from "ajv/dist/compile/errors" 7 | import N from "ajv/dist/compile/names" 8 | 9 | type ErrorsMap = {[P in T]?: ErrorObject[]} 10 | 11 | type StringMap = {[P in string]?: string} 12 | 13 | type ErrorMessageSchema = { 14 | properties?: StringMap 15 | items?: string[] 16 | required?: string | StringMap 17 | dependencies?: string | StringMap 18 | _?: string 19 | } & {[K in string]?: string | StringMap} 20 | 21 | interface ChildErrors { 22 | props?: ErrorsMap 23 | items?: ErrorsMap 24 | } 25 | 26 | const keyword = "errorMessage" 27 | 28 | const used: Name = new Name("emUsed") 29 | 30 | const KEYWORD_PROPERTY_PARAMS = { 31 | required: "missingProperty", 32 | dependencies: "property", 33 | dependentRequired: "property", 34 | } 35 | 36 | export interface ErrorMessageOptions { 37 | keepErrors?: boolean 38 | singleError?: boolean | string 39 | } 40 | 41 | const INTERPOLATION = /\$\{[^}]+\}/ 42 | const INTERPOLATION_REPLACE = /\$\{([^}]+)\}/g 43 | const EMPTY_STR = /^""\s*\+\s*|\s*\+\s*""$/g 44 | 45 | function errorMessage(options: ErrorMessageOptions): CodeKeywordDefinition { 46 | return { 47 | keyword, 48 | schemaType: ["string", "object"], 49 | post: true, 50 | code(cxt: KeywordCxt) { 51 | const {gen, data, schema, schemaValue, it} = cxt 52 | if (it.createErrors === false) return 53 | const sch: ErrorMessageSchema | string = schema 54 | const instancePath = strConcat(N.instancePath, it.errorPath) 55 | gen.if(_`${N.errors} > 0`, () => { 56 | if (typeof sch == "object") { 57 | const [kwdPropErrors, kwdErrors] = keywordErrorsConfig(sch) 58 | if (kwdErrors) processKeywordErrors(kwdErrors) 59 | if (kwdPropErrors) processKeywordPropErrors(kwdPropErrors) 60 | processChildErrors(childErrorsConfig(sch)) 61 | } 62 | const schMessage = typeof sch == "string" ? sch : sch._ 63 | if (schMessage) processAllErrors(schMessage) 64 | if (!options.keepErrors) removeUsedErrors() 65 | }) 66 | 67 | function childErrorsConfig({properties, items}: ErrorMessageSchema): ChildErrors { 68 | const errors: ChildErrors = {} 69 | if (properties) { 70 | errors.props = {} 71 | for (const p in properties) errors.props[p] = [] 72 | } 73 | if (items) { 74 | errors.items = {} 75 | for (let i = 0; i < items.length; i++) errors.items[i] = [] 76 | } 77 | return errors 78 | } 79 | 80 | function keywordErrorsConfig( 81 | emSchema: ErrorMessageSchema 82 | ): [{[K in string]?: ErrorsMap} | undefined, ErrorsMap | undefined] { 83 | let propErrors: {[K in string]?: ErrorsMap} | undefined 84 | let errors: ErrorsMap | undefined 85 | 86 | for (const k in emSchema) { 87 | if (k === "properties" || k === "items") continue 88 | const kwdSch = emSchema[k] 89 | if (typeof kwdSch == "object") { 90 | propErrors ||= {} 91 | const errMap: ErrorsMap = (propErrors[k] = {}) 92 | for (const p in kwdSch) errMap[p] = [] 93 | } else { 94 | errors ||= {} 95 | errors[k] = [] 96 | } 97 | } 98 | return [propErrors, errors] 99 | } 100 | 101 | function processKeywordErrors(kwdErrors: ErrorsMap): void { 102 | const kwdErrs = gen.const("emErrors", stringify(kwdErrors)) 103 | const templates = gen.const("templates", getTemplatesCode(kwdErrors, schema)) 104 | gen.forOf("err", N.vErrors, (err) => 105 | gen.if(matchKeywordError(err, kwdErrs), () => 106 | gen.code(_`${kwdErrs}[${err}.keyword].push(${err})`).assign(_`${err}.${used}`, true) 107 | ) 108 | ) 109 | const {singleError} = options 110 | if (singleError) { 111 | const message = gen.let("message", _`""`) 112 | const paramsErrors = gen.let("paramsErrors", _`[]`) 113 | loopErrors((key) => { 114 | gen.if(message, () => 115 | gen.code(_`${message} += ${typeof singleError == "string" ? singleError : ";"}`) 116 | ) 117 | gen.code(_`${message} += ${errMessage(key)}`) 118 | gen.assign(paramsErrors, _`${paramsErrors}.concat(${kwdErrs}[${key}])`) 119 | }) 120 | reportError(cxt, {message, params: _`{errors: ${paramsErrors}}`}) 121 | } else { 122 | loopErrors((key) => 123 | reportError(cxt, { 124 | message: errMessage(key), 125 | params: _`{errors: ${kwdErrs}[${key}]}`, 126 | }) 127 | ) 128 | } 129 | 130 | function loopErrors(body: (key: Name) => void): void { 131 | gen.forIn("key", kwdErrs, (key) => gen.if(_`${kwdErrs}[${key}].length`, () => body(key))) 132 | } 133 | 134 | function errMessage(key: Name): Code { 135 | return _`${key} in ${templates} ? ${templates}[${key}]() : ${schemaValue}[${key}]` 136 | } 137 | } 138 | 139 | function processKeywordPropErrors(kwdPropErrors: {[K in string]?: ErrorsMap}): void { 140 | const kwdErrs = gen.const("emErrors", stringify(kwdPropErrors)) 141 | const templatesCode: [string, Code][] = [] 142 | for (const k in kwdPropErrors) { 143 | templatesCode.push([ 144 | k, 145 | getTemplatesCode(kwdPropErrors[k] as ErrorsMap, schema[k]), 146 | ]) 147 | } 148 | const templates = gen.const("templates", gen.object(...templatesCode)) 149 | 150 | const kwdPropParams = gen.scopeValue("obj", { 151 | ref: KEYWORD_PROPERTY_PARAMS, 152 | code: stringify(KEYWORD_PROPERTY_PARAMS), 153 | }) 154 | const propParam = gen.let("emPropParams") 155 | const paramsErrors = gen.let("emParamsErrors") 156 | 157 | gen.forOf("err", N.vErrors, (err) => 158 | gen.if(matchKeywordError(err, kwdErrs), () => { 159 | gen.assign(propParam, _`${kwdPropParams}[${err}.keyword]`) 160 | gen.assign(paramsErrors, _`${kwdErrs}[${err}.keyword][${err}.params[${propParam}]]`) 161 | gen.if(paramsErrors, () => 162 | gen.code(_`${paramsErrors}.push(${err})`).assign(_`${err}.${used}`, true) 163 | ) 164 | }) 165 | ) 166 | 167 | gen.forIn("key", kwdErrs, (key) => 168 | gen.forIn("keyProp", _`${kwdErrs}[${key}]`, (keyProp) => { 169 | gen.assign(paramsErrors, _`${kwdErrs}[${key}][${keyProp}]`) 170 | gen.if(_`${paramsErrors}.length`, () => { 171 | const tmpl = gen.const( 172 | "tmpl", 173 | _`${templates}[${key}] && ${templates}[${key}][${keyProp}]` 174 | ) 175 | reportError(cxt, { 176 | message: _`${tmpl} ? ${tmpl}() : ${schemaValue}[${key}][${keyProp}]`, 177 | params: _`{errors: ${paramsErrors}}`, 178 | }) 179 | }) 180 | }) 181 | ) 182 | } 183 | 184 | function processChildErrors(childErrors: ChildErrors): void { 185 | const {props, items} = childErrors 186 | if (!props && !items) return 187 | const isObj = _`typeof ${data} == "object"` 188 | const isArr = _`Array.isArray(${data})` 189 | const childErrs = gen.let("emErrors") 190 | let childKwd: Name 191 | let childProp: Code 192 | const templates = gen.let("templates") 193 | if (props && items) { 194 | childKwd = gen.let("emChildKwd") 195 | gen.if(isObj) 196 | gen.if( 197 | isArr, 198 | () => { 199 | init(items, schema.items) 200 | gen.assign(childKwd, str`items`) 201 | }, 202 | () => { 203 | init(props, schema.properties) 204 | gen.assign(childKwd, str`properties`) 205 | } 206 | ) 207 | childProp = _`[${childKwd}]` 208 | } else if (items) { 209 | gen.if(isArr) 210 | init(items, schema.items) 211 | childProp = _`.items` 212 | } else if (props) { 213 | gen.if(and(isObj, not(isArr))) 214 | init(props, schema.properties) 215 | childProp = _`.properties` 216 | } 217 | 218 | gen.forOf("err", N.vErrors, (err) => 219 | ifMatchesChildError(err, childErrs, (child) => 220 | gen.code(_`${childErrs}[${child}].push(${err})`).assign(_`${err}.${used}`, true) 221 | ) 222 | ) 223 | 224 | gen.forIn("key", childErrs, (key) => 225 | gen.if(_`${childErrs}[${key}].length`, () => { 226 | reportError(cxt, { 227 | message: _`${key} in ${templates} ? ${templates}[${key}]() : ${schemaValue}${childProp}[${key}]`, 228 | params: _`{errors: ${childErrs}[${key}]}`, 229 | }) 230 | gen.assign( 231 | _`${N.vErrors}[${N.errors}-1].instancePath`, 232 | _`${instancePath} + "/" + ${key}.replace(/~/g, "~0").replace(/\\//g, "~1")` 233 | ) 234 | }) 235 | ) 236 | 237 | gen.endIf() 238 | 239 | function init( 240 | children: ErrorsMap, 241 | msgs: {[K in string]?: string} 242 | ): void { 243 | gen.assign(childErrs, stringify(children)) 244 | gen.assign(templates, getTemplatesCode(children, msgs)) 245 | } 246 | } 247 | 248 | function processAllErrors(schMessage: string): void { 249 | const errs = gen.const("emErrs", _`[]`) 250 | gen.forOf("err", N.vErrors, (err) => 251 | gen.if(matchAnyError(err), () => 252 | gen.code(_`${errs}.push(${err})`).assign(_`${err}.${used}`, true) 253 | ) 254 | ) 255 | gen.if(_`${errs}.length`, () => 256 | reportError(cxt, { 257 | message: templateExpr(schMessage), 258 | params: _`{errors: ${errs}}`, 259 | }) 260 | ) 261 | } 262 | 263 | function removeUsedErrors(): void { 264 | const errs = gen.const("emErrs", _`[]`) 265 | gen.forOf("err", N.vErrors, (err) => 266 | gen.if(_`!${err}.${used}`, () => gen.code(_`${errs}.push(${err})`)) 267 | ) 268 | gen.assign(N.vErrors, errs).assign(N.errors, _`${errs}.length`) 269 | } 270 | 271 | function matchKeywordError(err: Name, kwdErrs: Name): Code { 272 | return and( 273 | _`${err}.keyword !== ${keyword}`, 274 | _`!${err}.${used}`, 275 | _`${err}.instancePath === ${instancePath}`, 276 | _`${err}.keyword in ${kwdErrs}`, 277 | // TODO match the end of the string? 278 | _`${err}.schemaPath.indexOf(${it.errSchemaPath}) === 0`, 279 | _`/^\\/[^\\/]*$/.test(${err}.schemaPath.slice(${it.errSchemaPath.length}))` 280 | ) 281 | } 282 | 283 | function ifMatchesChildError( 284 | err: Name, 285 | childErrs: Name, 286 | thenBody: (child: Name) => void 287 | ): void { 288 | gen.if( 289 | and( 290 | _`${err}.keyword !== ${keyword}`, 291 | _`!${err}.${used}`, 292 | _`${err}.instancePath.indexOf(${instancePath}) === 0` 293 | ), 294 | () => { 295 | const childRegex = gen.scopeValue("pattern", { 296 | ref: /^\/([^/]*)(?:\/|$)/, 297 | code: _`new RegExp("^\\\/([^/]*)(?:\\\/|$)")`, 298 | }) 299 | const matches = gen.const( 300 | "emMatches", 301 | _`${childRegex}.exec(${err}.instancePath.slice(${instancePath}.length))` 302 | ) 303 | const child = gen.const( 304 | "emChild", 305 | _`${matches} && ${matches}[1].replace(/~1/g, "/").replace(/~0/g, "~")` 306 | ) 307 | gen.if(_`${child} !== undefined && ${child} in ${childErrs}`, () => thenBody(child)) 308 | } 309 | ) 310 | } 311 | 312 | function matchAnyError(err: Name): Code { 313 | return and( 314 | _`${err}.keyword !== ${keyword}`, 315 | _`!${err}.${used}`, 316 | or( 317 | _`${err}.instancePath === ${instancePath}`, 318 | and( 319 | _`${err}.instancePath.indexOf(${instancePath}) === 0`, 320 | _`${err}.instancePath[${instancePath}.length] === "/"` 321 | ) 322 | ), 323 | _`${err}.schemaPath.indexOf(${it.errSchemaPath}) === 0`, 324 | _`${err}.schemaPath[${it.errSchemaPath}.length] === "/"` 325 | ) 326 | } 327 | 328 | function getTemplatesCode(keys: Record, msgs: {[K in string]?: string}): Code { 329 | const templatesCode: [string, Code][] = [] 330 | for (const k in keys) { 331 | const msg = msgs[k] as string 332 | if (INTERPOLATION.test(msg)) templatesCode.push([k, templateFunc(msg)]) 333 | } 334 | return gen.object(...templatesCode) 335 | } 336 | 337 | function templateExpr(msg: string): Code { 338 | if (!INTERPOLATION.test(msg)) return stringify(msg) 339 | return new _Code( 340 | safeStringify(msg) 341 | .replace( 342 | INTERPOLATION_REPLACE, 343 | (_s, ptr) => `" + JSON.stringify(${getData(ptr, it)}) + "` 344 | ) 345 | .replace(EMPTY_STR, "") 346 | ) 347 | } 348 | 349 | function templateFunc(msg: string): Code { 350 | return _`function(){return ${templateExpr(msg)}}` 351 | } 352 | }, 353 | metaSchema: { 354 | anyOf: [ 355 | {type: "string"}, 356 | { 357 | type: "object", 358 | properties: { 359 | properties: {$ref: "#/$defs/stringMap"}, 360 | items: {$ref: "#/$defs/stringList"}, 361 | required: {$ref: "#/$defs/stringOrMap"}, 362 | dependencies: {$ref: "#/$defs/stringOrMap"}, 363 | }, 364 | additionalProperties: {type: "string"}, 365 | }, 366 | ], 367 | $defs: { 368 | stringMap: { 369 | type: "object", 370 | additionalProperties: {type: "string"}, 371 | }, 372 | stringOrMap: { 373 | anyOf: [{type: "string"}, {$ref: "#/$defs/stringMap"}], 374 | }, 375 | stringList: {type: "array", items: {type: "string"}}, 376 | }, 377 | }, 378 | } 379 | } 380 | 381 | const ajvErrors: Plugin = ( 382 | ajv: Ajv, 383 | options: ErrorMessageOptions = {} 384 | ): Ajv => { 385 | if (!ajv.opts.allErrors) throw new Error("ajv-errors: Ajv option allErrors must be true") 386 | if (ajv.opts.jsPropertySyntax) { 387 | throw new Error("ajv-errors: ajv option jsPropertySyntax is not supported") 388 | } 389 | return ajv.addKeyword(errorMessage(options)) 390 | } 391 | 392 | export default ajvErrors 393 | module.exports = ajvErrors 394 | module.exports.default = ajvErrors 395 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@ajv-validator/config", 3 | "include": ["src"], 4 | "compilerOptions": { 5 | "outDir": "dist" 6 | } 7 | } 8 | --------------------------------------------------------------------------------