asynchronous custom keywords can define custom errors by returning the promise that rejects with Ajv.ValidationError, closes #118

master
Evgeny Poberezkin 2016-02-28 22:06:54 +00:00
parent 3aaeaf6ec0
commit 53a6c70138
7 changed files with 226 additions and 35 deletions

View File

@ -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)).

View File

@ -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.

View File

@ -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; }}

View File

@ -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": {

View File

@ -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]);

View File

@ -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);
}
});
}

View File

@ -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,