From 53a6c70138772ca7c198ca1650ee04cf5b48b163 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 28 Feb 2016 22:06:54 +0000 Subject: [PATCH] asynchronous custom keywords can define custom errors by returning the promise that rejects with Ajv.ValidationError, closes #118 --- CUSTOM.md | 16 +++++--- README.md | 4 +- lib/dot/custom.def | 79 ++++++++++++++++++++++++++++--------- spec/async/keyword.json | 48 ++++++++++++++++++++++ spec/async_schemas.spec.js | 36 ++++++++++++++++- spec/async_validate.spec.js | 67 ++++++++++++++++++++++++++++++- spec/custom.spec.js | 11 +++--- 7 files changed, 226 insertions(+), 35 deletions(-) diff --git a/CUSTOM.md b/CUSTOM.md index a1247d9d5..a3fba77e3 100644 --- a/CUSTOM.md +++ b/CUSTOM.md @@ -34,7 +34,7 @@ ajv.addKeyword('constant', { validate: function (schema, data) { return typeof schema == 'object && schema !== null' ? deepEqual(schema, data) : schema === data; -} }); +}, errors: false }); var schema = { "constant": 2 }; var validate = ajv.compile(schema); @@ -49,6 +49,10 @@ console.log(validate({foo: 'baz'})); // false `constant` keyword is already available in Ajv with option `v5: true`. +__Please note:__ If the keyword does not define custom errors (see [Reporting errors in custom keywords](#reporting-errors-in-custom-keywords)) pass `errors: false` in its definition; it will make generated code more efficient. + +To add asynchronous keyword pass `async: true` in its definition. + ### Define keyword with "compilation" function @@ -66,7 +70,7 @@ ajv.addKeyword('range', { type: 'number', compile: function (sch, parentSchema) return parentSchema.exclusiveRange === true ? function (data) { return data > min && data < max; } : function (data) { return data >= min && data <= max; } -} }); +}, errors: false }); var schema = { "range": [2, 4], "exclusiveRange": true }; var validate = ajv.compile(schema); @@ -76,6 +80,8 @@ console.log(validate(2)); // false console.log(validate(4)); // false ``` +See note on custom errors and asynchronous keywords in the previous section. + ### Define keyword with "macro" function @@ -310,9 +316,9 @@ Converts the JSON-Pointer fragment from URI to the property name. ## Reporting errors in custom keywords -All custom keywords but macro keywords can create custom error messages. +All custom keywords but macro keywords can optionally create custom error messages. -Validating and compiled keywords should define errors by assigning them to `.errors` property of the validation function. It should not be done for asynchronous keywords (see #118). +Synchronous validating and compiled keywords should define errors by assigning them to `.errors` property of the validation function. Asynchronous keywords can return promise that rejects with `new Ajv.ValidationError(errors)`, where `errors` is an array of custom validation errors (if you don't want to define custom errors in asynchronous keyword, its validation function can return the promise that resolves with `false`). Inline custom keyword should increase error counter `errors` and add error to `vErrors` array (it can be null). This can be done for both synchronous and asynchronous keywords. See [example range keyword](https://github.com/epoberezkin/ajv/blob/master/spec/custom_rules/range_with_errors.jst). @@ -331,7 +337,7 @@ ajv.addKeyword('range', { Each error object should at least have properties `keyword`, `message` and `params`, other properties will be added. -Inlined keywords can optionally define `dataPath` property in error objects, that will be added by ajv unless `errors` option of the keyword is `"full"`. +Inlined keywords can optionally define `dataPath` and `schemaPath` properties in error objects, that will be assigned by Ajv unless `errors` option of the keyword is `"full"`. If custom keyword doesn't create errors, the default error will be created in case the keyword fails validation (see [Validation errors](#validation-errors)). diff --git a/README.md b/README.md index b1f095f4f..5f43e9e98 100644 --- a/README.md +++ b/README.md @@ -314,13 +314,13 @@ __Please note__: [Option](#options) `missingRefs` should NOT be set to `"ignore" Example in node REPL: https://tonicdev.com/esp/ajv-asynchronous-validation -Starting from version 3.5.0 you can define custom formats and keywords that perform validation asyncronously by accessing database or some service. You should add `async: true` in the keyword or format defnition (see [addFormat](#api-addformat) and [addKeyword](#api-addkeyword)). +Starting from version 3.5.0 you can define custom formats and keywords that perform validation asyncronously by accessing database or some service. You should add `async: true` in the keyword or format defnition (see [addFormat](#api-addformat), [addKeyword](#api-addkeyword) and [Defining custom keywords](#defining-custom-keywords)). If your schema uses asynchronous formats/keywords or refers to some schema that contains them it should have `"$async": true` keyword so that Ajv can compile it correctly. If asynchronous format/keyword or reference to asynchronous schema is used in the schema without `$async` keyword Ajv will throw an exception during schema compilation. __Please note__: all asynchronous subschemas that are referenced from the current or other schemas should have `"$async": true` keyword as well, otherwise the schema compilation will fail. -Validation function for an asynchronous custom format/keyword should return a promise that resolves to `true` or `false`. Ajv compiles asynchronous schemas to either [generator function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*) (default) that can be optionally transpiled with [regenerator](https://github.com/facebook/regenerator) or to [es7 async function](http://tc39.github.io/ecmascript-asyncawait/) that can be transpiled with [nodent](https://github.com/MatAtBread/nodent) or with regenerator as well. You can also supply any other transpiler as a function. See [Options](#options). +Validation function for an asynchronous custom format/keyword should return a promise that resolves to `true` or `false` (or rejects with `new Ajv.ValidationError(errors)` if you want to return custom errors from the keyword function). Ajv compiles asynchronous schemas to either [generator function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*) (default) that can be optionally transpiled with [regenerator](https://github.com/facebook/regenerator) or to [es7 async function](http://tc39.github.io/ecmascript-asyncawait/) that can be transpiled with [nodent](https://github.com/MatAtBread/nodent) or with regenerator as well. You can also supply any other transpiler as a function. See [Options](#options). The compiled validation function has `async: true` property (if the schema is asynchronous), so you can differentiate these functions if you are using both syncronous and asynchronous schemas. diff --git a/lib/dot/custom.def b/lib/dot/custom.def index d2a623b39..71230b23f 100644 --- a/lib/dot/custom.def +++ b/lib/dot/custom.def @@ -18,8 +18,25 @@ {{? !($inline || $macro) }}{{=$ruleErrs}} = null;{{?}} var {{=$errs}} = errors; +var valid{{=$lvl}}; {{## def.callRuleValidate: + {{=$ruleValidate.code}}.call( + {{? it.opts.passContext }}this{{??}}self{{?}} + {{ var $validateArgs = $ruleValidate.validate.length; }} + {{? $rDef.compile || $rDef.schema === false }} + , {{=$data}} + {{??}} + , validate.schema{{=$schemaPath}} + , {{=$data}} + , validate.schema{{=it.schemaPath}} + {{?}} + , {{# def.dataPath }} + {{# def.passParentData }} + ) +#}} + +{{## def.ruleValidationResult: {{? $inline }} {{? $rDef.statements }} valid{{=$lvl}} @@ -29,19 +46,15 @@ var {{=$errs}} = errors; {{?? $macro }} valid{{=$it.level}} {{??}} - {{?$asyncKeyword}}{{=it.yieldAwait}} {{?}}{{=$ruleValidate.code}}.call( - {{? it.opts.passContext }}this{{??}}self{{?}} - {{ var $validateArgs = $ruleValidate.validate.length; }} - {{? $rDef.compile || $rDef.schema === false }} - , {{=$data}} + {{? $asyncKeyword }} + {{? $rDef.errors === false }} + ({{=it.yieldAwait}}{{= def_callRuleValidate }}) {{??}} - , validate.schema{{=$schemaPath}} - , {{=$data}} - , validate.schema{{=it.schemaPath}} + valid{{=$lvl}} {{?}} - , {{# def.dataPath }} - {{# def.passParentData }} - ) + {{??}} + {{= def_callRuleValidate }} + {{?}} {{?}} #}} @@ -51,6 +64,9 @@ var {{=$errs}} = errors; {{# _inline ? 'if (\{\{=$ruleErr\}\}.dataPath === undefined) {' : '' }} {{=$ruleErr}}.dataPath = (dataPath || '') + {{= it.errorPath }}; {{# _inline ? '}' : '' }} + {{# _inline ? 'if (\{\{=$ruleErr\}\}.schemaPath === undefined) {' : '' }} + {{=$ruleErr}}.schemaPath = "{{=$errSchemaPath}}"; + {{# _inline ? '}' : '' }} {{? it.opts.verbose }} {{=$ruleErr}}.schema = validate.schema{{=$schemaPath}}; {{=$ruleErr}}.data = {{=$data}}; @@ -70,9 +86,30 @@ var {{=$errs}} = errors; {{ var $code = it.validate($it).replace(/validate\.schema/g, $ruleValidate.code); }} {{# def.resetCompositeRule }} {{= $code }} +{{?? $rDef.compile || $rDef.validate }} + {{# def.beginDefOut}} + {{# def.callRuleValidate }} + {{# def.storeDefOut:def_callRuleValidate }} + + {{? $rDef.errors !== false }} + {{? $asyncKeyword }} + {{ $ruleErrs = 'customErrors' + $lvl; }} + var {{=$ruleErrs}} = null; + try { + valid{{=$lvl}} = {{=it.yieldAwait}}{{= def_callRuleValidate }}; + } catch (e) { + valid{{=$lvl}} = false; + if (e instanceof ValidationError) {{=$ruleErrs}} = e.errors; + else throw e; + } + {{??}} + {{=$ruleValidate.code}}.errors = null; + {{?}} + {{?}} {{?}} -if (!({{# def.callRuleValidate }})) { + +if (!{{# def.ruleValidationResult }}) { {{ $errorKeyword = $rule.keyword; }} {{# def.beginDefOut}} {{# def.error:'custom' }} @@ -97,14 +134,18 @@ if (!({{# def.callRuleValidate }})) { {{?? $macro}} {{# def.extraError:'custom' }} {{??}} - if (Array.isArray({{=$ruleErrs}})) { - if (vErrors === null) vErrors = {{=$ruleErrs}}; - else vErrors.concat({{=$ruleErrs}}); - errors = vErrors.length; - {{# def.extendErrors:false }} - } else { + {{? $rDef.errors === false}} {{= def_customError }} - } + {{??}} + if (Array.isArray({{=$ruleErrs}})) { + if (vErrors === null) vErrors = {{=$ruleErrs}}; + else vErrors.concat({{=$ruleErrs}}); + errors = vErrors.length; + {{# def.extendErrors:false }} + } else { + {{= def_customError }} + } + {{?}} {{?}} {{ $errorKeyword = undefined; }} diff --git a/spec/async/keyword.json b/spec/async/keyword.json index ec1dda2e7..d0ac3ec0f 100644 --- a/spec/async/keyword.json +++ b/spec/async/keyword.json @@ -47,6 +47,54 @@ } ] }, + { + "description": "async custom keywords (validated with errors)", + "schema": { + "$async": true, + "properties": { + "userId": { + "type": "integer", + "idExistsWithError": { "table": "users" } + }, + "postId": { + "type": "integer", + "idExistsWithError": { "table": "posts" } + }, + "categoryId": { + "description": "will throw if present, no such table", + "type": "integer", + "idExistsWithError": { "table": "categories" } + } + } + }, + "tests": [ + { + "description": "valid object", + "data": { "userId": 1, "postId": 21 }, + "valid": true + }, + { + "description": "another valid object", + "data": { "userId": 5, "postId": 25 }, + "valid": true + }, + { + "description": "invalid - no such post", + "data": { "userId": 5, "postId": 10 }, + "valid": false + }, + { + "description": "invalid - no such user", + "data": { "userId": 9, "postId": 25 }, + "valid": false + }, + { + "description": "should throw exception during validation - no such table", + "data": { "postId": 25, "categoryId": 1 }, + "error": "no such table" + } + ] + }, { "description": "async custom keywords (compiled)", "schema": { diff --git a/spec/async_schemas.spec.js b/spec/async_schemas.spec.js index ff540b96a..9ad7b62d3 100644 --- a/spec/async_schemas.spec.js +++ b/spec/async_schemas.spec.js @@ -3,11 +3,13 @@ var jsonSchemaTest = require('json-schema-test') , Promise = require('./promise') , getAjvInstances = require('./ajv_async_instances') - , assert = require('./chai').assert; + , assert = require('./chai').assert + , Ajv = require('./ajv'); var instances = getAjvInstances({ v5: true }); + instances.forEach(addAsyncFormatsAndKeywords); @@ -57,7 +59,15 @@ function addAsyncFormatsAndKeywords (ajv) { ajv.addKeyword('idExists', { async: true, type: 'number', - validate: checkIdExists + validate: checkIdExists, + errors: false + }); + + ajv.addKeyword('idExistsWithError', { + async: true, + type: 'number', + validate: checkIdExistsWithError, + errors: true }); ajv.addKeyword('idExistsCompiled', { @@ -88,6 +98,28 @@ function checkIdExists(schema, data) { } +function checkIdExistsWithError(schema, data) { + var table = schema.table; + switch (table) { + case 'users': return check(table, [1, 5, 8]); + case 'posts': return check(table, [21, 25, 28]); + default: throw new Error('no such table'); + } + + function check(table, IDs) { + if (IDs.indexOf(data) >= 0) { + return Promise.resolve(true); + } else { + var error = { + keyword: 'idExistsWithError', + message: 'id not found in table ' + table + }; + return Promise.reject(new Ajv.ValidationError([error])); + } + } +} + + function compileCheckIdExists(schema) { switch (schema.table) { case 'users': return compileCheck([1, 5, 8]); diff --git a/spec/async_validate.spec.js b/spec/async_validate.spec.js index b57db6761..d710e0044 100644 --- a/spec/async_validate.spec.js +++ b/spec/async_validate.spec.js @@ -94,7 +94,15 @@ describe('async schemas, formats and keywords', function() { ajv.addKeyword('idExists', { async: true, type: 'number', - validate: checkIdExists + validate: checkIdExists, + errors: false + }); + + ajv.addKeyword('idExistsWithError', { + async: true, + type: 'number', + validate: checkIdExistsWithError, + errors: true }); }); }); @@ -122,6 +130,34 @@ describe('async schemas, formats and keywords', function() { }); + it('should return custom error', function() { + return Promise.all(instances.map(function (ajv) { + var schema = { + $async: true, + type: 'object', + properties: { + userId: { + type: 'integer', + idExistsWithError: { table: 'users' } + }, + postId: { + type: 'integer', + idExistsWithError: { table: 'posts' } + } + } + }; + + var validate = ajv.compile(schema); + var _co = useCo(ajv); + + return Promise.all([ + shouldBeInvalid(_co(validate({ userId: 5, postId: 10 })), [ 'id not found in table posts' ]), + shouldBeInvalid(_co(validate({ userId: 9, postId: 25 })), [ 'id not found in table users' ]) + ]); + })); + }); + + function checkIdExists(schema, data) { switch (schema.table) { case 'users': return check([1, 5, 8]); @@ -133,6 +169,27 @@ describe('async schemas, formats and keywords', function() { return Promise.resolve(IDs.indexOf(data) >= 0); } } + + function checkIdExistsWithError(schema, data) { + var table = schema.table; + switch (table) { + case 'users': return check(table, [1, 5, 8]); + case 'posts': return check(table, [21, 25, 28]); + default: throw new Error('no such table'); + } + + function check(table, IDs) { + if (IDs.indexOf(data) >= 0) { + return Promise.resolve(true); + } else { + var error = { + keyword: 'idExistsWithError', + message: 'id not found in table ' + table + }; + return Promise.reject(new Ajv.ValidationError([error])); + } + } + } }); @@ -366,12 +423,18 @@ function shouldBeValid(p) { var SHOULD_BE_INVALID = 'test: should be invalid'; -function shouldBeInvalid(p) { +function shouldBeInvalid(p, expectedMessages) { return checkNotValid(p) .then(function (err) { err .should.be.instanceof(Ajv.ValidationError); err.errors .should.be.an('array'); err.validation .should.equal(true); + if (expectedMessages) { + var messages = err.errors.map(function (e) { + return e.message; + }); + messages .should.eql(expectedMessages); + } }); } diff --git a/spec/custom.spec.js b/spec/custom.spec.js index a31580449..f8a1d9aba 100644 --- a/spec/custom.spec.js +++ b/spec/custom.spec.js @@ -512,9 +512,9 @@ describe('Custom keywords', function () { shouldBeValid(validate, 'abc'); shouldBeInvalid(validate, 1.99, numErrors); - if (customErrors) shouldBeRangeError(validate.errors[0], '', '>=', 2); + if (customErrors) shouldBeRangeError(validate.errors[0], '', '#/range', '>=', 2); shouldBeInvalid(validate, 4.01, numErrors); - if (customErrors) shouldBeRangeError(validate.errors[0], '', '<=', 4); + if (customErrors) shouldBeRangeError(validate.errors[0], '', '#/range','<=', 4); var schema = { "properties": { @@ -531,9 +531,9 @@ describe('Custom keywords', function () { shouldBeValid(validate, { foo: 3.99 }); shouldBeInvalid(validate, { foo: 2 }, numErrors); - if (customErrors) shouldBeRangeError(validate.errors[0], '.foo', '>', 2, true); + if (customErrors) shouldBeRangeError(validate.errors[0], '.foo', '#/properties/foo/range', '>', 2, true); shouldBeInvalid(validate, { foo: 4 }, numErrors); - if (customErrors) shouldBeRangeError(validate.errors[0], '.foo', '<', 4, true); + if (customErrors) shouldBeRangeError(validate.errors[0], '.foo', '#/properties/foo/range', '<', 4, true); }); } @@ -562,12 +562,13 @@ describe('Custom keywords', function () { }); } - function shouldBeRangeError(error, dataPath, comparison, limit, exclusive) { + function shouldBeRangeError(error, dataPath, schemaPath, comparison, limit, exclusive) { delete error.schema; delete error.data; error .should.eql({ keyword: 'range', dataPath: dataPath, + schemaPath: schemaPath, message: 'should be ' + comparison + ' ' + limit, params: { comparison: comparison,