package/.editorconfig000666 0000000301 3560116604 011733 0ustar00000000 000000 root = true [*] indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.yml] indent_style = space indent_size = 2 package/test/.eslintrc000666 0000000222 3560116604 012063 0ustar00000000 000000 { "globals": { "describe": true, "it": true, "beforeEach": true, "afterEach": true, "before": true, "after": true } } package/.eslintrc.js000666 0000000336 3560116604 011525 0ustar00000000 000000 module.exports = { extends: 'standard', rules: { 'space-before-function-paren': ['error', { anonymous: 'ignore', named: 'ignore', asyncArrow: 'ignore' }] } } package/src/common.js000666 0000002457 3560116604 011711 0ustar00000000 000000 const flatten = require('lodash/flatten') const flattenDeep = require('lodash/flattenDeep') const isPlainObject = require('lodash/isPlainObject') const uniq = require('lodash/uniq') const uniqWith = require('lodash/uniqWith') const without = require('lodash/without') function deleteUndefinedProps(returnObject) { // cleanup empty for (const prop in returnObject) { if (has(returnObject, prop) && isEmptySchema(returnObject[prop])) { delete returnObject[prop] } } return returnObject } const allUniqueKeys = (arr) => uniq(flattenDeep(arr.map(keys))) const getValues = (schemas, key) => schemas.map(schema => schema && schema[key]) const has = (obj, propName) => Object.prototype.hasOwnProperty.call(obj, propName) const keys = obj => { if (isPlainObject(obj) || Array.isArray(obj)) { return Object.keys(obj) } else { return [] } } const notUndefined = (val) => val !== undefined const isSchema = (val) => isPlainObject(val) || val === true || val === false const isEmptySchema = (obj) => (!keys(obj).length) && obj !== false && obj !== true const withoutArr = (arr, ...rest) => without.apply(null, [arr].concat(flatten(rest))) module.exports = { allUniqueKeys, deleteUndefinedProps, getValues, has, isEmptySchema, isSchema, keys, notUndefined, uniqWith, withoutArr } package/test/specs/custom-resolvers.spec.js000666 0000005534 3560116604 016212 0ustar00000000 000000 const chai = require('chai') const merger = require('../../src') const expect = chai.expect describe('simple resolver', () => { it('merges as expected (with enum)', () => { const result = merger({ enum: [1, 2], allOf: [{ enum: [2, 3] }] }) expect(result).to.eql({ enum: [2] }) const opts = { resolvers: { enum(schemas, paths, mergeSchemas, options) { expect(options).to.eql(opts) expect(schemas).to.have.length(2) expect(paths).to.have.length(1) expect(paths).to.eql(['enum']) // inner merge test const innerSchemas = [{ minLength: 1 }, { minLength: 7 }] const innerResult = mergeSchemas(innerSchemas) expect(innerResult).to.eql({ minLength: 7 }) return [5] } } } const resultCustom = merger({ enum: [1, 2], allOf: [{ enum: [2, 3] }] }, opts) expect(resultCustom).to.eql({ enum: [5] }) }) describe('group resolvers', () => { it('works as intended with if then else copy resolver', () => { const conditonalRelated = ['if', 'then', 'else'] const has = (obj, propName) => Object.prototype.hasOwnProperty.call(obj, propName) const opts = { complexResolvers: { if: { // test with same if-then-else resolver keywords: conditonalRelated, resolver(schemas, paths, mergers, options) { const allWithConditional = schemas.filter(schema => conditonalRelated.some(keyword => has(schema, keyword))) // merge sub schemas completely // if,then,else must not be merged to the base schema, but if they contain allOf themselves, that should be merged function merge(schema) { const obj = {} if (has(schema, 'if')) obj.if = mergers.if([schema.if]) if (has(schema, 'then')) obj.then = mergers.then([schema.then]) if (has(schema, 'else')) obj.else = mergers.else([schema.else]) return obj } // first schema with any of the 3 keywords is used as base const first = merge(allWithConditional.shift()) return allWithConditional.reduce((all, schema) => { all.allOf = (all.allOf || []).concat(merge(schema)) return all }, first) } } } } const resultCustom = merger({ allOf: [{ if: { required: ['def'] }, then: {}, else: {} }] }, opts) expect(resultCustom).to.eql({ if: { required: ['def'] }, then: {}, else: {} }) }) }) }) package/test/specs/extraction.spec.js000666 0000000217 3560116604 015027 0ustar00000000 000000 describe('extraction', function() { describe('anyOf', function() { it('extracts validation keywords found in all anyOf schemas') }) }) package/src/index.js000666 0000026033 3560116604 011524 0ustar00000000 000000 const cloneDeep = require('lodash/cloneDeep') const compare = require('json-schema-compare') const computeLcm = require('compute-lcm') const defaultsDeep = require('lodash/defaultsDeep') const flatten = require('lodash/flatten') const flattenDeep = require('lodash/flattenDeep') const intersection = require('lodash/intersection') const intersectionWith = require('lodash/intersectionWith') const isEqual = require('lodash/isEqual') const isPlainObject = require('lodash/isPlainObject') const pullAll = require('lodash/pullAll') const sortBy = require('lodash/sortBy') const uniq = require('lodash/uniq') const uniqWith = require('lodash/uniqWith') const propertiesResolver = require('./complex-resolvers/properties') const itemsResolver = require('./complex-resolvers/items') const contains = (arr, val) => arr.indexOf(val) !== -1 const isSchema = (val) => isPlainObject(val) || val === true || val === false const isFalse = (val) => val === false 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 allUniqueKeys = (arr) => uniq(flattenDeep(arr.map(keys))) // resolvers const first = compacted => compacted[0] const required = compacted => stringArray(compacted) const maximumValue = compacted => Math.max.apply(Math, compacted) const minimumValue = compacted => Math.min.apply(Math, compacted) const uniqueItems = compacted => compacted.some(isTrue) const examples = compacted => uniqWith(flatten(compacted), isEqual) function compareProp(key) { return function(a, b) { return compare({ [key]: a }, { [key]: b }) } } function getAllOf(schema) { let { allOf = [], ...copy } = schema copy = isPlainObject(schema) ? copy : schema // if schema is boolean return [copy, ...allOf.map(getAllOf)] } function getValues(schemas, key) { return schemas.map(schema => schema && schema[key]) } function tryMergeSchemaGroups(schemaGroups, mergeSchemas) { return schemaGroups.map(function(schemas, index) { try { return mergeSchemas(schemas, index) } catch (e) { return undefined } }).filter(notUndefined) } function keys(obj) { if (isPlainObject(obj) || Array.isArray(obj)) { return Object.keys(obj) } else { return [] } } 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 { asJSON = values.map(function(val) { return JSON.stringify(val, null, 2) }).join('\n') } catch (variable) { asJSON = values.join(', ') } throw new Error('Could not resolve values for path:"' + paths.join('.') + '". They are probably incompatible. Values: \n' + asJSON) } function callGroupResolver(complexKeywords, resolverName, schemas, mergeSchemas, options, parents) { if (complexKeywords.length) { const resolverConfig = options.complexResolvers[resolverName] if (!resolverConfig || !resolverConfig.resolver) { throw new Error('No resolver found for ' + resolverName) } // extract all keywords from all the schemas that have one or more // then remove all undefined ones and not unique const extractedKeywordsOnly = schemas.map(schema => complexKeywords.reduce((all, key) => { if (schema[key] !== undefined) all[key] = schema[key] return all }, {})) const unique = uniqWith(extractedKeywordsOnly, compare) // create mergers that automatically add the path of the keyword for use in the complex resolver const mergers = resolverConfig.keywords.reduce((all, key) => ({ ...all, [key]: (schemas, extraKey = []) => mergeSchemas(schemas, null, parents.concat(key, extraKey)) }), {}) const result = resolverConfig.resolver(unique, parents.concat(resolverName), mergers, options) if (!isPlainObject(result)) { throwIncompatible(unique, parents.concat(resolverName)) } return result } } function createRequiredMetaArray(arr) { return { required: arr } } const schemaGroupProps = ['properties', 'patternProperties', 'definitions', 'dependencies'] const schemaArrays = ['anyOf', 'oneOf'] const schemaProps = [ 'additionalProperties', 'additionalItems', 'contains', 'propertyNames', 'not', 'items' ] const defaultResolvers = { type(compacted) { if (compacted.some(Array.isArray)) { const normalized = compacted.map(function(val) { return Array.isArray(val) ? val : [val] }) const common = intersection.apply(null, normalized) if (common.length === 1) { return common[0] } else if (common.length > 1) { return uniq(common) } } }, dependencies(compacted, paths, mergeSchemas) { const allChildren = allUniqueKeys(compacted) return allChildren.reduce(function(all, childKey) { const childSchemas = getValues(compacted, childKey) let innerCompacted = uniqWith(childSchemas.filter(notUndefined), isEqual) // to support dependencies const innerArrays = innerCompacted.filter(Array.isArray) if (innerArrays.length) { if (innerArrays.length === innerCompacted.length) { all[childKey] = stringArray(innerCompacted) } else { const innerSchemas = innerCompacted.filter(isSchema) const arrayMetaScheams = innerArrays.map(createRequiredMetaArray) all[childKey] = mergeSchemas(innerSchemas.concat(arrayMetaScheams), childKey) } return all } innerCompacted = uniqWith(innerCompacted, compare) all[childKey] = mergeSchemas(innerCompacted, childKey) 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 while (integers.some(n => !Number.isInteger(n))) { integers = integers.map(n => n * 10) factor = factor * 10 } return computeLcm(integers) / factor }, enum(compacted) { const enums = intersectionWith.apply(null, compacted.concat(isEqual)) if (enums.length) { return sortBy(enums) } } } defaultResolvers.$id = first defaultResolvers.$ref = first defaultResolvers.$schema = first defaultResolvers.additionalItems = schemaResolver defaultResolvers.additionalProperties = schemaResolver defaultResolvers.anyOf = defaultResolvers.oneOf defaultResolvers.contains = schemaResolver defaultResolvers.default = first defaultResolvers.definitions = defaultResolvers.dependencies defaultResolvers.description = first defaultResolvers.examples = examples defaultResolvers.exclusiveMaximum = minimumValue defaultResolvers.exclusiveMinimum = maximumValue defaultResolvers.items = itemsResolver defaultResolvers.maximum = minimumValue defaultResolvers.maxItems = minimumValue defaultResolvers.maxLength = minimumValue defaultResolvers.maxProperties = minimumValue defaultResolvers.minimum = maximumValue defaultResolvers.minItems = maximumValue defaultResolvers.minLength = maximumValue defaultResolvers.minProperties = maximumValue defaultResolvers.properties = propertiesResolver defaultResolvers.propertyNames = schemaResolver defaultResolvers.required = required defaultResolvers.title = first defaultResolvers.uniqueItems = uniqueItems const defaultComplexResolvers = { properties: propertiesResolver, items: itemsResolver } function merger(rootSchema, options, totalSchemas) { totalSchemas = totalSchemas || [] options = defaultsDeep(options, { ignoreAdditionalProperties: false, resolvers: defaultResolvers, complexResolvers: defaultComplexResolvers, deep: true }) const complexResolvers = Object.entries(options.complexResolvers) function mergeSchemas(schemas, base, parents) { schemas = cloneDeep(schemas.filter(notUndefined)) parents = parents || [] const merged = isPlainObject(base) ? base : {} // return undefined, an empty schema if (!schemas.length) { return } if (schemas.some(isFalse)) { return false } if (schemas.every(isTrue)) { return true } // there are no false and we don't need the true ones as they accept everything schemas = schemas.filter(isPlainObject) const allKeys = allUniqueKeys(schemas) if (options.deep && contains(allKeys, 'allOf')) { return merger({ allOf: schemas }, options, totalSchemas) } const complexKeysArr = complexResolvers.map(([mainKeyWord, resolverConf]) => allKeys.filter(k => resolverConf.keywords.includes(k))) // remove all complex keys before simple resolvers complexKeysArr.forEach(keys => pullAll(allKeys, keys)) // call all simple resolvers for relevant keywords allKeys.forEach(function(key) { const values = getValues(schemas, key) const compacted = uniqWith(values.filter(notUndefined), compareProp(key)) // arrayprops like anyOf and oneOf must be merged first, as they contains schemas // allOf is treated differently alltogether if (compacted.length === 1 && contains(schemaArrays, key)) { merged[key] = compacted[0].map(schema => mergeSchemas([schema], schema)) // prop groups must always be resolved } else if (compacted.length === 1 && !contains(schemaGroupProps, key) && !contains(schemaProps, key)) { merged[key] = compacted[0] } else { const resolver = options.resolvers[key] || options.resolvers.defaultResolver if (!resolver) throw new Error('No resolver found for key ' + key + '. You can provide a resolver for this keyword in the options, or provide a default resolver.') const merger = (schemas, extraKey = []) => mergeSchemas(schemas, null, parents.concat(key, extraKey)) merged[key] = resolver(compacted, parents.concat(key), merger, options) if (merged[key] === undefined) { throwIncompatible(compacted, parents.concat(key)) } else if (merged[key] === undefined) { delete merged[key] } } }) return complexResolvers.reduce((all, [resolverKeyword, config], index) => ({ ...all, ...callGroupResolver(complexKeysArr[index], resolverKeyword, schemas, mergeSchemas, options, parents) }), merged) } const allSchemas = flattenDeep(getAllOf(rootSchema)) const merged = mergeSchemas(allSchemas) return merged } merger.options = { resolvers: defaultResolvers } module.exports = merger package/test/specs/index.spec.js000666 0000074400 3560116604 013763 0ustar00000000 000000 const chai = require('chai') const mergerModule = require('../../src') const Ajv = require('ajv').default const _ = require('lodash') const $RefParser = require('json-schema-ref-parser') const expect = chai.expect 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 } } } describe('module', function() { it('merges schema with same object reference multiple places', () => { const commonSchema = { allOf: [{ properties: { test: true } }] } const result = merger({ properties: { list: { items: commonSchema } }, allOf: [commonSchema] }) expect(result).to.eql({ properties: { list: { items: { properties: { test: true } } }, test: true } }) }) it('does not alter original schema', () => { const schema = { allOf: [{ properties: { test: true } }] } const result = merger(schema) expect(result).to.eql({ properties: { test: true } }) expect(result).not.to.equal(schema) // not strict equal (identity) expect(schema).to.eql({ allOf: [{ properties: { test: true } }] }) }) it('does not use any original objects or arrays', () => { const schema = { properties: { arr: { type: 'array', items: { type: 'object' }, additionalItems: [{ type: 'array' }] } }, allOf: [{ properties: { test: true } }] } function innerDeconstruct(schema) { const allChildObj = Object.entries(schema).map(([key, val]) => { if (_.isObject(val)) return innerDeconstruct(val) else return undefined }) return [schema, ..._.flatten(allChildObj)] } const getAllObjects = schema => _(innerDeconstruct(schema)).compact().value() const inputObjects = getAllObjects(schema) const result = merger(schema) const resultObjects = getAllObjects(result) const commonObjects = _.intersection(inputObjects, resultObjects) expect(commonObjects).to.have.length(0) }) it('combines simple usecase', function() { const result = merger({ allOf: [{ type: 'string', minLength: 1 }, { type: 'string', maxLength: 5 }] }) expect(result).to.eql({ type: 'string', minLength: 1, maxLength: 5 }) }) it('combines without allOf', function() { const result = merger({ properties: { foo: { type: 'string' } } }) expect(result).to.eql({ properties: { foo: { type: 'string' } } }) }) describe('simple resolve functionality', function() { it('merges with default resolver if not defined resolver', function() { const result = merger({ title: 'schema1', allOf: [{ title: 'schema2' }, { title: 'schema3' }] }) expect(result).to.eql({ title: 'schema1' }) const result3 = merger({ allOf: [{ title: 'schema2' }, { title: 'schema3' }] }) expect(result3).to.eql({ title: 'schema2' }) }) it('merges minLength if conflict', function() { const result = merger({ allOf: [{ minLength: 1 }, { minLength: 5 }] }) expect(result).to.eql({ minLength: 5 }) }) it('merges minimum if conflict', function() { const result = merger({ allOf: [{ minimum: 1 }, { minimum: 5 }] }) expect(result).to.eql({ minimum: 5 }) }) it('merges exclusiveMinimum if conflict', function() { const result = merger({ allOf: [{ exclusiveMinimum: 1 }, { exclusiveMinimum: 5 }] }) expect(result).to.eql({ exclusiveMinimum: 5 }) }) it('merges minItems if conflict', function() { const result = merger({ allOf: [{ minItems: 1 }, { minItems: 5 }] }) expect(result).to.eql({ minItems: 5 }) }) it('merges maximum if conflict', function() { const result = merger({ allOf: [{ maximum: 1 }, { maximum: 5 }] }) expect(result).to.eql({ maximum: 1 }) }) it('merges exclusiveMaximum if conflict', function() { const result = merger({ allOf: [{ exclusiveMaximum: 1 }, { exclusiveMaximum: 5 }] }) expect(result).to.eql({ exclusiveMaximum: 1 }) }) it('merges maxItems if conflict', function() { const result = merger({ allOf: [{ maxItems: 1 }, { maxItems: 5 }] }) expect(result).to.eql({ maxItems: 1 }) }) it('merges maxLength if conflict', function() { const result = merger({ allOf: [{ maxLength: 4 }, { maxLength: 5 }] }) expect(result).to.eql({ maxLength: 4 }) }) it('merges uniqueItems to most restrictive if conflict', function() { const result = merger({ allOf: [{ uniqueItems: true }, { uniqueItems: false }] }) expect(result).to.eql({ uniqueItems: true }) expect(merger({ allOf: [{ uniqueItems: false }, { uniqueItems: false }] })).to.eql({ uniqueItems: false }) }) it('throws if merging incompatible type', function() { expect(function() { merger({ allOf: [{ type: 'null' }, { type: 'text' }] }) }).to.throw(/incompatible/) }) it('merges type if conflict', function() { const result = merger({ allOf: [{}, { type: ['string', 'null', 'object', 'array'] }, { type: ['string', 'null'] }, { type: ['null', 'string'] }] }) expect(result).to.eql({ type: ['string', 'null'] }) const result2 = merger({ allOf: [{}, { type: ['string', 'null', 'object', 'array'] }, { type: 'string' }, { type: ['null', 'string'] }] }) expect(result2).to.eql({ type: 'string' }) expect(function() { merger({ allOf: [{ type: ['null'] }, { type: ['text', 'object'] }] }) }).to.throw(/incompatible/) }) it('merges enum', function() { const result = merger({ allOf: [{}, { enum: [ 'string', 'null', 'object', {}, [2], [1], null ] }, { enum: ['string', {}, [1], [1] ] }, { enum: [ 'null', 'string', {}, [3], [1], null ] }] }) expect(result).to.eql({ enum: [ [1], {}, 'string' ] }) }) it('throws if enum is incompatible', function() { expect(function() { merger({ allOf: [{}, { enum: ['string', {}] }, { enum: [{}, 'string'] }] }) }).not.to.throw(/incompatible/) expect(function() { merger({ allOf: [{}, { enum: ['string', {}] }, { enum: [ [], false ] }] }) }).to.throw(/incompatible/) }) it('merges const', function() { const result = merger({ allOf: [{}, { const: ['string', {}] }, { const: ['string', {}] }] }) expect(result).to.eql({ const: ['string', {}] }) }) 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: [{ not: { properties: { name: { type: 'string' } } } }, { not: { properties: { name: { type: ['string', 'null'] } } } }] }) expect(result).to.eql({ not: { anyOf: [{ properties: { name: { type: 'string' } } }, { properties: { name: { type: ['string', 'null'] } } } ] } }) }) 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({ allOf: [{ title: 'foo', type: [ 'number', 'integer' ], multipleOf: 2 }, { type: 'integer', multipleOf: 3 }] }) expect(result).to.eql({ type: 'integer', title: 'foo', allOf: [{ multipleOf: 2 }, { multipleOf: 3 }] }) const result2 = merger({ allOf: [{ multipleOf: 1 }] }) expect(result2).to.eql({ multipleOf: 1 }) }) it('merges multipleOf by finding lowest common multiple (LCM)', function() { const result = merger({ allOf: [{}, { multipleOf: 0.2, allOf: [{ multipleOf: 2, allOf: [{ multipleOf: 2, allOf: [{ multipleOf: 2, allOf: [{ multipleOf: 3, allOf: [{ multipleOf: 1.5, allOf: [{ multipleOf: 0.5 }] }] }] }] }] }] }, { multipleOf: 0.3 }] }) expect(result).to.eql({ multipleOf: 6 }) expect(merger({ allOf: [{ multipleOf: 4 }, { multipleOf: 15 }, { multipleOf: 3 }] })).to.eql({ multipleOf: 60 }) expect(merger({ allOf: [{ multipleOf: 0.3 }, { multipleOf: 0.7 }, { multipleOf: 1 }] })).to.eql({ multipleOf: 21 }) expect(merger({ allOf: [{ multipleOf: 0.5 }, { multipleOf: 2 }] })).to.eql({ multipleOf: 2 }) expect(merger({ allOf: [{ multipleOf: 0.3 }, { multipleOf: 0.5 }, { multipleOf: 1 }] })).to.eql({ multipleOf: 3 }) expect(merger({ allOf: [{ multipleOf: 0.3 }, { multipleOf: 0.7 }, { multipleOf: 1 }] })).to.eql({ multipleOf: 21 }) expect(merger({ allOf: [{ multipleOf: 0.4 }, { multipleOf: 0.7 }, { multipleOf: 3 }] })).to.eql({ multipleOf: 42 }) expect(merger({ allOf: [{ multipleOf: 0.2 }, { multipleOf: 0.65 }, { multipleOf: 1 }] })).to.eql({ multipleOf: 13 }) expect(merger({ allOf: [{ multipleOf: 100000 }, { multipleOf: 1000000 }, { multipleOf: 500000 }] })).to.eql({ multipleOf: 1000000 }) }) }) describe('merging arrays', function() { it('merges required object', function() { expect(merger({ required: ['prop2'], allOf: [{ required: ['prop2', 'prop1'] }] })).to.eql({ required: ['prop1', 'prop2'] }) }) it('merges default value', function() { expect(merger({ default: [ 'prop2', { prop1: 'foo' } ], allOf: [{ default: ['prop2', 'prop1'] }] })).to.eql({ default: [ 'prop2', { prop1: 'foo' } ] }) }) it('merges default value', function() { expect(merger({ default: { foo: 'bar' }, allOf: [{ default: ['prop2', 'prop1'] }] })).to.eql({ default: { foo: 'bar' } }) }) }) describe('merging objects', function() { it('merges child objects', function() { expect(merger({ properties: { name: { title: 'Name', type: 'string' } }, allOf: [{ properties: { name: { title: 'allof1', type: 'string' }, added: { type: 'integer' } } }, { properties: { name: { type: 'string' } } }] })).to.eql({ properties: { name: { title: 'Name', type: 'string' }, added: { type: 'integer' } } }) }) it('merges boolean schemas', function() { expect(merger({ properties: { name: true }, allOf: [{ properties: { name: { title: 'allof1', type: 'string' }, added: { type: 'integer' } } }, { properties: { name: { type: 'string', minLength: 5 } } }] })).to.eql({ properties: { name: { title: 'allof1', type: 'string', minLength: 5 }, added: { type: 'integer' } } }) expect(merger({ properties: { name: false }, allOf: [{ properties: { name: { title: 'allof1', type: 'string' }, added: { type: 'integer' } } }, { properties: { name: true } }] })).to.eql({ properties: { name: false, added: { type: 'integer' } } }) expect(merger({ allOf: [true, false] })).to.eql(false) expect(merger({ properties: { name: true }, allOf: [{ properties: { name: false, added: { type: 'integer' } } }, { properties: { name: true } }] })).to.eql({ properties: { name: false, added: { type: 'integer' } } }) }) it('merges all allOf', function() { expect(merger({ properties: { name: { allOf: [{ pattern: '^.+$' }] } }, allOf: [{ properties: { name: true, added: { type: 'integer', title: 'pri1', allOf: [{ title: 'pri2', type: [ 'string', 'integer' ], minimum: 15, maximum: 10 }] } }, allOf: [{ properties: { name: true, added: { type: 'integer', minimum: 5 } }, allOf: [{ properties: { added: { title: 'pri3', type: 'integer', minimum: 10 } } }] }] }, { properties: { name: true, added: { minimum: 7 } } }] })).to.eql({ properties: { name: { pattern: '^.+$' }, added: { type: 'integer', title: 'pri1', minimum: 15, maximum: 10 } } }) }) }) describe.skip('merging definitions', function() { it('merges circular', function() { const schema = { properties: { person: { properties: { name: { type: 'string', minLength: 8 } }, allOf: [{ properties: { name: { minLength: 5, maxLength: 10 } }, allOf: [{ properties: { prop1: { minLength: 7 } } }] }] } } } schema.properties.person.properties.child = schema.properties.person const expected = { person: { properties: { name: { minLength: 8, maxLength: 10, type: 'string' }, prop1: { minLength: 7 } } } } expected.person.properties.child = expected.person const result = merger(schema) expect(result).to.eql({ properties: expected }) }) it('merges any definitions and circular', function() { const schema = { properties: { person: { $ref: '#/definitions/person' } }, definitions: { person: { properties: { name: { type: 'string', minLength: 8 }, child: { $ref: '#/definitions/person' } }, allOf: [{ properties: { name: { minLength: 5, maxLength: 10 } }, allOf: [{ properties: { prop1: { minLength: 7 } } }] }] } } } return $RefParser.dereference(schema).then(function(dereferenced) { const expected = { person: { properties: { name: { minLength: 8, maxLength: 10, type: 'string' }, prop1: { minLength: 7 } } } } expected.person.properties.child = expected.person const result = merger(schema) expect(result).to.eql({ properties: expected, definitions: expected }) expect(result).to.equal(dereferenced) expect(result.properties.person.properties.child).to.equal(result.definitions.person.properties.child) expect(result.properties.person.properties.child).to.equal(dereferenced.properties.person) }) }) }) describe('dependencies', function() { it('merges simliar schemas', function() { const result = merger({ dependencies: { foo: { type: [ 'string', 'null', 'integer' ], allOf: [{ minimum: 5 }] }, bar: ['prop1', 'prop2'] }, allOf: [{ dependencies: { foo: { type: [ 'string', 'null' ], allOf: [{ minimum: 7 }] }, bar: ['prop4'] } }] }) expect(result).to.eql({ dependencies: { foo: { type: [ 'string', 'null' ], minimum: 7 }, bar: ['prop1', 'prop2', 'prop4'] } }) }) it('merges mixed mode dependency', function() { const result = merger({ dependencies: { bar: { type: [ 'string', 'null', 'integer' ], required: ['abc'] } }, allOf: [{ dependencies: { bar: ['prop4'] } }] }) expect(result).to.eql({ dependencies: { bar: { type: [ 'string', 'null', 'integer' ], required: ['abc', 'prop4'] } } }) }) }) describe('propertyNames', function() { it('merges simliar schemas', function() { const result = merger({ propertyNames: { type: 'string', allOf: [{ minLength: 5 }] }, allOf: [{ propertyNames: { type: 'string', pattern: 'abc.*', allOf: [{ maxLength: 7 }] } }] }) expect(result).to.eql({ propertyNames: { type: 'string', pattern: 'abc.*', minLength: 5, maxLength: 7 } }) }) }) }) package/src/complex-resolvers/items.js000666 0000005044 3560116604 015226 0ustar00000000 000000 const compare = require('json-schema-compare') const forEach = require('lodash/forEach') const { allUniqueKeys, deleteUndefinedProps, has, isSchema, notUndefined, uniqWith } = require('../common') function removeFalseSchemasFromArray(target) { forEach(target, function(schema, index) { if (schema === false) { target.splice(index, 1) } }) } function getItemSchemas(subSchemas, key) { return subSchemas.map(function(sub) { if (!sub) { return undefined } if (Array.isArray(sub.items)) { const schemaAtPos = sub.items[key] if (isSchema(schemaAtPos)) { return schemaAtPos } else if (has(sub, 'additionalItems')) { return sub.additionalItems } } else { return sub.items } return undefined }) } function getAdditionalSchemas(subSchemas) { return subSchemas.map(function(sub) { if (!sub) { return undefined } if (Array.isArray(sub.items)) { return sub.additionalItems } return sub.items }) } // Provide source when array function mergeItems(group, mergeSchemas, items) { const allKeys = allUniqueKeys(items) return allKeys.reduce(function(all, key) { const schemas = getItemSchemas(group, key) const compacted = uniqWith(schemas.filter(notUndefined), compare) all[key] = mergeSchemas(compacted, key) return all }, []) } module.exports = { keywords: ['items', 'additionalItems'], resolver(values, parents, mergers) { // const createSubMerger = groupKey => (schemas, key) => mergeSchemas(schemas, parents.concat(groupKey, key)) const items = values.map(s => s.items) const itemsCompacted = items.filter(notUndefined) const returnObject = {} // if all items keyword values are schemas, we can merge them as simple schemas // if not we need to merge them as mixed if (itemsCompacted.every(isSchema)) { returnObject.items = mergers.items(items) } else { returnObject.items = mergeItems(values, mergers.items, items) } let schemasAtLastPos if (itemsCompacted.every(Array.isArray)) { schemasAtLastPos = values.map(s => s.additionalItems) } else if (itemsCompacted.some(Array.isArray)) { schemasAtLastPos = getAdditionalSchemas(values) } if (schemasAtLastPos) { returnObject.additionalItems = mergers.additionalItems(schemasAtLastPos) } if (returnObject.additionalItems === false && Array.isArray(returnObject.items)) { removeFalseSchemasFromArray(returnObject.items) } return deleteUndefinedProps(returnObject) } } package/test/specs/items.spec.js000666 0000013272 3560116604 013775 0ustar00000000 000000 const chai = require('chai') const merger = require('../../src') const expect = chai.expect describe('items', function() { it('merges additionalItems', function() { const result = merger({ items: { type: 'object' }, allOf: [{ items: [true], additionalItems: { properties: { name: { type: 'string', pattern: 'bar' } } } }, { items: [true], additionalItems: { properties: { name: { type: 'string', pattern: 'foo' } } } }] }) expect(result).to.eql({ items: [{ type: 'object' }], additionalItems: { type: 'object', properties: { name: { type: 'string', pattern: '(?=bar)(?=foo)' } } } }) }) describe('when single schema', function() { it('merges them', function() { const result = merger({ items: { type: 'string', allOf: [{ minLength: 5 }] }, allOf: [{ items: { type: 'string', pattern: 'abc.*', allOf: [{ maxLength: 7 }] } }] }) expect(result).to.eql({ items: { type: 'string', pattern: 'abc.*', minLength: 5, maxLength: 7 } }) }) }) describe('when array', function() { it('merges them in when additionalItems are all undefined', function() { const result = merger({ items: [{ type: 'string', allOf: [{ minLength: 5 }] }], allOf: [{ items: [{ type: 'string', allOf: [{ minLength: 5 }] }, { type: 'integer' }] }] }) expect(result).to.eql({ items: [{ type: 'string', minLength: 5 }, { type: 'integer' }] }) }) it('merges in additionalItems from one if present', function() { const result = merger({ allOf: [{ items: [{ type: 'string', minLength: 10, allOf: [{ minLength: 5 }] }, { type: 'integer' }] }, { additionalItems: false, items: [{ type: 'string', allOf: [{ minLength: 7 }] }] }] }) expect(result).to.eql({ additionalItems: false, items: [{ type: 'string', minLength: 10 }] }) }) it('merges in additionalItems from one if present', function() { const result = merger({ allOf: [{ items: [{ type: 'string', minLength: 10, allOf: [{ minLength: 5 }] }, { type: 'integer' }], additionalItems: false }, { additionalItems: false, items: [{ type: 'string', allOf: [{ minLength: 7 }] }] }] }) expect(result).to.eql({ additionalItems: false, items: [{ type: 'string', minLength: 10 }] }) }) it('merges in additionalItems schema', function() { const result = merger({ allOf: [{ items: [{ type: 'string', minLength: 10, allOf: [{ minLength: 5 }] }, { type: 'integer' }], additionalItems: { type: 'integer', minimum: 15 } }, { additionalItems: { type: 'integer', minimum: 10 }, items: [{ type: 'string', allOf: [{ minLength: 7 }] }] }] }) expect(result).to.eql({ additionalItems: { type: 'integer', minimum: 15 }, items: [{ type: 'string', minLength: 10 }, { type: 'integer', minimum: 10 }] }) }) }) describe('when mixed array and object', function() { it('merges in additionalItems schema', function() { const result = merger({ // This should be ignored according to spec when items absent additionalItems: { type: 'integer', minimum: 50 }, allOf: [{ items: { type: 'integer', minimum: 5, maximum: 30, allOf: [{ minimum: 9 }] }, // This should be ignored according to spec when items is object additionalItems: { type: 'integer', minimum: 15 } }, { // this will be merged with first allOf items schema additionalItems: { type: 'integer', minimum: 10 }, // this will be merged with first allOf items schema items: [{ type: 'integer', allOf: [{ minimum: 7, maximum: 20 }] }] }] }) expect(result).to.eql({ additionalItems: { type: 'integer', minimum: 10, maximum: 30 }, items: [{ type: 'integer', minimum: 9, maximum: 20 }] }) it('considers additionalItems') }) }) }) package/test/specs/meta-schema.spec.js000666 0000001113 3560116604 015027 0ustar00000000 000000 const chai = require('chai') const merger = require('../../src') const _ = require('lodash') const $RefParser = require('json-schema-ref-parser') const metaSchema = require('../fixtures/schemas/meta-schema-v6.json') const expect = chai.expect let schema describe.skip('simplify the meta schema', function() { beforeEach(function() { return $RefParser.dereference(_.cloneDeep(metaSchema)).then(function(dereferenced) { schema = dereferenced }) }) it('simplifies', function() { const result = merger(_.cloneDeep(schema)) expect(result).to.eql(schema) }) }) package/test/specs/options.spec.js000666 0000007240 3560116604 014345 0ustar00000000 000000 const chai = require('chai') const merger = require('../../src') const expect = chai.expect describe('options', function() { it('allows otherwise incompatible properties if option ignoreAdditionalProperties is true', function() { const result = merger({ allOf: [{ properties: { foo: true }, additionalProperties: true }, { properties: { bar: true }, additionalProperties: false }] }, { ignoreAdditionalProperties: true }) expect(result).to.eql({ properties: { foo: true, bar: true }, additionalProperties: false }) const result2 = merger({ allOf: [{ additionalProperties: true }, { additionalProperties: true }] }) expect(result2).to.eql({}) }) it('ignoreAdditionalProperties is true, also allows merging of patternProperties', function() { const result = merger({ allOf: [{ properties: { foo: true }, patternProperties: { '^abc': true }, additionalProperties: true }, { properties: { bar: true }, patternProperties: { '123$': true }, additionalProperties: false }] }, { ignoreAdditionalProperties: true }) expect(result).to.eql({ properties: { foo: true, bar: true }, patternProperties: { '^abc': true, '123$': true }, additionalProperties: false }) const result2 = merger({ allOf: [{ additionalProperties: true }, { additionalProperties: true }] }) expect(result2).to.eql({}) }) it('throws if no resolver found for unknown keyword', function() { expect(function() { merger({ foo: 3, allOf: [{ foo: 7 }] }) }).to.throw(/no resolver found/i) }) it('uses supplied resolver for unknown keyword', function() { const result = merger({ foo: 3, allOf: [{ foo: 7 }] }, { resolvers: { foo: function(values) { return values.pop() } } }) expect(result).to.eql({ foo: 7 }) }) it('uses default merger if no resolver found', function() { const result = merger({ foo: 3, allOf: [{ foo: 7 }] }, { resolvers: { defaultResolver: function(values) { return values.pop() } } }) expect(result).to.eql({ foo: 7 }) }) it('merges deep by default', function() { const result = merger({ allOf: [{ properties: { foo: { type: 'string' }, bar: { allOf: [{ properties: { baz: { type: 'string' } } }] } } }] }) expect(result).to.eql({ properties: { foo: { type: 'string' }, bar: { properties: { baz: { type: 'string' } } } } }) }) it('doesn\'t merge deep when deep is false', function() { const result = merger({ allOf: [{ properties: { foo: { type: 'string' }, bar: { allOf: [{ properties: { baz: { type: 'string' } } }] } } }] }, { deep: false }) expect(result).to.eql({ properties: { foo: { type: 'string' }, bar: { allOf: [{ properties: { baz: { type: 'string' } } }] } } }) }) }) package/src/complex-resolvers/properties.js000666 0000005434 3560116604 016304 0ustar00000000 000000 const compare = require('json-schema-compare') const forEach = require('lodash/forEach') const { allUniqueKeys, deleteUndefinedProps, getValues, keys, notUndefined, uniqWith, withoutArr } = require('../common') function removeFalseSchemas(target) { forEach(target, function(schema, prop) { if (schema === false) { delete target[prop] } }) } function mergeSchemaGroup(group, mergeSchemas) { const allKeys = allUniqueKeys(group) return allKeys.reduce(function(all, key) { const schemas = getValues(group, key) const compacted = uniqWith(schemas.filter(notUndefined), compare) all[key] = mergeSchemas(compacted, key) return all }, {}) } module.exports = { keywords: ['properties', 'patternProperties', 'additionalProperties'], resolver(values, parents, mergers, options) { // first get rid of all non permitted properties if (!options.ignoreAdditionalProperties) { values.forEach(function(subSchema) { const otherSubSchemas = values.filter(s => s !== subSchema) const ownKeys = keys(subSchema.properties) const ownPatternKeys = keys(subSchema.patternProperties) const ownPatterns = ownPatternKeys.map(k => new RegExp(k)) otherSubSchemas.forEach(function(other) { const allOtherKeys = keys(other.properties) const keysMatchingPattern = allOtherKeys.filter(k => ownPatterns.some(pk => pk.test(k))) const additionalKeys = withoutArr(allOtherKeys, ownKeys, keysMatchingPattern) additionalKeys.forEach(function(key) { other.properties[key] = mergers.properties([ other.properties[key], subSchema.additionalProperties ], key) }) }) }) // remove disallowed patternProperties values.forEach(function(subSchema) { const otherSubSchemas = values.filter(s => s !== subSchema) const ownPatternKeys = keys(subSchema.patternProperties) if (subSchema.additionalProperties === false) { otherSubSchemas.forEach(function(other) { const allOtherPatterns = keys(other.patternProperties) const additionalPatternKeys = withoutArr(allOtherPatterns, ownPatternKeys) additionalPatternKeys.forEach(key => delete other.patternProperties[key]) }) } }) } const returnObject = { additionalProperties: mergers.additionalProperties(values.map(s => s.additionalProperties)), patternProperties: mergeSchemaGroup(values.map(s => s.patternProperties), mergers.patternProperties), properties: mergeSchemaGroup(values.map(s => s.properties), mergers.properties) } if (returnObject.additionalProperties === false) { removeFalseSchemas(returnObject.properties) } return deleteUndefinedProps(returnObject) } } package/test/specs/properties.spec.js000666 0000037362 3560116604 015056 0ustar00000000 000000 const chai = require('chai') const merger = require('../../src') const sinon = require('sinon') const _ = require('lodash') const expect = chai.expect const Ajv = require('ajv').default const ajv = new Ajv({ allowMatchingProperties: true }) describe('properties', function() { describe('when property name has same as a reserved word', function() { it('does not treat it as a reserved word', function() { const stub = sinon.stub().returns({ properties: { properties: { type: 'string', minLength: 5 } } }) merger({ allOf: [{ properties: { properties: { type: 'string', minLength: 5 } } }, { properties: { properties: { type: 'string', minLength: 7 } } }] }, { complexResolvers: { properties: { keywords: ['properties', 'patternProperties', 'additionalProperties'], resolver: stub } } }) sinon.assert.calledOnce(stub) }) }) describe('additionalProperties', function() { it('allows no extra properties if additionalProperties is false', function() { const result = merger({ allOf: [{ additionalProperties: true }, { additionalProperties: false }] }) expect(result).to.eql({ additionalProperties: false }) }) it('allows only intersecting properties', function() { const result = merger({ allOf: [{ properties: { foo: true }, additionalProperties: true }, { properties: { bar: true }, additionalProperties: false }] }) expect(result).to.eql({ properties: { bar: true }, additionalProperties: false }) }) it('allows intersecting patternproperties', function() { const result = merger({ allOf: [{ properties: { foo: true, foo123: true }, additionalProperties: true }, { properties: { bar: true }, patternProperties: { '.+\\d+$': true }, additionalProperties: false }] }) expect(result).to.eql({ properties: { bar: true, foo123: true }, patternProperties: { '.+\\d+$': true }, additionalProperties: false }) }) it('disallows all except matching patternProperties if both false', function() { const result = merger({ allOf: [{ properties: { foo: true, foo123: true }, additionalProperties: false }, { properties: { bar: true }, patternProperties: { '.+\\d+$': true }, additionalProperties: false }] }) expect(result).to.eql({ properties: { foo123: true }, additionalProperties: false }) }) it('disallows all except matching patternProperties if both false', function() { const result = merger({ allOf: [{ properties: { foo: true, foo123: true }, patternProperties: { '.+\\d+$': { type: 'string' } }, additionalProperties: false }, { properties: { bar: true, bar123: true }, patternProperties: { '.+\\d+$': true }, additionalProperties: false }] }) expect(result).to.eql({ properties: { foo123: true, bar123: true }, patternProperties: { '.+\\d+$': { type: 'string' } }, additionalProperties: false }) }) it('disallows all except matching patternProperties if both false', function() { const schema = { allOf: [{ type: 'object', properties: { foo: true, foo123: true }, patternProperties: { '^bar': true }, additionalProperties: false }, { type: 'object', properties: { bar: true, bar123: true }, patternProperties: { '.+\\d+$': true }, additionalProperties: false }] } const origSchema = _.cloneDeep(schema) const result = merger(schema) expect(result).not.to.eql(origSchema) expect(result).to.eql({ type: 'object', properties: { bar: true, foo123: true, bar123: true }, additionalProperties: false }) ; [{ foo123: 'testfdsdfsfd' }, { bar123: 'testfdsdfsfd' }, { foo123: 'testfdsdfsfd' }, { bar: 'fdsaf' }, { abc123: 'fdsaf' }, { bar123: 'fdsaf' }, { barabc: 'fdsaf' }, { // additionalProp foo234: 'testffdsafdsads' }].forEach(function(val) { validateInputOutput(origSchema, result, val) }) }) it('disallows all except matching patternProperties if both true', function() { const schema = { allOf: [{ type: 'object', properties: { foo: true, foo123: true }, patternProperties: { '^bar': true } }, { type: 'object', properties: { bar: true, bar123: true }, patternProperties: { '.+\\d+$': true } }] } const origSchema = _.cloneDeep(schema) const result = merger(schema) expect(result).not.to.eql(origSchema) expect(result).to.eql({ type: 'object', properties: { foo: true, bar: true, foo123: true, bar123: true }, patternProperties: { '^bar': true, '.+\\d+$': true } }) ; [{ foo123: 'testfdsdfsfd' }, { bar123: 'testfdsdfsfd' }, { foo123: 'testfdsdfsfd' }, { foo: 'fdsaf' }, { bar: 'fdsaf' }, { abc123: 'fdsaf' }, { bar123: 'fdsaf' }, { barabc: 'fdsaf' }, { foo234: 'testffdsafdsads' }].forEach(function(val) { validateInputOutput(origSchema, result, val) }) }) it('disallows all except matching patternProperties if one false', function() { const schema = { allOf: [{ type: 'object', properties: { foo: true, foo123: true } }, { type: 'object', properties: { bar: true, bar123: true }, patternProperties: { '.+\\d+$': true }, additionalProperties: false }] } const origSchema = _.cloneDeep(schema) const result = merger(schema) expect(result).not.to.eql(origSchema) expect(result).to.eql({ type: 'object', properties: { bar: true, foo123: true, bar123: true }, patternProperties: { '.+\\d+$': true }, additionalProperties: false }) ; [{ foo123: 'testfdsdfsfd' }, { bar123: 'testfdsdfsfd' }, { foo123: 'testfdsdfsfd' }, { foo: 'fdsaf' }, { bar: 'fdsaf' }, { abc123: 'fdsaf' }, { bar123: 'fdsaf' }, { barabc: 'fdsaf' }, { foo234: 'testffdsafdsads' }].forEach(function(val) { validateInputOutput(origSchema, result, val) }) }) it('disallows all if no patternProperties and if both false', function() { const result = merger({ allOf: [{ properties: { foo: true, foo123: true }, additionalProperties: false }, { properties: { bar: true }, additionalProperties: false }] }) expect(result).to.eql({ additionalProperties: false }) }) it('applies additionalProperties to other schemas properties if they have any', function() { const result = merger({ properties: { common: true, root: true }, additionalProperties: false, allOf: [{ properties: { common: { type: 'string' }, allof1: true }, additionalProperties: { type: [ 'string', 'null' ], maxLength: 10 } }, { properties: { common: { minLength: 1 }, allof2: true }, additionalProperties: { type: [ 'string', 'integer', 'null' ], maxLength: 8 } }, { properties: { common: { minLength: 6 }, allof3: true } }] }) expect(result).to.eql({ properties: { common: { type: 'string', minLength: 6 }, root: { type: [ 'string', 'null' ], maxLength: 8 } }, additionalProperties: false }) }) 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 } }, { properties: { common: { minLength: 1 }, allof2: true, allowed123: { type: 'string' } }, patternProperties: { '.+\\d{2,}$': { minLength: 9 } }, additionalProperties: { type: [ 'string', 'integer', 'null' ], maxLength: 8 } }, { properties: { common: { minLength: 6 }, allof3: true, allowed456: { type: 'integer' } } }] }) expect(result).to.eql({ properties: { common: { type: 'string', minLength: 6 }, root: { type: [ 'string', 'null', 'integer' ], maxLength: 8 }, allowed123: { type: 'string', maxLength: 10 }, allowed456: { type: 'integer', maxLength: 10 } }, patternProperties: { '.+\\d{2,}$': { minLength: 9 } }, additionalProperties: false }) }) it('combines additionalProperties when schemas', function() { const result = merger({ additionalProperties: true, allOf: [{ additionalProperties: { type: [ 'string', 'null' ], maxLength: 10 } }, { additionalProperties: { type: [ 'string', 'integer', 'null' ], maxLength: 8 } }] }) expect(result).to.eql({ additionalProperties: { type: [ 'string', 'null' ], maxLength: 8 } }) }) }) describe('patternProperties', function() { it('merges simliar schemas', function() { const result = merger({ patternProperties: { '^\\$.+': { type: [ 'string', 'null', 'integer' ], allOf: [{ minimum: 5 }] } }, allOf: [{ patternProperties: { '^\\$.+': { type: [ 'string', 'null' ], allOf: [{ minimum: 7 }] }, '.*': { type: 'null' } } }] }) expect(result).to.eql({ patternProperties: { '^\\$.+': { type: [ 'string', 'null' ], minimum: 7 }, '.*': { type: 'null' } } }) }) }) describe('when patternProperties present', function() { it('merges patternproperties', function() { const result = merger({ allOf: [{ patternProperties: { '.*': { type: 'string', minLength: 5 } } }, { patternProperties: { '.*': { type: 'string', minLength: 7 } } }] }) expect(result).to.eql({ patternProperties: { '.*': { type: 'string', minLength: 7 } } }) }) it('merges with properties if matching property name', function() { const schema = { allOf: [{ type: 'object', properties: { name: { type: 'string', minLength: 1 } }, patternProperties: { _long$: { type: 'string', minLength: 7 } } }, { type: 'object', properties: { foo_long: { type: 'string', minLength: 9 } }, patternProperties: { '^name.*': { type: 'string', minLength: 8 } } }] } const origSchema = _.cloneDeep(schema) const result = merger(schema) expect(result).not.to.eql(origSchema) expect(result).to.eql({ type: 'object', properties: { foo_long: { type: 'string', minLength: 9 }, name: { type: 'string', minLength: 1 } }, patternProperties: { _long$: { type: 'string', minLength: 7 }, '^name.*': { type: 'string', minLength: 8 } } }); [{ name: 'test' }, { name: 'fdsaffsda', name_long: 'testfdsdfsfd' }, { name: 'fdsafdsafas', foo_long: 'testfdsdfsfd' }, { name: 'dfsafdsa', name_long: 'testfdsdfsfd' }, { name: 'test', name2: 'testffdsafdsads' }].forEach(function(val) { validateInputOutput(schema, result, val) }) }) }) }) function validateInputOutput(schema, transformedSchema, obj) { const validOriginal = ajv.validate(schema, obj) const validNew = ajv.validate(transformedSchema, obj) expect(validOriginal).to.eql(validNew) } package/test/specs/stripping.spec.js000666 0000000253 3560116604 014666 0ustar00000000 000000 describe('stripping', function() { it('strips away validators that makes no sense for the given type(s)') it('strips away properties not allowed by propertyNames') }) package/test/specs/validation.spec.js000666 0000000313 3560116604 014776 0ustar00000000 000000 describe('validation', function() { it('is false if property is required, but not allowed by patternProperties or additionalProperties') it('is false if any min value is bigger than a max value') }) package/test/fixtures/schemas/meta-schema-v6.json000666 0000007533 3560116604 017157 0ustar00000000 000000 { "$schema": "http://json-schema.org/draft-06/schema#", "$id": "http://json-schema.org/draft-06/schema#", "title": "Core schema meta-schema", "definitions": { "schemaArray": { "type": "array", "minItems": 1, "items": { "$ref": "#" } }, "nonNegativeInteger": { "type": "integer", "minimum": 0 }, "nonNegativeIntegerDefault0": { "allOf": [{ "$ref": "#/definitions/nonNegativeInteger" }, { "default": 0 } ] }, "simpleTypes": { "enum": [ "array", "boolean", "integer", "null", "number", "object", "string" ] }, "stringArray": { "type": "array", "items": { "type": "string" }, "uniqueItems": true, "default": [] } }, "type": ["object", "boolean"], "properties": { "$id": { "type": "string", "format": "uri-reference" }, "$schema": { "type": "string", "format": "uri" }, "$ref": { "type": "string", "format": "uri-reference" }, "title": { "type": "string" }, "description": { "type": "string" }, "default": {}, "multipleOf": { "type": "number", "exclusiveMinimum": 0 }, "maximum": { "type": "number" }, "exclusiveMaximum": { "type": "number" }, "minimum": { "type": "number" }, "exclusiveMinimum": { "type": "number" }, "maxLength": { "$ref": "#/definitions/nonNegativeInteger" }, "minLength": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, "pattern": { "type": "string", "format": "regex" }, "additionalItems": { "$ref": "#" }, "items": { "anyOf": [{ "$ref": "#" }, { "$ref": "#/definitions/schemaArray" } ], "default": {} }, "maxItems": { "$ref": "#/definitions/nonNegativeInteger" }, "minItems": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, "uniqueItems": { "type": "boolean", "default": false }, "contains": { "$ref": "#" }, "maxProperties": { "$ref": "#/definitions/nonNegativeInteger" }, "minProperties": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, "required": { "$ref": "#/definitions/stringArray" }, "additionalProperties": { "$ref": "#" }, "definitions": { "type": "object", "additionalProperties": { "$ref": "#" }, "default": {} }, "properties": { "type": "object", "additionalProperties": { "$ref": "#" }, "default": {} }, "patternProperties": { "type": "object", "additionalProperties": { "$ref": "#" }, "default": {} }, "dependencies": { "type": "object", "additionalProperties": { "anyOf": [{ "$ref": "#" }, { "$ref": "#/definitions/stringArray" } ] } }, "propertyNames": { "$ref": "#" }, "const": {}, "enum": { "type": "array", "minItems": 1, "uniqueItems": true }, "type": { "anyOf": [{ "$ref": "#/definitions/simpleTypes" }, { "type": "array", "items": { "$ref": "#/definitions/simpleTypes" }, "minItems": 1, "uniqueItems": true } ] }, "format": { "type": "string" }, "allOf": { "$ref": "#/definitions/schemaArray" }, "anyOf": { "$ref": "#/definitions/schemaArray" }, "oneOf": { "$ref": "#/definitions/schemaArray" }, "not": { "$ref": "#" } }, "default": {} } package/package.json000666 0000003132 3560116604 011551 0ustar00000000 000000 { "name": "json-schema-merge-allof", "version": "0.8.1", "description": "Simplify your schema by combining allOf into the root schema, safely.", "main": "src/index.js", "engines": { "node": ">=12.0.0" }, "scripts": { "eslint": "eslint src test", "test": "npm run eslint && nyc --reporter=html --reporter=text mocha test/specs", "develop-coverage": "nodemon --exec nyc --reporter=lcov mocha test/specs", "develop": "mocha test/specs --recursive --watch", "coverage": "nyc report --reporter=text-lcov | coveralls" }, "directories": { "lib": "src", "test": "test" }, "repository": { "type": "git", "url": "git+https://github.com/mokkabonna/json-schema-merge-allof.git" }, "keywords": [ "json", "schema", "jsonschema" ], "author": "Martin Hansen", "license": "MIT", "bugs": { "url": "https://github.com/mokkabonna/json-schema-merge-allof/issues" }, "homepage": "https://github.com/mokkabonna/json-schema-merge-allof#readme", "devDependencies": { "ajv": "^7.0.4", "chai": "^4.3.0", "coveralls": "^3.1.0", "eslint": "^7.19.0", "eslint-config": "^0.3.0", "eslint-config-standard": "^16.0.2", "eslint-plugin-import": "^2.22.1", "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^4.2.1", "eslint-plugin-standard": "^5.0.0", "json-schema-ref-parser": "^9.0.7", "json-stringify-safe": "^5.0.1", "mocha": "^8.2.1", "nyc": "^15.1.0", "sinon": "^9.2.4" }, "dependencies": { "compute-lcm": "^1.1.2", "json-schema-compare": "^0.2.2", "lodash": "^4.17.20" } } package/README.md000666 0000021330 3560116604 010542 0ustar00000000 000000 # json-schema-merge-allof [![Build Status](https://travis-ci.org/mokkabonna/json-schema-merge-allof.svg?branch=master)](https://travis-ci.org/mokkabonna/json-schema-merge-allof) [![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) > Merge schemas combined using allOf into a more readable composed schema free from allOf. ```bash npm install json-schema-merge-allof --save ``` ## Features - **Real** and **safe** merging of schemas combined with **allOf** - Takes away all allOf found in the whole schema - Lossless in terms of validation rules, merged schema does not validate more or less than the original schema - Results in a more readable root schema - Removes almost all logical impossibilities - Throws if no logical intersection is found (your schema would not validate anything from the start) - Validates in a way not possible by regular simple meta validators - Correctly considers additionalProperties, patternProperties and properties as a part of an whole when merging schemas containing those - Correctly considers items and additionalItems as a whole when merging schemas containing those - Supports merging schemas with items as array and direct schema - Supports merging dependencies when mixed array and schema - Supports all JSON schema core/validation keywords (v6, use custom resolvers to support other keywords) - Option to override common impossibility like adding properties when using **additionalProperties: false** - Pluggable keyword resolvers ## How 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: ```js { type: ['object', 'null'], additionalProperties: { type: 'string', minLength: 5 }, allOf: [{ type: ['array', 'object'], additionalProperties: { type: 'string', minLength: 10, maxLength: 20 } }] } ``` This result in the schema : ```js { type: 'object', additionalProperties: { type: 'string', minLength: 10, maxLength: 20 } } ``` 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. For other keywords other methods are used, here are some simple examples: - minLength, minimum, minItems etc chooses the **highest** value of the conflicting values. - maxLength, maximum, maxItems etc chooses the **lowest** value of the conflicting values. - uniqueItems is true if **any** of the conflicting values are true 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. 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: ### 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* 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) { // 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: - **values** an array of the conflicting values that need to be resolved - **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 ### 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 mergeAllOf(schema, { resolvers: { properties: keywords: ['properties', 'patternProperties', 'additionalProperties'], resolver(values, parents, mergers, options) { } } } }) ``` 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: ```js { properties: ..., patternProperties: ..., additionalProperties: ..., } ``` 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: ```js const mergers = { properties: function mergeSchemas(schemas, childSchemaName){...}, patternProperties: function mergeSchemas(schemas, childSchemaName){...}, additionalProperties: function mergeSchemas(schemas){...}, } ``` 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 mergeJsonSchema({ ... }, { resolvers: { defaultResolver: mergeJsonSchema.options.resolvers.title } }) ``` ## Resolvers Resolvers are called whenever multiple conflicting values are found on the same position in the schemas. You can override a resolver by supplying it in the options. ### Lossy vs lossless All built in reducers for validation keywords are lossless, meaning that they don't remove or add anything in terms of validation. 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. ## 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: ```js { type: 'object', allOf: [{ type: 'array' }] } ``` 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) - [ ] Extract repeating validators from anyOf/oneOf and merge them with parent schema - [ ] After extraction of validators from anyOf/oneOf, compare them and remove duplicates. - [ ] If left with only one in anyOf/oneOf then merge it to the parent schema. - [ ] Expose seperate tools for validation, extraction - [ ] Consider adding even more logical validation (like minLength <= maxLength) ## Contributing Create tests for new functionality and follow the eslint rules. ## License MIT © [Martin Hansen](http://martinhansen.com) package/.travis.yml000666 0000000134 3560116604 011373 0ustar00000000 000000 language: node_js after_success: npm run coverage node_js: - 12 - 14 - node - lts/*