├── .editorconfig ├── .eslintrc ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── README.md ├── package-lock.json ├── package.json ├── src ├── common.js ├── complex-resolvers │ ├── items.js │ └── properties.js └── index.js └── test ├── .eslintrc ├── fixtures └── schemas │ └── meta-schema-v6.json ├── package.json ├── specs ├── custom-resolvers.spec.js ├── extraction.spec.js ├── index.spec.js ├── items.spec.js ├── meta-schema.spec.js ├── options.spec.js ├── properties.spec.js ├── stripping.spec.js └── validation.spec.js ├── vitest.config.js └── vitest.develop.config.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: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2020": true, 4 | "node": true 5 | }, 6 | "extends": "eslint:recommended" 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | env: 9 | FORCE_COLOR: 2 10 | 11 | jobs: 12 | test: 13 | name: Node ${{ matrix.node }} 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | node: [16, 18, 20] 20 | 21 | steps: 22 | - name: Clone repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up Node.js 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version: ${{ matrix.node }} 29 | cache: npm 30 | 31 | - name: Install npm dependencies 32 | run: npm ci 33 | 34 | - name: Run tests 35 | run: npm test 36 | 37 | - name: Run Coveralls 38 | uses: coverallsapp/github-action@v2 39 | if: matrix.node == 20 40 | with: 41 | github-token: '${{ secrets.GITHUB_TOKEN }}' 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | dist 5 | tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /packages/*/coverage 32 | /libpeerconnection.log 33 | npm-debug.log 34 | yarn-error.log 35 | testem.log 36 | /typings 37 | .eslintcache 38 | 39 | 40 | # System Files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | /dist 3 | /coverage -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "none" 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # json-schema-merge-allof [![build status](https://github.com/mokkabonna/json-schema-merge-allof/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/mokkabonna/json-schema-merge-allof/actions/workflows/ci.yml) [![Coverage Status](https://coveralls.io/repos/github/mokkabonna/json-schema-merge-allof/badge.svg?branch=master)](https://coveralls.io/github/mokkabonna/json-schema-merge-allof?branch=master) 2 | 3 | > Merge schemas combined using allOf into a more readable composed schema free from allOf. 4 | 5 | ```bash 6 | npm install json-schema-merge-allof --save 7 | ``` 8 | 9 | ## Features 10 | 11 | - **Real** and **safe** merging of schemas combined with **allOf** 12 | - Takes away all allOf found in the whole schema 13 | - Lossless in terms of validation rules, merged schema does not validate more or less than the original schema 14 | - Results in a more readable root schema 15 | - Removes almost all logical impossibilities 16 | - Throws if no logical intersection is found (your schema would not validate anything from the start) 17 | - Validates in a way not possible by regular simple meta validators 18 | - Correctly considers additionalProperties, patternProperties and properties as a part of an whole when merging schemas containing those 19 | - Correctly considers items and additionalItems as a whole when merging schemas containing those 20 | - Supports merging schemas with items as array and direct schema 21 | - Supports merging dependencies when mixed array and schema 22 | - Supports all JSON schema core/validation keywords (v6, use custom resolvers to support other keywords) 23 | - Option to override common impossibility like adding properties when using **additionalProperties: false** 24 | - Pluggable keyword resolvers 25 | 26 | ## How 27 | 28 | Since allOf require ALL schemas provided (including the parent schema) to apply, we can iterate over all the schemas, extracting all the values for say, **type**, and find the **intersection** of valid values. Here is an example: 29 | 30 | ```js 31 | { 32 | type: ['object', 'null'], 33 | additionalProperties: { 34 | type: 'string', 35 | minLength: 5 36 | }, 37 | allOf: [{ 38 | type: ['array', 'object'], 39 | additionalProperties: { 40 | type: 'string', 41 | minLength: 10, 42 | maxLength: 20 43 | } 44 | }] 45 | } 46 | ``` 47 | 48 | This result in the schema : 49 | ```js 50 | { 51 | type: 'object', 52 | additionalProperties: { 53 | type: 'string', 54 | minLength: 10, 55 | maxLength: 20 56 | } 57 | } 58 | ``` 59 | 60 | Notice that type now excludes null and array since those are not logically possible. Also minLength is raised to 10. The other properties have no conflict and are merged into the root schema with no resolving needed. 61 | 62 | For other keywords other methods are used, here are some simple examples: 63 | 64 | - minLength, minimum, minItems etc chooses the **highest** value of the conflicting values. 65 | - maxLength, maximum, maxItems etc chooses the **lowest** value of the conflicting values. 66 | - uniqueItems is true if **any** of the conflicting values are true 67 | 68 | As you can see above the strategy is to choose the **most** restrictive of the set of values that conflict. For some keywords that is done by intersection, for others like **required** it is done by a union of all the values, since that is the most restrictive. 69 | 70 | What you are left with is a schema completely free of allOf. Except for in a couple of values that are impossible to properly intersect/combine: 71 | 72 | ### not 73 | 74 | When multiple conflicting **not** values are found, we also use the approach that pattern use, but instead of allOf we use anyOf. When extraction of common rules from anyOf is in place this can be further simplified. 75 | 76 | ## Options 77 | **ignoreAdditionalProperties** default **false** 78 | 79 | Allows you to combine schema properties even though some schemas have `additionalProperties: false` This is the most common issue people face when trying to expand schemas using allOf and a limitation of the json schema spec. Be aware though that the schema produced will allow more than the original schema. But this is useful if just want to combine schemas using allOf as if additionalProperties wasn't false during the merge process. The resulting schema will still get additionalProperties set to false. 80 | 81 | **deep** boolean, default *true* 82 | If false, resolves only the top-level `allOf` keyword in the schema. 83 | 84 | If true, resolves all `allOf` keywords in the schema. 85 | 86 | 87 | **resolvers** Object 88 | Override any default resolver like this: 89 | 90 | ```js 91 | mergeAllOf(schema, { 92 | resolvers: { 93 | title: function(values, path, mergeSchemas, options) { 94 | // choose what title you want to be used based on the conflicting values 95 | // resolvers MUST return a value other than undefined 96 | } 97 | } 98 | }) 99 | ``` 100 | 101 | The function is passed: 102 | 103 | - **values** an array of the conflicting values that need to be resolved 104 | - **path** an array of strings containing the path to the position in the schema that caused the resolver to be called (useful if you use the same resolver for multiple keywords, or want to implement specific logic for custom paths) 105 | - **mergeSchemas** a function you can call that merges an array of schemas 106 | - **options** the options mergeAllOf was called with 107 | 108 | 109 | ### Combined resolvers 110 | Some keyword are dependant on other keywords, like properties, patternProperties, additionalProperties. To create a resolver for these the resolver requires this structure: 111 | 112 | ```js 113 | mergeAllOf(schema, { 114 | resolvers: { 115 | properties: 116 | keywords: ['properties', 'patternProperties', 'additionalProperties'], 117 | resolver(values, parents, mergers, options) { 118 | 119 | } 120 | } 121 | } 122 | }) 123 | ``` 124 | 125 | This type of resolvers are expected to return an object containing the resolved values of all the associated keywords. The keys must be the name of the keywords. So the properties resolver need to return an object like this containing the resolved values for each keyword: 126 | 127 | ```js 128 | { 129 | properties: ..., 130 | patternProperties: ..., 131 | additionalProperties: ..., 132 | } 133 | ``` 134 | 135 | Also the resolve function is not passed **mergeSchemas**, but an object **mergers** that contains mergers for each of the related keywords. So properties get passed an object like this: 136 | 137 | ```js 138 | const mergers = { 139 | properties: function mergeSchemas(schemas, childSchemaName){...}, 140 | patternProperties: function mergeSchemas(schemas, childSchemaName){...}, 141 | additionalProperties: function mergeSchemas(schemas){...}, 142 | } 143 | ``` 144 | 145 | Some of the mergers requires you to supply a string of the name or index of the subschema you are currently merging. This is to make sure the path passed to child resolvers are correct. 146 | 147 | ### Default resolver 148 | You can set a default resolver that catches any unknown keyword. Let's say you want to use the same strategy as the ones for the meta keywords, to use the first value found. You can accomplish that like this: 149 | 150 | ```js 151 | mergeJsonSchema({ 152 | ... 153 | }, { 154 | resolvers: { 155 | defaultResolver: mergeJsonSchema.options.resolvers.title 156 | } 157 | }) 158 | ``` 159 | 160 | 161 | ## Resolvers 162 | 163 | Resolvers are called whenever multiple conflicting values are found on the same position in the schemas. 164 | 165 | You can override a resolver by supplying it in the options. 166 | 167 | ### Lossy vs lossless 168 | 169 | All built in reducers for validation keywords are lossless, meaning that they don't remove or add anything in terms of validation. 170 | 171 | For meta keywords like title, description, $id, $schema, default the strategy is to use the first possible value if there are conflicting ones. So the root schema is prioritized. This process possibly removes some meta information from your schema. So it's lossy. Override this by providing custom resolvers. 172 | 173 | 174 | ## $ref 175 | 176 | If one of your schemas contain a $ref property you should resolve them using a ref resolver like [json-schema-ref-parser](https://github.com/BigstickCarpet/json-schema-ref-parser) to dereference your schema for you first. Resolving $refs is not the task of this library. Currently it does not support circular references either. But if you use `bundle` in json-schema-ref-parser it should work as expected. 177 | 178 | 179 | ## Other libraries 180 | 181 | There exists some libraries that claim to merge schemas combined with allOf, but they just merge schemas using a **very** basic logic. Basically just the same as lodash merge. So you risk ending up with a schema that allows more or less than the original schema would allow. 182 | 183 | 184 | ## Restrictions 185 | 186 | We cannot merge schemas that are a logical impossibility, like: 187 | 188 | ```js 189 | { 190 | type: 'object', 191 | allOf: [{ 192 | type: 'array' 193 | }] 194 | } 195 | ``` 196 | 197 | The library will then throw an error reporting the values that had no valid intersection. But then again, your original schema wouldn't validate anything either. 198 | 199 | 200 | ## Roadmap 201 | 202 | - [x] Treat the interdependent validations like properties and additionalProperties as one resolver (and items additionalItems) 203 | - [ ] Extract repeating validators from anyOf/oneOf and merge them with parent schema 204 | - [ ] After extraction of validators from anyOf/oneOf, compare them and remove duplicates. 205 | - [ ] If left with only one in anyOf/oneOf then merge it to the parent schema. 206 | - [ ] Expose seperate tools for validation, extraction 207 | - [ ] Consider adding even more logical validation (like minLength <= maxLength) 208 | 209 | ## Contributing 210 | 211 | Create tests for new functionality and follow the eslint rules. 212 | 213 | ## License 214 | 215 | MIT © [Martin Hansen](http://martinhansen.com) 216 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "json-schema-merge-allof", 3 | "version": "0.8.1", 4 | "description": "Simplify your schema by combining allOf into the root schema, safely.", 5 | "main": "src/index.js", 6 | "engines": { 7 | "node": ">=12.0.0" 8 | }, 9 | "scripts": { 10 | "lint": "npm run prettier:check && npm run eslint", 11 | "lint:fix": "npm run eslint:fix && npm run prettier:fix", 12 | "eslint": "eslint src test", 13 | "eslint:fix": "npm run eslint -- --fix", 14 | "prettier:cli": "prettier src test", 15 | "prettier:check": "npm run prettier:cli -- --check", 16 | "prettier:fix": "npm run prettier:cli -- --write", 17 | "pretest": "npm run eslint", 18 | "test": "vitest run test -c test/vitest.config.js", 19 | "develop": "vitest watch test -c test/vitest.develop.config.js" 20 | }, 21 | "directories": { 22 | "lib": "src", 23 | "test": "test" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/mokkabonna/json-schema-merge-allof.git" 28 | }, 29 | "keywords": [ 30 | "json", 31 | "schema", 32 | "jsonschema" 33 | ], 34 | "author": "Martin Hansen", 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/mokkabonna/json-schema-merge-allof/issues" 38 | }, 39 | "homepage": "https://github.com/mokkabonna/json-schema-merge-allof#readme", 40 | "devDependencies": { 41 | "@istanbuljs/schema": "^0.1.3", 42 | "@vitest/coverage-v8": "^1.1.1", 43 | "ajv": "^8.12.0", 44 | "c8": "^9.0.0", 45 | "chai": "^5.0.0", 46 | "coveralls": "^3.1.0", 47 | "eslint": "^8.56.0", 48 | "eslint-plugin-node": "^11.1.0", 49 | "json-schema-ref-parser": "^9.0.9", 50 | "json-stringify-safe": "^5.0.1", 51 | "prettier": "^3.1.1", 52 | "sinon": "^17.0.1", 53 | "vitest": "^1.1.1" 54 | }, 55 | "dependencies": { 56 | "compute-lcm": "^1.1.2", 57 | "json-schema-compare": "^0.2.2", 58 | "lodash": "^4.17.20" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/common.js: -------------------------------------------------------------------------------- 1 | const flatten = require('lodash/flatten'); 2 | const flattenDeep = require('lodash/flattenDeep'); 3 | const isPlainObject = require('lodash/isPlainObject'); 4 | const uniq = require('lodash/uniq'); 5 | const uniqWith = require('lodash/uniqWith'); 6 | const without = require('lodash/without'); 7 | 8 | function deleteUndefinedProps(returnObject) { 9 | // cleanup empty 10 | for (const prop in returnObject) { 11 | if (has(returnObject, prop) && isEmptySchema(returnObject[prop])) { 12 | delete returnObject[prop]; 13 | } 14 | } 15 | return returnObject; 16 | } 17 | 18 | const allUniqueKeys = (arr) => uniq(flattenDeep(arr.map(keys))); 19 | const getValues = (schemas, key) => 20 | schemas.map((schema) => schema && schema[key]); 21 | const has = (obj, propName) => 22 | Object.prototype.hasOwnProperty.call(obj, propName); 23 | const keys = (obj) => { 24 | if (isPlainObject(obj) || Array.isArray(obj)) { 25 | return Object.keys(obj); 26 | } else { 27 | return []; 28 | } 29 | }; 30 | 31 | const notUndefined = (val) => val !== undefined; 32 | const isSchema = (val) => isPlainObject(val) || val === true || val === false; 33 | const isEmptySchema = (obj) => 34 | !keys(obj).length && obj !== false && obj !== true; 35 | const withoutArr = (arr, ...rest) => 36 | without.apply(null, [arr].concat(flatten(rest))); 37 | 38 | module.exports = { 39 | allUniqueKeys, 40 | deleteUndefinedProps, 41 | getValues, 42 | has, 43 | isEmptySchema, 44 | isSchema, 45 | keys, 46 | notUndefined, 47 | uniqWith, 48 | withoutArr 49 | }; 50 | -------------------------------------------------------------------------------- /src/complex-resolvers/items.js: -------------------------------------------------------------------------------- 1 | const compare = require('json-schema-compare'); 2 | const forEach = require('lodash/forEach'); 3 | const { 4 | allUniqueKeys, 5 | deleteUndefinedProps, 6 | has, 7 | isSchema, 8 | notUndefined, 9 | uniqWith 10 | } = require('../common'); 11 | 12 | function removeFalseSchemasFromArray(target) { 13 | forEach(target, function (schema, index) { 14 | if (schema === false) { 15 | target.splice(index, 1); 16 | } 17 | }); 18 | } 19 | 20 | function getItemSchemas(subSchemas, key) { 21 | return subSchemas.map(function (sub) { 22 | if (!sub) { 23 | return undefined; 24 | } 25 | 26 | if (Array.isArray(sub.items)) { 27 | const schemaAtPos = sub.items[key]; 28 | if (isSchema(schemaAtPos)) { 29 | return schemaAtPos; 30 | } else if (has(sub, 'additionalItems')) { 31 | return sub.additionalItems; 32 | } 33 | } else { 34 | return sub.items; 35 | } 36 | 37 | return undefined; 38 | }); 39 | } 40 | 41 | function getAdditionalSchemas(subSchemas) { 42 | return subSchemas.map(function (sub) { 43 | if (!sub) { 44 | return undefined; 45 | } 46 | if (Array.isArray(sub.items)) { 47 | return sub.additionalItems; 48 | } 49 | return sub.items; 50 | }); 51 | } 52 | 53 | // Provide source when array 54 | function mergeItems(group, mergeSchemas, items) { 55 | const allKeys = allUniqueKeys(items); 56 | return allKeys.reduce(function (all, key) { 57 | const schemas = getItemSchemas(group, key); 58 | const compacted = uniqWith(schemas.filter(notUndefined), compare); 59 | all[key] = mergeSchemas(compacted, key); 60 | return all; 61 | }, []); 62 | } 63 | 64 | module.exports = { 65 | keywords: ['items', 'additionalItems'], 66 | resolver(values, parents, mergers) { 67 | // const createSubMerger = groupKey => (schemas, key) => mergeSchemas(schemas, parents.concat(groupKey, key)) 68 | const items = values.map((s) => s.items); 69 | const itemsCompacted = items.filter(notUndefined); 70 | const returnObject = {}; 71 | 72 | // if all items keyword values are schemas, we can merge them as simple schemas 73 | // if not we need to merge them as mixed 74 | if (itemsCompacted.every(isSchema)) { 75 | returnObject.items = mergers.items(items); 76 | } else { 77 | returnObject.items = mergeItems(values, mergers.items, items); 78 | } 79 | 80 | let schemasAtLastPos; 81 | if (itemsCompacted.every(Array.isArray)) { 82 | schemasAtLastPos = values.map((s) => s.additionalItems); 83 | } else if (itemsCompacted.some(Array.isArray)) { 84 | schemasAtLastPos = getAdditionalSchemas(values); 85 | } 86 | 87 | if (schemasAtLastPos) { 88 | returnObject.additionalItems = mergers.additionalItems(schemasAtLastPos); 89 | } 90 | 91 | if ( 92 | returnObject.additionalItems === false && 93 | Array.isArray(returnObject.items) 94 | ) { 95 | removeFalseSchemasFromArray(returnObject.items); 96 | } 97 | 98 | return deleteUndefinedProps(returnObject); 99 | } 100 | }; 101 | -------------------------------------------------------------------------------- /src/complex-resolvers/properties.js: -------------------------------------------------------------------------------- 1 | const compare = require('json-schema-compare'); 2 | const forEach = require('lodash/forEach'); 3 | const { 4 | allUniqueKeys, 5 | deleteUndefinedProps, 6 | getValues, 7 | keys, 8 | notUndefined, 9 | uniqWith, 10 | withoutArr 11 | } = require('../common'); 12 | 13 | function removeFalseSchemas(target) { 14 | forEach(target, function (schema, prop) { 15 | if (schema === false) { 16 | delete target[prop]; 17 | } 18 | }); 19 | } 20 | 21 | function mergeSchemaGroup(group, mergeSchemas) { 22 | const allKeys = allUniqueKeys(group); 23 | return allKeys.reduce(function (all, key) { 24 | const schemas = getValues(group, key); 25 | const compacted = uniqWith(schemas.filter(notUndefined), compare); 26 | all[key] = mergeSchemas(compacted, key); 27 | return all; 28 | }, {}); 29 | } 30 | 31 | module.exports = { 32 | keywords: ['properties', 'patternProperties', 'additionalProperties'], 33 | resolver(values, parents, mergers, options) { 34 | // first get rid of all non permitted properties 35 | if (!options.ignoreAdditionalProperties) { 36 | values.forEach(function (subSchema) { 37 | const otherSubSchemas = values.filter((s) => s !== subSchema); 38 | const ownKeys = keys(subSchema.properties); 39 | const ownPatternKeys = keys(subSchema.patternProperties); 40 | const ownPatterns = ownPatternKeys.map((k) => new RegExp(k)); 41 | otherSubSchemas.forEach(function (other) { 42 | const allOtherKeys = keys(other.properties); 43 | const keysMatchingPattern = allOtherKeys.filter((k) => 44 | ownPatterns.some((pk) => pk.test(k)) 45 | ); 46 | const additionalKeys = withoutArr( 47 | allOtherKeys, 48 | ownKeys, 49 | keysMatchingPattern 50 | ); 51 | additionalKeys.forEach(function (key) { 52 | other.properties[key] = mergers.properties( 53 | [other.properties[key], subSchema.additionalProperties], 54 | key 55 | ); 56 | }); 57 | }); 58 | }); 59 | 60 | // remove disallowed patternProperties 61 | values.forEach(function (subSchema) { 62 | const otherSubSchemas = values.filter((s) => s !== subSchema); 63 | const ownPatternKeys = keys(subSchema.patternProperties); 64 | if (subSchema.additionalProperties === false) { 65 | otherSubSchemas.forEach(function (other) { 66 | const allOtherPatterns = keys(other.patternProperties); 67 | const additionalPatternKeys = withoutArr( 68 | allOtherPatterns, 69 | ownPatternKeys 70 | ); 71 | additionalPatternKeys.forEach( 72 | (key) => delete other.patternProperties[key] 73 | ); 74 | }); 75 | } 76 | }); 77 | } 78 | 79 | const returnObject = { 80 | additionalProperties: mergers.additionalProperties( 81 | values.map((s) => s.additionalProperties) 82 | ), 83 | patternProperties: mergeSchemaGroup( 84 | values.map((s) => s.patternProperties), 85 | mergers.patternProperties 86 | ), 87 | properties: mergeSchemaGroup( 88 | values.map((s) => s.properties), 89 | mergers.properties 90 | ) 91 | }; 92 | 93 | if (returnObject.additionalProperties === false) { 94 | removeFalseSchemas(returnObject.properties); 95 | } 96 | 97 | return deleteUndefinedProps(returnObject); 98 | } 99 | }; 100 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const cloneDeep = require('lodash/cloneDeep'); 2 | const compare = require('json-schema-compare'); 3 | const computeLcm = require('compute-lcm'); 4 | const defaultsDeep = require('lodash/defaultsDeep'); 5 | const flatten = require('lodash/flatten'); 6 | const flattenDeep = require('lodash/flattenDeep'); 7 | const intersection = require('lodash/intersection'); 8 | const intersectionWith = require('lodash/intersectionWith'); 9 | const isEqual = require('lodash/isEqual'); 10 | const isPlainObject = require('lodash/isPlainObject'); 11 | const pullAll = require('lodash/pullAll'); 12 | const sortBy = require('lodash/sortBy'); 13 | const uniq = require('lodash/uniq'); 14 | const uniqWith = require('lodash/uniqWith'); 15 | 16 | const propertiesResolver = require('./complex-resolvers/properties'); 17 | const itemsResolver = require('./complex-resolvers/items'); 18 | 19 | const contains = (arr, val) => arr.indexOf(val) !== -1; 20 | const isSchema = (val) => isPlainObject(val) || val === true || val === false; 21 | const isFalse = (val) => val === false; 22 | const isTrue = (val) => val === true; 23 | const schemaResolver = (compacted, key, mergeSchemas) => 24 | mergeSchemas(compacted); 25 | const stringArray = (values) => sortBy(uniq(flattenDeep(values))); 26 | const notUndefined = (val) => val !== undefined; 27 | const allUniqueKeys = (arr) => uniq(flattenDeep(arr.map(keys))); 28 | 29 | // resolvers 30 | const first = (compacted) => compacted[0]; 31 | const required = (compacted) => stringArray(compacted); 32 | const maximumValue = (compacted) => Math.max.apply(Math, compacted); 33 | const minimumValue = (compacted) => Math.min.apply(Math, compacted); 34 | const uniqueItems = (compacted) => compacted.some(isTrue); 35 | const examples = (compacted) => uniqWith(flatten(compacted), isEqual); 36 | 37 | function compareProp(key) { 38 | return function (a, b) { 39 | return compare( 40 | { 41 | [key]: a 42 | }, 43 | { [key]: b } 44 | ); 45 | }; 46 | } 47 | 48 | function getAllOf(schema) { 49 | let { allOf = [], ...copy } = schema; 50 | copy = isPlainObject(schema) ? copy : schema; // if schema is boolean 51 | return [copy, ...allOf.map(getAllOf)]; 52 | } 53 | 54 | function getValues(schemas, key) { 55 | return schemas.map((schema) => schema && schema[key]); 56 | } 57 | 58 | function tryMergeSchemaGroups(schemaGroups, mergeSchemas) { 59 | return schemaGroups 60 | .map(function (schemas, index) { 61 | try { 62 | return mergeSchemas(schemas, index); 63 | } catch (e) { 64 | return undefined; 65 | } 66 | }) 67 | .filter(notUndefined); 68 | } 69 | 70 | function keys(obj) { 71 | if (isPlainObject(obj) || Array.isArray(obj)) { 72 | return Object.keys(obj); 73 | } else { 74 | return []; 75 | } 76 | } 77 | 78 | function getAnyOfCombinations(arrOfArrays, combinations) { 79 | combinations = combinations || []; 80 | if (!arrOfArrays.length) { 81 | return combinations; 82 | } 83 | 84 | const values = arrOfArrays.slice(0).shift(); 85 | const rest = arrOfArrays.slice(1); 86 | if (combinations.length) { 87 | return getAnyOfCombinations( 88 | rest, 89 | flatten( 90 | combinations.map((combination) => 91 | values.map((item) => [item].concat(combination)) 92 | ) 93 | ) 94 | ); 95 | } 96 | return getAnyOfCombinations( 97 | rest, 98 | values.map((item) => item) 99 | ); 100 | } 101 | 102 | function throwIncompatible(values, paths) { 103 | let asJSON; 104 | try { 105 | asJSON = values 106 | .map(function (val) { 107 | return JSON.stringify(val, null, 2); 108 | }) 109 | .join('\n'); 110 | } catch (variable) { 111 | asJSON = values.join(', '); 112 | } 113 | throw new Error( 114 | 'Could not resolve values for path:"' + 115 | paths.join('.') + 116 | '". They are probably incompatible. Values: \n' + 117 | asJSON 118 | ); 119 | } 120 | 121 | function callGroupResolver( 122 | complexKeywords, 123 | resolverName, 124 | schemas, 125 | mergeSchemas, 126 | options, 127 | parents 128 | ) { 129 | if (complexKeywords.length) { 130 | const resolverConfig = options.complexResolvers[resolverName]; 131 | if (!resolverConfig || !resolverConfig.resolver) { 132 | throw new Error('No resolver found for ' + resolverName); 133 | } 134 | 135 | // extract all keywords from all the schemas that have one or more 136 | // then remove all undefined ones and not unique 137 | const extractedKeywordsOnly = schemas.map((schema) => 138 | complexKeywords.reduce((all, key) => { 139 | if (schema[key] !== undefined) all[key] = schema[key]; 140 | return all; 141 | }, {}) 142 | ); 143 | const unique = uniqWith(extractedKeywordsOnly, compare); 144 | 145 | // create mergers that automatically add the path of the keyword for use in the complex resolver 146 | const mergers = resolverConfig.keywords.reduce( 147 | (all, key) => ({ 148 | ...all, 149 | [key]: (schemas, extraKey = []) => 150 | mergeSchemas(schemas, null, parents.concat(key, extraKey)) 151 | }), 152 | {} 153 | ); 154 | 155 | const result = resolverConfig.resolver( 156 | unique, 157 | parents.concat(resolverName), 158 | mergers, 159 | options 160 | ); 161 | 162 | if (!isPlainObject(result)) { 163 | throwIncompatible(unique, parents.concat(resolverName)); 164 | } 165 | 166 | return result; 167 | } 168 | } 169 | 170 | function createRequiredMetaArray(arr) { 171 | return { required: arr }; 172 | } 173 | 174 | const schemaGroupProps = [ 175 | 'properties', 176 | 'patternProperties', 177 | 'definitions', 178 | 'dependencies' 179 | ]; 180 | const schemaArrays = ['anyOf', 'oneOf']; 181 | const schemaProps = [ 182 | 'additionalProperties', 183 | 'additionalItems', 184 | 'contains', 185 | 'propertyNames', 186 | 'not', 187 | 'items' 188 | ]; 189 | 190 | const defaultResolvers = { 191 | type(compacted) { 192 | if (compacted.some(Array.isArray)) { 193 | const normalized = compacted.map(function (val) { 194 | return Array.isArray(val) ? val : [val]; 195 | }); 196 | const common = intersection.apply(null, normalized); 197 | 198 | if (common.length === 1) { 199 | return common[0]; 200 | } else if (common.length > 1) { 201 | return uniq(common); 202 | } 203 | } 204 | }, 205 | dependencies(compacted, paths, mergeSchemas) { 206 | const allChildren = allUniqueKeys(compacted); 207 | 208 | return allChildren.reduce(function (all, childKey) { 209 | const childSchemas = getValues(compacted, childKey); 210 | let innerCompacted = uniqWith(childSchemas.filter(notUndefined), isEqual); 211 | 212 | // to support dependencies 213 | const innerArrays = innerCompacted.filter(Array.isArray); 214 | 215 | if (innerArrays.length) { 216 | if (innerArrays.length === innerCompacted.length) { 217 | all[childKey] = stringArray(innerCompacted); 218 | } else { 219 | const innerSchemas = innerCompacted.filter(isSchema); 220 | const arrayMetaScheams = innerArrays.map(createRequiredMetaArray); 221 | all[childKey] = mergeSchemas( 222 | innerSchemas.concat(arrayMetaScheams), 223 | childKey 224 | ); 225 | } 226 | return all; 227 | } 228 | 229 | innerCompacted = uniqWith(innerCompacted, compare); 230 | 231 | all[childKey] = mergeSchemas(innerCompacted, childKey); 232 | return all; 233 | }, {}); 234 | }, 235 | oneOf(compacted, paths, mergeSchemas) { 236 | const combinations = getAnyOfCombinations(cloneDeep(compacted)); 237 | const result = tryMergeSchemaGroups(combinations, mergeSchemas); 238 | const unique = uniqWith(result, compare); 239 | 240 | if (unique.length) { 241 | return unique; 242 | } 243 | }, 244 | not(compacted) { 245 | return { anyOf: compacted }; 246 | }, 247 | pattern(compacted) { 248 | return compacted.map((r) => '(?=' + r + ')').join(''); 249 | }, 250 | multipleOf(compacted) { 251 | let integers = compacted.slice(0); 252 | let factor = 1; 253 | while (integers.some((n) => !Number.isInteger(n))) { 254 | integers = integers.map((n) => n * 10); 255 | factor = factor * 10; 256 | } 257 | return computeLcm(integers) / factor; 258 | }, 259 | enum(compacted) { 260 | const enums = intersectionWith.apply(null, compacted.concat(isEqual)); 261 | if (enums.length) { 262 | return sortBy(enums); 263 | } 264 | } 265 | }; 266 | 267 | defaultResolvers.$id = first; 268 | defaultResolvers.$ref = first; 269 | defaultResolvers.$schema = first; 270 | defaultResolvers.additionalItems = schemaResolver; 271 | defaultResolvers.additionalProperties = schemaResolver; 272 | defaultResolvers.anyOf = defaultResolvers.oneOf; 273 | defaultResolvers.contains = schemaResolver; 274 | defaultResolvers.default = first; 275 | defaultResolvers.definitions = defaultResolvers.dependencies; 276 | defaultResolvers.description = first; 277 | defaultResolvers.examples = examples; 278 | defaultResolvers.exclusiveMaximum = minimumValue; 279 | defaultResolvers.exclusiveMinimum = maximumValue; 280 | defaultResolvers.items = itemsResolver; 281 | defaultResolvers.maximum = minimumValue; 282 | defaultResolvers.maxItems = minimumValue; 283 | defaultResolvers.maxLength = minimumValue; 284 | defaultResolvers.maxProperties = minimumValue; 285 | defaultResolvers.minimum = maximumValue; 286 | defaultResolvers.minItems = maximumValue; 287 | defaultResolvers.minLength = maximumValue; 288 | defaultResolvers.minProperties = maximumValue; 289 | defaultResolvers.properties = propertiesResolver; 290 | defaultResolvers.propertyNames = schemaResolver; 291 | defaultResolvers.required = required; 292 | defaultResolvers.title = first; 293 | defaultResolvers.uniqueItems = uniqueItems; 294 | 295 | const defaultComplexResolvers = { 296 | properties: propertiesResolver, 297 | items: itemsResolver 298 | }; 299 | 300 | function merger(rootSchema, options, totalSchemas) { 301 | totalSchemas = totalSchemas || []; 302 | options = defaultsDeep(options, { 303 | ignoreAdditionalProperties: false, 304 | resolvers: defaultResolvers, 305 | complexResolvers: defaultComplexResolvers, 306 | deep: true 307 | }); 308 | 309 | const complexResolvers = Object.entries(options.complexResolvers); 310 | 311 | function mergeSchemas(schemas, base, parents) { 312 | schemas = cloneDeep(schemas.filter(notUndefined)); 313 | parents = parents || []; 314 | const merged = isPlainObject(base) ? base : {}; 315 | 316 | // return undefined, an empty schema 317 | if (!schemas.length) { 318 | return; 319 | } 320 | 321 | if (schemas.some(isFalse)) { 322 | return false; 323 | } 324 | 325 | if (schemas.every(isTrue)) { 326 | return true; 327 | } 328 | 329 | // there are no false and we don't need the true ones as they accept everything 330 | schemas = schemas.filter(isPlainObject); 331 | 332 | const allKeys = allUniqueKeys(schemas); 333 | if (options.deep && contains(allKeys, 'allOf')) { 334 | return merger( 335 | { 336 | allOf: schemas 337 | }, 338 | options, 339 | totalSchemas 340 | ); 341 | } 342 | 343 | const complexKeysArr = complexResolvers.map(([, resolverConf]) => 344 | allKeys.filter((k) => resolverConf.keywords.includes(k)) 345 | ); 346 | 347 | // remove all complex keys before simple resolvers 348 | complexKeysArr.forEach((keys) => pullAll(allKeys, keys)); 349 | 350 | // call all simple resolvers for relevant keywords 351 | allKeys.forEach(function (key) { 352 | const values = getValues(schemas, key); 353 | const compacted = uniqWith(values.filter(notUndefined), compareProp(key)); 354 | 355 | // arrayprops like anyOf and oneOf must be merged first, as they contains schemas 356 | // allOf is treated differently alltogether 357 | if (compacted.length === 1 && contains(schemaArrays, key)) { 358 | merged[key] = compacted[0].map((schema) => 359 | mergeSchemas([schema], schema) 360 | ); 361 | // prop groups must always be resolved 362 | } else if ( 363 | compacted.length === 1 && 364 | !contains(schemaGroupProps, key) && 365 | !contains(schemaProps, key) 366 | ) { 367 | merged[key] = compacted[0]; 368 | } else { 369 | const resolver = 370 | options.resolvers[key] || options.resolvers.defaultResolver; 371 | if (!resolver) 372 | throw new Error( 373 | 'No resolver found for key ' + 374 | key + 375 | '. You can provide a resolver for this keyword in the options, or provide a default resolver.' 376 | ); 377 | 378 | const merger = (schemas, extraKey = []) => 379 | mergeSchemas(schemas, null, parents.concat(key, extraKey)); 380 | merged[key] = resolver(compacted, parents.concat(key), merger, options); 381 | 382 | if (merged[key] === undefined) { 383 | throwIncompatible(compacted, parents.concat(key)); 384 | } 385 | } 386 | }); 387 | 388 | return complexResolvers.reduce( 389 | (all, [resolverKeyword], index) => ({ 390 | ...all, 391 | ...callGroupResolver( 392 | complexKeysArr[index], 393 | resolverKeyword, 394 | schemas, 395 | mergeSchemas, 396 | options, 397 | parents 398 | ) 399 | }), 400 | merged 401 | ); 402 | } 403 | 404 | const allSchemas = flattenDeep(getAllOf(rootSchema)); 405 | const merged = mergeSchemas(allSchemas); 406 | 407 | return merged; 408 | } 409 | 410 | merger.options = { 411 | resolvers: defaultResolvers 412 | }; 413 | 414 | module.exports = merger; 415 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": "latest", 4 | "sourceType": "module" 5 | }, 6 | "extends": "eslint:recommended" 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/schemas/meta-schema-v6.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "$id": "http://json-schema.org/draft-06/schema#", 4 | "title": "Core schema meta-schema", 5 | "definitions": { 6 | "schemaArray": { 7 | "type": "array", 8 | "minItems": 1, 9 | "items": { 10 | "$ref": "#" 11 | } 12 | }, 13 | "nonNegativeInteger": { 14 | "type": "integer", 15 | "minimum": 0 16 | }, 17 | "nonNegativeIntegerDefault0": { 18 | "allOf": [ 19 | { 20 | "$ref": "#/definitions/nonNegativeInteger" 21 | }, 22 | { 23 | "default": 0 24 | } 25 | ] 26 | }, 27 | "simpleTypes": { 28 | "enum": [ 29 | "array", 30 | "boolean", 31 | "integer", 32 | "null", 33 | "number", 34 | "object", 35 | "string" 36 | ] 37 | }, 38 | "stringArray": { 39 | "type": "array", 40 | "items": { 41 | "type": "string" 42 | }, 43 | "uniqueItems": true, 44 | "default": [] 45 | } 46 | }, 47 | "type": ["object", "boolean"], 48 | "properties": { 49 | "$id": { 50 | "type": "string", 51 | "format": "uri-reference" 52 | }, 53 | "$schema": { 54 | "type": "string", 55 | "format": "uri" 56 | }, 57 | "$ref": { 58 | "type": "string", 59 | "format": "uri-reference" 60 | }, 61 | "title": { 62 | "type": "string" 63 | }, 64 | "description": { 65 | "type": "string" 66 | }, 67 | "default": {}, 68 | "multipleOf": { 69 | "type": "number", 70 | "exclusiveMinimum": 0 71 | }, 72 | "maximum": { 73 | "type": "number" 74 | }, 75 | "exclusiveMaximum": { 76 | "type": "number" 77 | }, 78 | "minimum": { 79 | "type": "number" 80 | }, 81 | "exclusiveMinimum": { 82 | "type": "number" 83 | }, 84 | "maxLength": { 85 | "$ref": "#/definitions/nonNegativeInteger" 86 | }, 87 | "minLength": { 88 | "$ref": "#/definitions/nonNegativeIntegerDefault0" 89 | }, 90 | "pattern": { 91 | "type": "string", 92 | "format": "regex" 93 | }, 94 | "additionalItems": { 95 | "$ref": "#" 96 | }, 97 | "items": { 98 | "anyOf": [ 99 | { 100 | "$ref": "#" 101 | }, 102 | { 103 | "$ref": "#/definitions/schemaArray" 104 | } 105 | ], 106 | "default": {} 107 | }, 108 | "maxItems": { 109 | "$ref": "#/definitions/nonNegativeInteger" 110 | }, 111 | "minItems": { 112 | "$ref": "#/definitions/nonNegativeIntegerDefault0" 113 | }, 114 | "uniqueItems": { 115 | "type": "boolean", 116 | "default": false 117 | }, 118 | "contains": { 119 | "$ref": "#" 120 | }, 121 | "maxProperties": { 122 | "$ref": "#/definitions/nonNegativeInteger" 123 | }, 124 | "minProperties": { 125 | "$ref": "#/definitions/nonNegativeIntegerDefault0" 126 | }, 127 | "required": { 128 | "$ref": "#/definitions/stringArray" 129 | }, 130 | "additionalProperties": { 131 | "$ref": "#" 132 | }, 133 | "definitions": { 134 | "type": "object", 135 | "additionalProperties": { 136 | "$ref": "#" 137 | }, 138 | "default": {} 139 | }, 140 | "properties": { 141 | "type": "object", 142 | "additionalProperties": { 143 | "$ref": "#" 144 | }, 145 | "default": {} 146 | }, 147 | "patternProperties": { 148 | "type": "object", 149 | "additionalProperties": { 150 | "$ref": "#" 151 | }, 152 | "default": {} 153 | }, 154 | "dependencies": { 155 | "type": "object", 156 | "additionalProperties": { 157 | "anyOf": [ 158 | { 159 | "$ref": "#" 160 | }, 161 | { 162 | "$ref": "#/definitions/stringArray" 163 | } 164 | ] 165 | } 166 | }, 167 | "propertyNames": { 168 | "$ref": "#" 169 | }, 170 | "const": {}, 171 | "enum": { 172 | "type": "array", 173 | "minItems": 1, 174 | "uniqueItems": true 175 | }, 176 | "type": { 177 | "anyOf": [ 178 | { 179 | "$ref": "#/definitions/simpleTypes" 180 | }, 181 | { 182 | "type": "array", 183 | "items": { 184 | "$ref": "#/definitions/simpleTypes" 185 | }, 186 | "minItems": 1, 187 | "uniqueItems": true 188 | } 189 | ] 190 | }, 191 | "format": { 192 | "type": "string" 193 | }, 194 | "allOf": { 195 | "$ref": "#/definitions/schemaArray" 196 | }, 197 | "anyOf": { 198 | "$ref": "#/definitions/schemaArray" 199 | }, 200 | "oneOf": { 201 | "$ref": "#/definitions/schemaArray" 202 | }, 203 | "not": { 204 | "$ref": "#" 205 | } 206 | }, 207 | "default": {} 208 | } 209 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /test/specs/custom-resolvers.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import merger from '../../src'; 3 | const { describe, it } = await import('vitest'); 4 | 5 | describe('simple resolver', () => { 6 | it('merges as expected (with enum)', () => { 7 | const result = merger({ 8 | enum: [1, 2], 9 | allOf: [ 10 | { 11 | enum: [2, 3] 12 | } 13 | ] 14 | }); 15 | 16 | expect(result).to.eql({ 17 | enum: [2] 18 | }); 19 | 20 | const opts = { 21 | resolvers: { 22 | enum(schemas, paths, mergeSchemas, options) { 23 | expect(options).to.eql(opts); 24 | expect(schemas).to.have.length(2); 25 | expect(paths).to.have.length(1); 26 | expect(paths).to.eql(['enum']); 27 | 28 | // inner merge test 29 | const innerSchemas = [ 30 | { 31 | minLength: 1 32 | }, 33 | { 34 | minLength: 7 35 | } 36 | ]; 37 | 38 | const innerResult = mergeSchemas(innerSchemas); 39 | expect(innerResult).to.eql({ 40 | minLength: 7 41 | }); 42 | 43 | return [5]; 44 | } 45 | } 46 | }; 47 | 48 | const resultCustom = merger( 49 | { 50 | enum: [1, 2], 51 | allOf: [ 52 | { 53 | enum: [2, 3] 54 | } 55 | ] 56 | }, 57 | opts 58 | ); 59 | 60 | expect(resultCustom).to.eql({ 61 | enum: [5] 62 | }); 63 | }); 64 | 65 | describe('group resolvers', () => { 66 | it('works as intended with if then else copy resolver', () => { 67 | const conditonalRelated = ['if', 'then', 'else']; 68 | const has = (obj, propName) => 69 | Object.prototype.hasOwnProperty.call(obj, propName); 70 | const opts = { 71 | complexResolvers: { 72 | if: { 73 | // test with same if-then-else resolver 74 | keywords: conditonalRelated, 75 | resolver(schemas, paths, mergers) { 76 | const allWithConditional = schemas.filter((schema) => 77 | conditonalRelated.some((keyword) => has(schema, keyword)) 78 | ); 79 | 80 | // merge sub schemas completely 81 | // if,then,else must not be merged to the base schema, but if they contain allOf themselves, that should be merged 82 | function merge(schema) { 83 | const obj = {}; 84 | if (has(schema, 'if')) obj.if = mergers.if([schema.if]); 85 | if (has(schema, 'then')) obj.then = mergers.then([schema.then]); 86 | if (has(schema, 'else')) obj.else = mergers.else([schema.else]); 87 | return obj; 88 | } 89 | 90 | // first schema with any of the 3 keywords is used as base 91 | const first = merge(allWithConditional.shift()); 92 | return allWithConditional.reduce((all, schema) => { 93 | all.allOf = (all.allOf || []).concat(merge(schema)); 94 | return all; 95 | }, first); 96 | } 97 | } 98 | } 99 | }; 100 | 101 | const resultCustom = merger( 102 | { 103 | allOf: [ 104 | { 105 | if: { 106 | required: ['def'] 107 | }, 108 | then: {}, 109 | else: {} 110 | } 111 | ] 112 | }, 113 | opts 114 | ); 115 | 116 | expect(resultCustom).to.eql({ 117 | if: { 118 | required: ['def'] 119 | }, 120 | then: {}, 121 | else: {} 122 | }); 123 | }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /test/specs/extraction.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'vitest'; 2 | describe('extraction', function () { 3 | describe('anyOf', function () { 4 | it('extracts validation keywords found in all anyOf schemas'); 5 | }); 6 | }); 7 | -------------------------------------------------------------------------------- /test/specs/index.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'vitest'; 2 | import { expect } from 'chai'; 3 | import mergerModule from '../../src'; 4 | import Ajv from 'ajv'; 5 | import _, { isObject, flatten, intersection } from 'lodash'; 6 | import { dereference } from 'json-schema-ref-parser'; 7 | 8 | const ajv = new Ajv(); 9 | 10 | function merger(schema, options) { 11 | const result = mergerModule(schema, options); 12 | try { 13 | if (!ajv.validateSchema(result)) { 14 | throw new Error("Schema returned by resolver isn't valid."); 15 | } 16 | return result; 17 | } catch (e) { 18 | if (!/stack/i.test(e.message)) { 19 | throw e; 20 | } 21 | } 22 | } 23 | 24 | describe('module', function () { 25 | it('merges schema with same object reference multiple places', () => { 26 | const commonSchema = { 27 | allOf: [ 28 | { 29 | properties: { 30 | test: true 31 | } 32 | } 33 | ] 34 | }; 35 | const result = merger({ 36 | properties: { 37 | list: { 38 | items: commonSchema 39 | } 40 | }, 41 | allOf: [commonSchema] 42 | }); 43 | 44 | expect(result).to.eql({ 45 | properties: { 46 | list: { 47 | items: { 48 | properties: { 49 | test: true 50 | } 51 | } 52 | }, 53 | test: true 54 | } 55 | }); 56 | }); 57 | 58 | it('does not alter original schema', () => { 59 | const schema = { 60 | allOf: [ 61 | { 62 | properties: { 63 | test: true 64 | } 65 | } 66 | ] 67 | }; 68 | 69 | const result = merger(schema); 70 | 71 | expect(result).to.eql({ 72 | properties: { 73 | test: true 74 | } 75 | }); 76 | 77 | expect(result).not.to.equal(schema); // not strict equal (identity) 78 | expect(schema).to.eql({ 79 | allOf: [ 80 | { 81 | properties: { 82 | test: true 83 | } 84 | } 85 | ] 86 | }); 87 | }); 88 | 89 | it('does not use any original objects or arrays', () => { 90 | const schema = { 91 | properties: { 92 | arr: { 93 | type: 'array', 94 | items: { 95 | type: 'object' 96 | }, 97 | additionalItems: [ 98 | { 99 | type: 'array' 100 | } 101 | ] 102 | } 103 | }, 104 | allOf: [ 105 | { 106 | properties: { 107 | test: true 108 | } 109 | } 110 | ] 111 | }; 112 | 113 | function innerDeconstruct(schema) { 114 | const allChildObj = Object.entries(schema).map(([, val]) => { 115 | if (isObject(val)) return innerDeconstruct(val); 116 | else return undefined; 117 | }); 118 | return [schema, ...flatten(allChildObj)]; 119 | } 120 | 121 | const getAllObjects = (schema) => 122 | _(innerDeconstruct(schema)).compact().value(); 123 | const inputObjects = getAllObjects(schema); 124 | 125 | const result = merger(schema); 126 | const resultObjects = getAllObjects(result); 127 | 128 | const commonObjects = intersection(inputObjects, resultObjects); 129 | expect(commonObjects).to.have.length(0); 130 | }); 131 | 132 | it('combines simple usecase', function () { 133 | const result = merger({ 134 | allOf: [ 135 | { 136 | type: 'string', 137 | minLength: 1 138 | }, 139 | { 140 | type: 'string', 141 | maxLength: 5 142 | } 143 | ] 144 | }); 145 | 146 | expect(result).to.eql({ 147 | type: 'string', 148 | minLength: 1, 149 | maxLength: 5 150 | }); 151 | }); 152 | 153 | it('combines without allOf', function () { 154 | const result = merger({ 155 | properties: { 156 | foo: { 157 | type: 'string' 158 | } 159 | } 160 | }); 161 | 162 | expect(result).to.eql({ 163 | properties: { 164 | foo: { 165 | type: 'string' 166 | } 167 | } 168 | }); 169 | }); 170 | 171 | describe('simple resolve functionality', function () { 172 | it('merges with default resolver if not defined resolver', function () { 173 | const result = merger({ 174 | title: 'schema1', 175 | allOf: [ 176 | { 177 | title: 'schema2' 178 | }, 179 | { 180 | title: 'schema3' 181 | } 182 | ] 183 | }); 184 | 185 | expect(result).to.eql({ 186 | title: 'schema1' 187 | }); 188 | 189 | const result3 = merger({ 190 | allOf: [ 191 | { 192 | title: 'schema2' 193 | }, 194 | { 195 | title: 'schema3' 196 | } 197 | ] 198 | }); 199 | 200 | expect(result3).to.eql({ 201 | title: 'schema2' 202 | }); 203 | }); 204 | 205 | it('merges minLength if conflict', function () { 206 | const result = merger({ 207 | allOf: [ 208 | { 209 | minLength: 1 210 | }, 211 | { 212 | minLength: 5 213 | } 214 | ] 215 | }); 216 | 217 | expect(result).to.eql({ 218 | minLength: 5 219 | }); 220 | }); 221 | 222 | it('merges minimum if conflict', function () { 223 | const result = merger({ 224 | allOf: [ 225 | { 226 | minimum: 1 227 | }, 228 | { 229 | minimum: 5 230 | } 231 | ] 232 | }); 233 | 234 | expect(result).to.eql({ 235 | minimum: 5 236 | }); 237 | }); 238 | 239 | it('merges exclusiveMinimum if conflict', function () { 240 | const result = merger({ 241 | allOf: [ 242 | { 243 | exclusiveMinimum: 1 244 | }, 245 | { 246 | exclusiveMinimum: 5 247 | } 248 | ] 249 | }); 250 | 251 | expect(result).to.eql({ 252 | exclusiveMinimum: 5 253 | }); 254 | }); 255 | 256 | it('merges minItems if conflict', function () { 257 | const result = merger({ 258 | allOf: [ 259 | { 260 | minItems: 1 261 | }, 262 | { 263 | minItems: 5 264 | } 265 | ] 266 | }); 267 | 268 | expect(result).to.eql({ 269 | minItems: 5 270 | }); 271 | }); 272 | 273 | it('merges maximum if conflict', function () { 274 | const result = merger({ 275 | allOf: [ 276 | { 277 | maximum: 1 278 | }, 279 | { 280 | maximum: 5 281 | } 282 | ] 283 | }); 284 | 285 | expect(result).to.eql({ 286 | maximum: 1 287 | }); 288 | }); 289 | 290 | it('merges exclusiveMaximum if conflict', function () { 291 | const result = merger({ 292 | allOf: [ 293 | { 294 | exclusiveMaximum: 1 295 | }, 296 | { 297 | exclusiveMaximum: 5 298 | } 299 | ] 300 | }); 301 | 302 | expect(result).to.eql({ 303 | exclusiveMaximum: 1 304 | }); 305 | }); 306 | 307 | it('merges maxItems if conflict', function () { 308 | const result = merger({ 309 | allOf: [ 310 | { 311 | maxItems: 1 312 | }, 313 | { 314 | maxItems: 5 315 | } 316 | ] 317 | }); 318 | 319 | expect(result).to.eql({ 320 | maxItems: 1 321 | }); 322 | }); 323 | 324 | it('merges maxLength if conflict', function () { 325 | const result = merger({ 326 | allOf: [ 327 | { 328 | maxLength: 4 329 | }, 330 | { 331 | maxLength: 5 332 | } 333 | ] 334 | }); 335 | 336 | expect(result).to.eql({ 337 | maxLength: 4 338 | }); 339 | }); 340 | 341 | it('merges uniqueItems to most restrictive if conflict', function () { 342 | const result = merger({ 343 | allOf: [ 344 | { 345 | uniqueItems: true 346 | }, 347 | { 348 | uniqueItems: false 349 | } 350 | ] 351 | }); 352 | 353 | expect(result).to.eql({ 354 | uniqueItems: true 355 | }); 356 | 357 | expect( 358 | merger({ 359 | allOf: [ 360 | { 361 | uniqueItems: false 362 | }, 363 | { 364 | uniqueItems: false 365 | } 366 | ] 367 | }) 368 | ).to.eql({ 369 | uniqueItems: false 370 | }); 371 | }); 372 | 373 | it('throws if merging incompatible type', function () { 374 | expect(function () { 375 | merger({ 376 | allOf: [ 377 | { 378 | type: 'null' 379 | }, 380 | { 381 | type: 'text' 382 | } 383 | ] 384 | }); 385 | }).to.throw(/incompatible/); 386 | }); 387 | 388 | it('merges type if conflict', function () { 389 | const result = merger({ 390 | allOf: [ 391 | {}, 392 | { 393 | type: ['string', 'null', 'object', 'array'] 394 | }, 395 | { 396 | type: ['string', 'null'] 397 | }, 398 | { 399 | type: ['null', 'string'] 400 | } 401 | ] 402 | }); 403 | 404 | expect(result).to.eql({ 405 | type: ['string', 'null'] 406 | }); 407 | 408 | const result2 = merger({ 409 | allOf: [ 410 | {}, 411 | { 412 | type: ['string', 'null', 'object', 'array'] 413 | }, 414 | { 415 | type: 'string' 416 | }, 417 | { 418 | type: ['null', 'string'] 419 | } 420 | ] 421 | }); 422 | 423 | expect(result2).to.eql({ 424 | type: 'string' 425 | }); 426 | 427 | expect(function () { 428 | merger({ 429 | allOf: [ 430 | { 431 | type: ['null'] 432 | }, 433 | { 434 | type: ['text', 'object'] 435 | } 436 | ] 437 | }); 438 | }).to.throw(/incompatible/); 439 | }); 440 | 441 | it('merges enum', function () { 442 | const result = merger({ 443 | allOf: [ 444 | {}, 445 | { 446 | enum: ['string', 'null', 'object', {}, [2], [1], null] 447 | }, 448 | { 449 | enum: ['string', {}, [1], [1]] 450 | }, 451 | { 452 | enum: ['null', 'string', {}, [3], [1], null] 453 | } 454 | ] 455 | }); 456 | 457 | expect(result).to.eql({ 458 | enum: [[1], {}, 'string'] 459 | }); 460 | }); 461 | 462 | it('throws if enum is incompatible', function () { 463 | expect(function () { 464 | merger({ 465 | allOf: [ 466 | {}, 467 | { 468 | enum: ['string', {}] 469 | }, 470 | { 471 | enum: [{}, 'string'] 472 | } 473 | ] 474 | }); 475 | }).not.to.throw(/incompatible/); 476 | 477 | expect(function () { 478 | merger({ 479 | allOf: [ 480 | {}, 481 | { 482 | enum: ['string', {}] 483 | }, 484 | { 485 | enum: [[], false] 486 | } 487 | ] 488 | }); 489 | }).to.throw(/incompatible/); 490 | }); 491 | 492 | it('merges const', function () { 493 | const result = merger({ 494 | allOf: [ 495 | {}, 496 | { 497 | const: ['string', {}] 498 | }, 499 | { 500 | const: ['string', {}] 501 | } 502 | ] 503 | }); 504 | 505 | expect(result).to.eql({ 506 | const: ['string', {}] 507 | }); 508 | }); 509 | 510 | it('merges anyOf', function () { 511 | const result = merger({ 512 | allOf: [ 513 | { 514 | anyOf: [ 515 | { 516 | required: ['123'] 517 | } 518 | ] 519 | }, 520 | { 521 | anyOf: [ 522 | { 523 | required: ['123'] 524 | }, 525 | { 526 | required: ['456'] 527 | } 528 | ] 529 | } 530 | ] 531 | }); 532 | 533 | expect(result).to.eql({ 534 | anyOf: [ 535 | { 536 | required: ['123'] 537 | }, 538 | { 539 | required: ['123', '456'] 540 | } 541 | ] 542 | }); 543 | }); 544 | 545 | it('merges anyOf by finding valid combinations', function () { 546 | const result = merger({ 547 | allOf: [ 548 | { 549 | anyOf: [ 550 | { 551 | type: ['null', 'string', 'array'] 552 | }, 553 | { 554 | type: ['null', 'string', 'object'] 555 | } 556 | ] 557 | }, 558 | { 559 | anyOf: [ 560 | { 561 | type: ['null', 'string'] 562 | }, 563 | { 564 | type: ['integer', 'object', 'null'] 565 | } 566 | ] 567 | } 568 | ] 569 | }); 570 | 571 | expect(result).to.eql({ 572 | anyOf: [ 573 | { 574 | type: ['null', 'string'] 575 | }, 576 | { 577 | type: 'null' 578 | }, 579 | { 580 | type: ['object', 'null'] 581 | } 582 | ] 583 | }); 584 | }); 585 | 586 | it.skip('extracts common logic', function () { 587 | const result = merger({ 588 | allOf: [ 589 | { 590 | anyOf: [ 591 | { 592 | type: ['null', 'string', 'array'], 593 | minLength: 5 594 | }, 595 | { 596 | type: ['null', 'string', 'object'], 597 | minLength: 5 598 | } 599 | ] 600 | }, 601 | { 602 | anyOf: [ 603 | { 604 | type: ['null', 'string'], 605 | minLength: 5 606 | }, 607 | { 608 | type: ['integer', 'object', 'null'] 609 | } 610 | ] 611 | } 612 | ] 613 | }); 614 | 615 | // TODO I think this is correct 616 | // TODO implement functionality 617 | expect(result).to.eql({ 618 | type: 'null', 619 | minLength: 5, 620 | anyOf: [ 621 | { 622 | type: 'string' 623 | } 624 | ] 625 | }); 626 | }); 627 | 628 | it.skip('merges anyOf into main schema if left with only one combination', function () { 629 | const result = merger({ 630 | required: ['abc'], 631 | allOf: [ 632 | { 633 | anyOf: [ 634 | { 635 | required: ['123'] 636 | }, 637 | { 638 | required: ['456'] 639 | } 640 | ] 641 | }, 642 | { 643 | anyOf: [ 644 | { 645 | required: ['123'] 646 | } 647 | ] 648 | } 649 | ] 650 | }); 651 | 652 | expect(result).to.eql({ 653 | required: ['abc', '123'] 654 | }); 655 | }); 656 | 657 | it('merges nested allOf if inside singular anyOf', function () { 658 | const result = merger({ 659 | allOf: [ 660 | { 661 | anyOf: [ 662 | { 663 | required: ['123'], 664 | allOf: [ 665 | { 666 | required: ['768'] 667 | } 668 | ] 669 | } 670 | ] 671 | }, 672 | { 673 | anyOf: [ 674 | { 675 | required: ['123'] 676 | }, 677 | { 678 | required: ['456'] 679 | } 680 | ] 681 | } 682 | ] 683 | }); 684 | 685 | expect(result).to.eql({ 686 | anyOf: [ 687 | { 688 | required: ['123', '768'] 689 | }, 690 | { 691 | required: ['123', '456', '768'] 692 | } 693 | ] 694 | }); 695 | }); 696 | 697 | it('throws if no intersection at all', function () { 698 | expect(function () { 699 | merger({ 700 | allOf: [ 701 | { 702 | anyOf: [ 703 | { 704 | type: ['object', 'string', 'null'] 705 | } 706 | ] 707 | }, 708 | { 709 | anyOf: [ 710 | { 711 | type: ['array', 'integer'] 712 | } 713 | ] 714 | } 715 | ] 716 | }); 717 | }).to.throw(/incompatible/); 718 | 719 | expect(function () { 720 | merger({ 721 | allOf: [ 722 | { 723 | anyOf: [ 724 | { 725 | type: ['object', 'string', 'null'] 726 | } 727 | ] 728 | }, 729 | { 730 | anyOf: [ 731 | { 732 | type: ['array', 'integer'] 733 | } 734 | ] 735 | } 736 | ] 737 | }); 738 | }).to.throw(/incompatible/); 739 | }); 740 | 741 | it('merges more complex oneOf', function () { 742 | const result = merger({ 743 | allOf: [ 744 | { 745 | oneOf: [ 746 | { 747 | type: ['array', 'string', 'object'], 748 | required: ['123'] 749 | }, 750 | { 751 | required: ['abc'] 752 | } 753 | ] 754 | }, 755 | { 756 | oneOf: [ 757 | { 758 | type: ['string'] 759 | }, 760 | { 761 | type: ['object', 'array'], 762 | required: ['abc'] 763 | } 764 | ] 765 | } 766 | ] 767 | }); 768 | 769 | expect(result).to.eql({ 770 | oneOf: [ 771 | { 772 | type: 'string', 773 | required: ['123'] 774 | }, 775 | { 776 | type: ['object', 'array'], 777 | required: ['123', 'abc'] 778 | }, 779 | { 780 | type: ['string'], 781 | required: ['abc'] 782 | }, 783 | { 784 | type: ['object', 'array'], 785 | required: ['abc'] 786 | } 787 | ] 788 | }); 789 | }); 790 | 791 | it('merges nested allOf if inside singular oneOf', function () { 792 | const result = merger({ 793 | allOf: [ 794 | { 795 | type: ['array', 'string', 'number'], 796 | oneOf: [ 797 | { 798 | required: ['123'], 799 | allOf: [ 800 | { 801 | required: ['768'] 802 | } 803 | ] 804 | } 805 | ] 806 | }, 807 | { 808 | type: ['array', 'string'] 809 | } 810 | ] 811 | }); 812 | 813 | expect(result).to.eql({ 814 | type: ['array', 'string'], 815 | oneOf: [ 816 | { 817 | required: ['123', '768'] 818 | } 819 | ] 820 | }); 821 | }); 822 | 823 | it('merges nested allOf if inside multiple oneOf', function () { 824 | const result = merger({ 825 | allOf: [ 826 | { 827 | type: ['array', 'string', 'number'], 828 | oneOf: [ 829 | { 830 | type: ['array', 'object'], 831 | allOf: [ 832 | { 833 | type: 'object' 834 | } 835 | ] 836 | } 837 | ] 838 | }, 839 | { 840 | type: ['array', 'string'], 841 | oneOf: [ 842 | { 843 | type: 'string' 844 | }, 845 | { 846 | type: 'object' 847 | } 848 | ] 849 | } 850 | ] 851 | }); 852 | 853 | expect(result).to.eql({ 854 | type: ['array', 'string'], 855 | oneOf: [ 856 | { 857 | type: 'object' 858 | } 859 | ] 860 | }); 861 | }); 862 | 863 | it.skip('throws if no compatible when merging oneOf', function () { 864 | expect(function () { 865 | merger({ 866 | allOf: [ 867 | {}, 868 | { 869 | oneOf: [ 870 | { 871 | required: ['123'] 872 | } 873 | ] 874 | }, 875 | { 876 | oneOf: [ 877 | { 878 | required: ['fdasfd'] 879 | } 880 | ] 881 | } 882 | ] 883 | }); 884 | }).to.throw(/incompatible/); 885 | 886 | expect(function () { 887 | merger({ 888 | allOf: [ 889 | {}, 890 | { 891 | oneOf: [ 892 | { 893 | required: ['123'] 894 | }, 895 | { 896 | properties: { 897 | name: { 898 | type: 'string' 899 | } 900 | } 901 | } 902 | ] 903 | }, 904 | { 905 | oneOf: [ 906 | { 907 | required: ['fdasfd'] 908 | } 909 | ] 910 | } 911 | ] 912 | }); 913 | }).to.throw(/incompatible/); 914 | }); 915 | 916 | // not ready to implement this yet 917 | it.skip('merges singular oneOf', function () { 918 | const result = merger({ 919 | properties: { 920 | name: { 921 | type: 'string' 922 | } 923 | }, 924 | allOf: [ 925 | { 926 | properties: { 927 | name: { 928 | type: 'string', 929 | minLength: 10 930 | } 931 | } 932 | }, 933 | { 934 | oneOf: [ 935 | { 936 | required: ['123'] 937 | }, 938 | { 939 | properties: { 940 | name: { 941 | type: 'string', 942 | minLength: 15 943 | } 944 | } 945 | } 946 | ] 947 | }, 948 | { 949 | oneOf: [ 950 | { 951 | required: ['abc'] 952 | }, 953 | { 954 | properties: { 955 | name: { 956 | type: 'string', 957 | minLength: 15 958 | } 959 | } 960 | } 961 | ] 962 | } 963 | ] 964 | }); 965 | 966 | expect(result).to.eql({ 967 | properties: { 968 | name: { 969 | type: 'string', 970 | minLength: 15 971 | } 972 | } 973 | }); 974 | }); 975 | 976 | it('merges not using allOf', function () { 977 | const result = merger({ 978 | allOf: [ 979 | { 980 | not: { 981 | properties: { 982 | name: { 983 | type: 'string' 984 | } 985 | } 986 | } 987 | }, 988 | { 989 | not: { 990 | properties: { 991 | name: { 992 | type: ['string', 'null'] 993 | } 994 | } 995 | } 996 | } 997 | ] 998 | }); 999 | 1000 | expect(result).to.eql({ 1001 | not: { 1002 | anyOf: [ 1003 | { 1004 | properties: { 1005 | name: { 1006 | type: 'string' 1007 | } 1008 | } 1009 | }, 1010 | { 1011 | properties: { 1012 | name: { 1013 | type: ['string', 'null'] 1014 | } 1015 | } 1016 | } 1017 | ] 1018 | } 1019 | }); 1020 | }); 1021 | 1022 | it('merges contains', function () { 1023 | const result = merger({ 1024 | allOf: [ 1025 | {}, 1026 | { 1027 | contains: { 1028 | properties: { 1029 | name: { 1030 | type: 'string', 1031 | pattern: 'bar' 1032 | } 1033 | } 1034 | } 1035 | }, 1036 | { 1037 | contains: { 1038 | properties: { 1039 | name: { 1040 | type: 'string', 1041 | pattern: 'foo' 1042 | } 1043 | } 1044 | } 1045 | } 1046 | ] 1047 | }); 1048 | 1049 | expect(result).to.eql({ 1050 | contains: { 1051 | properties: { 1052 | name: { 1053 | type: 'string', 1054 | pattern: '(?=bar)(?=foo)' 1055 | } 1056 | } 1057 | } 1058 | }); 1059 | }); 1060 | 1061 | it('merges pattern using allOf', function () { 1062 | const result = merger({ 1063 | allOf: [ 1064 | {}, 1065 | { 1066 | pattern: 'fdsaf' 1067 | }, 1068 | { 1069 | pattern: 'abba' 1070 | } 1071 | ] 1072 | }); 1073 | 1074 | expect(result).to.eql({ 1075 | pattern: '(?=fdsaf)(?=abba)' 1076 | }); 1077 | 1078 | const result2 = merger({ 1079 | allOf: [ 1080 | { 1081 | pattern: 'abba' 1082 | } 1083 | ] 1084 | }); 1085 | 1086 | expect(result2).to.eql({ 1087 | pattern: 'abba' 1088 | }); 1089 | }); 1090 | 1091 | it('extracts pattern from anyOf and oneOf using | operator in regexp'); 1092 | 1093 | it.skip('merges multipleOf using allOf or direct assignment', function () { 1094 | const result = merger({ 1095 | allOf: [ 1096 | { 1097 | title: 'foo', 1098 | type: ['number', 'integer'], 1099 | multipleOf: 2 1100 | }, 1101 | { 1102 | type: 'integer', 1103 | multipleOf: 3 1104 | } 1105 | ] 1106 | }); 1107 | 1108 | expect(result).to.eql({ 1109 | type: 'integer', 1110 | title: 'foo', 1111 | allOf: [ 1112 | { 1113 | multipleOf: 2 1114 | }, 1115 | { 1116 | multipleOf: 3 1117 | } 1118 | ] 1119 | }); 1120 | 1121 | const result2 = merger({ 1122 | allOf: [ 1123 | { 1124 | multipleOf: 1 1125 | } 1126 | ] 1127 | }); 1128 | 1129 | expect(result2).to.eql({ 1130 | multipleOf: 1 1131 | }); 1132 | }); 1133 | 1134 | it('merges multipleOf by finding lowest common multiple (LCM)', function () { 1135 | const result = merger({ 1136 | allOf: [ 1137 | {}, 1138 | { 1139 | multipleOf: 0.2, 1140 | allOf: [ 1141 | { 1142 | multipleOf: 2, 1143 | allOf: [ 1144 | { 1145 | multipleOf: 2, 1146 | allOf: [ 1147 | { 1148 | multipleOf: 2, 1149 | allOf: [ 1150 | { 1151 | multipleOf: 3, 1152 | allOf: [ 1153 | { 1154 | multipleOf: 1.5, 1155 | allOf: [ 1156 | { 1157 | multipleOf: 0.5 1158 | } 1159 | ] 1160 | } 1161 | ] 1162 | } 1163 | ] 1164 | } 1165 | ] 1166 | } 1167 | ] 1168 | } 1169 | ] 1170 | }, 1171 | { 1172 | multipleOf: 0.3 1173 | } 1174 | ] 1175 | }); 1176 | 1177 | expect(result).to.eql({ 1178 | multipleOf: 6 1179 | }); 1180 | 1181 | expect( 1182 | merger({ 1183 | allOf: [ 1184 | { 1185 | multipleOf: 4 1186 | }, 1187 | { 1188 | multipleOf: 15 1189 | }, 1190 | { 1191 | multipleOf: 3 1192 | } 1193 | ] 1194 | }) 1195 | ).to.eql({ 1196 | multipleOf: 60 1197 | }); 1198 | 1199 | expect( 1200 | merger({ 1201 | allOf: [ 1202 | { 1203 | multipleOf: 0.3 1204 | }, 1205 | { 1206 | multipleOf: 0.7 1207 | }, 1208 | { 1209 | multipleOf: 1 1210 | } 1211 | ] 1212 | }) 1213 | ).to.eql({ 1214 | multipleOf: 21 1215 | }); 1216 | 1217 | expect( 1218 | merger({ 1219 | allOf: [ 1220 | { 1221 | multipleOf: 0.5 1222 | }, 1223 | { 1224 | multipleOf: 2 1225 | } 1226 | ] 1227 | }) 1228 | ).to.eql({ 1229 | multipleOf: 2 1230 | }); 1231 | 1232 | expect( 1233 | merger({ 1234 | allOf: [ 1235 | { 1236 | multipleOf: 0.3 1237 | }, 1238 | { 1239 | multipleOf: 0.5 1240 | }, 1241 | { 1242 | multipleOf: 1 1243 | } 1244 | ] 1245 | }) 1246 | ).to.eql({ 1247 | multipleOf: 3 1248 | }); 1249 | 1250 | expect( 1251 | merger({ 1252 | allOf: [ 1253 | { 1254 | multipleOf: 0.3 1255 | }, 1256 | { 1257 | multipleOf: 0.7 1258 | }, 1259 | { 1260 | multipleOf: 1 1261 | } 1262 | ] 1263 | }) 1264 | ).to.eql({ 1265 | multipleOf: 21 1266 | }); 1267 | 1268 | expect( 1269 | merger({ 1270 | allOf: [ 1271 | { 1272 | multipleOf: 0.4 1273 | }, 1274 | { 1275 | multipleOf: 0.7 1276 | }, 1277 | { 1278 | multipleOf: 3 1279 | } 1280 | ] 1281 | }) 1282 | ).to.eql({ 1283 | multipleOf: 42 1284 | }); 1285 | 1286 | expect( 1287 | merger({ 1288 | allOf: [ 1289 | { 1290 | multipleOf: 0.2 1291 | }, 1292 | { 1293 | multipleOf: 0.65 1294 | }, 1295 | { 1296 | multipleOf: 1 1297 | } 1298 | ] 1299 | }) 1300 | ).to.eql({ 1301 | multipleOf: 13 1302 | }); 1303 | 1304 | expect( 1305 | merger({ 1306 | allOf: [ 1307 | { 1308 | multipleOf: 100000 1309 | }, 1310 | { 1311 | multipleOf: 1000000 1312 | }, 1313 | { 1314 | multipleOf: 500000 1315 | } 1316 | ] 1317 | }) 1318 | ).to.eql({ 1319 | multipleOf: 1000000 1320 | }); 1321 | }); 1322 | }); 1323 | 1324 | describe('merging arrays', function () { 1325 | it('merges required object', function () { 1326 | expect( 1327 | merger({ 1328 | required: ['prop2'], 1329 | allOf: [ 1330 | { 1331 | required: ['prop2', 'prop1'] 1332 | } 1333 | ] 1334 | }) 1335 | ).to.eql({ 1336 | required: ['prop1', 'prop2'] 1337 | }); 1338 | }); 1339 | 1340 | it('merges default value', function () { 1341 | expect( 1342 | merger({ 1343 | default: [ 1344 | 'prop2', 1345 | { 1346 | prop1: 'foo' 1347 | } 1348 | ], 1349 | allOf: [ 1350 | { 1351 | default: ['prop2', 'prop1'] 1352 | } 1353 | ] 1354 | }) 1355 | ).to.eql({ 1356 | default: [ 1357 | 'prop2', 1358 | { 1359 | prop1: 'foo' 1360 | } 1361 | ] 1362 | }); 1363 | }); 1364 | 1365 | it('merges default value', function () { 1366 | expect( 1367 | merger({ 1368 | default: { 1369 | foo: 'bar' 1370 | }, 1371 | allOf: [ 1372 | { 1373 | default: ['prop2', 'prop1'] 1374 | } 1375 | ] 1376 | }) 1377 | ).to.eql({ 1378 | default: { 1379 | foo: 'bar' 1380 | } 1381 | }); 1382 | }); 1383 | }); 1384 | 1385 | describe('merging objects', function () { 1386 | it('merges child objects', function () { 1387 | expect( 1388 | merger({ 1389 | properties: { 1390 | name: { 1391 | title: 'Name', 1392 | type: 'string' 1393 | } 1394 | }, 1395 | allOf: [ 1396 | { 1397 | properties: { 1398 | name: { 1399 | title: 'allof1', 1400 | type: 'string' 1401 | }, 1402 | added: { 1403 | type: 'integer' 1404 | } 1405 | } 1406 | }, 1407 | { 1408 | properties: { 1409 | name: { 1410 | type: 'string' 1411 | } 1412 | } 1413 | } 1414 | ] 1415 | }) 1416 | ).to.eql({ 1417 | properties: { 1418 | name: { 1419 | title: 'Name', 1420 | type: 'string' 1421 | }, 1422 | added: { 1423 | type: 'integer' 1424 | } 1425 | } 1426 | }); 1427 | }); 1428 | 1429 | it('merges boolean schemas', function () { 1430 | expect( 1431 | merger({ 1432 | properties: { 1433 | name: true 1434 | }, 1435 | allOf: [ 1436 | { 1437 | properties: { 1438 | name: { 1439 | title: 'allof1', 1440 | type: 'string' 1441 | }, 1442 | added: { 1443 | type: 'integer' 1444 | } 1445 | } 1446 | }, 1447 | { 1448 | properties: { 1449 | name: { 1450 | type: 'string', 1451 | minLength: 5 1452 | } 1453 | } 1454 | } 1455 | ] 1456 | }) 1457 | ).to.eql({ 1458 | properties: { 1459 | name: { 1460 | title: 'allof1', 1461 | type: 'string', 1462 | minLength: 5 1463 | }, 1464 | added: { 1465 | type: 'integer' 1466 | } 1467 | } 1468 | }); 1469 | 1470 | expect( 1471 | merger({ 1472 | properties: { 1473 | name: false 1474 | }, 1475 | allOf: [ 1476 | { 1477 | properties: { 1478 | name: { 1479 | title: 'allof1', 1480 | type: 'string' 1481 | }, 1482 | added: { 1483 | type: 'integer' 1484 | } 1485 | } 1486 | }, 1487 | { 1488 | properties: { 1489 | name: true 1490 | } 1491 | } 1492 | ] 1493 | }) 1494 | ).to.eql({ 1495 | properties: { 1496 | name: false, 1497 | added: { 1498 | type: 'integer' 1499 | } 1500 | } 1501 | }); 1502 | 1503 | expect( 1504 | merger({ 1505 | allOf: [true, false] 1506 | }) 1507 | ).to.eql(false); 1508 | 1509 | expect( 1510 | merger({ 1511 | properties: { 1512 | name: true 1513 | }, 1514 | allOf: [ 1515 | { 1516 | properties: { 1517 | name: false, 1518 | added: { 1519 | type: 'integer' 1520 | } 1521 | } 1522 | }, 1523 | { 1524 | properties: { 1525 | name: true 1526 | } 1527 | } 1528 | ] 1529 | }) 1530 | ).to.eql({ 1531 | properties: { 1532 | name: false, 1533 | added: { 1534 | type: 'integer' 1535 | } 1536 | } 1537 | }); 1538 | }); 1539 | 1540 | it('merges all allOf', function () { 1541 | expect( 1542 | merger({ 1543 | properties: { 1544 | name: { 1545 | allOf: [ 1546 | { 1547 | pattern: '^.+$' 1548 | } 1549 | ] 1550 | } 1551 | }, 1552 | allOf: [ 1553 | { 1554 | properties: { 1555 | name: true, 1556 | added: { 1557 | type: 'integer', 1558 | title: 'pri1', 1559 | allOf: [ 1560 | { 1561 | title: 'pri2', 1562 | type: ['string', 'integer'], 1563 | minimum: 15, 1564 | maximum: 10 1565 | } 1566 | ] 1567 | } 1568 | }, 1569 | allOf: [ 1570 | { 1571 | properties: { 1572 | name: true, 1573 | added: { 1574 | type: 'integer', 1575 | minimum: 5 1576 | } 1577 | }, 1578 | allOf: [ 1579 | { 1580 | properties: { 1581 | added: { 1582 | title: 'pri3', 1583 | type: 'integer', 1584 | minimum: 10 1585 | } 1586 | } 1587 | } 1588 | ] 1589 | } 1590 | ] 1591 | }, 1592 | { 1593 | properties: { 1594 | name: true, 1595 | added: { 1596 | minimum: 7 1597 | } 1598 | } 1599 | } 1600 | ] 1601 | }) 1602 | ).to.eql({ 1603 | properties: { 1604 | name: { 1605 | pattern: '^.+$' 1606 | }, 1607 | added: { 1608 | type: 'integer', 1609 | title: 'pri1', 1610 | minimum: 15, 1611 | maximum: 10 1612 | } 1613 | } 1614 | }); 1615 | }); 1616 | }); 1617 | 1618 | describe.skip('merging definitions', function () { 1619 | it('merges circular', function () { 1620 | const schema = { 1621 | properties: { 1622 | person: { 1623 | properties: { 1624 | name: { 1625 | type: 'string', 1626 | minLength: 8 1627 | } 1628 | }, 1629 | allOf: [ 1630 | { 1631 | properties: { 1632 | name: { 1633 | minLength: 5, 1634 | maxLength: 10 1635 | } 1636 | }, 1637 | allOf: [ 1638 | { 1639 | properties: { 1640 | prop1: { 1641 | minLength: 7 1642 | } 1643 | } 1644 | } 1645 | ] 1646 | } 1647 | ] 1648 | } 1649 | } 1650 | }; 1651 | 1652 | schema.properties.person.properties.child = schema.properties.person; 1653 | 1654 | const expected = { 1655 | person: { 1656 | properties: { 1657 | name: { 1658 | minLength: 8, 1659 | maxLength: 10, 1660 | type: 'string' 1661 | }, 1662 | prop1: { 1663 | minLength: 7 1664 | } 1665 | } 1666 | } 1667 | }; 1668 | 1669 | expected.person.properties.child = expected.person; 1670 | 1671 | const result = merger(schema); 1672 | 1673 | expect(result).to.eql({ 1674 | properties: expected 1675 | }); 1676 | }); 1677 | 1678 | it('merges any definitions and circular', function () { 1679 | const schema = { 1680 | properties: { 1681 | person: { 1682 | $ref: '#/definitions/person' 1683 | } 1684 | }, 1685 | definitions: { 1686 | person: { 1687 | properties: { 1688 | name: { 1689 | type: 'string', 1690 | minLength: 8 1691 | }, 1692 | child: { 1693 | $ref: '#/definitions/person' 1694 | } 1695 | }, 1696 | allOf: [ 1697 | { 1698 | properties: { 1699 | name: { 1700 | minLength: 5, 1701 | maxLength: 10 1702 | } 1703 | }, 1704 | allOf: [ 1705 | { 1706 | properties: { 1707 | prop1: { 1708 | minLength: 7 1709 | } 1710 | } 1711 | } 1712 | ] 1713 | } 1714 | ] 1715 | } 1716 | } 1717 | }; 1718 | 1719 | return dereference(schema).then(function (dereferenced) { 1720 | const expected = { 1721 | person: { 1722 | properties: { 1723 | name: { 1724 | minLength: 8, 1725 | maxLength: 10, 1726 | type: 'string' 1727 | }, 1728 | prop1: { 1729 | minLength: 7 1730 | } 1731 | } 1732 | } 1733 | }; 1734 | 1735 | expected.person.properties.child = expected.person; 1736 | 1737 | const result = merger(schema); 1738 | 1739 | expect(result).to.eql({ 1740 | properties: expected, 1741 | definitions: expected 1742 | }); 1743 | 1744 | expect(result).to.equal(dereferenced); 1745 | 1746 | expect(result.properties.person.properties.child).to.equal( 1747 | result.definitions.person.properties.child 1748 | ); 1749 | expect(result.properties.person.properties.child).to.equal( 1750 | dereferenced.properties.person 1751 | ); 1752 | }); 1753 | }); 1754 | }); 1755 | 1756 | describe('dependencies', function () { 1757 | it('merges simliar schemas', function () { 1758 | const result = merger({ 1759 | dependencies: { 1760 | foo: { 1761 | type: ['string', 'null', 'integer'], 1762 | allOf: [ 1763 | { 1764 | minimum: 5 1765 | } 1766 | ] 1767 | }, 1768 | bar: ['prop1', 'prop2'] 1769 | }, 1770 | allOf: [ 1771 | { 1772 | dependencies: { 1773 | foo: { 1774 | type: ['string', 'null'], 1775 | allOf: [ 1776 | { 1777 | minimum: 7 1778 | } 1779 | ] 1780 | }, 1781 | bar: ['prop4'] 1782 | } 1783 | } 1784 | ] 1785 | }); 1786 | 1787 | expect(result).to.eql({ 1788 | dependencies: { 1789 | foo: { 1790 | type: ['string', 'null'], 1791 | minimum: 7 1792 | }, 1793 | bar: ['prop1', 'prop2', 'prop4'] 1794 | } 1795 | }); 1796 | }); 1797 | 1798 | it('merges mixed mode dependency', function () { 1799 | const result = merger({ 1800 | dependencies: { 1801 | bar: { 1802 | type: ['string', 'null', 'integer'], 1803 | required: ['abc'] 1804 | } 1805 | }, 1806 | allOf: [ 1807 | { 1808 | dependencies: { 1809 | bar: ['prop4'] 1810 | } 1811 | } 1812 | ] 1813 | }); 1814 | 1815 | expect(result).to.eql({ 1816 | dependencies: { 1817 | bar: { 1818 | type: ['string', 'null', 'integer'], 1819 | required: ['abc', 'prop4'] 1820 | } 1821 | } 1822 | }); 1823 | }); 1824 | }); 1825 | 1826 | describe('propertyNames', function () { 1827 | it('merges simliar schemas', function () { 1828 | const result = merger({ 1829 | propertyNames: { 1830 | type: 'string', 1831 | allOf: [ 1832 | { 1833 | minLength: 5 1834 | } 1835 | ] 1836 | }, 1837 | allOf: [ 1838 | { 1839 | propertyNames: { 1840 | type: 'string', 1841 | pattern: 'abc.*', 1842 | allOf: [ 1843 | { 1844 | maxLength: 7 1845 | } 1846 | ] 1847 | } 1848 | } 1849 | ] 1850 | }); 1851 | 1852 | expect(result).to.eql({ 1853 | propertyNames: { 1854 | type: 'string', 1855 | pattern: 'abc.*', 1856 | minLength: 5, 1857 | maxLength: 7 1858 | } 1859 | }); 1860 | }); 1861 | }); 1862 | }); 1863 | -------------------------------------------------------------------------------- /test/specs/items.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'vitest'; 2 | import { expect } from 'chai'; 3 | import merger from '../../src'; 4 | 5 | describe('items', function () { 6 | it('merges additionalItems', function () { 7 | const result = merger({ 8 | items: { 9 | type: 'object' 10 | }, 11 | allOf: [ 12 | { 13 | items: [true], 14 | additionalItems: { 15 | properties: { 16 | name: { 17 | type: 'string', 18 | pattern: 'bar' 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | items: [true], 25 | additionalItems: { 26 | properties: { 27 | name: { 28 | type: 'string', 29 | pattern: 'foo' 30 | } 31 | } 32 | } 33 | } 34 | ] 35 | }); 36 | 37 | expect(result).to.eql({ 38 | items: [ 39 | { 40 | type: 'object' 41 | } 42 | ], 43 | additionalItems: { 44 | type: 'object', 45 | properties: { 46 | name: { 47 | type: 'string', 48 | pattern: '(?=bar)(?=foo)' 49 | } 50 | } 51 | } 52 | }); 53 | }); 54 | 55 | describe('when single schema', function () { 56 | it('merges them', function () { 57 | const result = merger({ 58 | items: { 59 | type: 'string', 60 | allOf: [ 61 | { 62 | minLength: 5 63 | } 64 | ] 65 | }, 66 | allOf: [ 67 | { 68 | items: { 69 | type: 'string', 70 | pattern: 'abc.*', 71 | allOf: [ 72 | { 73 | maxLength: 7 74 | } 75 | ] 76 | } 77 | } 78 | ] 79 | }); 80 | 81 | expect(result).to.eql({ 82 | items: { 83 | type: 'string', 84 | pattern: 'abc.*', 85 | minLength: 5, 86 | maxLength: 7 87 | } 88 | }); 89 | }); 90 | }); 91 | 92 | describe('when array', function () { 93 | it('merges them in when additionalItems are all undefined', function () { 94 | const result = merger({ 95 | items: [ 96 | { 97 | type: 'string', 98 | allOf: [ 99 | { 100 | minLength: 5 101 | } 102 | ] 103 | } 104 | ], 105 | allOf: [ 106 | { 107 | items: [ 108 | { 109 | type: 'string', 110 | allOf: [ 111 | { 112 | minLength: 5 113 | } 114 | ] 115 | }, 116 | { 117 | type: 'integer' 118 | } 119 | ] 120 | } 121 | ] 122 | }); 123 | 124 | expect(result).to.eql({ 125 | items: [ 126 | { 127 | type: 'string', 128 | minLength: 5 129 | }, 130 | { 131 | type: 'integer' 132 | } 133 | ] 134 | }); 135 | }); 136 | 137 | it('merges in additionalItems from one if present', function () { 138 | const result = merger({ 139 | allOf: [ 140 | { 141 | items: [ 142 | { 143 | type: 'string', 144 | minLength: 10, 145 | allOf: [ 146 | { 147 | minLength: 5 148 | } 149 | ] 150 | }, 151 | { 152 | type: 'integer' 153 | } 154 | ] 155 | }, 156 | { 157 | additionalItems: false, 158 | items: [ 159 | { 160 | type: 'string', 161 | allOf: [ 162 | { 163 | minLength: 7 164 | } 165 | ] 166 | } 167 | ] 168 | } 169 | ] 170 | }); 171 | 172 | expect(result).to.eql({ 173 | additionalItems: false, 174 | items: [ 175 | { 176 | type: 'string', 177 | minLength: 10 178 | } 179 | ] 180 | }); 181 | }); 182 | 183 | it('merges in additionalItems from one if present', function () { 184 | const result = merger({ 185 | allOf: [ 186 | { 187 | items: [ 188 | { 189 | type: 'string', 190 | minLength: 10, 191 | allOf: [ 192 | { 193 | minLength: 5 194 | } 195 | ] 196 | }, 197 | { 198 | type: 'integer' 199 | } 200 | ], 201 | additionalItems: false 202 | }, 203 | { 204 | additionalItems: false, 205 | items: [ 206 | { 207 | type: 'string', 208 | allOf: [ 209 | { 210 | minLength: 7 211 | } 212 | ] 213 | } 214 | ] 215 | } 216 | ] 217 | }); 218 | 219 | expect(result).to.eql({ 220 | additionalItems: false, 221 | items: [ 222 | { 223 | type: 'string', 224 | minLength: 10 225 | } 226 | ] 227 | }); 228 | }); 229 | 230 | it('merges in additionalItems schema', function () { 231 | const result = merger({ 232 | allOf: [ 233 | { 234 | items: [ 235 | { 236 | type: 'string', 237 | minLength: 10, 238 | allOf: [ 239 | { 240 | minLength: 5 241 | } 242 | ] 243 | }, 244 | { 245 | type: 'integer' 246 | } 247 | ], 248 | additionalItems: { 249 | type: 'integer', 250 | minimum: 15 251 | } 252 | }, 253 | { 254 | additionalItems: { 255 | type: 'integer', 256 | minimum: 10 257 | }, 258 | items: [ 259 | { 260 | type: 'string', 261 | allOf: [ 262 | { 263 | minLength: 7 264 | } 265 | ] 266 | } 267 | ] 268 | } 269 | ] 270 | }); 271 | 272 | expect(result).to.eql({ 273 | additionalItems: { 274 | type: 'integer', 275 | minimum: 15 276 | }, 277 | items: [ 278 | { 279 | type: 'string', 280 | minLength: 10 281 | }, 282 | { 283 | type: 'integer', 284 | minimum: 10 285 | } 286 | ] 287 | }); 288 | }); 289 | }); 290 | 291 | describe('when mixed array and object', function () { 292 | it('merges in additionalItems schema', function () { 293 | const result = merger({ 294 | // This should be ignored according to spec when items absent 295 | additionalItems: { 296 | type: 'integer', 297 | minimum: 50 298 | }, 299 | allOf: [ 300 | { 301 | items: { 302 | type: 'integer', 303 | minimum: 5, 304 | maximum: 30, 305 | allOf: [ 306 | { 307 | minimum: 9 308 | } 309 | ] 310 | }, 311 | // This should be ignored according to spec when items is object 312 | additionalItems: { 313 | type: 'integer', 314 | minimum: 15 315 | } 316 | }, 317 | { 318 | // this will be merged with first allOf items schema 319 | additionalItems: { 320 | type: 'integer', 321 | minimum: 10 322 | }, 323 | // this will be merged with first allOf items schema 324 | items: [ 325 | { 326 | type: 'integer', 327 | allOf: [ 328 | { 329 | minimum: 7, 330 | maximum: 20 331 | } 332 | ] 333 | } 334 | ] 335 | } 336 | ] 337 | }); 338 | 339 | expect(result).to.eql({ 340 | additionalItems: { 341 | type: 'integer', 342 | minimum: 10, 343 | maximum: 30 344 | }, 345 | items: [ 346 | { 347 | type: 'integer', 348 | minimum: 9, 349 | maximum: 20 350 | } 351 | ] 352 | }); 353 | }); 354 | 355 | it('considers additionalItems'); 356 | }); 357 | }); 358 | -------------------------------------------------------------------------------- /test/specs/meta-schema.spec.js: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, it } from 'vitest'; 2 | import { expect } from 'chai'; 3 | import merger from '../../src'; 4 | import { cloneDeep } from 'lodash'; 5 | import { dereference } from 'json-schema-ref-parser'; 6 | import metaSchema from '../fixtures/schemas/meta-schema-v6.json'; 7 | 8 | let schema; 9 | describe.skip('simplify the meta schema', function () { 10 | beforeEach(function () { 11 | return dereference(cloneDeep(metaSchema)).then(function (dereferenced) { 12 | schema = dereferenced; 13 | }); 14 | }); 15 | 16 | it('simplifies', function () { 17 | const result = merger(cloneDeep(schema)); 18 | expect(result).to.eql(schema); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /test/specs/options.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'vitest'; 2 | import { expect } from 'chai'; 3 | import merger from '../../src'; 4 | 5 | describe('options', function () { 6 | it('allows otherwise incompatible properties if option ignoreAdditionalProperties is true', function () { 7 | const result = merger( 8 | { 9 | allOf: [ 10 | { 11 | properties: { 12 | foo: true 13 | }, 14 | additionalProperties: true 15 | }, 16 | { 17 | properties: { 18 | bar: true 19 | }, 20 | additionalProperties: false 21 | } 22 | ] 23 | }, 24 | { 25 | ignoreAdditionalProperties: true 26 | } 27 | ); 28 | 29 | expect(result).to.eql({ 30 | properties: { 31 | foo: true, 32 | bar: true 33 | }, 34 | additionalProperties: false 35 | }); 36 | 37 | const result2 = merger({ 38 | allOf: [ 39 | { 40 | additionalProperties: true 41 | }, 42 | { 43 | additionalProperties: true 44 | } 45 | ] 46 | }); 47 | 48 | expect(result2).to.eql({}); 49 | }); 50 | 51 | it('ignoreAdditionalProperties is true, also allows merging of patternProperties', function () { 52 | const result = merger( 53 | { 54 | allOf: [ 55 | { 56 | properties: { 57 | foo: true 58 | }, 59 | patternProperties: { 60 | '^abc': true 61 | }, 62 | additionalProperties: true 63 | }, 64 | { 65 | properties: { 66 | bar: true 67 | }, 68 | patternProperties: { 69 | '123$': true 70 | }, 71 | additionalProperties: false 72 | } 73 | ] 74 | }, 75 | { 76 | ignoreAdditionalProperties: true 77 | } 78 | ); 79 | 80 | expect(result).to.eql({ 81 | properties: { 82 | foo: true, 83 | bar: true 84 | }, 85 | patternProperties: { 86 | '^abc': true, 87 | '123$': true 88 | }, 89 | additionalProperties: false 90 | }); 91 | 92 | const result2 = merger({ 93 | allOf: [ 94 | { 95 | additionalProperties: true 96 | }, 97 | { 98 | additionalProperties: true 99 | } 100 | ] 101 | }); 102 | 103 | expect(result2).to.eql({}); 104 | }); 105 | 106 | it('throws if no resolver found for unknown keyword', function () { 107 | expect(function () { 108 | merger({ 109 | foo: 3, 110 | allOf: [ 111 | { 112 | foo: 7 113 | } 114 | ] 115 | }); 116 | }).to.throw(/no resolver found/i); 117 | }); 118 | 119 | it('uses supplied resolver for unknown keyword', function () { 120 | const result = merger( 121 | { 122 | foo: 3, 123 | allOf: [ 124 | { 125 | foo: 7 126 | } 127 | ] 128 | }, 129 | { 130 | resolvers: { 131 | foo: function (values) { 132 | return values.pop(); 133 | } 134 | } 135 | } 136 | ); 137 | 138 | expect(result).to.eql({ 139 | foo: 7 140 | }); 141 | }); 142 | 143 | it('uses default merger if no resolver found', function () { 144 | const result = merger( 145 | { 146 | foo: 3, 147 | allOf: [ 148 | { 149 | foo: 7 150 | } 151 | ] 152 | }, 153 | { 154 | resolvers: { 155 | defaultResolver: function (values) { 156 | return values.pop(); 157 | } 158 | } 159 | } 160 | ); 161 | 162 | expect(result).to.eql({ 163 | foo: 7 164 | }); 165 | }); 166 | 167 | it('merges deep by default', function () { 168 | const result = merger({ 169 | allOf: [ 170 | { 171 | properties: { 172 | foo: { type: 'string' }, 173 | bar: { 174 | allOf: [ 175 | { 176 | properties: { 177 | baz: { type: 'string' } 178 | } 179 | } 180 | ] 181 | } 182 | } 183 | } 184 | ] 185 | }); 186 | 187 | expect(result).to.eql({ 188 | properties: { 189 | foo: { type: 'string' }, 190 | bar: { 191 | properties: { 192 | baz: { type: 'string' } 193 | } 194 | } 195 | } 196 | }); 197 | }); 198 | 199 | it("doesn't merge deep when deep is false", function () { 200 | const result = merger( 201 | { 202 | allOf: [ 203 | { 204 | properties: { 205 | foo: { type: 'string' }, 206 | bar: { 207 | allOf: [ 208 | { 209 | properties: { 210 | baz: { type: 'string' } 211 | } 212 | } 213 | ] 214 | } 215 | } 216 | } 217 | ] 218 | }, 219 | { deep: false } 220 | ); 221 | 222 | expect(result).to.eql({ 223 | properties: { 224 | foo: { type: 'string' }, 225 | bar: { 226 | allOf: [ 227 | { 228 | properties: { 229 | baz: { type: 'string' } 230 | } 231 | } 232 | ] 233 | } 234 | } 235 | }); 236 | }); 237 | }); 238 | -------------------------------------------------------------------------------- /test/specs/properties.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'vitest'; 2 | import { expect } from 'chai'; 3 | import merger from '../../src'; 4 | import { stub as _stub, assert } from 'sinon'; 5 | import { cloneDeep } from 'lodash'; 6 | import Ajv from 'ajv'; 7 | 8 | const ajv = new Ajv({ 9 | allowMatchingProperties: true 10 | }); 11 | describe('properties', function () { 12 | describe('when property name has same as a reserved word', function () { 13 | it('does not treat it as a reserved word', function () { 14 | const stub = _stub().returns({ 15 | properties: { 16 | properties: { 17 | type: 'string', 18 | minLength: 5 19 | } 20 | } 21 | }); 22 | 23 | merger( 24 | { 25 | allOf: [ 26 | { 27 | properties: { 28 | properties: { 29 | type: 'string', 30 | minLength: 5 31 | } 32 | } 33 | }, 34 | { 35 | properties: { 36 | properties: { 37 | type: 'string', 38 | minLength: 7 39 | } 40 | } 41 | } 42 | ] 43 | }, 44 | { 45 | complexResolvers: { 46 | properties: { 47 | keywords: [ 48 | 'properties', 49 | 'patternProperties', 50 | 'additionalProperties' 51 | ], 52 | resolver: stub 53 | } 54 | } 55 | } 56 | ); 57 | 58 | assert.calledOnce(stub); 59 | }); 60 | }); 61 | 62 | describe('additionalProperties', function () { 63 | it('allows no extra properties if additionalProperties is false', function () { 64 | const result = merger({ 65 | allOf: [ 66 | { 67 | additionalProperties: true 68 | }, 69 | { 70 | additionalProperties: false 71 | } 72 | ] 73 | }); 74 | 75 | expect(result).to.eql({ 76 | additionalProperties: false 77 | }); 78 | }); 79 | 80 | it('allows only intersecting properties', function () { 81 | const result = merger({ 82 | allOf: [ 83 | { 84 | properties: { 85 | foo: true 86 | }, 87 | additionalProperties: true 88 | }, 89 | { 90 | properties: { 91 | bar: true 92 | }, 93 | additionalProperties: false 94 | } 95 | ] 96 | }); 97 | 98 | expect(result).to.eql({ 99 | properties: { 100 | bar: true 101 | }, 102 | additionalProperties: false 103 | }); 104 | }); 105 | 106 | it('allows intersecting patternproperties', function () { 107 | const result = merger({ 108 | allOf: [ 109 | { 110 | properties: { 111 | foo: true, 112 | foo123: true 113 | }, 114 | additionalProperties: true 115 | }, 116 | { 117 | properties: { 118 | bar: true 119 | }, 120 | patternProperties: { 121 | '.+\\d+$': true 122 | }, 123 | additionalProperties: false 124 | } 125 | ] 126 | }); 127 | 128 | expect(result).to.eql({ 129 | properties: { 130 | bar: true, 131 | foo123: true 132 | }, 133 | patternProperties: { 134 | '.+\\d+$': true 135 | }, 136 | additionalProperties: false 137 | }); 138 | }); 139 | 140 | it('disallows all except matching patternProperties if both false', function () { 141 | const result = merger({ 142 | allOf: [ 143 | { 144 | properties: { 145 | foo: true, 146 | foo123: true 147 | }, 148 | additionalProperties: false 149 | }, 150 | { 151 | properties: { 152 | bar: true 153 | }, 154 | patternProperties: { 155 | '.+\\d+$': true 156 | }, 157 | additionalProperties: false 158 | } 159 | ] 160 | }); 161 | 162 | expect(result).to.eql({ 163 | properties: { 164 | foo123: true 165 | }, 166 | additionalProperties: false 167 | }); 168 | }); 169 | 170 | it('disallows all except matching patternProperties if both false', function () { 171 | const result = merger({ 172 | allOf: [ 173 | { 174 | properties: { 175 | foo: true, 176 | foo123: true 177 | }, 178 | patternProperties: { 179 | '.+\\d+$': { 180 | type: 'string' 181 | } 182 | }, 183 | additionalProperties: false 184 | }, 185 | { 186 | properties: { 187 | bar: true, 188 | bar123: true 189 | }, 190 | patternProperties: { 191 | '.+\\d+$': true 192 | }, 193 | additionalProperties: false 194 | } 195 | ] 196 | }); 197 | 198 | expect(result).to.eql({ 199 | properties: { 200 | foo123: true, 201 | bar123: true 202 | }, 203 | patternProperties: { 204 | '.+\\d+$': { 205 | type: 'string' 206 | } 207 | }, 208 | additionalProperties: false 209 | }); 210 | }); 211 | 212 | it('disallows all except matching patternProperties if both false', function () { 213 | const schema = { 214 | allOf: [ 215 | { 216 | type: 'object', 217 | properties: { 218 | foo: true, 219 | foo123: true 220 | }, 221 | patternProperties: { 222 | '^bar': true 223 | }, 224 | additionalProperties: false 225 | }, 226 | { 227 | type: 'object', 228 | properties: { 229 | bar: true, 230 | bar123: true 231 | }, 232 | patternProperties: { 233 | '.+\\d+$': true 234 | }, 235 | additionalProperties: false 236 | } 237 | ] 238 | }; 239 | const origSchema = cloneDeep(schema); 240 | const result = merger(schema); 241 | expect(result).not.to.eql(origSchema); 242 | 243 | expect(result).to.eql({ 244 | type: 'object', 245 | properties: { 246 | bar: true, 247 | foo123: true, 248 | bar123: true 249 | }, 250 | additionalProperties: false 251 | }); 252 | [ 253 | { 254 | foo123: 'testfdsdfsfd' 255 | }, 256 | { 257 | bar123: 'testfdsdfsfd' 258 | }, 259 | { 260 | foo123: 'testfdsdfsfd' 261 | }, 262 | { 263 | bar: 'fdsaf' 264 | }, 265 | { 266 | abc123: 'fdsaf' 267 | }, 268 | { 269 | bar123: 'fdsaf' 270 | }, 271 | { 272 | barabc: 'fdsaf' 273 | }, 274 | { 275 | // additionalProp 276 | foo234: 'testffdsafdsads' 277 | } 278 | ].forEach(function (val) { 279 | validateInputOutput(origSchema, result, val); 280 | }); 281 | }); 282 | 283 | it('disallows all except matching patternProperties if both true', function () { 284 | const schema = { 285 | allOf: [ 286 | { 287 | type: 'object', 288 | properties: { 289 | foo: true, 290 | foo123: true 291 | }, 292 | patternProperties: { 293 | '^bar': true 294 | } 295 | }, 296 | { 297 | type: 'object', 298 | properties: { 299 | bar: true, 300 | bar123: true 301 | }, 302 | patternProperties: { 303 | '.+\\d+$': true 304 | } 305 | } 306 | ] 307 | }; 308 | const origSchema = cloneDeep(schema); 309 | const result = merger(schema); 310 | expect(result).not.to.eql(origSchema); 311 | 312 | expect(result).to.eql({ 313 | type: 'object', 314 | properties: { 315 | foo: true, 316 | bar: true, 317 | foo123: true, 318 | bar123: true 319 | }, 320 | patternProperties: { 321 | '^bar': true, 322 | '.+\\d+$': true 323 | } 324 | }); 325 | [ 326 | { 327 | foo123: 'testfdsdfsfd' 328 | }, 329 | { 330 | bar123: 'testfdsdfsfd' 331 | }, 332 | { 333 | foo123: 'testfdsdfsfd' 334 | }, 335 | { 336 | foo: 'fdsaf' 337 | }, 338 | { 339 | bar: 'fdsaf' 340 | }, 341 | { 342 | abc123: 'fdsaf' 343 | }, 344 | { 345 | bar123: 'fdsaf' 346 | }, 347 | { 348 | barabc: 'fdsaf' 349 | }, 350 | { 351 | foo234: 'testffdsafdsads' 352 | } 353 | ].forEach(function (val) { 354 | validateInputOutput(origSchema, result, val); 355 | }); 356 | }); 357 | 358 | it('disallows all except matching patternProperties if one false', function () { 359 | const schema = { 360 | allOf: [ 361 | { 362 | type: 'object', 363 | properties: { 364 | foo: true, 365 | foo123: true 366 | } 367 | }, 368 | { 369 | type: 'object', 370 | properties: { 371 | bar: true, 372 | bar123: true 373 | }, 374 | patternProperties: { 375 | '.+\\d+$': true 376 | }, 377 | additionalProperties: false 378 | } 379 | ] 380 | }; 381 | const origSchema = cloneDeep(schema); 382 | const result = merger(schema); 383 | expect(result).not.to.eql(origSchema); 384 | 385 | expect(result).to.eql({ 386 | type: 'object', 387 | properties: { 388 | bar: true, 389 | foo123: true, 390 | bar123: true 391 | }, 392 | patternProperties: { 393 | '.+\\d+$': true 394 | }, 395 | additionalProperties: false 396 | }); 397 | [ 398 | { 399 | foo123: 'testfdsdfsfd' 400 | }, 401 | { 402 | bar123: 'testfdsdfsfd' 403 | }, 404 | { 405 | foo123: 'testfdsdfsfd' 406 | }, 407 | { 408 | foo: 'fdsaf' 409 | }, 410 | { 411 | bar: 'fdsaf' 412 | }, 413 | { 414 | abc123: 'fdsaf' 415 | }, 416 | { 417 | bar123: 'fdsaf' 418 | }, 419 | { 420 | barabc: 'fdsaf' 421 | }, 422 | { 423 | foo234: 'testffdsafdsads' 424 | } 425 | ].forEach(function (val) { 426 | validateInputOutput(origSchema, result, val); 427 | }); 428 | }); 429 | 430 | it('disallows all if no patternProperties and if both false', function () { 431 | const result = merger({ 432 | allOf: [ 433 | { 434 | properties: { 435 | foo: true, 436 | foo123: true 437 | }, 438 | additionalProperties: false 439 | }, 440 | { 441 | properties: { 442 | bar: true 443 | }, 444 | additionalProperties: false 445 | } 446 | ] 447 | }); 448 | 449 | expect(result).to.eql({ 450 | additionalProperties: false 451 | }); 452 | }); 453 | 454 | it('applies additionalProperties to other schemas properties if they have any', function () { 455 | const result = merger({ 456 | properties: { 457 | common: true, 458 | root: true 459 | }, 460 | additionalProperties: false, 461 | allOf: [ 462 | { 463 | properties: { 464 | common: { 465 | type: 'string' 466 | }, 467 | allof1: true 468 | }, 469 | additionalProperties: { 470 | type: ['string', 'null'], 471 | maxLength: 10 472 | } 473 | }, 474 | { 475 | properties: { 476 | common: { 477 | minLength: 1 478 | }, 479 | allof2: true 480 | }, 481 | additionalProperties: { 482 | type: ['string', 'integer', 'null'], 483 | maxLength: 8 484 | } 485 | }, 486 | { 487 | properties: { 488 | common: { 489 | minLength: 6 490 | }, 491 | allof3: true 492 | } 493 | } 494 | ] 495 | }); 496 | 497 | expect(result).to.eql({ 498 | properties: { 499 | common: { 500 | type: 'string', 501 | minLength: 6 502 | }, 503 | root: { 504 | type: ['string', 'null'], 505 | maxLength: 8 506 | } 507 | }, 508 | additionalProperties: false 509 | }); 510 | }); 511 | 512 | it('considers patternProperties before merging additionalProperties to other schemas properties if they have any', function () { 513 | const result = merger({ 514 | properties: { 515 | common: true, 516 | root: true 517 | }, 518 | patternProperties: { 519 | '.+\\d{2,}$': { 520 | minLength: 7 521 | } 522 | }, 523 | additionalProperties: false, 524 | allOf: [ 525 | { 526 | properties: { 527 | common: { 528 | type: 'string' 529 | }, 530 | allof1: true 531 | }, 532 | additionalProperties: { 533 | type: ['string', 'null', 'integer'], 534 | maxLength: 10 535 | } 536 | }, 537 | { 538 | properties: { 539 | common: { 540 | minLength: 1 541 | }, 542 | allof2: true, 543 | allowed123: { 544 | type: 'string' 545 | } 546 | }, 547 | patternProperties: { 548 | '.+\\d{2,}$': { 549 | minLength: 9 550 | } 551 | }, 552 | additionalProperties: { 553 | type: ['string', 'integer', 'null'], 554 | maxLength: 8 555 | } 556 | }, 557 | { 558 | properties: { 559 | common: { 560 | minLength: 6 561 | }, 562 | allof3: true, 563 | allowed456: { 564 | type: 'integer' 565 | } 566 | } 567 | } 568 | ] 569 | }); 570 | 571 | expect(result).to.eql({ 572 | properties: { 573 | common: { 574 | type: 'string', 575 | minLength: 6 576 | }, 577 | root: { 578 | type: ['string', 'null', 'integer'], 579 | maxLength: 8 580 | }, 581 | allowed123: { 582 | type: 'string', 583 | maxLength: 10 584 | }, 585 | allowed456: { 586 | type: 'integer', 587 | maxLength: 10 588 | } 589 | }, 590 | patternProperties: { 591 | '.+\\d{2,}$': { 592 | minLength: 9 593 | } 594 | }, 595 | additionalProperties: false 596 | }); 597 | }); 598 | 599 | it('combines additionalProperties when schemas', function () { 600 | const result = merger({ 601 | additionalProperties: true, 602 | allOf: [ 603 | { 604 | additionalProperties: { 605 | type: ['string', 'null'], 606 | maxLength: 10 607 | } 608 | }, 609 | { 610 | additionalProperties: { 611 | type: ['string', 'integer', 'null'], 612 | maxLength: 8 613 | } 614 | } 615 | ] 616 | }); 617 | 618 | expect(result).to.eql({ 619 | additionalProperties: { 620 | type: ['string', 'null'], 621 | maxLength: 8 622 | } 623 | }); 624 | }); 625 | }); 626 | 627 | describe('patternProperties', function () { 628 | it('merges simliar schemas', function () { 629 | const result = merger({ 630 | patternProperties: { 631 | '^\\$.+': { 632 | type: ['string', 'null', 'integer'], 633 | allOf: [ 634 | { 635 | minimum: 5 636 | } 637 | ] 638 | } 639 | }, 640 | allOf: [ 641 | { 642 | patternProperties: { 643 | '^\\$.+': { 644 | type: ['string', 'null'], 645 | allOf: [ 646 | { 647 | minimum: 7 648 | } 649 | ] 650 | }, 651 | '.*': { 652 | type: 'null' 653 | } 654 | } 655 | } 656 | ] 657 | }); 658 | 659 | expect(result).to.eql({ 660 | patternProperties: { 661 | '^\\$.+': { 662 | type: ['string', 'null'], 663 | minimum: 7 664 | }, 665 | '.*': { 666 | type: 'null' 667 | } 668 | } 669 | }); 670 | }); 671 | }); 672 | 673 | describe('when patternProperties present', function () { 674 | it('merges patternproperties', function () { 675 | const result = merger({ 676 | allOf: [ 677 | { 678 | patternProperties: { 679 | '.*': { 680 | type: 'string', 681 | minLength: 5 682 | } 683 | } 684 | }, 685 | { 686 | patternProperties: { 687 | '.*': { 688 | type: 'string', 689 | minLength: 7 690 | } 691 | } 692 | } 693 | ] 694 | }); 695 | 696 | expect(result).to.eql({ 697 | patternProperties: { 698 | '.*': { 699 | type: 'string', 700 | minLength: 7 701 | } 702 | } 703 | }); 704 | }); 705 | 706 | it('merges with properties if matching property name', function () { 707 | const schema = { 708 | allOf: [ 709 | { 710 | type: 'object', 711 | properties: { 712 | name: { 713 | type: 'string', 714 | minLength: 1 715 | } 716 | }, 717 | patternProperties: { 718 | _long$: { 719 | type: 'string', 720 | minLength: 7 721 | } 722 | } 723 | }, 724 | { 725 | type: 'object', 726 | properties: { 727 | foo_long: { 728 | type: 'string', 729 | minLength: 9 730 | } 731 | }, 732 | patternProperties: { 733 | '^name.*': { 734 | type: 'string', 735 | minLength: 8 736 | } 737 | } 738 | } 739 | ] 740 | }; 741 | 742 | const origSchema = cloneDeep(schema); 743 | const result = merger(schema); 744 | 745 | expect(result).not.to.eql(origSchema); 746 | 747 | expect(result).to.eql({ 748 | type: 'object', 749 | properties: { 750 | foo_long: { 751 | type: 'string', 752 | minLength: 9 753 | }, 754 | name: { 755 | type: 'string', 756 | minLength: 1 757 | } 758 | }, 759 | patternProperties: { 760 | _long$: { 761 | type: 'string', 762 | minLength: 7 763 | }, 764 | '^name.*': { 765 | type: 'string', 766 | minLength: 8 767 | } 768 | } 769 | }); 770 | [ 771 | { 772 | name: 'test' 773 | }, 774 | { 775 | name: 'fdsaffsda', 776 | name_long: 'testfdsdfsfd' 777 | }, 778 | { 779 | name: 'fdsafdsafas', 780 | foo_long: 'testfdsdfsfd' 781 | }, 782 | { 783 | name: 'dfsafdsa', 784 | name_long: 'testfdsdfsfd' 785 | }, 786 | { 787 | name: 'test', 788 | name2: 'testffdsafdsads' 789 | } 790 | ].forEach(function (val) { 791 | validateInputOutput(schema, result, val); 792 | }); 793 | }); 794 | }); 795 | }); 796 | 797 | function validateInputOutput(schema, transformedSchema, obj) { 798 | const validOriginal = ajv.validate(schema, obj); 799 | const validNew = ajv.validate(transformedSchema, obj); 800 | expect(validOriginal).to.eql(validNew); 801 | } 802 | -------------------------------------------------------------------------------- /test/specs/stripping.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'vitest'; 2 | 3 | describe('stripping', function () { 4 | it('strips away validators that makes no sense for the given type(s)'); 5 | it('strips away properties not allowed by propertyNames'); 6 | }); 7 | -------------------------------------------------------------------------------- /test/specs/validation.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'vitest'; 2 | describe('validation', function () { 3 | it( 4 | 'is false if property is required, but not allowed by patternProperties or additionalProperties' 5 | ); 6 | it('is false if any min value is bigger than a max value'); 7 | }); 8 | -------------------------------------------------------------------------------- /test/vitest.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | enabled: true, 7 | all: true, 8 | include: ['src/**/*.js'], 9 | reporter: ['text', 'lcov', 'html'] 10 | } 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /test/vitest.develop.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | reporter: ['basic'], 6 | coverage: { 7 | enabled: true, 8 | reporter: ['lcov'] 9 | } 10 | } 11 | }); 12 | --------------------------------------------------------------------------------