diff --git a/README.md b/README.md index 3269141..d27268e 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ Since allOf require ALL schemas provided (including the parent schema) to apply, ``` This result in the schema : + ```js { type: 'object', @@ -69,33 +70,50 @@ As you can see above the strategy is to choose the **most** restrictive of the s 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: +### pattern + +If a schema have more than one pattern keyword, then it is left expressed like this: + +```js +{ + type: 'string', + allOf: [{ + pattern: '\\w+\\s\\w+' + }, { + pattern: '123$' + }] +} +``` + +This is mostly for readability. It could be combined like this `(?=[\\s\\S]*)\\w+\\s\\w+)(?=[\\s\\S]*123$)` but as regex patterns already are quite complicated this is avoided. + ### not 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. ## Options + **ignoreAdditionalProperties** default **false** 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. -**deep** boolean, default *true* +**deep** boolean, default _true_ If false, resolves only the top-level `allOf` keyword in the schema. If true, resolves all `allOf` keywords in the schema. - **resolvers** Object Override any default resolver like this: ```js mergeAllOf(schema, { resolvers: { - title: function(values, path, mergeSchemas, options) { + title: function (values, path, mergeSchemas, options, abort) { // choose what title you want to be used based on the conflicting values // resolvers MUST return a value other than undefined } } -}) +}); ``` The function is passed: @@ -104,9 +122,10 @@ The function is passed: - **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) - **mergeSchemas** a function you can call that merges an array of schemas - **options** the options mergeAllOf was called with - +- **abort** a function to call if you give up on resolving. If called then the return value is ignored and the original values are kept in a allOf. If there are no keyword on the root schema, the first one is moved to the root schema. ### Combined resolvers + Some keyword are dependant on other keywords, like properties, patternProperties, additionalProperties. To create a resolver for these the resolver requires this structure: ```js @@ -145,6 +164,7 @@ const mergers = { 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. ### Default resolver + 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: ```js @@ -157,7 +177,6 @@ mergeJsonSchema({ }) ``` - ## Resolvers Resolvers are called whenever multiple conflicting values are found on the same position in the schemas. @@ -170,17 +189,14 @@ All built in reducers for validation keywords are lossless, meaning that they do 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. - ## $ref 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. - ## Other libraries 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. - ## Restrictions We cannot merge schemas that are a logical impossibility, like: @@ -196,7 +212,6 @@ We cannot merge schemas that are a logical impossibility, like: 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. - ## Roadmap - [x] Treat the interdependent validations like properties and additionalProperties as one resolver (and items additionalItems) diff --git a/package-lock.json b/package-lock.json index 01f440a..1fd78ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2168,7 +2168,7 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, "node_modules/fsevents": { @@ -2230,26 +2230,6 @@ "assert-plus": "^1.0.0" } }, - "node_modules/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -2404,7 +2384,7 @@ "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "dev": true, "dependencies": { "once": "^1.3.0", @@ -2951,7 +2931,7 @@ "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, "dependencies": { "wrappy": "1" @@ -3043,7 +3023,7 @@ "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, "engines": { "node": ">=0.10.0" @@ -3370,6 +3350,26 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rollup": { "version": "4.9.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.9.2.tgz", @@ -3672,6 +3672,26 @@ "node": ">=8" } }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -4148,7 +4168,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, "node_modules/y18n": { @@ -5675,7 +5695,7 @@ "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, "fsevents": { @@ -5718,20 +5738,6 @@ "assert-plus": "^1.0.0" } }, - "glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, "globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -5846,7 +5852,7 @@ "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "dev": true, "requires": { "once": "^1.3.0", @@ -6293,7 +6299,7 @@ "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, "requires": { "wrappy": "1" @@ -6358,7 +6364,7 @@ "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true }, "path-key": { @@ -6573,6 +6579,22 @@ "dev": true, "requires": { "glob": "^7.1.3" + }, + "dependencies": { + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + } } }, "rollup": { @@ -6774,6 +6796,22 @@ "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", "minimatch": "^3.0.4" + }, + "dependencies": { + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + } } }, "text-table": { @@ -7081,7 +7119,7 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, "y18n": { diff --git a/src/index.js b/src/index.js index 0c28df2..4404815 100644 --- a/src/index.js +++ b/src/index.js @@ -23,7 +23,8 @@ const isTrue = (val) => val === true; const schemaResolver = (compacted, key, mergeSchemas) => mergeSchemas(compacted); const stringArray = (values) => sortBy(uniq(flattenDeep(values))); -const notUndefined = (val) => val !== undefined; +const isUndefined = (val) => val === undefined; +const notUndefined = (val) => !isUndefined(val); const allUniqueKeys = (arr) => uniq(flattenDeep(arr.map(keys))); // resolvers @@ -34,6 +35,10 @@ const minimumValue = (compacted) => Math.min.apply(Math, compacted); const uniqueItems = (compacted) => compacted.some(isTrue); const examples = (compacted) => uniqWith(flatten(compacted), isEqual); +function unresolvable(compacted, paths, mergeSchemas, options, abort) { + abort(); +} + function compareProp(key) { return function (a, b) { return compare( @@ -51,20 +56,17 @@ function getAllOf(schema) { return [copy, ...allOf.map(getAllOf)]; } -function getValues(schemas, key) { - return schemas.map((schema) => schema && schema[key]); +function mergeWithArray(base, newItems) { + if (Array.isArray(base)) { + base.splice.apply(base, [0, 0].concat(newItems)); + return base; + } else { + return newItems; + } } -function tryMergeSchemaGroups(schemaGroups, mergeSchemas) { - return schemaGroups - .map(function (schemas, index) { - try { - return mergeSchemas(schemas, index); - } catch (e) { - return undefined; - } - }) - .filter(notUndefined); +function getValues(schemas, key) { + return schemas.map((schema) => schema && schema[key]); } function keys(obj) { @@ -75,30 +77,6 @@ function keys(obj) { } } -function getAnyOfCombinations(arrOfArrays, combinations) { - combinations = combinations || []; - if (!arrOfArrays.length) { - return combinations; - } - - const values = arrOfArrays.slice(0).shift(); - const rest = arrOfArrays.slice(1); - if (combinations.length) { - return getAnyOfCombinations( - rest, - flatten( - combinations.map((combination) => - values.map((item) => [item].concat(combination)) - ) - ) - ); - } - return getAnyOfCombinations( - rest, - values.map((item) => item) - ); -} - function throwIncompatible(values, paths) { let asJSON; try { @@ -232,21 +210,9 @@ const defaultResolvers = { return all; }, {}); }, - oneOf(compacted, paths, mergeSchemas) { - const combinations = getAnyOfCombinations(cloneDeep(compacted)); - const result = tryMergeSchemaGroups(combinations, mergeSchemas); - const unique = uniqWith(result, compare); - - if (unique.length) { - return unique; - } - }, not(compacted) { return { anyOf: compacted }; }, - pattern(compacted) { - return compacted.map((r) => '(?=' + r + ')').join(''); - }, multipleOf(compacted) { let integers = compacted.slice(0); let factor = 1; @@ -269,8 +235,8 @@ defaultResolvers.$ref = first; defaultResolvers.$schema = first; defaultResolvers.additionalItems = schemaResolver; defaultResolvers.additionalProperties = schemaResolver; -defaultResolvers.anyOf = defaultResolvers.oneOf; -defaultResolvers.contains = schemaResolver; +defaultResolvers.anyOf = unresolvable; +defaultResolvers.contains = unresolvable; defaultResolvers.default = first; defaultResolvers.definitions = defaultResolvers.dependencies; defaultResolvers.description = first; @@ -286,6 +252,8 @@ defaultResolvers.minimum = maximumValue; defaultResolvers.minItems = maximumValue; defaultResolvers.minLength = maximumValue; defaultResolvers.minProperties = maximumValue; +defaultResolvers.oneOf = unresolvable; +defaultResolvers.pattern = unresolvable; defaultResolvers.properties = propertiesResolver; defaultResolvers.propertyNames = schemaResolver; defaultResolvers.required = required; @@ -313,6 +281,11 @@ function merger(rootSchema, options, totalSchemas) { parents = parents || []; const merged = isPlainObject(base) ? base : {}; + // adds any unresolved schemas to the allOf array + function addToAllOf(unresolvedSchemas) { + merged.allOf = mergeWithArray(merged.allOf, unresolvedSchemas); + } + // return undefined, an empty schema if (!schemas.length) { return; @@ -377,10 +350,45 @@ function merger(rootSchema, options, totalSchemas) { const merger = (schemas, extraKey = []) => mergeSchemas(schemas, null, parents.concat(key, extraKey)); - merged[key] = resolver(compacted, parents.concat(key), merger, options); - if (merged[key] === undefined) { + let abortCalled = false; + const result = resolver( + compacted, + parents.concat(key), + merger, + options, + function abort() { + abortCalled = true; + } + ); + + if (abortCalled) { + const [first, ...rest] = compacted.map((value) => { + // if we are dealing with a schema, merge it standalone as a schema, + // but outside the context of the parent schema + + if (schemaArrays.includes(key)) { + return value.map((val) => mergeSchemas([val], val)); + } + + if (schemaProps.includes(key)) { + return mergeSchemas([value], value); + } + return value; + }); + merged[key] = first; + addToAllOf( + rest.map((val) => ({ + [key]: val + })) + ); + return; + } + + if (isUndefined(result)) { throwIncompatible(compacted, parents.concat(key)); + } else { + merged[key] = result; } } }); diff --git a/test/specs/anyOf.spec.js b/test/specs/anyOf.spec.js new file mode 100644 index 0000000..abc7f63 --- /dev/null +++ b/test/specs/anyOf.spec.js @@ -0,0 +1,176 @@ +import { describe, it, expect } from 'vitest'; +import { mergeAndTest } from '../utils/merger.js'; + +describe('anyOf', function () { + it('does not merge anyOf, only simplifies if no anyOf in base schema', function () { + const result = mergeAndTest({ + allOf: [ + { + anyOf: [ + { + type: ['null', 'string', 'array'] + }, + { + type: ['null', 'string', 'object'] + } + ] + }, + { + anyOf: [ + { + type: ['null', 'string'] + }, + { + type: ['integer', 'object', 'null'] + } + ] + } + ] + }); + + expect(result).to.eql({ + anyOf: [ + { + type: ['null', 'string', 'array'] + }, + { + type: ['null', 'string', 'object'] + } + ], + allOf: [ + { + anyOf: [ + { + type: ['null', 'string'] + }, + { + type: ['integer', 'object', 'null'] + } + ] + } + ] + }); + }); + + it('merges anyOf', function () { + const result = mergeAndTest({ + allOf: [ + { + anyOf: [ + { + required: ['123'] + } + ] + }, + { + anyOf: [ + { + required: ['123'] + }, + { + required: ['456'] + } + ] + } + ] + }); + + expect(result).to.eql({ + anyOf: [ + { + required: ['123'] + } + ], + allOf: [ + { + anyOf: [ + { + required: ['123'] + }, + { + required: ['456'] + } + ] + } + ] + }); + }); + + it('merges nested allOf if inside singular anyOf', function () { + const result = mergeAndTest({ + allOf: [ + { + anyOf: [ + { + required: ['123'], + allOf: [ + { + required: ['768'] + } + ] + } + ] + }, + { + anyOf: [ + { + required: ['123'] + }, + { + required: ['456'] + } + ] + } + ] + }); + + expect(result).to.eql({ + anyOf: [ + { + required: ['123', '768'] + } + ], + allOf: [ + { + anyOf: [ + { + required: ['123'] + }, + { + required: ['456'] + } + ] + } + ] + }); + }); + + it.skip('merges anyOf into main schema if left with only one combination', function () { + const result = mergeAndTest({ + required: ['abc'], + allOf: [ + { + anyOf: [ + { + required: ['123'] + }, + { + required: ['456'] + } + ] + }, + { + anyOf: [ + { + required: ['123'] + } + ] + } + ] + }); + + expect(result).to.eql({ + required: ['abc', '123'] + }); + }); +}); diff --git a/test/specs/contains.spec.js b/test/specs/contains.spec.js new file mode 100644 index 0000000..847d052 --- /dev/null +++ b/test/specs/contains.spec.js @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest'; +import { mergeAndTest } from '../utils/merger.js'; + +describe('pattern', function () { + it('merges contains', function () { + const result = mergeAndTest({ + allOf: [ + {}, + { + contains: { + properties: { + name: { + type: 'string', + minLength: 2, + pattern: 'bar' + } + } + } + }, + { + contains: { + properties: { + name: { + type: 'string', + minLength: 1, + pattern: 'foo' + } + } + } + } + ] + }); + + expect(result).to.eql({ + contains: { + properties: { + name: { + type: 'string', + minLength: 2, + pattern: 'bar' + } + } + }, + allOf: [ + { + contains: { + properties: { + name: { + type: 'string', + minLength: 1, + pattern: 'foo' + } + } + } + } + ] + }); + }); + + it('merges valid subschemas inside a contains', async () => { + const result = mergeAndTest({ + contains: { + minLength: 2 + }, + allOf: [ + { + contains: { + maxLength: 10, + allOf: [ + { + maxLength: 8 + } + ] + } + } + ] + }); + + expect(result).to.eql({ + contains: { + minLength: 2 + }, + allOf: [ + { + contains: { + maxLength: 8 + } + } + ] + }); + }); + + it('does not combine with base schema', async () => { + const result = mergeAndTest({ + contains: { + minLength: 2 + }, + allOf: [ + { + contains: { + minLength: 3 + } + }, + { + contains: { + maxLength: 10 + } + } + ] + }); + + expect(result).to.eql({ + contains: { + minLength: 2 + }, + allOf: [ + { + contains: { + minLength: 3 + } + }, + { + contains: { + maxLength: 10 + } + } + ] + }); + }); +}); diff --git a/test/specs/custom-resolvers.spec.js b/test/specs/custom-resolvers.spec.js index 6832d2f..1d8d5c4 100644 --- a/test/specs/custom-resolvers.spec.js +++ b/test/specs/custom-resolvers.spec.js @@ -1,10 +1,11 @@ import { expect } from 'chai'; import merger from '../../src'; +import { mergeAndTest } from '../utils/merger.js'; const { describe, it } = await import('vitest'); describe('simple resolver', () => { it('merges as expected (with enum)', () => { - const result = merger({ + const result = mergeAndTest({ enum: [1, 2], allOf: [ { @@ -98,15 +99,19 @@ describe('simple resolver', () => { } }; - const resultCustom = merger( + const resultCustom = mergeAndTest( { allOf: [ { if: { required: ['def'] }, - then: {}, - else: {} + then: { + maxLength: 2 + }, + else: { + maxLength: 150 + } } ] }, @@ -117,8 +122,12 @@ describe('simple resolver', () => { if: { required: ['def'] }, - then: {}, - else: {} + then: { + maxLength: 2 + }, + else: { + maxLength: 150 + } }); }); }); diff --git a/test/specs/index.spec.js b/test/specs/index.spec.js index 8b0cf4b..b7cd110 100644 --- a/test/specs/index.spec.js +++ b/test/specs/index.spec.js @@ -1,25 +1,9 @@ import { describe, it } from 'vitest'; import { expect } from 'chai'; -import mergerModule from '../../src'; -import Ajv from 'ajv'; import _, { isObject, flatten, intersection } from 'lodash'; import { dereference } from 'json-schema-ref-parser'; - -const ajv = new Ajv(); - -function merger(schema, options) { - const result = mergerModule(schema, options); - try { - if (!ajv.validateSchema(result)) { - throw new Error("Schema returned by resolver isn't valid."); - } - return result; - } catch (e) { - if (!/stack/i.test(e.message)) { - throw e; - } - } -} +import { mergeAndTest } from '../utils/merger.js'; +import merger from '../../src/index.js'; describe('module', function () { it('merges schema with same object reference multiple places', () => { @@ -130,7 +114,7 @@ describe('module', function () { }); it('combines simple usecase', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ { type: 'string', @@ -151,7 +135,7 @@ describe('module', function () { }); it('combines without allOf', function () { - const result = merger({ + const result = mergeAndTest({ properties: { foo: { type: 'string' @@ -203,7 +187,7 @@ describe('module', function () { }); it('merges minLength if conflict', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ { minLength: 1 @@ -220,7 +204,7 @@ describe('module', function () { }); it('merges minimum if conflict', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ { minimum: 1 @@ -254,7 +238,7 @@ describe('module', function () { }); it('merges minItems if conflict', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ { minItems: 1 @@ -271,7 +255,7 @@ describe('module', function () { }); it('merges maximum if conflict', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ { maximum: 1 @@ -288,7 +272,7 @@ describe('module', function () { }); it('merges exclusiveMaximum if conflict', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ { exclusiveMaximum: 1 @@ -305,7 +289,7 @@ describe('module', function () { }); it('merges maxItems if conflict', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ { maxItems: 1 @@ -322,7 +306,7 @@ describe('module', function () { }); it('merges maxLength if conflict', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ { maxLength: 4 @@ -339,7 +323,7 @@ describe('module', function () { }); it('merges uniqueItems to most restrictive if conflict', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ { uniqueItems: true @@ -372,7 +356,7 @@ describe('module', function () { it('throws if merging incompatible type', function () { expect(function () { - merger({ + mergeAndTest({ allOf: [ { type: 'null' @@ -386,7 +370,7 @@ describe('module', function () { }); it('merges type if conflict', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ {}, { @@ -405,7 +389,7 @@ describe('module', function () { type: ['string', 'null'] }); - const result2 = merger({ + const result2 = mergeAndTest({ allOf: [ {}, { @@ -425,7 +409,7 @@ describe('module', function () { }); expect(function () { - merger({ + mergeAndTest({ allOf: [ { type: ['null'] @@ -439,14 +423,14 @@ describe('module', function () { }); it('merges enum', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ {}, { enum: ['string', 'null', 'object', {}, [2], [1], null] }, { - enum: ['string', {}, [1], [1]] + enum: ['string', {}, [1]] }, { enum: ['null', 'string', {}, [3], [1], null] @@ -461,7 +445,7 @@ describe('module', function () { it('throws if enum is incompatible', function () { expect(function () { - merger({ + mergeAndTest({ allOf: [ {}, { @@ -475,7 +459,7 @@ describe('module', function () { }).not.to.throw(/incompatible/); expect(function () { - merger({ + mergeAndTest({ allOf: [ {}, { @@ -490,7 +474,7 @@ describe('module', function () { }); it('merges const', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ {}, { @@ -507,472 +491,6 @@ describe('module', function () { }); }); - it('merges anyOf', function () { - const result = merger({ - allOf: [ - { - anyOf: [ - { - required: ['123'] - } - ] - }, - { - anyOf: [ - { - required: ['123'] - }, - { - required: ['456'] - } - ] - } - ] - }); - - expect(result).to.eql({ - anyOf: [ - { - required: ['123'] - }, - { - required: ['123', '456'] - } - ] - }); - }); - - it('merges anyOf by finding valid combinations', function () { - const result = merger({ - allOf: [ - { - anyOf: [ - { - type: ['null', 'string', 'array'] - }, - { - type: ['null', 'string', 'object'] - } - ] - }, - { - anyOf: [ - { - type: ['null', 'string'] - }, - { - type: ['integer', 'object', 'null'] - } - ] - } - ] - }); - - expect(result).to.eql({ - anyOf: [ - { - type: ['null', 'string'] - }, - { - type: 'null' - }, - { - type: ['object', 'null'] - } - ] - }); - }); - - it.skip('extracts common logic', function () { - const result = merger({ - allOf: [ - { - anyOf: [ - { - type: ['null', 'string', 'array'], - minLength: 5 - }, - { - type: ['null', 'string', 'object'], - minLength: 5 - } - ] - }, - { - anyOf: [ - { - type: ['null', 'string'], - minLength: 5 - }, - { - type: ['integer', 'object', 'null'] - } - ] - } - ] - }); - - // TODO I think this is correct - // TODO implement functionality - expect(result).to.eql({ - type: 'null', - minLength: 5, - anyOf: [ - { - type: 'string' - } - ] - }); - }); - - it.skip('merges anyOf into main schema if left with only one combination', function () { - const result = merger({ - required: ['abc'], - allOf: [ - { - anyOf: [ - { - required: ['123'] - }, - { - required: ['456'] - } - ] - }, - { - anyOf: [ - { - required: ['123'] - } - ] - } - ] - }); - - expect(result).to.eql({ - required: ['abc', '123'] - }); - }); - - it('merges nested allOf if inside singular anyOf', function () { - const result = merger({ - allOf: [ - { - anyOf: [ - { - required: ['123'], - allOf: [ - { - required: ['768'] - } - ] - } - ] - }, - { - anyOf: [ - { - required: ['123'] - }, - { - required: ['456'] - } - ] - } - ] - }); - - expect(result).to.eql({ - anyOf: [ - { - required: ['123', '768'] - }, - { - required: ['123', '456', '768'] - } - ] - }); - }); - - it('throws if no intersection at all', function () { - expect(function () { - merger({ - allOf: [ - { - anyOf: [ - { - type: ['object', 'string', 'null'] - } - ] - }, - { - anyOf: [ - { - type: ['array', 'integer'] - } - ] - } - ] - }); - }).to.throw(/incompatible/); - - expect(function () { - merger({ - allOf: [ - { - anyOf: [ - { - type: ['object', 'string', 'null'] - } - ] - }, - { - anyOf: [ - { - type: ['array', 'integer'] - } - ] - } - ] - }); - }).to.throw(/incompatible/); - }); - - it('merges more complex oneOf', function () { - const result = merger({ - allOf: [ - { - oneOf: [ - { - type: ['array', 'string', 'object'], - required: ['123'] - }, - { - required: ['abc'] - } - ] - }, - { - oneOf: [ - { - type: ['string'] - }, - { - type: ['object', 'array'], - required: ['abc'] - } - ] - } - ] - }); - - expect(result).to.eql({ - oneOf: [ - { - type: 'string', - required: ['123'] - }, - { - type: ['object', 'array'], - required: ['123', 'abc'] - }, - { - type: ['string'], - required: ['abc'] - }, - { - type: ['object', 'array'], - required: ['abc'] - } - ] - }); - }); - - it('merges nested allOf if inside singular oneOf', function () { - const result = merger({ - allOf: [ - { - type: ['array', 'string', 'number'], - oneOf: [ - { - required: ['123'], - allOf: [ - { - required: ['768'] - } - ] - } - ] - }, - { - type: ['array', 'string'] - } - ] - }); - - expect(result).to.eql({ - type: ['array', 'string'], - oneOf: [ - { - required: ['123', '768'] - } - ] - }); - }); - - it('merges nested allOf if inside multiple oneOf', function () { - const result = merger({ - allOf: [ - { - type: ['array', 'string', 'number'], - oneOf: [ - { - type: ['array', 'object'], - allOf: [ - { - type: 'object' - } - ] - } - ] - }, - { - type: ['array', 'string'], - oneOf: [ - { - type: 'string' - }, - { - type: 'object' - } - ] - } - ] - }); - - expect(result).to.eql({ - type: ['array', 'string'], - oneOf: [ - { - type: 'object' - } - ] - }); - }); - - it.skip('throws if no compatible when merging oneOf', function () { - expect(function () { - merger({ - allOf: [ - {}, - { - oneOf: [ - { - required: ['123'] - } - ] - }, - { - oneOf: [ - { - required: ['fdasfd'] - } - ] - } - ] - }); - }).to.throw(/incompatible/); - - expect(function () { - merger({ - allOf: [ - {}, - { - oneOf: [ - { - required: ['123'] - }, - { - properties: { - name: { - type: 'string' - } - } - } - ] - }, - { - oneOf: [ - { - required: ['fdasfd'] - } - ] - } - ] - }); - }).to.throw(/incompatible/); - }); - - // not ready to implement this yet - it.skip('merges singular oneOf', function () { - const result = merger({ - properties: { - name: { - type: 'string' - } - }, - allOf: [ - { - properties: { - name: { - type: 'string', - minLength: 10 - } - } - }, - { - oneOf: [ - { - required: ['123'] - }, - { - properties: { - name: { - type: 'string', - minLength: 15 - } - } - } - ] - }, - { - oneOf: [ - { - required: ['abc'] - }, - { - properties: { - name: { - type: 'string', - minLength: 15 - } - } - } - ] - } - ] - }); - - expect(result).to.eql({ - properties: { - name: { - type: 'string', - minLength: 15 - } - } - }); - }); - it('merges not using allOf', function () { const result = merger({ allOf: [ @@ -1019,79 +537,8 @@ describe('module', function () { }); }); - it('merges contains', function () { - const result = merger({ - allOf: [ - {}, - { - contains: { - properties: { - name: { - type: 'string', - pattern: 'bar' - } - } - } - }, - { - contains: { - properties: { - name: { - type: 'string', - pattern: 'foo' - } - } - } - } - ] - }); - - expect(result).to.eql({ - contains: { - properties: { - name: { - type: 'string', - pattern: '(?=bar)(?=foo)' - } - } - } - }); - }); - - it('merges pattern using allOf', function () { - const result = merger({ - allOf: [ - {}, - { - pattern: 'fdsaf' - }, - { - pattern: 'abba' - } - ] - }); - - expect(result).to.eql({ - pattern: '(?=fdsaf)(?=abba)' - }); - - const result2 = merger({ - allOf: [ - { - pattern: 'abba' - } - ] - }); - - expect(result2).to.eql({ - pattern: 'abba' - }); - }); - - it('extracts pattern from anyOf and oneOf using | operator in regexp'); - it.skip('merges multipleOf using allOf or direct assignment', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ { title: 'foo', @@ -1132,7 +579,7 @@ describe('module', function () { }); it('merges multipleOf by finding lowest common multiple (LCM)', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ {}, { @@ -1179,7 +626,7 @@ describe('module', function () { }); expect( - merger({ + mergeAndTest({ allOf: [ { multipleOf: 4 @@ -1197,13 +644,13 @@ describe('module', function () { }); expect( - merger({ + mergeAndTest({ allOf: [ { - multipleOf: 0.3 + multipleOf: 3 }, { - multipleOf: 0.7 + multipleOf: 7 }, { multipleOf: 1 @@ -1215,10 +662,10 @@ describe('module', function () { }); expect( - merger({ + mergeAndTest({ allOf: [ { - multipleOf: 0.5 + multipleOf: 5 }, { multipleOf: 2 @@ -1226,17 +673,17 @@ describe('module', function () { ] }) ).to.eql({ - multipleOf: 2 + multipleOf: 10 }); expect( - merger({ + mergeAndTest({ allOf: [ { - multipleOf: 0.3 + multipleOf: 3 }, { - multipleOf: 0.5 + multipleOf: 5 }, { multipleOf: 1 @@ -1244,17 +691,17 @@ describe('module', function () { ] }) ).to.eql({ - multipleOf: 3 + multipleOf: 15 }); expect( - merger({ + mergeAndTest({ allOf: [ { - multipleOf: 0.3 + multipleOf: 3 }, { - multipleOf: 0.7 + multipleOf: 7 }, { multipleOf: 1 @@ -1266,13 +713,13 @@ describe('module', function () { }); expect( - merger({ + mergeAndTest({ allOf: [ { - multipleOf: 0.4 + multipleOf: 4 }, { - multipleOf: 0.7 + multipleOf: 7 }, { multipleOf: 3 @@ -1280,17 +727,17 @@ describe('module', function () { ] }) ).to.eql({ - multipleOf: 42 + multipleOf: 84 }); expect( - merger({ + mergeAndTest({ allOf: [ { - multipleOf: 0.2 + multipleOf: 2 }, { - multipleOf: 0.65 + multipleOf: 65 }, { multipleOf: 1 @@ -1298,11 +745,11 @@ describe('module', function () { ] }) ).to.eql({ - multipleOf: 13 + multipleOf: 130 }); expect( - merger({ + mergeAndTest({ allOf: [ { multipleOf: 100000 @@ -1324,7 +771,7 @@ describe('module', function () { describe('merging arrays', function () { it('merges required object', function () { expect( - merger({ + mergeAndTest({ required: ['prop2'], allOf: [ { @@ -1385,7 +832,7 @@ describe('module', function () { describe('merging objects', function () { it('merges child objects', function () { expect( - merger({ + mergeAndTest({ properties: { name: { title: 'Name', @@ -1428,7 +875,7 @@ describe('module', function () { it('merges boolean schemas', function () { expect( - merger({ + mergeAndTest({ properties: { name: true }, @@ -1468,7 +915,7 @@ describe('module', function () { }); expect( - merger({ + mergeAndTest({ properties: { name: false }, @@ -1507,7 +954,7 @@ describe('module', function () { ).to.eql(false); expect( - merger({ + mergeAndTest({ properties: { name: true }, @@ -1539,7 +986,7 @@ describe('module', function () { it('merges all allOf', function () { expect( - merger({ + mergeAndTest({ properties: { name: { allOf: [ @@ -1668,7 +1115,7 @@ describe('module', function () { expected.person.properties.child = expected.person; - const result = merger(schema); + const result = mergeAndTest(schema); expect(result).to.eql({ properties: expected @@ -1734,7 +1181,7 @@ describe('module', function () { expected.person.properties.child = expected.person; - const result = merger(schema); + const result = mergeAndTest(schema); expect(result).to.eql({ properties: expected, @@ -1755,7 +1202,7 @@ describe('module', function () { describe('dependencies', function () { it('merges simliar schemas', function () { - const result = merger({ + const result = mergeAndTest({ dependencies: { foo: { type: ['string', 'null', 'integer'], @@ -1799,7 +1246,7 @@ describe('module', function () { const result = merger({ dependencies: { bar: { - type: ['string', 'null', 'integer'], + type: ['string', 'null', 'integer', 'object'], required: ['abc'] } }, @@ -1815,7 +1262,7 @@ describe('module', function () { expect(result).to.eql({ dependencies: { bar: { - type: ['string', 'null', 'integer'], + type: ['string', 'null', 'integer', 'object'], required: ['abc', 'prop4'] } } @@ -1825,7 +1272,7 @@ describe('module', function () { describe('propertyNames', function () { it('merges simliar schemas', function () { - const result = merger({ + const result = mergeAndTest({ propertyNames: { type: 'string', allOf: [ diff --git a/test/specs/items.spec.js b/test/specs/items.spec.js index 9dda3eb..1902f20 100644 --- a/test/specs/items.spec.js +++ b/test/specs/items.spec.js @@ -1,10 +1,10 @@ import { describe, it } from 'vitest'; import { expect } from 'chai'; -import merger from '../../src'; +import { mergeAndTest } from '../utils/merger.js'; describe('items', function () { it('merges additionalItems', function () { - const result = merger({ + const result = mergeAndTest({ items: { type: 'object' }, @@ -45,7 +45,12 @@ describe('items', function () { properties: { name: { type: 'string', - pattern: '(?=bar)(?=foo)' + pattern: 'bar', + allOf: [ + { + pattern: 'foo' + } + ] } } } @@ -54,7 +59,7 @@ describe('items', function () { describe('when single schema', function () { it('merges them', function () { - const result = merger({ + const result = mergeAndTest({ items: { type: 'string', allOf: [ @@ -91,7 +96,7 @@ describe('items', function () { describe('when array', function () { it('merges them in when additionalItems are all undefined', function () { - const result = merger({ + const result = mergeAndTest({ items: [ { type: 'string', @@ -135,7 +140,7 @@ describe('items', function () { }); it('merges in additionalItems from one if present', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ { items: [ @@ -181,7 +186,7 @@ describe('items', function () { }); it('merges in additionalItems from one if present', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ { items: [ @@ -228,7 +233,7 @@ describe('items', function () { }); it('merges in additionalItems schema', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ { items: [ @@ -290,7 +295,7 @@ describe('items', function () { describe('when mixed array and object', function () { it('merges in additionalItems schema', function () { - const result = merger({ + const result = mergeAndTest({ // This should be ignored according to spec when items absent additionalItems: { type: 'integer', diff --git a/test/specs/oneOf.spec.js b/test/specs/oneOf.spec.js new file mode 100644 index 0000000..3c51612 --- /dev/null +++ b/test/specs/oneOf.spec.js @@ -0,0 +1,202 @@ +import { describe, it, expect } from 'vitest'; +import { mergeAndTest } from '../utils/merger.js'; +import merger from '../../src/index.js'; + +describe('oneOf', function () { + it('merges more complex oneOf', function () { + const result = merger({ + allOf: [ + { + oneOf: [ + { + type: ['array', 'string', 'object'], + required: ['123'] + }, + { + required: ['abc'] + } + ] + }, + { + oneOf: [ + { + type: ['string'] + }, + { + type: ['object', 'array'], + required: ['abc'] + } + ] + } + ] + }); + + expect(result).to.eql({ + oneOf: [ + { + type: ['array', 'string', 'object'], + required: ['123'] + }, + { + required: ['abc'] + } + ], + allOf: [ + { + oneOf: [ + { + type: ['string'] + }, + { + type: ['object', 'array'], + required: ['abc'] + } + ] + } + ] + }); + }); + + it('merges nested allOf if inside multiple oneOf', function () { + const result = merger({ + allOf: [ + { + type: ['array', 'string', 'number'], + oneOf: [ + { + type: ['array', 'object'], + allOf: [ + { + type: 'object' + } + ] + } + ] + }, + { + type: ['array', 'string'], + oneOf: [ + { + type: 'string' + }, + { + type: 'object' + } + ] + } + ] + }); + + expect(result).to.eql({ + type: ['array', 'string'], + oneOf: [ + { + type: 'object' + } + ], + allOf: [ + { + oneOf: [ + { + type: 'string' + }, + { + type: 'object' + } + ] + } + ] + }); + }); + + it('merges nested allOf if inside singular oneOf', function () { + const result = mergeAndTest({ + allOf: [ + { + type: ['array', 'string', 'number'], + oneOf: [ + { + required: ['123'], + allOf: [ + { + required: ['768'] + } + ] + } + ] + }, + { + type: ['array', 'string'] + } + ] + }); + + expect(result).to.eql({ + type: ['array', 'string'], + oneOf: [ + { + required: ['123', '768'] + } + ] + }); + }); + + it.skip('merges singular oneOf', function () { + const result = merger({ + properties: { + name: { + type: 'string' + } + }, + allOf: [ + { + properties: { + name: { + type: 'string', + minLength: 10 + } + } + }, + { + oneOf: [ + { + required: ['123'] + }, + { + properties: { + name: { + type: 'string', + minLength: 15 + } + } + } + ] + }, + { + oneOf: [ + { + required: ['abc'] + }, + { + properties: { + name: { + type: 'string', + minLength: 15 + } + } + } + ] + } + ] + }); + + expect(result).to.eql({ + properties: { + name: { + type: 'string', + minLength: 15 + } + } + }); + }); +}); diff --git a/test/specs/pattern.spec.js b/test/specs/pattern.spec.js new file mode 100644 index 0000000..0ccf85e --- /dev/null +++ b/test/specs/pattern.spec.js @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { mergeAndTest } from '../utils/merger.js'; + +describe('pattern', function () { + it('does not merge pattern as it is not possible', function () { + const result = mergeAndTest( + { + allOf: [ + {}, + { + pattern: 'fdsaf' + }, + { + pattern: 'abba' + } + ] + }, + null, + ['fdsafabba', 'fdsaf', 'abba', 'fdfdsf', 'abbafdsaf', 'fdsafabba'] + ); + + expect(result).toEqual({ + pattern: 'fdsaf', + allOf: [ + { + pattern: 'abba' + } + ] + }); + + const result2 = mergeAndTest({ + allOf: [ + { + pattern: 'abba' + } + ] + }); + + expect(result2).to.eql({ + pattern: 'abba' + }); + }); +}); diff --git a/test/specs/properties.spec.js b/test/specs/properties.spec.js index 120ebaa..99f4de4 100644 --- a/test/specs/properties.spec.js +++ b/test/specs/properties.spec.js @@ -4,6 +4,7 @@ import merger from '../../src'; import { stub as _stub, assert } from 'sinon'; import { cloneDeep } from 'lodash'; import Ajv from 'ajv'; +import { mergeAndTest } from '../utils/merger.js'; const ajv = new Ajv({ allowMatchingProperties: true @@ -61,7 +62,7 @@ describe('properties', function () { describe('additionalProperties', function () { it('allows no extra properties if additionalProperties is false', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ { additionalProperties: true @@ -78,7 +79,7 @@ describe('properties', function () { }); it('allows only intersecting properties', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ { properties: { @@ -104,7 +105,7 @@ describe('properties', function () { }); it('allows intersecting patternproperties', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ { properties: { @@ -138,7 +139,7 @@ describe('properties', function () { }); it('disallows all except matching patternProperties if both false', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ { properties: { @@ -168,7 +169,7 @@ describe('properties', function () { }); it('disallows all except matching patternProperties if both false', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ { properties: { @@ -237,7 +238,7 @@ describe('properties', function () { ] }; const origSchema = cloneDeep(schema); - const result = merger(schema); + const result = mergeAndTest(schema); expect(result).not.to.eql(origSchema); expect(result).to.eql({ @@ -306,7 +307,7 @@ describe('properties', function () { ] }; const origSchema = cloneDeep(schema); - const result = merger(schema); + const result = mergeAndTest(schema); expect(result).not.to.eql(origSchema); expect(result).to.eql({ @@ -379,7 +380,7 @@ describe('properties', function () { ] }; const origSchema = cloneDeep(schema); - const result = merger(schema); + const result = mergeAndTest(schema); expect(result).not.to.eql(origSchema); expect(result).to.eql({ @@ -428,7 +429,7 @@ describe('properties', function () { }); it('disallows all if no patternProperties and if both false', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ { properties: { @@ -452,7 +453,7 @@ describe('properties', function () { }); it('applies additionalProperties to other schemas properties if they have any', function () { - const result = merger({ + const result = mergeAndTest({ properties: { common: true, root: true @@ -509,64 +510,77 @@ describe('properties', function () { }); }); - it('considers patternProperties before merging additionalProperties to other schemas properties if they have any', function () { - const result = merger({ - properties: { - common: true, - root: true - }, - patternProperties: { - '.+\\d{2,}$': { - minLength: 7 - } - }, - additionalProperties: false, - allOf: [ - { - properties: { - common: { - type: 'string' - }, - allof1: true - }, - additionalProperties: { - type: ['string', 'null', 'integer'], - maxLength: 10 + it.skip('considers patternProperties before merging additionalProperties to other schemas properties if they have any', function () { + const result = mergeAndTest( + { + properties: { + common: true, + root: true + }, + patternProperties: { + '.+\\d{2,}$': { + minLength: 7 } }, - { - properties: { - common: { - minLength: 1 + additionalProperties: false, + allOf: [ + { + properties: { + common: { + type: 'string' + }, + allof1: true }, - allof2: true, - allowed123: { - type: 'string' + additionalProperties: { + type: ['string', 'null', 'integer'], + maxLength: 10 } }, - patternProperties: { - '.+\\d{2,}$': { - minLength: 9 + { + properties: { + common: { + minLength: 1 + }, + allof2: true, + allowed123: { + type: 'string' + } + }, + patternProperties: { + '.+\\d{2,}$': { + minLength: 9 + } + }, + additionalProperties: { + type: ['string', 'integer', 'null'], + maxLength: 8 } }, - additionalProperties: { - type: ['string', 'integer', 'null'], - maxLength: 8 - } - }, - { - properties: { - common: { - minLength: 6 - }, - allof3: true, - allowed456: { - type: 'integer' + { + properties: { + common: { + minLength: 6 + }, + allof3: true, + allowed456: { + type: 'integer' + } } } - } + ] + }, + null, + [ + { '000': true }, + { + '000': 'abcdefghi' + }, + // { + // abc: 'abcdefghi' + // }, + { 123: 'abcdefghi' } ] - }); + ); expect(result).to.eql({ properties: { @@ -597,7 +611,7 @@ describe('properties', function () { }); it('combines additionalProperties when schemas', function () { - const result = merger({ + const result = mergeAndTest({ additionalProperties: true, allOf: [ { @@ -626,7 +640,7 @@ describe('properties', function () { describe('patternProperties', function () { it('merges simliar schemas', function () { - const result = merger({ + const result = mergeAndTest({ patternProperties: { '^\\$.+': { type: ['string', 'null', 'integer'], @@ -672,7 +686,7 @@ describe('properties', function () { describe('when patternProperties present', function () { it('merges patternproperties', function () { - const result = merger({ + const result = mergeAndTest({ allOf: [ { patternProperties: { @@ -740,7 +754,7 @@ describe('properties', function () { }; const origSchema = cloneDeep(schema); - const result = merger(schema); + const result = mergeAndTest(schema); expect(result).not.to.eql(origSchema); diff --git a/test/utils/merger.js b/test/utils/merger.js new file mode 100644 index 0000000..e264fd3 --- /dev/null +++ b/test/utils/merger.js @@ -0,0 +1,183 @@ +import mergerModule from '../../src'; +import Ajv from 'ajv'; + +const ajv = new Ajv({ strict: false }); + +export function merger(schema, options, instance) { + const result = mergerModule(schema, options); + const firstSchemaResult = ajv.validate(schema, instance); + const alteredSchemaResult = ajv.validate(result, instance); + + if (firstSchemaResult !== alteredSchemaResult) { + throw new Error( + 'Schema returned by merger does not validate same as original schema.' + ); + } + + try { + if (!ajv.validateSchema(result)) { + throw new Error("Schema returned by resolver isn't valid."); + } + return result; + } catch (e) { + if (!/stack/i.test(e.message)) { + throw e; + } + } +} + +// all different json instances that should be tested against the schema before and after merge +// it does not matter if they do not apply to all schemas, since we only check for that the result +// validates the same as the original schema, either true or false +const baseInstances = [ + { '000': true }, + { + '000': 'abcdefghi' + }, + ['abc'], + ['abc'], + ['abc', 'de'], + ['a'], + ['abcdefgfdsafds'], + 'fdsafabba', + 'fdsaf', + 'abba', + 'fdfdsf', + 'abbafdsaf', + 'fdsafabba', + 1, + 2, + 3, + 4, + 5, + 6, + 1.1, + 1.2, + 9.9, + 60, + 21, + 10, + [], + {}, + '', + null, + [1], + [1, 1], + ['string', {}], + { def: 'abc' }, + { abc: 'fds', prop4: true, bar: { abc: true, prop4: true } }, + { name: 'test', added: 123 }, + { name: 'test', added: false }, + { foo: null }, + { 123: true, abc: 2 }, + { list: [{ test: 1 }] }, + { list: [{ notAllowed: 1 }] }, + [ + { + name: 'somethingelse' + }, + { + name: 'bar' + } + ], + [ + { + name: 'foobar' + } + ], + [ + { + name: 'bar' + }, + { + name: 'foo' + } + ], + [ + { + name: 'bar' + } + ], + [ + { + name: 'foo' + } + ] +]; + +export function mergeAndTest(schema, options, extraInstances) { + const instances = baseInstances.concat(extraInstances ?? []); + if (!instances?.length) throw new Error('No instances provided for testing.'); + + const mergedSchema = mergerModule(schema, options); + + const validationResults = instances.map((instance) => { + const originalSchemaValidationResult = ajv.validate(schema, instance); + const originalErrors = ajv.errors; + const mergedSchemaValidationResult = ajv.validate(mergedSchema, instance); + const mergedErrors = ajv.errors; + return { + originalSchema: schema, + originalErrors, + mergedSchema, + mergedErrors, + instance, + isDifferent: + mergedSchemaValidationResult !== originalSchemaValidationResult, + mergedSchemaValidationResult, + originalSchemaValidationResult + }; + }); + + const allDiffs = validationResults.filter((result) => result.isDifferent); + + const hasPositive = validationResults.some( + (result) => result.originalSchemaValidationResult + ); + const hasNegative = validationResults.some( + (result) => !result.originalSchemaValidationResult + ); + + if (validationResults.length && !(hasPositive && hasNegative)) { + throw new Error('Test data must have both positive and negative cases.'); + } + + if (allDiffs.length) { + const message = allDiffs.map((result) => { + return ( + `Data: ${JSON.stringify(result.instance)}\n` + + `Original valid: ${result.originalSchemaValidationResult}\n` + + `Merged valid: ${result.mergedSchemaValidationResult}\n` + + `Original schema: ${JSON.stringify(schema)}\n` + + `Merged schema: ${JSON.stringify(result.mergedSchema)}` + ); + }); + + throw new Error( + `Some test data validates differently after the merge.\n` + + message.join('\n') + ); + } + + try { + if (!ajv.validateSchema(mergedSchema)) { + throw new Error("Schema returned by resolver isn't valid."); + } + return mergedSchema; + } catch (e) { + if (!/stack/i.test(e.message)) { + throw e; + } + } +} + +export function testEqualValidation(instance, schemas) { + const firstSchemaResult = ajv.validate(schemas[0], instance); + const allEqual = schemas + .slice(1) + .filter((schema) => ajv.validate(schema, instance) === firstSchemaResult); + + if (!allEqual) { + throw new Error('Schemas do not validate equally.'); + } +}