diff --git a/CUSTOM.md b/CUSTOM.md index e110ad9..66f7e10 100644 --- a/CUSTOM.md +++ b/CUSTOM.md @@ -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). +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: ```javascript @@ -70,7 +72,11 @@ 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 }); +}, errors: false, metaSchema: { + type: 'array', + items: [ { type: 'number' }, { type: 'number' } ], + additionalItems: false +} }); var schema = { "range": [2, 4], "exclusiveRange": true }; var validate = ajv.compile(schema); @@ -102,6 +108,10 @@ ajv.addKeyword('range', { type: 'number', macro: function (schema, parentSchema) exclusiveMinimum: !!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) { var op = schema ? '===' : '!=='; return 'data' + (it.dataLevel || '') + ' % 2 ' + op + ' 0'; -} }); +}, metaSchema: { type: 'boolean' } }); var schema = { "even": true }; @@ -179,7 +189,12 @@ var valid{{=it.level}} = {{=$data}} {{=$gt}} {{=$min}} && {{=$data}} {{=$lt}} {{ ajv.addKeyword('range', { type: 'number', inline: inlineRangeTemplate, - statements: true + statements: true, + metaSchema: { + type: 'array', + items: [ { type: 'number' }, { type: 'number' } ], + additionalItems: false + } }); ``` diff --git a/README.md b/README.md index a8e6767..818678c 100644 --- a/README.md +++ b/README.md @@ -813,6 +813,8 @@ Keyword definition is an object with the following properties: - _compile_: compiling function - _macro_: macro function - _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. - _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. diff --git a/lib/ajv.js b/lib/ajv.js index 3e45429..bf57da7 100644 --- a/lib/ajv.js +++ b/lib/ajv.js @@ -101,10 +101,12 @@ function Ajv(opts) { /** * Create validating function for passed schema. * @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 */ - function compile(schema) { + function compile(schema, _meta) { var schemaObj = _addSchema(schema); + schemaObj.meta = _meta; return schemaObj.validate || _compile(schemaObj); } @@ -153,10 +155,11 @@ function Ajv(opts) { self._formats.uri = typeof currentUriFormat == 'function' ? SCHEMA_URI_FORMAT_FUNC : SCHEMA_URI_FORMAT; - var valid = validate($schema, schema); - self._formats.uri = currentUriFormat; + var valid; + try { valid = validate($schema, schema); } + finally { self._formats.uri = currentUriFormat; } if (!valid && throwOrLogError) { - var message = 'schema is invalid:' + errorsText(); + var message = 'schema is invalid: ' + errorsText(); if (self._opts.validateSchema == 'log') console.error(message); else throw new Error(message); } diff --git a/lib/compile/index.js b/lib/compile/index.js index 0cf1248..217cf57 100644 --- a/lib/compile/index.js +++ b/lib/compile/index.js @@ -214,6 +214,16 @@ function compile(schema, root, localRefs, baseId) { } 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 , inline = rule.definition.inline , macro = rule.definition.macro; diff --git a/lib/keyword.js b/lib/keyword.js index c50147e..a40d5d3 100644 --- a/lib/keyword.js +++ b/lib/keyword.js @@ -27,6 +27,9 @@ module.exports = function addKeyword(keyword, definition) { if (dataType) checkDataType(dataType); _addRule(keyword, dataType, definition); } + + if (definition.metaSchema) + definition.validateSchema = self.compile(definition.metaSchema, true); } this.RULES.keywords[keyword] = true; diff --git a/spec/custom.spec.js b/spec/custom.spec.js index f8a1d9a..2c97daa 100644 --- a/spec/custom.spec.js +++ b/spec/custom.spec.js @@ -20,90 +20,151 @@ describe('Custom keywords', function () { describe('custom rules', function() { - it('should add and validate rule with "interpreted" keyword validation', function() { - testEvenKeyword({ type: 'number', validate: validateEven }); + describe('rule with "interpreted" keyword validation', function() { + it('should add and validate rule', function() { + testEvenKeyword({ type: 'number', validate: validateEven }); - function validateEven(schema, data) { - if (typeof schema != 'boolean') throw new Error('The value of "even" keyword must be boolean'); - return data % 2 ? !schema : schema; - } - }); + function validateEven(schema, data) { + if (typeof schema != 'boolean') throw new Error('The value of "even" keyword must be boolean'); + return data % 2 ? !schema : schema; + } + }); - it('should add and validate rule with "compiled" keyword validation', function() { - testEvenKeyword({ type: 'number', compile: compileEven }); + it('should add, validate keyword schema and validate rule', function() { + testEvenKeyword({ + type: 'number', + validate: validateEven, + metaSchema: { "type": "boolean" } + }); - function compileEven(schema) { - if (typeof schema != 'boolean') throw new Error('The value of "even" keyword must be boolean'); - return schema ? isEven : isOdd; - } + shouldBeInvalidSchema({ "even": "not_boolean" }); - function isEven(data) { return data % 2 === 0; } - function isOdd(data) { return data % 2 !== 0; } - }); + function validateEven(schema, data) { + return data % 2 ? !schema : schema; + } + }); - it('should compile keyword validating function only once per schema', function () { - testConstantKeyword({ compile: compileConstant }); - }); + it('should pass parent schema to "interpreted" keyword validation', function() { + testRangeKeyword({ + type: 'number', + validate: validateRange + }); - it('should allow multiple schemas for the same keyword', function () { - testMultipleConstantKeyword({ compile: compileConstant }); - }); + function validateRange(schema, data, parentSchema) { + validateRangeSchema(schema, parentSchema); - it('should pass parent schema to "interpreted" keyword validation', function() { - testRangeKeyword({ type: 'number', validate: validateRange }); + return parentSchema.exclusiveRange === true + ? data > schema[0] && data < schema[1] + : data >= schema[0] && data <= schema[1]; + } + }); - function validateRange(schema, data, parentSchema) { - validateRangeSchema(schema, 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; + it('should validate meta schema and pass parent schema to "interpreted" keyword validation', function() { + testRangeKeyword({ + type: 'number', + validate: validateRange, + metaSchema: { + "type": "array", + "items": [ { "type": "number" }, { "type": "number" } ], + "additionalItems": false } - err.message = 'should be ' + comparison + ' ' + limit; - err.params = { - comparison: comparison, - limit: limit, - exclusive: exclusive - }; + }); + shouldBeInvalidSchema({ range: [ "1", 2 ] }); + shouldBeInvalidSchema({ range: {} }); + shouldBeInvalidSchema({ range: [ 1, 2, 3 ] }); + + 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) { return typeof schema == 'object' && schema !== null @@ -599,6 +660,13 @@ describe('Custom keywords', function () { validate.errors .should.have.length(numErrors || 1); } + function shouldBeInvalidSchema(schema) { + instances.forEach(function (ajv) { + should.throw(function() { + ajv.compile(schema); + }); + }); + } describe('addKeyword method', function() { var TEST_TYPES = [ undefined, 'number', 'string', 'boolean', ['number', 'string']];