feat: meta schema for custom keywords schemas, closes #230

master
Evgeny Poberezkin 2016-07-17 11:20:47 +01:00
parent 2bcb74fc8c
commit 1a6e4b576c
No known key found for this signature in database
GPG Key ID: 016D62451CED9D8E
6 changed files with 179 additions and 78 deletions

View File

@ -60,6 +60,8 @@ Compilation function will be called during schema compilation. It will be passed
In some cases it is the best approach to define keywords, but it has the performance cost of an extra function call during validation. If keyword logic can be expressed via some other JSON-schema then `macro` keyword definition is more efficient (see below). In some cases it is the best approach to define keywords, but it has the performance cost of an extra function call during validation. If keyword logic can be expressed via some other JSON-schema then `macro` keyword definition is more efficient (see below).
All custom keywords types can have an optional `metaSchema` property in their definitions. It is a schema against which the value of keyword will be validated during schema compilation.
Example. `range` and `exclusiveRange` keywords using compiled schema: Example. `range` and `exclusiveRange` keywords using compiled schema:
```javascript ```javascript
@ -70,7 +72,11 @@ ajv.addKeyword('range', { type: 'number', compile: function (sch, parentSchema)
return parentSchema.exclusiveRange === true return parentSchema.exclusiveRange === true
? function (data) { return data > min && data < max; } ? function (data) { return data > min && data < max; }
: function (data) { return data >= min && data <= max; } : function (data) { return data >= min && data <= max; }
}, errors: false }); }, errors: false, metaSchema: {
type: 'array',
items: [ { type: 'number' }, { type: 'number' } ],
additionalItems: false
} });
var schema = { "range": [2, 4], "exclusiveRange": true }; var schema = { "range": [2, 4], "exclusiveRange": true };
var validate = ajv.compile(schema); var validate = ajv.compile(schema);
@ -102,6 +108,10 @@ ajv.addKeyword('range', { type: 'number', macro: function (schema, parentSchema)
exclusiveMinimum: !!parentSchema.exclusiveRange, exclusiveMinimum: !!parentSchema.exclusiveRange,
exclusiveMaximum: !!parentSchema.exclusiveRange exclusiveMaximum: !!parentSchema.exclusiveRange
}; };
}, metaSchema: {
type: 'array',
items: [ { type: 'number' }, { type: 'number' } ],
additionalItems: false
} }); } });
``` ```
@ -149,7 +159,7 @@ Example `even` keyword:
ajv.addKeyword('even', { type: 'number', inline: function (it, keyword, schema) { ajv.addKeyword('even', { type: 'number', inline: function (it, keyword, schema) {
var op = schema ? '===' : '!=='; var op = schema ? '===' : '!==';
return 'data' + (it.dataLevel || '') + ' % 2 ' + op + ' 0'; return 'data' + (it.dataLevel || '') + ' % 2 ' + op + ' 0';
} }); }, metaSchema: { type: 'boolean' } });
var schema = { "even": true }; var schema = { "even": true };
@ -179,7 +189,12 @@ var valid{{=it.level}} = {{=$data}} {{=$gt}} {{=$min}} && {{=$data}} {{=$lt}} {{
ajv.addKeyword('range', { ajv.addKeyword('range', {
type: 'number', type: 'number',
inline: inlineRangeTemplate, inline: inlineRangeTemplate,
statements: true statements: true,
metaSchema: {
type: 'array',
items: [ { type: 'number' }, { type: 'number' } ],
additionalItems: false
}
}); });
``` ```

View File

@ -813,6 +813,8 @@ Keyword definition is an object with the following properties:
- _compile_: compiling function - _compile_: compiling function
- _macro_: macro function - _macro_: macro function
- _inline_: compiling function that returns code (as string) - _inline_: compiling function that returns code (as string)
- _schema_: an optional `false` value used with "validate" keyword to not pass schema
- _metaSchema_: an optional meta-schema for keyword schema
- _async_: an optional `true` value if the validation function is asynchronous (whether it is compiled or passed in _validate_ property); in this case it should return a promise that resolves with a value `true` or `false`. This option is ignored in case of "macro" and "inline" keywords. - _async_: an optional `true` value if the validation function is asynchronous (whether it is compiled or passed in _validate_ property); in this case it should return a promise that resolves with a value `true` or `false`. This option is ignored in case of "macro" and "inline" keywords.
- _errors_: an optional boolean indicating whether keyword returns errors. If this property is not set Ajv will determine if the errors were set in case of failed validation. - _errors_: an optional boolean indicating whether keyword returns errors. If this property is not set Ajv will determine if the errors were set in case of failed validation.

View File

@ -101,10 +101,12 @@ function Ajv(opts) {
/** /**
* Create validating function for passed schema. * Create validating function for passed schema.
* @param {Object} schema schema object * @param {Object} schema schema object
* @param {Boolean} _meta true if schema is a meta-schema. Used internally to compile meta schemas of custom keywords.
* @return {Function} validating function * @return {Function} validating function
*/ */
function compile(schema) { function compile(schema, _meta) {
var schemaObj = _addSchema(schema); var schemaObj = _addSchema(schema);
schemaObj.meta = _meta;
return schemaObj.validate || _compile(schemaObj); return schemaObj.validate || _compile(schemaObj);
} }
@ -153,10 +155,11 @@ function Ajv(opts) {
self._formats.uri = typeof currentUriFormat == 'function' self._formats.uri = typeof currentUriFormat == 'function'
? SCHEMA_URI_FORMAT_FUNC ? SCHEMA_URI_FORMAT_FUNC
: SCHEMA_URI_FORMAT; : SCHEMA_URI_FORMAT;
var valid = validate($schema, schema); var valid;
self._formats.uri = currentUriFormat; try { valid = validate($schema, schema); }
finally { self._formats.uri = currentUriFormat; }
if (!valid && throwOrLogError) { if (!valid && throwOrLogError) {
var message = 'schema is invalid:' + errorsText(); var message = 'schema is invalid: ' + errorsText();
if (self._opts.validateSchema == 'log') console.error(message); if (self._opts.validateSchema == 'log') console.error(message);
else throw new Error(message); else throw new Error(message);
} }

View File

@ -214,6 +214,16 @@ function compile(schema, root, localRefs, baseId) {
} }
function useCustomRule(rule, schema, parentSchema, it) { function useCustomRule(rule, schema, parentSchema, it) {
var validateSchema = rule.definition.validateSchema;
if (validateSchema && self._opts.validateSchema !== false) {
var valid = validateSchema(schema);
if (!valid) {
var message = 'keyword schema is invalid: ' + self.errorsText(validateSchema.errors);
if (self._opts.validateSchema == 'log') console.error(message);
else throw new Error(message);
}
}
var compile = rule.definition.compile var compile = rule.definition.compile
, inline = rule.definition.inline , inline = rule.definition.inline
, macro = rule.definition.macro; , macro = rule.definition.macro;

View File

@ -27,6 +27,9 @@ module.exports = function addKeyword(keyword, definition) {
if (dataType) checkDataType(dataType); if (dataType) checkDataType(dataType);
_addRule(keyword, dataType, definition); _addRule(keyword, dataType, definition);
} }
if (definition.metaSchema)
definition.validateSchema = self.compile(definition.metaSchema, true);
} }
this.RULES.keywords[keyword] = true; this.RULES.keywords[keyword] = true;

View File

@ -20,90 +20,151 @@ describe('Custom keywords', function () {
describe('custom rules', function() { describe('custom rules', function() {
it('should add and validate rule with "interpreted" keyword validation', function() { describe('rule with "interpreted" keyword validation', function() {
testEvenKeyword({ type: 'number', validate: validateEven }); it('should add and validate rule', function() {
testEvenKeyword({ type: 'number', validate: validateEven });
function validateEven(schema, data) { function validateEven(schema, data) {
if (typeof schema != 'boolean') throw new Error('The value of "even" keyword must be boolean'); if (typeof schema != 'boolean') throw new Error('The value of "even" keyword must be boolean');
return data % 2 ? !schema : schema; return data % 2 ? !schema : schema;
} }
}); });
it('should add and validate rule with "compiled" keyword validation', function() { it('should add, validate keyword schema and validate rule', function() {
testEvenKeyword({ type: 'number', compile: compileEven }); testEvenKeyword({
type: 'number',
validate: validateEven,
metaSchema: { "type": "boolean" }
});
function compileEven(schema) { shouldBeInvalidSchema({ "even": "not_boolean" });
if (typeof schema != 'boolean') throw new Error('The value of "even" keyword must be boolean');
return schema ? isEven : isOdd;
}
function isEven(data) { return data % 2 === 0; } function validateEven(schema, data) {
function isOdd(data) { return data % 2 !== 0; } return data % 2 ? !schema : schema;
}); }
});
it('should compile keyword validating function only once per schema', function () { it('should pass parent schema to "interpreted" keyword validation', function() {
testConstantKeyword({ compile: compileConstant }); testRangeKeyword({
}); type: 'number',
validate: validateRange
});
it('should allow multiple schemas for the same keyword', function () { function validateRange(schema, data, parentSchema) {
testMultipleConstantKeyword({ compile: compileConstant }); validateRangeSchema(schema, parentSchema);
});
it('should pass parent schema to "interpreted" keyword validation', function() { return parentSchema.exclusiveRange === true
testRangeKeyword({ type: 'number', validate: validateRange }); ? data > schema[0] && data < schema[1]
: data >= schema[0] && data <= schema[1];
}
});
function validateRange(schema, data, parentSchema) { it('should validate meta schema and pass parent schema to "interpreted" keyword validation', function() {
validateRangeSchema(schema, parentSchema); testRangeKeyword({
type: 'number',
return parentSchema.exclusiveRange === true validate: validateRange,
? data > schema[0] && data < schema[1] metaSchema: {
: data >= schema[0] && data <= schema[1]; "type": "array",
} "items": [ { "type": "number" }, { "type": "number" } ],
}); "additionalItems": false
it('should allow defining custom errors for "interpreted" keyword', function() {
testRangeKeyword({ type: 'number', validate: validateRange }, true);
function validateRange(schema, data, parentSchema) {
validateRangeSchema(schema, parentSchema);
var min = schema[0]
, max = schema[1]
, exclusive = parentSchema.exclusiveRange === true;
var minOk = exclusive ? data > min : data >= min;
var maxOk = exclusive ? data < max : data <= max;
var valid = minOk && maxOk;
if (!valid) {
var err = { keyword: 'range' };
validateRange.errors = [err];
var comparison, limit;
if (minOk) {
comparison = exclusive ? '<' : '<=';
limit = max;
} else {
comparison = exclusive ? '>' : '>=';
limit = min;
} }
err.message = 'should be ' + comparison + ' ' + limit; });
err.params = { shouldBeInvalidSchema({ range: [ "1", 2 ] });
comparison: comparison, shouldBeInvalidSchema({ range: {} });
limit: limit, shouldBeInvalidSchema({ range: [ 1, 2, 3 ] });
exclusive: exclusive
}; function validateRange(schema, data, parentSchema) {
return parentSchema.exclusiveRange === true
? data > schema[0] && data < schema[1]
: data >= schema[0] && data <= schema[1];
}
});
it('should allow defining custom errors for "interpreted" keyword', function() {
testRangeKeyword({ type: 'number', validate: validateRange }, true);
function validateRange(schema, data, parentSchema) {
validateRangeSchema(schema, parentSchema);
var min = schema[0]
, max = schema[1]
, exclusive = parentSchema.exclusiveRange === true;
var minOk = exclusive ? data > min : data >= min;
var maxOk = exclusive ? data < max : data <= max;
var valid = minOk && maxOk;
if (!valid) {
var err = { keyword: 'range' };
validateRange.errors = [err];
var comparison, limit;
if (minOk) {
comparison = exclusive ? '<' : '<=';
limit = max;
} else {
comparison = exclusive ? '>' : '>=';
limit = min;
}
err.message = 'should be ' + comparison + ' ' + limit;
err.params = {
comparison: comparison,
limit: limit,
exclusive: exclusive
};
}
return valid;
}
});
});
describe('rule with "compiled" keyword validation', function() {
it('should add and validate rule', function() {
testEvenKeyword({ type: 'number', compile: compileEven });
shouldBeInvalidSchema({ "even": "not_boolean" });
function compileEven(schema) {
if (typeof schema != 'boolean') throw new Error('The value of "even" keyword must be boolean');
return schema ? isEven : isOdd;
} }
return valid; function isEven(data) { return data % 2 === 0; }
} function isOdd(data) { return data % 2 !== 0; }
});
it('should add, validate keyword schema and validate rule', function() {
testEvenKeyword({
type: 'number',
compile: compileEven,
metaSchema: { "type": "boolean" }
});
shouldBeInvalidSchema({ "even": "not_boolean" });
function compileEven(schema) {
return schema ? isEven : isOdd;
}
function isEven(data) { return data % 2 === 0; }
function isOdd(data) { return data % 2 !== 0; }
});
it('should compile keyword validating function only once per schema', function () {
testConstantKeyword({ compile: compileConstant });
});
it('should allow multiple schemas for the same keyword', function () {
testMultipleConstantKeyword({ compile: compileConstant });
});
it('should pass parent schema to "compiled" keyword validation', function() {
testRangeKeyword({ type: 'number', compile: compileRange });
});
it('should allow multiple parent schemas for the same keyword', function () {
testMultipleRangeKeyword({ type: 'number', compile: compileRange });
});
}); });
it('should pass parent schema to "compiled" keyword validation', function() {
testRangeKeyword({ type: 'number', compile: compileRange });
});
it('should allow multiple parent schemas for the same keyword', function () {
testMultipleRangeKeyword({ type: 'number', compile: compileRange });
});
function compileConstant(schema) { function compileConstant(schema) {
return typeof schema == 'object' && schema !== null return typeof schema == 'object' && schema !== null
@ -599,6 +660,13 @@ describe('Custom keywords', function () {
validate.errors .should.have.length(numErrors || 1); validate.errors .should.have.length(numErrors || 1);
} }
function shouldBeInvalidSchema(schema) {
instances.forEach(function (ajv) {
should.throw(function() {
ajv.compile(schema);
});
});
}
describe('addKeyword method', function() { describe('addKeyword method', function() {
var TEST_TYPES = [ undefined, 'number', 'string', 'boolean', ['number', 'string']]; var TEST_TYPES = [ undefined, 'number', 'string', 'boolean', ['number', 'string']];