├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src └── index.js └── test ├── .eslintrc └── specs └── index.spec.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yml] 12 | indent_style = space 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'standard', 3 | rules: { 4 | 'space-before-function-paren': ['error', { 5 | anonymous: 'ignore', 6 | named: 'ignore', 7 | asyncArrow: 'ignore' 8 | }] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .nyc_output 3 | coverage 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | after_success: npm run coverage 3 | node_js: 4 | - '8' 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Martin Hansen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # json-schema-compare [![Build Status](https://travis-ci.org/mokkabonna/json-schema-compare.svg?branch=master)](https://travis-ci.org/mokkabonna/json-schema-compare) [![Coverage Status](https://coveralls.io/repos/github/mokkabonna/json-schema-compare/badge.svg?branch=master)](https://coveralls.io/github/mokkabonna/json-schema-compare?branch=master) 2 | 3 | 4 | > Compare json schemas correctly 5 | 6 | ```bash 7 | npm install json-schema-compare --save 8 | ``` 9 | 10 | ```js 11 | var compare = require('json-schema-compare') 12 | 13 | var isEqual = compare({ 14 | title: 'title 1', 15 | type: ['object'], 16 | uniqueItems: false, 17 | dependencies: { 18 | name: ['age', 'lastName'] 19 | }, 20 | required: ['name', 'age', 'name'] 21 | }, { 22 | title: 'title 2', 23 | type: 'object', 24 | required: ['age', 'name'], 25 | dependencies: { 26 | name: ['lastName', 'age'] 27 | }, 28 | properties: { 29 | name: { 30 | minLength: 0 31 | } 32 | } 33 | }, { 34 | ignore: ['title'] 35 | }) 36 | 37 | console.log(isEqual) // => true 38 | ``` 39 | 40 | Compare json schemas correctly. 41 | 42 | - Ignores sort for arrays where sort does not matter, like required, enum, type, anyOf, oneOf, anyOf, dependencies (if array) 43 | - Compares correctly type when array or string 44 | - Ignores duplicate values before comparing 45 | - For schemas and sub schemas `undefined`, `true` and `{}` are equal 46 | - For minLength, minItems and minProperties `undefined` and `0` are equal 47 | - For uniqueItems, `undefined` and `false` are equal 48 | 49 | 50 | ## Options 51 | 52 | **ignore** array - default: `[]` 53 | 54 | Ignores certain keywords, useful to exclude meta keywords like title, description etc or custom keywords. If all you want to know if they are the same in terms of validation keywords. 55 | 56 | 57 | ## Contributing 58 | 59 | Create tests for new functionality and follow the eslint rules. 60 | 61 | ## License 62 | 63 | MIT © [Martin Hansen](http://martinhansen.com) 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "json-schema-compare", 3 | "version": "0.2.2", 4 | "description": "Compare json schemas smarter.", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "eslint": "eslint src test", 8 | "test": "npm run eslint && nyc --reporter=html --reporter=text mocha test/specs", 9 | "develop": "mocha test/specs --recursive --watch", 10 | "coverage": "nyc report --reporter=text-lcov | coveralls" 11 | }, 12 | "directories": { 13 | "lib": "src", 14 | "test": "test" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/mokkabonna/json-schema-compare.git" 19 | }, 20 | "keywords": [ 21 | "json", 22 | "schema", 23 | "jsonschema", 24 | "json-schema", 25 | "comparison" 26 | ], 27 | "author": "Martin Hansen", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/mokkabonna/json-schema-compare/issues" 31 | }, 32 | "homepage": "https://github.com/mokkabonna/json-schema-compare#readme", 33 | "devDependencies": { 34 | "chai": "^4.1.2", 35 | "coveralls": "^3.0.0", 36 | "eslint": "^6.8.0", 37 | "eslint-config": "^0.3.0", 38 | "eslint-config-standard": "^10.2.1", 39 | "eslint-plugin-import": "^2.7.0", 40 | "eslint-plugin-node": "^5.2.0", 41 | "eslint-plugin-promise": "^3.5.0", 42 | "eslint-plugin-standard": "^3.0.1", 43 | "json-schema-ref-parser": "^3.3.1", 44 | "json-stringify-safe": "^5.0.1", 45 | "mocha": "^7.1.0", 46 | "nyc": "^15.0.0", 47 | "sinon": "^4.0.1" 48 | }, 49 | "dependencies": { 50 | "lodash": "^4.17.15" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | var isEqual = require('lodash/isEqual') 2 | var sortBy = require('lodash/sortBy') 3 | var uniq = require('lodash/uniq') 4 | var uniqWith = require('lodash/uniqWith') 5 | var defaults = require('lodash/defaults') 6 | var intersectionWith = require('lodash/intersectionWith') 7 | var isPlainObject = require('lodash/isPlainObject') 8 | var isBoolean = require('lodash/isBoolean') 9 | 10 | var normalizeArray = val => Array.isArray(val) 11 | ? val : [val] 12 | var undef = val => val === undefined 13 | var keys = obj => isPlainObject(obj) || Array.isArray(obj) ? Object.keys(obj) : [] 14 | var has = (obj, key) => obj.hasOwnProperty(key) 15 | var stringArray = arr => sortBy(uniq(arr)) 16 | var undefEmpty = val => undef(val) || (Array.isArray(val) && val.length === 0) 17 | var keyValEqual = (a, b, key, compare) => b && has(b, key) && a && has(a, key) && compare(a[key], b[key]) 18 | var undefAndZero = (a, b) => (undef(a) && b === 0) || (undef(b) && a === 0) || isEqual(a, b) 19 | var falseUndefined = (a, b) => (undef(a) && b === false) || (undef(b) && a === false) || isEqual(a, b) 20 | var emptySchema = schema => undef(schema) || isEqual(schema, {}) || schema === true 21 | var emptyObjUndef = schema => undef(schema) || isEqual(schema, {}) 22 | var isSchema = val => undef(val) || isPlainObject(val) || val === true || val === false 23 | 24 | function undefArrayEqual(a, b) { 25 | if (undefEmpty(a) && undefEmpty(b)) { 26 | return true 27 | } else { 28 | return isEqual(stringArray(a), stringArray(b)) 29 | } 30 | } 31 | 32 | function unsortedNormalizedArray(a, b) { 33 | a = normalizeArray(a) 34 | b = normalizeArray(b) 35 | return isEqual(stringArray(a), stringArray(b)) 36 | } 37 | 38 | function schemaGroup(a, b, key, compare) { 39 | var allProps = uniq(keys(a).concat(keys(b))) 40 | if (emptyObjUndef(a) && emptyObjUndef(b)) { 41 | return true 42 | } else if (emptyObjUndef(a) && keys(b).length) { 43 | return false 44 | } else if (emptyObjUndef(b) && keys(a).length) { 45 | return false 46 | } 47 | 48 | return allProps.every(function(key) { 49 | var aVal = a[key] 50 | var bVal = b[key] 51 | if (Array.isArray(aVal) && Array.isArray(bVal)) { 52 | return isEqual(stringArray(a), stringArray(b)) 53 | } else if (Array.isArray(aVal) && !Array.isArray(bVal)) { 54 | return false 55 | } else if (Array.isArray(bVal) && !Array.isArray(aVal)) { 56 | return false 57 | } 58 | return keyValEqual(a, b, key, compare) 59 | }) 60 | } 61 | 62 | function items(a, b, key, compare) { 63 | if (isPlainObject(a) && isPlainObject(b)) { 64 | return compare(a, b) 65 | } else if (Array.isArray(a) && Array.isArray(b)) { 66 | return schemaGroup(a, b, key, compare) 67 | } else { 68 | return isEqual(a, b) 69 | } 70 | } 71 | 72 | function unsortedArray(a, b, key, compare) { 73 | var uniqueA = uniqWith(a, compare) 74 | var uniqueB = uniqWith(b, compare) 75 | var inter = intersectionWith(uniqueA, uniqueB, compare) 76 | return inter.length === Math.max(uniqueA.length, uniqueB.length) 77 | } 78 | 79 | var comparers = { 80 | title: isEqual, 81 | uniqueItems: falseUndefined, 82 | minLength: undefAndZero, 83 | minItems: undefAndZero, 84 | minProperties: undefAndZero, 85 | required: undefArrayEqual, 86 | enum: undefArrayEqual, 87 | type: unsortedNormalizedArray, 88 | items: items, 89 | anyOf: unsortedArray, 90 | allOf: unsortedArray, 91 | oneOf: unsortedArray, 92 | properties: schemaGroup, 93 | patternProperties: schemaGroup, 94 | dependencies: schemaGroup 95 | } 96 | 97 | var acceptsUndefined = [ 98 | 'properties', 99 | 'patternProperties', 100 | 'dependencies', 101 | 'uniqueItems', 102 | 'minLength', 103 | 'minItems', 104 | 'minProperties', 105 | 'required' 106 | ] 107 | 108 | var schemaProps = ['additionalProperties', 'additionalItems', 'contains', 'propertyNames', 'not'] 109 | 110 | function compare(a, b, options) { 111 | options = defaults(options, { 112 | ignore: [] 113 | }) 114 | 115 | if (emptySchema(a) && emptySchema(b)) { 116 | return true 117 | } 118 | 119 | if (!isSchema(a) || !isSchema(b)) { 120 | throw new Error('Either of the values are not a JSON schema.') 121 | } 122 | if (a === b) { 123 | return true 124 | } 125 | 126 | if (isBoolean(a) && isBoolean(b)) { 127 | return a === b 128 | } 129 | 130 | if ((a === undefined && b === false) || (b === undefined && a === false)) { 131 | return false 132 | } 133 | 134 | if ((undef(a) && !undef(b)) || (!undef(a) && undef(b))) { 135 | return false 136 | } 137 | 138 | var allKeys = uniq(Object.keys(a).concat(Object.keys(b))) 139 | 140 | if (options.ignore.length) { 141 | allKeys = allKeys.filter(k => options.ignore.indexOf(k) === -1) 142 | } 143 | 144 | if (!allKeys.length) { 145 | return true 146 | } 147 | 148 | function innerCompare(a, b) { 149 | return compare(a, b, options) 150 | } 151 | 152 | return allKeys.every(function(key) { 153 | var aValue = a[key] 154 | var bValue = b[key] 155 | 156 | if (schemaProps.indexOf(key) !== -1) { 157 | return compare(aValue, bValue, options) 158 | } 159 | 160 | var comparer = comparers[key] 161 | if (!comparer) { 162 | comparer = isEqual 163 | } 164 | 165 | // do simple lodash check first 166 | if (isEqual(aValue, bValue)) { 167 | return true 168 | } 169 | 170 | if (acceptsUndefined.indexOf(key) === -1) { 171 | if ((!has(a, key) && has(b, key)) || (has(a, key) && !has(b, key))) { 172 | return aValue === bValue 173 | } 174 | } 175 | 176 | var result = comparer(aValue, bValue, key, innerCompare) 177 | if (!isBoolean(result)) { 178 | throw new Error('Comparer must return true or false') 179 | } 180 | return result 181 | }) 182 | } 183 | 184 | module.exports = compare 185 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "describe": true, 4 | "it": true, 5 | "beforeEach": true, 6 | "afterEach": true, 7 | "before": true, 8 | "after": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/specs/index.spec.js: -------------------------------------------------------------------------------- 1 | var chai = require('chai') 2 | var compareModule = require('../../src') 3 | var expect = chai.expect 4 | 5 | var compare = function(a, b, expected, options) { 6 | var result = compareModule(a, b, options) 7 | expect(result).to.equal(expected) 8 | } 9 | 10 | describe('comparison', function() { 11 | describe('validation only', function() { 12 | it('checks the readme example', function() { 13 | compare({ 14 | title: 'title 1', 15 | type: ['object'], 16 | uniqueItems: false, 17 | dependencies: { 18 | name: ['age', 'lastName'] 19 | }, 20 | required: ['name', 'age', 'name'] 21 | }, { 22 | title: 'title 2', 23 | type: 'object', 24 | required: ['age', 'name'], 25 | dependencies: { 26 | name: ['lastName', 'age'] 27 | }, 28 | properties: { 29 | name: { 30 | minLength: 0 31 | } 32 | } 33 | }, false, { 34 | ignore: ['title'] 35 | }) 36 | }) 37 | 38 | it('compares false and undefined', function() { 39 | compare(undefined, false, false) 40 | }) 41 | it('compares required unsorted', function() { 42 | compare({ 43 | required: ['test', 'rest'] 44 | }, { 45 | required: ['rest', 'test', 'rest'] 46 | }, true) 47 | }) 48 | it('compares equal required empty array and undefined', function() { 49 | compare({ 50 | required: [] 51 | }, {}, true) 52 | 53 | compare({ 54 | required: ['fds'] 55 | }, {}, false) 56 | }) 57 | it('compares equal properties empty object and undefined', function() { 58 | compare({ 59 | properties: {} 60 | }, {}, true) 61 | }) 62 | it('compares properties', function() { 63 | compare({ 64 | properties: { 65 | foo: { 66 | type: 'string' 67 | } 68 | } 69 | }, { 70 | properties: { 71 | foo: { 72 | type: 'string' 73 | } 74 | } 75 | }, true) 76 | }) 77 | it('compares equal patternProperties empty object and undefined', function() { 78 | compare({ 79 | patternProperties: {} 80 | }, {}, true) 81 | }) 82 | it('compares equal dependencies empty object and undefined', function() { 83 | compare({ 84 | dependencies: {} 85 | }, {}, true) 86 | }) 87 | it('compares type unsorted', function() { 88 | compare({ 89 | type: ['string', 'array'] 90 | }, { 91 | type: ['array', 'string', 'array'] 92 | }, true) 93 | 94 | compare({}, { 95 | type: [] 96 | }, false) 97 | 98 | compare({ 99 | type: 'string' 100 | }, { 101 | type: ['string'] 102 | }, true) 103 | }) 104 | it('compares equal an empty schema, true and undefined', function() { 105 | compare({}, true, true) 106 | compare({}, undefined, true) 107 | compare(false, false, true) 108 | compare(true, true, true) 109 | }) 110 | it('ignores any in ignore list', function() { 111 | compare({ 112 | title: 'title' 113 | }, { 114 | title: 'foobar' 115 | }, true, {ignore: ['title']}) 116 | }) 117 | 118 | it('diffs this', function() { 119 | compare({ 120 | type: ['string'], 121 | minLength: 5 122 | }, { 123 | type: ['string'] 124 | }, false) 125 | }) 126 | it('sorts anyOf before comparing', function() { 127 | compare({ 128 | anyOf: [ 129 | { 130 | type: 'string' 131 | }, { 132 | type: 'integer' 133 | } 134 | ] 135 | }, { 136 | anyOf: [ 137 | { 138 | type: 'integer' 139 | }, { 140 | type: 'string' 141 | } 142 | ] 143 | }, true) 144 | 145 | compare({ 146 | anyOf: [ 147 | { 148 | type: 'string' 149 | }, { 150 | type: 'integer' 151 | } 152 | ] 153 | }, { 154 | anyOf: [ 155 | { 156 | type: 'integer' 157 | }, { 158 | type: 'string' 159 | }, 160 | { 161 | type: ['string'], 162 | minLength: 5, 163 | fdsafads: '34534' 164 | } 165 | ] 166 | }, false) 167 | 168 | compare({ 169 | anyOf: [ 170 | { 171 | type: 'string' 172 | }, { 173 | type: 'integer' 174 | } 175 | ] 176 | }, { 177 | anyOf: [ 178 | { 179 | type: 'integer' 180 | }, { 181 | type: 'array' 182 | } 183 | ] 184 | }, false) 185 | 186 | compare({ 187 | anyOf: [ 188 | { 189 | type: 'string' 190 | }, { 191 | type: ['string'] 192 | }, { 193 | type: 'integer' 194 | } 195 | ] 196 | }, { 197 | anyOf: [ 198 | { 199 | type: 'integer' 200 | }, { 201 | type: 'string' 202 | } 203 | ] 204 | }, true) 205 | }) 206 | it('sorts allOf before comparing', function() { 207 | compare({ 208 | allOf: [ 209 | { 210 | type: 'string' 211 | }, { 212 | type: 'integer' 213 | } 214 | ] 215 | }, { 216 | allOf: [ 217 | { 218 | type: 'integer' 219 | }, { 220 | type: 'string' 221 | } 222 | ] 223 | }, true) 224 | 225 | compare({ 226 | allOf: [ 227 | { 228 | type: 'string' 229 | }, { 230 | type: 'integer' 231 | } 232 | ] 233 | }, { 234 | allOf: [ 235 | { 236 | type: 'integer' 237 | }, { 238 | type: 'string' 239 | }, 240 | { 241 | type: ['string'], 242 | minLength: 5, 243 | fdsafads: '34534' 244 | } 245 | ] 246 | }, false) 247 | 248 | compare({ 249 | allOf: [ 250 | { 251 | type: 'string' 252 | }, { 253 | type: 'integer' 254 | } 255 | ] 256 | }, { 257 | allOf: [ 258 | { 259 | type: 'integer' 260 | }, { 261 | type: 'array' 262 | } 263 | ] 264 | }, false) 265 | 266 | compare({ 267 | allOf: [ 268 | { 269 | type: 'string' 270 | }, { 271 | type: ['string'] 272 | }, { 273 | type: 'integer' 274 | } 275 | ] 276 | }, { 277 | allOf: [ 278 | { 279 | type: 'integer' 280 | }, { 281 | type: 'string' 282 | } 283 | ] 284 | }, true) 285 | }) 286 | it('sorts oneOf before comparing', function() { 287 | compare({ 288 | oneOf: [ 289 | { 290 | type: 'string' 291 | }, { 292 | type: 'integer' 293 | } 294 | ] 295 | }, { 296 | oneOf: [ 297 | { 298 | type: 'integer' 299 | }, { 300 | type: 'string' 301 | } 302 | ] 303 | }, true) 304 | 305 | compare({ 306 | oneOf: [ 307 | { 308 | type: 'string' 309 | }, { 310 | type: 'integer' 311 | } 312 | ] 313 | }, { 314 | oneOf: [ 315 | { 316 | type: 'integer' 317 | }, { 318 | type: 'string' 319 | }, 320 | { 321 | type: ['string'], 322 | minLength: 5, 323 | fdsafads: '34534' 324 | } 325 | ] 326 | }, false) 327 | 328 | compare({ 329 | oneOf: [ 330 | { 331 | type: 'string' 332 | }, { 333 | type: 'integer' 334 | } 335 | ] 336 | }, { 337 | oneOf: [ 338 | { 339 | type: 'integer' 340 | }, { 341 | type: 'array' 342 | } 343 | ] 344 | }, false) 345 | 346 | compare({ 347 | oneOf: [ 348 | { 349 | type: 'string' 350 | }, { 351 | type: ['string'] 352 | }, { 353 | type: 'integer' 354 | } 355 | ] 356 | }, { 357 | oneOf: [ 358 | { 359 | type: 'integer' 360 | }, { 361 | type: 'string' 362 | } 363 | ] 364 | }, true) 365 | }) 366 | it('compares enum unsorted', function() { 367 | compare({ 368 | enum: ['abc', '123'] 369 | }, { 370 | enum: ['123', 'abc', 'abc'] 371 | }, true) 372 | }) 373 | it('compares dependencies value if array unsorted', function() { 374 | compare({ 375 | dependencies: { 376 | foo: ['abc', '123'] 377 | } 378 | }, { 379 | dependencies: { 380 | foo: ['123', 'abc', 'abc'] 381 | } 382 | }, true) 383 | }) 384 | it('compares items SORTED', function() { 385 | compare({ 386 | items: [true, false] 387 | }, { 388 | items: [true, true] 389 | }, false) 390 | 391 | compare({ 392 | items: [{}, false] 393 | }, { 394 | items: [true, false] 395 | }, true) 396 | }) 397 | it('compares equal uniqueItems false and undefined', function() { 398 | compare({ 399 | uniqueItems: false 400 | }, {}, true) 401 | }) 402 | it('compares equal minLength undefined and 0', function() { 403 | compare({ 404 | minLength: 0 405 | }, {}, true) 406 | }) 407 | it('compares equal minItems undefined and 0', function() { 408 | compare({ 409 | minItems: 0 410 | }, {}, true) 411 | }) 412 | it('compares equal minProperties undefined and 0', function() { 413 | compare({ 414 | minProperties: 0 415 | }, {}, true) 416 | }) 417 | }) 418 | 419 | describe('complete', function() { 420 | it('includes all properties, like title') 421 | }) 422 | }) 423 | --------------------------------------------------------------------------------