diff --git a/.travis.yml b/.travis.yml index 6ada95b..c69a671 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,5 +5,7 @@ before_script: node_js: - "0.10" - "0.12" + - "4" + - "5" after_script: - codeclimate-test-reporter < coverage/lcov.info diff --git a/README.md b/README.md index 438455d..66f70aa 100644 --- a/README.md +++ b/README.md @@ -4,23 +4,32 @@ Currently the fastest JSON Schema validator for node.js and browser. It uses [doT templates](https://github.com/olado/doT) to generate super-fast validating functions. -[![Build Status](https://travis-ci.org/epoberezkin/ajv.svg?branch=master)](https://travis-ci.org/epoberezkin/ajv) +[![Build Status](https://travis-ci.org/epoberezkin/ajv.svg)](https://travis-ci.org/epoberezkin/ajv) [![npm version](https://badge.fury.io/js/ajv.svg)](http://badge.fury.io/js/ajv) [![Code Climate](https://codeclimate.com/github/epoberezkin/ajv/badges/gpa.svg)](https://codeclimate.com/github/epoberezkin/ajv) [![Test Coverage](https://codeclimate.com/github/epoberezkin/ajv/badges/coverage.svg)](https://codeclimate.com/github/epoberezkin/ajv/coverage) -## JSON Schema standard +NB: [Upgrading to version 2.0.0](https://github.com/epoberezkin/ajv/releases/tag/2.0.0). -ajv implements full [JSON Schema draft 4](http://json-schema.org/) standard: -- all validation keywords (see [JSON-Schema validation keywords](https://github.com/epoberezkin/ajv/blob/master/KEYWORDS.md)) -- full support of remote refs (remote schemas have to be added with `addSchema` or compiled to be available) -- [asynchronous loading](#asynchronous-compilation) of referenced schemas during compilation. -- support of circular dependencies between schemas -- correct string lengths for strings with unicode pairs (can be turned off) -- formats defined by JSON Schema draft 4 standard and custom formats (can be turned off) -- BETA: [custom keywords](https://github.com/epoberezkin/ajv/tree/v2.0#defining-custom-keywords) supported starting from version [2.0.0](https://github.com/epoberezkin/ajv/tree/v2.0), `npm install ajv@^2.0.0-beta` to use it +## Features + +- ajv implements full [JSON Schema draft 4](http://json-schema.org/) standard: + - all validation keywords (see [JSON-Schema validation keywords](https://github.com/epoberezkin/ajv/blob/master/KEYWORDS.md)) + - full support of remote refs (remote schemas have to be added with `addSchema` or compiled to be available) + - support of circular dependencies between schemas + - correct string lengths for strings with unicode pairs (can be turned off) + - [formats](#formats) defined by JSON Schema draft 4 standard and custom formats (can be turned off) + - [validates schemas against meta-schema](#api-validateschema) +- supports [browsers](#using-in-browser) and nodejs 0.10-5.0 +- [asynchronous loading](#asynchronous-compilation) of referenced schemas during compilation +- "All errors" validation mode with [option allErrors](#options) +- [error messages with parameters](#validation-errors) describing error reasons to allow creating custom error messages +- i18n error messages support with [ajv-i18n](https://github.com/epoberezkin/ajv-i18n) package +- [filtering data](#filtering-data) from additional properties +- NEW: [custom keywords](#defining-custom-keywords) +- NEW: keywords `constant` and `contains` from [JSON-schema v5 proposals](https://github.com/json-schema/json-schema/wiki/v5-Proposals) with [option v5](#options) Currently ajv is the only validator that passes all the tests from [JSON Schema Test Suite](https://github.com/json-schema/JSON-Schema-Test-Suite) (according to [json-schema-benchmark](https://github.com/ebdrup/json-schema-benchmark), apart from the test that requires that `1.0` is not an integer that is impossible to satisfy in JavaScript). @@ -53,7 +62,7 @@ The fastest validation call: ``` var Ajv = require('ajv'); -var ajv = Ajv(); // options can be passed +var ajv = Ajv(); // options can be passed, e.g. {allErrors: true} var validate = ajv.compile(schema); var valid = validate(data); if (!valid) console.log(validate.errors); @@ -82,7 +91,7 @@ ajv compiles schemas to functions and caches them in all cases (using stringifie The best performance is achieved when using compiled functions returned by `compile` or `getSchema` methods (there is no additional function call). -__Please note__: every time validation function or `ajv.validate` are called `errors` property is overwritten. You need to copy `errors` array reference to another variable if you want to use it later (e.g., in the callback). +__Please note__: every time validation function or `ajv.validate` are called `errors` property is overwritten. You need to copy `errors` array reference to another variable if you want to use it later (e.g., in the callback). See [Validation errors](#validation-errors) ## Using in browser @@ -123,6 +132,202 @@ You can add additional formats and replace any of the formats above using [addFo You can find patterns used for format validation and the sources that were used in [formats.js](https://github.com/epoberezkin/ajv/blob/master/lib/compile/formats.js). +## Defining custom keywords + +Starting from version 2.0 (ajv@^2.0.0-beta) ajv supports custom keyword definitions. + +WARNING: The main drawback of extending JSON-schema standard with custom keywords is the loss of portability of your schemas - it may not be possible to support these custom keywords on some other platforms. Also your schemas may be more challenging to read for other people. If portability is important you may prefer using additional validation logic outside of JSON-schema rather than putting it inside your JSON-schema. + +The advantages of using custom keywords are: +- they allow you keeping a larger portion of your validation logic in the schema +- they make your schemas more expressive and less verbose +- they are fun to use + +You can define custom keywords with [addKeyword](#api-addkeyword) method. Keywords are defined on the `ajv` instance level - new instances will not have previously defined keywords. + +Ajv allows defining keywords with: +- validation function +- compilation function +- macro function +- inline compilation function that should return code (as string) that will be inlined in the currently compiled schema. + + +### Define keyword with validation function (NOT RECOMMENDED) + +Validation function will be called during data validation. It will be passed schema, data and parentSchema (if it has 3 arguments) at validation time and it should return validation result as boolean. It can return an array of validation errors via `.errors` property of itself (otherwise a standard error will be used). + +This way to define keywords is added as a way to quickly test your keyword and is not recommended because of worse performance than compiling schemas. + + +Example. draft5 `constant` keyword (that is equivalent to `enum` keyword with one item): + +``` +ajv.addKeyword('constant', { validate: function (schema, data) { + return typeof schema == 'object && schema !== null' + ? deepEqual(schema, data) + : schema === data; +} }); + +var schema = { "constant": 2 }; +var validate = ajv.compile(schema); +console.log(validate(2)); // true +console.log(validate(3)); // false + +var schema = { "constant": { "foo": "bar" } }; +var validate = ajv.compile(schema); +console.log(validate({foo: 'bar'})); // true +console.log(validate({foo: 'baz'})); // false +``` + + +### Define keyword with "compilation" function + +Compilation function will be called during schema compilation. It will be passed schema and parent schema and it should return a validation function. This validation function will be passed data during validation; it should return validation result as boolean and it can return an array of validation errors via `.errors` property of itself (otherwise a standard error will be used). + +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). + +Example. `range` and `exclusiveRange` keywords using compiled schema: + +``` +ajv.addKeyword('range', { type: 'number', compile: function (sch, parentSchema) { + var min = sch[0]; + var max = sch[1]; + + return parentSchema.exclusiveRange === true + ? function (data) { return data > min && data < max; } + : function (data) { return data >= min && data <= max; } +} }); + +var schema = { "range": [2, 4], "exclusiveRange": true }; +var validate = ajv.compile(schema); +console.log(validate(2.01)); // true +console.log(validate(3.99)); // true +console.log(validate(2)); // false +console.log(validate(4)); // false +``` + + +### Define keyword with "macro" function + +"Macro" function is called during schema compilation. It is passed schema and parent schema and it should return another schema that will be applied to the data in addition to the original schema (if schemas have different keys they are merged, otherwise `allOf` keyword is used). + +It is the most efficient approach (in cases when the keyword logic can be expressed with another JSON-schema) because it is usually easy to implement and there is no extra function call during validation. + + +`range` and `exclusiveRange` keywords from the previous example defined with macro: + +``` +ajv.addKeyword('range', { macro: function (schema, parentSchema) { + return { + minimum: schema[0], + maximum: schema[1], + exclusiveMinimum: !!parentSchema.exclusiveRange, + exclusiveMaximum: !!parentSchema.exclusiveRange + }; +} }); +``` + +Example draft5 `contains` keyword that requires that the array has at least one item matching schema (see https://github.com/json-schema/json-schema/wiki/contains-(v5-proposal)): + +``` +ajv.addKeyword('contains', { macro: function (schema) { + return { "not": { "items": { "not": schema } } }; +} }); + +var schema = { + "contains": { + "type": "number", + "minimum": 4, + "exclusiveMinimum": true + } +}; + +var validate = ajv.compile(schema); +console.log(validate([1,2,3])); // false +console.log(validate([2,3,4])); // false +console.log(validate([3,4,5])); // true, number 5 matches schema inside "contains" +``` + +See the example of defining recursive macro keyword `deepProperties` in the [test](https://github.com/epoberezkin/ajv/blob/master/spec/custom.spec.js#L151). + + +### Define keyword with "inline" compilation function + +Inline compilation function is called during schema compilation. It is passed three parameters: `it` (the current schema compilation context), `schema` and `parentSchema` and it should return the code (as a string) that will be inlined in the code of compiled schema. This code can be either an expression that evaluates to the validation result (boolean) or a set of statements that assign the validation result to a variable. + +While it can be more difficult to define keywords with "inline" functions, it can have the best performance. + +Example `even` keyword: + +``` +ajv.addKeyword('even', { type: 'number', inline: function (it, schema) { + var op = schema ? '===' : '!=='; + return 'data' + (it.dataLevel || '') + ' % 2 ' + op + ' 0'; +} }); + +var schema = { "even": true }; + +var validate = ajv.compile(schema); +console.log(validate(2)); // true +console.log(validate(3)); // false +``` + +`'data' + (it.dataLevel || '')` in the example above is the reference to the currently validated data. Also note that `schema` (keyword schema) is the same as `it.schema.even`, so schema is not strictly necessary here - it is passed for convenience. + + +Example `range` keyword defined using [doT template](https://github.com/olado/doT): + +``` +var doT = require('dot'); +var inlineRangeTemplate = doT.compile("\ +{{ \ + var $data = 'data' + (it.dataLevel || '') \ + , $min = it.schema.range[0] \ + , $max = it.schema.range[1] \ + , $gt = it.schema.exclusiveRange ? '>' : '>=' \ + , $lt = it.schema.exclusiveRange ? '<' : '<='; \ +}} \ +var valid{{=it.level}} = {{=$data}} {{=$gt}} {{=$min}} && {{=$data}} {{=$lt}} {{=$max}}; \ +"); + +ajv.addKeyword('range', { + type: 'number', + inline: inlineRangeTemplate, + statements: true +}); +``` + +`'valid' + it.level` in the example above is the expected name of the variable that should be set to the validation result. + +Property `statements` in the keyword definition should be set to `true` if the validation code sets the variable instead of evaluating to the validation result. + + +### Defining errors in custom keywords + +All custom keywords but macro keywords can create custom error messages. + +Validating and compiled keywords should define errors by assigning them to `.errors` property of validation function. + +Inline custom keyword should increase error counter `errors` and add error to `vErrors` array (it can be null). See [example range keyword](https://github.com/epoberezkin/ajv/blob/master/spec/custom_rules/range_with_errors.jst). + +When inline keyword performes validation Ajv checks whether it created errors by comparing errors count before and after validation. To skip this check add option `errors` to keyword definition: + +``` +ajv.addKeyword('range', { + type: 'number', + inline: inlineRangeTemplate, + statements: true, + errors: true // keyword should create custom errors when validation fails +}); +``` + +Each error object should have properties `keyword`, `message` and `params`, other properties will be added. + +Inlined keywords can optionally define `dataPath` property in error objects. + +If custom keyword doesn't create errors, the default error will be created in case the keyword fails validation (see [Validation errors](#validation-errors)). + + ## Asynchronous compilation Starting from version 1.3 ajv supports asynchronous compilation when remote references are loaded using supplied function. See `compileAsync` method and `loadSchema` option. @@ -154,6 +359,8 @@ With [option `removeAdditional`](#options) (added by [andyscott](https://github. This option modifies original object. +TODO: example + ## API @@ -220,7 +427,7 @@ Adds meta schema that can be used to validate other schemas. That function shoul There is no need to explicitly add draft 4 meta schema (http://json-schema.org/draft-04/schema and http://json-schema.org/schema) - it is added by default, unless option `meta` is set to `false`. You only need to use it if you have a changed meta-schema that you want to use to validate your schemas. See `validateSchema`. -##### .validateSchema(Object schema) -> Boolean +##### .validateSchema(Object schema) -> Boolean Validates schema. This method should be used to validate schemas rather than `validate` due to the inconsistency of `uri` format in JSON-Schema standart. @@ -256,6 +463,29 @@ Function should return validation result as `true` or `false`. Custom formats can be also added via `formats` option. +##### .addKeyword(String keyword, Object definition) + +Add custom validation keyword to ajv instance. + +Keyword should be a valid JavaScript identifier. + +Keyword should be different from all standard JSON schema keywords and different from previously defined keywords. There is no way to redefine keywords or remove keyword definition from the instance. + +Keyword definition is an object with the following properties: + +- _type_: optional string or array of strings with data type(s) that the keyword will apply to. If keyword is validating another type the validation function will not be called, so there is no need to check for data type inside validation function if `type` property is used. +- _validate_: validating function +- _compile_: compiling function +- _macro_: macro function +- _inline_: compiling function that returns code (as string) + +_validate_, _compile_, _macro_ and _inline_ are mutually exclusive, only one should be used at a time. + +With _macro_ function _type_ must not be specified, the types that the keyword will be applied for will be determined by the final schema. + +See [Defining custom keywords](#defining-custom-keywords) for more details. + + ##### .errorsText([Array<Object> errors [, Object options]]) -> String Returns the text with all errors in a String. @@ -284,9 +514,10 @@ Defaults: unicode: true, beautify: false, cache: new Cache, + errorDataPath: 'object', jsonPointers: false, - i18n: false, messages: true + v5: true } ``` @@ -305,9 +536,53 @@ Defaults: - _unicode_: calculate correct length of strings with unicode pairs (true by default). Pass `false` to use `.length` of strings that is faster, but gives "incorrect" lengths of strings with unicode pairs - each unicode pair is counted as two characters. - _beautify_: format the generated function with [js-beautify](https://github.com/beautify-web/js-beautify) (the validating function is generated without line-breaks). `npm install js-beautify` to use this option. `true` or js-beautify options can be passed. - _cache_: an optional instance of cache to store compiled schemas using stable-stringified schema as a key. For example, set-associative cache [sacjs](https://github.com/epoberezkin/sacjs) can be used. If not passed then a simple hash is used which is good enough for the common use case (a limited number of statically defined schemas). Cache should have methods `put(key, value)`, `get(key)` and `del(key)`. -- _jsonPointers_: Output `dataPath` using JSON Pointers instead of JS path notation. -- _i18n_: Support internationalization of error messages using [ajv-i18n](https://github.com/epoberezkin/ajv-i18n). See its repo for details. -- _messages_: Include human-readable messages in errors. `true` by default. `messages: false` can be added when internationalization (options `i18n`) is used. +- _errorDataPath_: set `dataPath` to point to 'object' (default) or to 'property' (default behavior in versions before 2.0) when validating keywords `required`, `additionalProperties` and `dependencies`. +- _jsonPointers_: set `dataPath` propery of errors using [JSON Pointers](https://tools.ietf.org/html/rfc6901) instead of JavaScript property access notation. +- _messages_: Include human-readable messages in errors. `true` by default. `messages: false` can be added when custom messages are used (e.g. with [ajv-i18n](https://github.com/epoberezkin/ajv-i18n)). +- _v5_: add keywords `constant` and `contains` from [JSON-schema v5 proposals](https://github.com/json-schema/json-schema/wiki/v5-Proposals) + + +## Validation errors + +In case of validation failure Ajv assigns the array of errors to `.errors` property of validation function (or to `.errors` property of ajv instance in case `validate` or `validateSchema` methods were called). + + +### Error objects + +Each error is an object with the following properties: + +- _keyword_: validation keyword. For user defined validation keywords it is set to `"custom"` (with the exception of macro keywords and unless keyword definition defines its own errors). +- _dataPath_: the path to the part of the data that was validated. By default `dataPath` uses JavaScript property access notation (e.g., `".prop[1].subProp"`). When the option `jsonPointers` is true (see [Options](#options)) `dataPath` will be set using JSON pointer standard (e.g., `"/prop/1/subProp"`). +- _params_: the object with the additional information about error that can be used to create custom error messages (e.g., using [ajv-i18n](https://github.com/epoberezkin/ajv-i18n) package). See below for parameters set by all keywords. +- _message_: the standard error message (can be excluded with option `messages` set to false). +- _schema_: the schema of the keyword (added with `verbose` option). +- _data_: the data validated by the keyword (added with `verbose` option). + + +### Error parameters + +Properties of `params` object in errors depend on the keyword that failed validation. + +- `maxItems`, `minItems`, `maxLength`, `minLength`, `maxProperties`, `minProperties` - property `limit` (number, the schema of the keyword). +- `additionalItems` - property `limit` (the maximum number of allowed items in case when `items` keyword is an array of schemas and `additionalItems` is false). +- `additionalProperties` - property `additionalProperty` (the property not used in `properties` and `patternProperties` keywords). +- `dependencies` - properties: + - `property` (dependent property), + - `missingProperty` (required missing dependency - only the first one is reported currently) + - `deps` (required dependencies, comma separated list as a string), + - `depsCount` (the number of required dependedncies). +- `format` - property `format` (the schema of the keyword). +- `maximum`, `minimum` - properties: + - `limit` (number, the schema of the keyword), + - `exclusive` (boolean, the schema of `exclusiveMaximum` or `exclusiveMinimum`), + - `comparison` (string, comparison operation to compare the data to the limit, with the data on the left and the limit on the right; can be "<", "<=", ">", ">=") +- `multipleOf` - property `multipleOf` (the schema of the keyword) +- `pattern` - property `pattern` (the schema of the keyword) +- `required` - property `missingProperty` (required property that is missing). +- `type` - property `type` (required type(s), a string, can be a comma-separated list) +- `uniqueItems` - properties `i` and `j` (indices of duplicate items). +- `$ref` - property `ref` with the referenced schema URI. +- custom keywords (in case keyword definition doesn't create errors) - property `keyword` (the keyword name). ## Command line interface diff --git a/lib/ajv.js b/lib/ajv.js index b1cef99..63f3c74 100644 --- a/lib/ajv.js +++ b/lib/ajv.js @@ -5,7 +5,9 @@ var compileSchema = require('./compile') , Cache = require('./cache') , SchemaObject = require('./compile/schema_obj') , stableStringify = require('json-stable-stringify') - , formats = require('./compile/formats'); + , formats = require('./compile/formats') + , rules = require('./compile/rules') + , v5 = require('./v5'); module.exports = Ajv; @@ -31,6 +33,7 @@ function Ajv(opts) { this._formats = formats(this.opts.format); this._cache = this.opts.cache || new Cache; this._loadingSchemas = {}; + this.RULES = rules(); // this is done on purpose, so that methods are bound to the instance // (without using bind) so that they can be used without the instance @@ -43,6 +46,7 @@ function Ajv(opts) { this.getSchema = getSchema; this.removeSchema = removeSchema; this.addFormat = addFormat; + this.addKeyword = addKeyword; this.errorsText = errorsText; this._compile = _compile; @@ -50,6 +54,11 @@ function Ajv(opts) { addInitialSchemas(); if (this.opts.formats) addInitialFormats(); + if (this.opts.errorDataPath == 'property') + this.opts._errorDataPathProperty = true; + + if (this.opts.v5) v5.enable(this); + /** * Validate data using schema @@ -196,10 +205,11 @@ function Ajv(opts) { /** * Validate schema - * @param {Object} schema schema to validate + * @param {Object} schema schema to validate + * @param {Boolean} throwOrLogError pass true to throw on error * @return {Boolean} */ - function validateSchema(schema) { + function validateSchema(schema, throwOrLogError) { var $schema = schema.$schema || META_SCHEMA_ID; var currentUriFormat = self._formats.uri; self._formats.uri = typeof currentUriFormat == 'function' @@ -207,6 +217,11 @@ function Ajv(opts) { : SCHEMA_URI_FORMAT; var valid = validate($schema, schema); self._formats.uri = currentUriFormat; + if (!valid && throwOrLogError) { + var message = 'schema is invalid:' + errorsText(); + if (self.opts.validateSchema == 'log') console.error(message); + else throw new Error(message); + } return valid; } @@ -265,13 +280,8 @@ function Ajv(opts) { var id = resolve.normalizeId(schema.id); if (id) checkUnique(id); - var ok = skipValidation || self.opts.validateSchema === false - || validateSchema(schema); - if (!ok) { - var message = 'schema is invalid:' + errorsText(); - if (self.opts.validateSchema == 'log') console.error(message); - else throw new Error(message); - } + if (self.opts.validateSchema !== false && !skipValidation) + validateSchema(schema, true); var localRefs = resolve.ids.call(self, schema); @@ -324,6 +334,12 @@ function Ajv(opts) { } + /** + * Convert array of error message objects to string + * @param {Array} errors optional array of validation errors, if not passed errors from the instance are used. + * @param {Object} opts optional options with properties `separator` and `dataVar`. + * @return {String} + */ function errorsText(errors, opts) { errors = errors || self.errors; if (!errors) return 'No errors'; @@ -338,12 +354,81 @@ function Ajv(opts) { } + /** + * Add custom format + * @param {String} name format name + * @param {String|RegExp|Function} format string is converted to RegExp; function should return boolean (true when valid) + */ function addFormat(name, format) { if (typeof format == 'string') format = new RegExp(format); self._formats[name] = format; } + /** + * Define custom keyword + * @param {String} keyword custom keyword, should be a valid identifier, should be different from all standard, custom and macro keywords. + * @param {Object} definition keyword definition object with properties `type` (type(s) which the keyword applies to), `validate` or `compile`. + */ + function addKeyword(keyword, definition) { + if (self.RULES.keywords[keyword]) + throw new Error('Keyword ' + keyword + ' is already defined'); + + if (definition.macro) { + if (definition.type) throw new Error('type cannot be defined for macro keywords'); + _addMacro(keyword, definition.macro); + } else { + var dataType = definition.type; + if (Array.isArray(dataType)) { + var i, len = dataType.length; + for (i=0; i 0) + schemaCopy = util.copy(schema); + + var success = true; + out: // try to merge schemas without merging keywords + for (i=0; i 2 }} + , validate.schema{{=it.schemaPath}} + {{?}} + {{?}} + ) + {{?}} +#}} + +{{## def.extendErrors:_inline: + for (var {{=$i}}={{=$errs}}; {{=$i}} 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; + } + }); + + 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 + ? isDeepEqual + : isStrictEqual; + + function isDeepEqual(data) { return equal(data, schema); } + function isStrictEqual(data) { return data === schema; } + } + + function compileRange(schema, parentSchema) { + validateRangeSchema(schema, parentSchema); + + var min = schema[0]; + var max = schema[1]; + + return parentSchema.exclusiveRange === true + ? function (data) { return data > min && data < max; } + : function (data) { return data >= min && data <= max; } + } + }); + + + describe('macro rules', function() { + it('should add and validate rule with "macro" keyword', function() { + testEvenKeyword({ macro: macroEven }); + }); + + it('should add and expand macro rule', function() { + testConstantKeyword({ macro: macroConstant }); + }); + + it('should allow multiple schemas for the same macro keyword', function () { + testMultipleConstantKeyword({ macro: macroConstant }); + }); + + it('should pass parent schema to "macro" keyword', function() { + testRangeKeyword({ macro: macroRange }); + }); + + it('should allow multiple parent schemas for the same macro keyword', function () { + testMultipleRangeKeyword({ macro: macroRange }); + }); + + it('should recursively expand macro keywords', function() { + instances.forEach(function (ajv) { + ajv.addKeyword('deepProperties', { macro: macroDeepProperties }); + ajv.addKeyword('range', { macro: macroRange }); + + var schema = { + "deepProperties": { + "a.b.c": { "type": "number", "range": [2,4] }, + "d.e.f.g": { "type": "string" } + } + }; + + /* This schema recursively expands to: + { + "allOf": [ + { + "properties": { + "a": { + "properties": { + "b": { + "properties": { + "c": { + "type": "number", + "minimum": 2, + "exclusiveMinimum": false, + "maximum": 4, + "exclusiveMaximum": false + } + } + } + } + } + } + }, + { + "properties": { + "d": { + "properties": { + "e": { + "properties": { + "f": { + "properties": { + "g": { + "type": "string" + } + } + } + } + } + } + } + } + } + ] + } + */ + + var validate = ajv.compile(schema); + + shouldBeValid(validate, { + a: {b: {c: 3}}, + d: {e: {f: {g: 'foo'}}} + }); + + shouldBeInvalid(validate, { + a: {b: {c: 5}}, // out of range + d: {e: {f: {g: 'foo'}}} + }); + + shouldBeInvalid(validate, { + a: {b: {c: 'bar'}}, // not number + d: {e: {f: {g: 'foo'}}} + }); + + shouldBeInvalid(validate, { + a: {b: {c: 3}}, + d: {e: {f: {g: 2}}} // not string + }); + + function macroDeepProperties(schema) { + if (typeof schema != 'object') + throw new Error('schema of deepProperty should be an object'); + + var expanded = []; + + for (var prop in schema) { + var path = prop.split('.'); + var properties = {}; + if (path.length == 1) { + properties[prop] = schema[prop]; + } else { + var deepProperties = {}; + deepProperties[path.slice(1).join('.')] = schema[prop]; + properties[path[0]] = { "deepProperties": deepProperties }; + } + expanded.push({ "properties": properties }); + } + + return expanded.length == 1 ? expanded[0] : { "allOf": expanded }; + } + }); + }); + + it('should correctly expand multiple macros on the same level', function() { + instances.forEach(function (ajv) { + ajv.addKeyword('range', { macro: macroRange }); + ajv.addKeyword('even', { macro: macroEven }); + + var schema = { + "range": [4,6], + "even": true + }; + + var validate = ajv.compile(schema); + var numErrors = ajv.opts.allErrors ? 2 : 1; + + shouldBeInvalid(validate, 2); + shouldBeInvalid(validate, 3, numErrors); + shouldBeValid(validate, 4); + shouldBeInvalid(validate, 5); + shouldBeValid(validate, 6); + shouldBeInvalid(validate, 7, numErrors); + shouldBeInvalid(validate, 8); + }); + }); + + it('should use "allOf" keyword if macro schemas cannot be merged', function() { + instances.forEach(function (ajv) { + ajv.addKeyword('range', { macro: macroRange }); + + var schema = { + "range": [1,4], + "minimum": 2.5 + }; + + var validate = ajv.compile(schema); + validate.schema.allOf .should.be.an('array'); + validate.schema.allOf .should.have.length(2); + + shouldBeValid(validate, 3); + shouldBeInvalid(validate, 2); + }); + }); + + it('should correctly expand macros in subschemas', function() { + instances.forEach(function (ajv) { + ajv.addKeyword('range', { macro: macroRange }); + + var schema = { + "allOf": [ + { "range": [4,8] }, + { "range": [2,6] } + ] + } + + var validate = ajv.compile(schema); + + shouldBeInvalid(validate, 2); + shouldBeInvalid(validate, 3); + shouldBeValid(validate, 4); + shouldBeValid(validate, 5); + shouldBeValid(validate, 6); + shouldBeInvalid(validate, 7); + shouldBeInvalid(validate, 8); + }); + }); + + it('should correctly expand macros in macro expansions', function() { + instances.forEach(function (ajv) { + ajv.addKeyword('range', { macro: macroRange }); + ajv.addKeyword('contains', { macro: macroContains }); + + var schema = { + "contains": { + "type": "number", + "range": [4,7], + "exclusiveRange": true + } + }; + + var validate = ajv.compile(schema); + + shouldBeInvalid(validate, [1,2,3]); + shouldBeInvalid(validate, [2,3,4]); + shouldBeValid(validate, [3,4,5]); // only 5 is in range + shouldBeValid(validate, [6,7,8]); // only 6 is in range + shouldBeInvalid(validate, [7,8,9]); + shouldBeInvalid(validate, [8,9,10]); + + function macroContains(schema) { + return { "not": { "items": { "not": schema } } }; + } + }); + }); + + it('should throw exception is macro expansion is an invalid schema', function() { + ajv.addKeyword('invalid', { macro: macroInvalid }); + var schema = { "invalid": true }; + + should.throw(function() { + var validate = ajv.compile(schema); + }); + + function macroInvalid(schema) { + return { "type": "invalid" }; + } + }); + + function macroEven(schema) { + if (schema === true) return { "multipleOf": 2 }; + if (schema === false) return { "not": { "multipleOf": 2 } }; + throw new Error('Schema for "even" keyword should be boolean'); + } + + function macroConstant(schema, parentSchema) { + return { "enum": [schema] }; + } + + function macroRange(schema, parentSchema) { + validateRangeSchema(schema, parentSchema); + var exclusive = !!parentSchema.exclusiveRange; + + return { + minimum: schema[0], + exclusiveMinimum: exclusive, + maximum: schema[1], + exclusiveMaximum: exclusive + }; + } + }); + + + describe('inline rules', function() { + it('should add and validate rule with "inline" code keyword', function() { + testEvenKeyword({ type: 'number', inline: inlineEven }); + }); + + it('should pass parent schema to "inline" keyword', function() { + testRangeKeyword({ type: 'number', inline: inlineRange, statements: true }); + }); + + it('should define "inline" keyword as template', function() { + var inlineRangeTemplate = customRules.range; + + testRangeKeyword({ + type: 'number', + inline: inlineRangeTemplate, + statements: true + }); + }); + + it('should allow defining optional errors', function() { + var inlineRangeTemplate = customRules.rangeWithErrors; + + testRangeKeyword({ + type: 'number', + inline: inlineRangeTemplate, + statements: true + }, true); + }); + + it('should allow defining required errors', function() { + var inlineRangeTemplate = customRules.rangeWithErrors; + + testRangeKeyword({ + type: 'number', + inline: inlineRangeTemplate, + statements: true, + errors: true + }, true); + }); + + + function inlineEven(it, schema) { + var op = schema ? '===' : '!=='; + return 'data' + (it.dataLevel || '') + ' % 2 ' + op + ' 0'; + } + + function inlineRange(it, schema, parentSchema) { + var min = schema[0] + , max = schema[1] + , data = 'data' + (it.dataLevel || '') + , gt = parentSchema.exclusiveRange ? ' > ' : ' >= ' + , lt = parentSchema.exclusiveRange ? ' < ' : ' <= '; + return 'var valid' + it.level + ' = ' + data + gt + min + ' && ' + data + lt + max + ';'; + } + }); + + + function testEvenKeyword(definition) { + instances.forEach(function (ajv) { + ajv.addKeyword('even', definition); + var schema = { "even": true }; + var validate = ajv.compile(schema); + + shouldBeValid(validate, 2); + shouldBeValid(validate, 'abc'); + shouldBeInvalid(validate, 2.5); + shouldBeInvalid(validate, 3); + }); + } + + function testConstantKeyword(definition) { + instances.forEach(function (ajv) { + ajv.addKeyword('constant', definition); + + var schema = { "constant": "abc" }; + var validate = ajv.compile(schema); + + shouldBeValid(validate, 'abc'); + shouldBeInvalid(validate, 2); + shouldBeInvalid(validate, {}); + }); + } + + function testMultipleConstantKeyword(definition) { + instances.forEach(function (ajv) { + ajv.addKeyword('constant', definition); + + var schema = { + "properties": { + "a": { "constant": 1 }, + "b": { "constant": 1 } + }, + "additionalProperties": { "constant": { "foo": "bar" } }, + "items": { "constant": { "foo": "bar" } } + }; + var validate = ajv.compile(schema); + + shouldBeValid(validate, {a:1, b:1}); + shouldBeInvalid(validate, {a:2, b:1}); + + shouldBeValid(validate, {a:1, c: {foo: 'bar'}}); + shouldBeInvalid(validate, {a:1, c: {foo: 'baz'}}); + + shouldBeValid(validate, [{foo: 'bar'}]); + shouldBeValid(validate, [{foo: 'bar'}, {foo: 'bar'}]); + + shouldBeInvalid(validate, [1]); + }); + } + + function testRangeKeyword(definition, customErrors) { + instances.forEach(function (ajv) { + ajv.addKeyword('range', definition); + + var schema = { "range": [2, 4] }; + var validate = ajv.compile(schema); + + shouldBeValid(validate, 2); + shouldBeValid(validate, 3); + shouldBeValid(validate, 4); + shouldBeValid(validate, 'abc'); + + shouldBeInvalid(validate, 1.99); + if (customErrors) shouldBeRangeError(validate.errors[0], '', '>=', 2); + shouldBeInvalid(validate, 4.01); + if (customErrors) shouldBeRangeError(validate.errors[0], '', '<=', 4); + + var schema = { + "properties": { + "foo": { + "range": [2, 4], + "exclusiveRange": true + } + } + }; + var validate = ajv.compile(schema); + + shouldBeValid(validate, { foo: 2.01 }); + shouldBeValid(validate, { foo: 3 }); + shouldBeValid(validate, { foo: 3.99 }); + + shouldBeInvalid(validate, { foo: 2 }); + if (customErrors) shouldBeRangeError(validate.errors[0], '.foo', '>', 2, true); + shouldBeInvalid(validate, { foo: 4 }); + if (customErrors) shouldBeRangeError(validate.errors[0], '.foo', '<', 4, true); + }); + } + + function testMultipleRangeKeyword(definition) { + instances.forEach(function (ajv) { + ajv.addKeyword('range', definition); + + var schema = { + "properties": { + "a": { "range": [2, 4], "exclusiveRange": true }, + "b": { "range": [2, 4], "exclusiveRange": false } + }, + "additionalProperties": { "range": [5, 7] }, + "items": { "range": [5, 7] } + }; + var validate = ajv.compile(schema); + + shouldBeValid(validate, {a:3.99, b:4}); + shouldBeInvalid(validate, {a:4, b:4}); + + shouldBeValid(validate, {a:2.01, c: 7}); + shouldBeInvalid(validate, {a:2.01, c: 7.01}); + + shouldBeValid(validate, [5, 6, 7]); + shouldBeInvalid(validate, [7.01]); + }); + } + + function shouldBeRangeError(error, dataPath, comparison, limit, exclusive) { + delete error.schema; + delete error.data; + error .should.eql({ + keyword: 'range', + dataPath: dataPath, + message: 'should be ' + comparison + ' ' + limit, + params: { + comparison: comparison, + limit: limit, + exclusive: !!exclusive + } + }); + } + + function validateRangeSchema(schema, parentSchema) { + var schemaValid = Array.isArray(schema) && schema.length == 2 + && typeof schema[0] == 'number' + && typeof schema[1] == 'number'; + if (!schemaValid) throw new Error('Invalid schema for range keyword, should be array of 2 numbers'); + + var exclusiveRangeSchemaValid = parentSchema.exclusiveRange === undefined + || typeof parentSchema.exclusiveRange == 'boolean'; + if (!exclusiveRangeSchemaValid) throw new Error('Invalid schema for exclusiveRange keyword, should be bolean'); + } + + function shouldBeValid(validate, data) { + validate(data) .should.equal(true); + should.not.exist(validate.errors); + } + + function shouldBeInvalid(validate, data, numErrors) { + validate(data) .should.equal(false); + validate.errors .should.have.length(numErrors || 1); + } + + + describe('addKeyword method', function() { + var TEST_TYPES = [ undefined, 'number', 'string', 'boolean', ['number', 'string']]; + + it('should throw if defined keyword is passed', function() { + testThrow(['minimum', 'maximum', 'multipleOf', 'minLength', 'maxLength']); + testThrowDuplicate('custom'); + + function testThrow(keywords) { + TEST_TYPES.forEach(function (dataType, index) { + should.throw(function(){ + addKeyword(keywords[index], dataType); + }); + }); + } + + function testThrowDuplicate(keywordPrefix) { + var index = 0; + TEST_TYPES.forEach(function (dataType1) { + TEST_TYPES.forEach(function (dataType2) { + var keyword = keywordPrefix + (index++); + addKeyword(keyword, dataType1); + should.throw(function() { + addKeyword(keyword, dataType2); + }); + }); + }); + } + }); + + it('should throw if unknown type is passed', function() { + should.throw(function() { + addKeyword('custom1', 'wrongtype'); + }); + + should.throw(function() { + addKeyword('custom2', ['number', 'wrongtype']); + }); + + should.throw(function() { + addKeyword('custom3', ['number', undefined]); + }); + }); + + it('should throw if type is passed to macro keyword', function() { + should.throw(function() { + ajv.addKeyword(keyword, { + type: 'number', + macro: function() {} + }); + }); + }); + + function addKeyword(keyword, dataType) { + ajv.addKeyword(keyword, { + type: dataType, + validate: function() {} + }); + } + }); +}); diff --git a/spec/custom_rules/index.js b/spec/custom_rules/index.js new file mode 100644 index 0000000..8f9c450 --- /dev/null +++ b/spec/custom_rules/index.js @@ -0,0 +1,9 @@ +'use strict'; + +var fs = require('fs') + , doT = require('dot'); + +module.exports = { + range: doT.compile(fs.readFileSync(__dirname + '/range.jst')), + rangeWithErrors: doT.compile(fs.readFileSync(__dirname + '/range_with_errors.jst')) +}; diff --git a/spec/custom_rules/range.jst b/spec/custom_rules/range.jst new file mode 100644 index 0000000..74f5686 --- /dev/null +++ b/spec/custom_rules/range.jst @@ -0,0 +1,8 @@ +{{ + var $data = 'data' + (it.dataLevel || '') + , $min = it.schema.range[0] + , $max = it.schema.range[1] + , $gt = it.schema.exclusiveRange ? '>' : '>=' + , $lt = it.schema.exclusiveRange ? '<' : '<='; +}} +var valid{{=it.level}} = {{=$data}} {{=$gt}} {{=$min}} && {{=$data}} {{=$lt}} {{=$max}}; diff --git a/spec/custom_rules/range_with_errors.jst b/spec/custom_rules/range_with_errors.jst new file mode 100644 index 0000000..a9bb7da --- /dev/null +++ b/spec/custom_rules/range_with_errors.jst @@ -0,0 +1,42 @@ +{{ + var $data = 'data' + (it.dataLevel || '') + , $min = it.schema.range[0] + , $max = it.schema.range[1] + , $exclusive = !!it.schema.exclusiveRange + , $gt = $exclusive ? '>' : '>=' + , $lt = $exclusive ? '<' : '<=' + , $lvl = it.level + , $err = 'err' + $lvl; +}} + +var minOk{{=$lvl}} = {{=$data}} {{=$gt}} {{=$min}}; +var valid{{=$lvl}} = minOk{{=$lvl}} && {{=$data}} {{=$lt}} {{=$max}}; + +if (!valid{{=$lvl}}) { + var {{=$err}}; + if (minOk{{=$lvl}}) { + {{=$err}} = { + keyword: 'range', + message: 'should be {{=$lt}} {{=$max}}', + params: { + comparison: '{{=$lt}}', + limit: {{=$max}}, + exclusive: {{=$exclusive}} + } + }; + } else { + {{=$err}} = { + keyword: 'range', + message: 'should be {{=$gt}} {{=$min}}', + params: { + comparison: '{{=$gt}}', + limit: {{=$min}}, + exclusive: {{=$exclusive}} + } + }; + } + + errors++; + if (vErrors) vErrors[vErrors.length] = {{=$err}}; + else vErrors = [{{=$err}}]; +} diff --git a/spec/errors.spec.js b/spec/errors.spec.js index 2060d2f..9b42ebf 100644 --- a/spec/errors.spec.js +++ b/spec/errors.spec.js @@ -9,11 +9,15 @@ describe('Validation errors', function () { var ajv, ajvJP, fullAjv; beforeEach(function() { - ajv = Ajv(); - ajvJP = Ajv({ jsonPointers: true }); - fullAjv = Ajv({ allErrors: true, jsonPointers: true }); + createInstances(); }); + function createInstances(errorDataPath) { + ajv = Ajv({ errorDataPath: errorDataPath }); + ajvJP = Ajv({ errorDataPath: errorDataPath, jsonPointers: true }); + fullAjv = Ajv({ errorDataPath: errorDataPath, allErrors: true, jsonPointers: true }); + } + it('error should include dataPath', function() { testSchema1({ properties: { @@ -22,7 +26,7 @@ describe('Validation errors', function () { }); }); - it('error should include dataPath in refs', function() { + it('"refs" error should include dataPath', function() { testSchema1({ definitions: { num: { type: 'number' } @@ -34,115 +38,289 @@ describe('Validation errors', function () { }); - it('errors for additionalProperties should include property in dataPath', function() { - var schema = { - properties: { - foo: {}, - bar: {} - }, - additionalProperties: false - }; + describe('"additionalProperties" errors', function() { + it('should include property in dataPath with option errorDataPath="property"', function() { + createInstances('property'); + testAdditional('property'); + }); - var data = { foo: 1, bar: 2 } - , invalidData = { foo: 1, bar: 2, baz: 3, quux: 4 }; + it('should NOT include property in dataPath WITHOUT option errorDataPath', function() { + testAdditional(); + }); - var validate = ajv.compile(schema); - shouldBeValid(validate, data); - shouldBeInvalid(validate, invalidData); - shouldBeError(validate.errors[0], 'additionalProperties', "['baz']"); + function testAdditional(errorDataPath) { + var schema = { + properties: { + foo: {}, + bar: {} + }, + additionalProperties: false + }; - var validateJP = ajvJP.compile(schema); - shouldBeValid(validateJP, data); - shouldBeInvalid(validateJP, invalidData); - shouldBeError(validateJP.errors[0], 'additionalProperties', "/baz"); + var data = { foo: 1, bar: 2 } + , invalidData = { foo: 1, bar: 2, baz: 3, quux: 4 }; - var fullValidate = fullAjv.compile(schema); - shouldBeValid(fullValidate, data); - shouldBeInvalid(fullValidate, invalidData, 2); - shouldBeError(fullValidate.errors[0], 'additionalProperties', '/baz'); - shouldBeError(fullValidate.errors[1], 'additionalProperties', '/quux'); + var path = pathFunc(errorDataPath); + var msg = msgFunc(errorDataPath); - fullValidate.errors - .filter(function(err) { return err.keyword == 'additionalProperties'; }) - .map(function(err) { return fullAjv.opts.jsonPointers ? err.dataPath.substr(1) : err.dataPath.slice(2,-2); }) - .forEach(function(p) { delete invalidData[p]; }); + var validate = ajv.compile(schema); + shouldBeValid(validate, data); + shouldBeInvalid(validate, invalidData); + shouldBeError(validate.errors[0], 'additionalProperties', path("['baz']"), undefined, { additionalProperty: 'baz' }); - invalidData .should.eql({ foo: 1, bar: 2 }); + var validateJP = ajvJP.compile(schema); + shouldBeValid(validateJP, data); + shouldBeInvalid(validateJP, invalidData); + shouldBeError(validateJP.errors[0], 'additionalProperties', path("/baz"), undefined, { additionalProperty: 'baz' }); + + var fullValidate = fullAjv.compile(schema); + shouldBeValid(fullValidate, data); + shouldBeInvalid(fullValidate, invalidData, 2); + shouldBeError(fullValidate.errors[0], 'additionalProperties', path('/baz'), undefined, { additionalProperty: 'baz' }); + shouldBeError(fullValidate.errors[1], 'additionalProperties', path('/quux'), undefined, { additionalProperty: 'quux' }); + + if (errorDataPath == 'property') { + fullValidate.errors + .filter(function(err) { return err.keyword == 'additionalProperties'; }) + .map(function(err) { return fullAjv.opts.jsonPointers ? err.dataPath.substr(1) : err.dataPath.slice(2,-2); }) + .forEach(function(p) { delete invalidData[p]; }); + + invalidData .should.eql({ foo: 1, bar: 2 }); + } + } }); - it('errors for required should include missing property in dataPath', function() { - var schema = { - required: ['foo', 'bar', 'baz'] - }; + describe('"required" errors', function() { + it('should include missing property in dataPath with option errorDataPath="property"', function() { + createInstances('property'); + testRequired('property'); + }); + + it('should NOT include missing property in dataPath WITHOUT option errorDataPath', function() { + testRequired(); + }); + + function testRequired(errorDataPath) { + var schema = { + required: ['foo', 'bar', 'baz'] + }; + + _testRequired(errorDataPath, schema, '.'); + } + + + it('large data/schemas with option errorDataPath="property"', function() { + createInstances('property'); + testRequiredLargeSchema('property'); + }); + + it('large data/schemas WITHOUT option errorDataPath', function() { + testRequiredLargeSchema(); + }); + + function testRequiredLargeSchema(errorDataPath) { + var schema = { required: [] } + , data = {} + , invalidData1 = {} + , invalidData2 = {}; + for (var i=0; i<100; i++) { + schema.required.push(''+i); // properties from '0' to '99' are required + data[i] = invalidData1[i] = invalidData2[i] = i; + } + + delete invalidData1[1]; // property '1' will be missing + delete invalidData2[2]; // properties '2' and '198' will be missing + delete invalidData2[98]; + + var path = pathFunc(errorDataPath); + var msg = msgFunc(errorDataPath); + + test(); + + var schema = { anyOf: [ schema ] }; + test(1); + + function test(extraErrors) { + extraErrors = extraErrors || 0; + var validate = ajv.compile(schema); + shouldBeValid(validate, data); + shouldBeInvalid(validate, invalidData1, 1 + extraErrors); + shouldBeError(validate.errors[0], 'required', path("['1']"), msg('1'), { missingProperty: '1' }); + shouldBeInvalid(validate, invalidData2, 1 + extraErrors); + shouldBeError(validate.errors[0], 'required', path("['2']"), msg('2'), { missingProperty: '2' }); + + var validateJP = ajvJP.compile(schema); + shouldBeValid(validateJP, data); + shouldBeInvalid(validateJP, invalidData1, 1 + extraErrors); + shouldBeError(validateJP.errors[0], 'required', path("/1"), msg('1'), { missingProperty: '1' }); + shouldBeInvalid(validateJP, invalidData2, 1 + extraErrors); + shouldBeError(validateJP.errors[0], 'required', path("/2"), msg('2'), { missingProperty: '2' }); + + var fullValidate = fullAjv.compile(schema); + shouldBeValid(fullValidate, data); + shouldBeInvalid(fullValidate, invalidData1, 1 + extraErrors); + shouldBeError(fullValidate.errors[0], 'required', path('/1'), msg('1'), { missingProperty: '1' }); + shouldBeInvalid(fullValidate, invalidData2, 2 + extraErrors); + shouldBeError(fullValidate.errors[0], 'required', path('/2'), msg('2'), { missingProperty: '2' }); + shouldBeError(fullValidate.errors[1], 'required', path('/98'), msg('98'), { missingProperty: '98' }); + } + } + + + it('with "properties" with option errorDataPath="property"', function() { + createInstances('property'); + testRequiredAndProperties('property'); + }); + + it('with "properties" WITHOUT option errorDataPath', function() { + testRequiredAndProperties(); + }); + + function testRequiredAndProperties(errorDataPath) { + var schema = { + properties: { + 'foo': { type: 'number' }, + 'bar': { type: 'number' }, + 'baz': { type: 'number' }, + }, + required: ['foo', 'bar', 'baz'] + }; + + _testRequired(errorDataPath, schema); + } + + + it('in "anyOf" with option errorDataPath="property"', function() { + createInstances('property'); + testRequiredInAnyOf('property'); + }); + + it('in "anyOf" WITHOUT option errorDataPath', function() { + testRequiredInAnyOf(); + }); + + function testRequiredInAnyOf(errorDataPath) { + var schema = { + anyOf: [ + { required: ['foo', 'bar', 'baz'] } + ] + }; + + _testRequired(errorDataPath, schema, '.', 1); + } + }); + + + describe('"dependencies" errors', function() { + it('should include missing property in dataPath with option errorDataPath="property"', function() { + createInstances('property'); + testDependencies('property'); + }); + + it('should NOT include missing property in dataPath WITHOUT option errorDataPath', function() { + testDependencies(); + }); + + function testDependencies(errorDataPath) { + var schema = { + dependencies: { + a: ['foo', 'bar', 'baz'] + } + }; + + var data = { a: 0, foo: 1, bar: 2, baz: 3 } + , invalidData1 = { a: 0, foo: 1, baz: 3 } + , invalidData2 = { a: 0, bar: 2 }; + + var path = pathFunc(errorDataPath); + var msg = 'should have properties foo, bar, baz when property a is present'; + + var validate = ajv.compile(schema); + shouldBeValid(validate, data); + shouldBeInvalid(validate, invalidData1); + shouldBeError(validate.errors[0], 'dependencies', path('.bar'), msg, params('.bar')); + shouldBeInvalid(validate, invalidData2); + shouldBeError(validate.errors[0], 'dependencies', path('.foo'), msg, params('.foo')); + + var validateJP = ajvJP.compile(schema); + shouldBeValid(validateJP, data); + shouldBeInvalid(validateJP, invalidData1); + shouldBeError(validateJP.errors[0], 'dependencies', path('/bar'), msg, params('bar')); + shouldBeInvalid(validateJP, invalidData2); + shouldBeError(validateJP.errors[0], 'dependencies', path('/foo'), msg, params('foo')); + + var fullValidate = fullAjv.compile(schema); + shouldBeValid(fullValidate, data); + shouldBeInvalid(fullValidate, invalidData1); + shouldBeError(fullValidate.errors[0], 'dependencies', path('/bar'), msg, params('bar')); + shouldBeInvalid(fullValidate, invalidData2/*, 2*/); + shouldBeError(fullValidate.errors[0], 'dependencies', path('/foo'), msg, params('foo')); + // shouldBeError(fullValidate.errors[1], 'dependencies', path('/baz'), msg, params('baz')); + + function params(missing) { + var p = { + property: 'a', + deps: 'foo, bar, baz', + depsCount: 3 + }; + p.missingProperty = missing; + return p; + } + } + }); + + + function _testRequired(errorDataPath, schema, prefix, extraErrors) { + prefix = prefix || ''; + extraErrors = extraErrors || 0; var data = { foo: 1, bar: 2, baz: 3 } , invalidData1 = { foo: 1, baz: 3 } , invalidData2 = { bar: 2 }; - var validate = ajv.compile(schema); - shouldBeValid(validate, data); - shouldBeInvalid(validate, invalidData1); - shouldBeError(validate.errors[0], 'required', '.bar', 'is a required property'); - shouldBeInvalid(validate, invalidData2); - shouldBeError(validate.errors[0], 'required', '.foo', 'is a required property'); - - var validateJP = ajvJP.compile(schema); - shouldBeValid(validateJP, data); - shouldBeInvalid(validateJP, invalidData1); - shouldBeError(validateJP.errors[0], 'required', '/bar', 'is a required property'); - shouldBeInvalid(validateJP, invalidData2); - shouldBeError(validateJP.errors[0], 'required', '/foo', 'is a required property'); - - var fullValidate = fullAjv.compile(schema); - shouldBeValid(fullValidate, data); - shouldBeInvalid(fullValidate, invalidData1); - shouldBeError(fullValidate.errors[0], 'required', '/bar', 'is a required property'); - shouldBeInvalid(fullValidate, invalidData2, 2); - shouldBeError(fullValidate.errors[0], 'required', '/foo', 'is a required property'); - shouldBeError(fullValidate.errors[1], 'required', '/baz', 'is a required property'); - }); - - - it('required validation and errors for large data/schemas', function() { - var schema = { required: [] } - , data = {} - , invalidData1 = {} - , invalidData2 = {}; - for (var i=0; i<100; i++) { - schema.required.push(''+i); // properties from '0' to '99' are required - data[i] = invalidData1[i] = invalidData2[i] = i; - } - - delete invalidData1[1]; // property '1' will be missing - delete invalidData2[2]; // properties '2' and '198' will be missing - delete invalidData2[98]; + var path = pathFunc(errorDataPath); + var msg = msgFunc(errorDataPath); var validate = ajv.compile(schema); shouldBeValid(validate, data); - shouldBeInvalid(validate, invalidData1); - shouldBeError(validate.errors[0], 'required', "['1']", "is a required property"); - shouldBeInvalid(validate, invalidData2); - shouldBeError(validate.errors[0], 'required', "['2']", "is a required property"); + shouldBeInvalid(validate, invalidData1, 1 + extraErrors); + shouldBeError(validate.errors[0], 'required', path('.bar'), msg(prefix + 'bar'), { missingProperty: prefix + 'bar' }); + shouldBeInvalid(validate, invalidData2, 1 + extraErrors); + shouldBeError(validate.errors[0], 'required', path('.foo'), msg(prefix + 'foo'), { missingProperty: prefix + 'foo' }); var validateJP = ajvJP.compile(schema); shouldBeValid(validateJP, data); - shouldBeInvalid(validateJP, invalidData1); - shouldBeError(validateJP.errors[0], 'required', "/1", "is a required property"); - shouldBeInvalid(validateJP, invalidData2); - shouldBeError(validateJP.errors[0], 'required', "/2", "is a required property"); + shouldBeInvalid(validateJP, invalidData1, 1 + extraErrors); + shouldBeError(validateJP.errors[0], 'required', path('/bar'), msg('bar'), { missingProperty: 'bar' }); + shouldBeInvalid(validateJP, invalidData2, 1 + extraErrors); + shouldBeError(validateJP.errors[0], 'required', path('/foo'), msg('foo'), { missingProperty: 'foo' }); var fullValidate = fullAjv.compile(schema); shouldBeValid(fullValidate, data); - shouldBeInvalid(fullValidate, invalidData1); - shouldBeError(fullValidate.errors[0], 'required', '/1', "is a required property"); - shouldBeInvalid(fullValidate, invalidData2, 2); - shouldBeError(fullValidate.errors[0], 'required', '/2', "is a required property"); - shouldBeError(fullValidate.errors[1], 'required', '/98', "is a required property"); - }); + shouldBeInvalid(fullValidate, invalidData1, 1 + extraErrors); + shouldBeError(fullValidate.errors[0], 'required', path('/bar'), msg('bar'), { missingProperty: 'bar' }); + shouldBeInvalid(fullValidate, invalidData2, 2 + extraErrors); + shouldBeError(fullValidate.errors[0], 'required', path('/foo'), msg('foo'), { missingProperty: 'foo' }); + shouldBeError(fullValidate.errors[1], 'required', path('/baz'), msg('baz'), { missingProperty: 'baz' }); + } + + function pathFunc(errorDataPath) { + return function (dataPath) { + return errorDataPath == 'property' ? dataPath : ''; + }; + } + + function msgFunc(errorDataPath) { + return function (prop) { + return errorDataPath == 'property' + ? 'is a required property' + : 'should have required property \'' + prop + '\''; + }; + } - it('errors for items should include item index without quotes in dataPath (#48)', function() { + it('"items" errors should include item index without quotes in dataPath (#48)', function() { var schema1 = { id: 'schema1', type: 'array', @@ -223,11 +401,11 @@ describe('Validation errors', function () { } - function shouldBeError(error, keyword, dataPath, message) { + function shouldBeError(error, keyword, dataPath, message, params) { error.keyword .should.equal(keyword); error.dataPath .should.equal(dataPath); error.message .should.be.a('string'); - if (message !== undefined) - error.message .should.equal(message); + if (message !== undefined) error.message .should.equal(message); + if (params !== undefined) error.params .should.eql(params); } }); diff --git a/spec/json-schema.spec.js b/spec/json-schema.spec.js index 640f432..57eb4f1 100644 --- a/spec/json-schema.spec.js +++ b/spec/json-schema.spec.js @@ -4,7 +4,6 @@ var jsonSchemaTest = require('json-schema-test') , getAjvInstances = require('./ajv_instances'); var isBrowser = typeof window == 'object'; -var Ajv = require(isBrowser ? 'ajv' : '../lib/ajv'); var fullTest = isBrowser || !process.env.AJV_FAST_TEST; var instances = getAjvInstances(fullTest ? { @@ -14,7 +13,6 @@ var instances = getAjvInstances(fullTest ? { format: 'full', inlineRefs: false, jsonPointers: true, - i18n: true } : { allErrors: true }); var remoteRefs = { diff --git a/spec/options.spec.js b/spec/options.spec.js index 1588a23..95dfd2b 100644 --- a/spec/options.spec.js +++ b/spec/options.spec.js @@ -193,9 +193,9 @@ describe('Ajv Options', function () { it('should not throw and fail validation with missingRef == "fail" if the ref is used', function() { testMissingRefsFail(Ajv({ missingRefs: 'fail' })); - testMissingRefsFail(Ajv({ missingRefs: 'fail', verbose: true, i18n: true })); + testMissingRefsFail(Ajv({ missingRefs: 'fail', verbose: true })); testMissingRefsFail(Ajv({ missingRefs: 'fail', allErrors: true })); - testMissingRefsFail(Ajv({ missingRefs: 'fail', allErrors: true, verbose: true, i18n: true })); + testMissingRefsFail(Ajv({ missingRefs: 'fail', allErrors: true, verbose: true })); function testMissingRefsFail(ajv) { var validate = ajv.compile({ @@ -249,4 +249,29 @@ describe('Ajv Options', function () { } }); }); + + + describe('v5', function() { + it('should define keywords "constant" and "contains"', function() { + testV5(Ajv({ v5: true })); + testV5(Ajv({ v5: true, allErrors: true })); + + function testV5(ajv) { + var validate = ajv.compile({ constant: 2 }); + validate(2) .should.equal(true); + validate(5) .should.equal(false); + validate('a') .should.equal(false); + + var validate = ajv.compile({ contains: { minimum: 5 }}); + validate([1,2,3,4]) .should.equal(false); + validate([3,4,5]) .should.equal(true); + validate([3,4,6]) .should.equal(true); + + var validate = ajv.compile({ contains: { constant: 5 }}); + validate([1,2,3,4]) .should.equal(false); + validate([3,4,6]) .should.equal(false); + validate([3,4,5]) .should.equal(true); + } + }); + }); }); diff --git a/spec/resolve.spec.js b/spec/resolve.spec.js index c6fb1dd..6247dfb 100644 --- a/spec/resolve.spec.js +++ b/spec/resolve.spec.js @@ -39,8 +39,7 @@ describe('resolve', function () { instances = getAjvInstances({ allErrors: true, verbose: true, - inlineRefs: false, - i18n: true + inlineRefs: false }); });