From 672d8974f65f63dc7305fcbac5e7483c8c2115d7 Mon Sep 17 00:00:00 2001 From: Ivan Tymoshenko Date: Wed, 6 Dec 2023 00:13:36 +0100 Subject: [PATCH 1/7] feat: merge schemas --- index.js | 477 ++++++++++++++++++++++++-------------- lib/location.js | 31 +-- package.json | 1 - test/allof.test.js | 88 +++++++ test/anyof.test.js | 82 +++++++ test/if-then-else.test.js | 10 +- 6 files changed, 481 insertions(+), 208 deletions(-) diff --git a/index.js b/index.js index b407b82f..a844edbc 100644 --- a/index.js +++ b/index.js @@ -2,8 +2,6 @@ /* eslint no-prototype-builtins: 0 */ -const merge = require('@fastify/deepmerge')() -const clone = require('rfdc')({ proto: true }) const { RefResolver } = require('json-schema-ref-resolver') const validate = require('./lib/schema-validator') @@ -32,6 +30,8 @@ const addComma = '!addComma && (addComma = true) || (json += \',\')' let schemaIdCounter = 0 +const mergedSchemaRef = Symbol('fjs-merged-schema-ref') + function isValidSchema (schema, name) { if (!validate(schema)) { if (name) { @@ -46,13 +46,15 @@ function isValidSchema (schema, name) { } } -function resolveRef (context, location, ref) { +function resolveRef (context, location) { + const ref = location.schema.$ref + let hashIndex = ref.indexOf('#') if (hashIndex === -1) { hashIndex = ref.length } - const schemaId = ref.slice(0, hashIndex) || location.getOriginSchemaId() + const schemaId = ref.slice(0, hashIndex) || location.schemaId const jsonPointer = ref.slice(hashIndex) || '#' const schema = context.refResolver.getSchema(schemaId, jsonPointer) @@ -62,12 +64,17 @@ function resolveRef (context, location, ref) { const newLocation = new Location(schema, schemaId, jsonPointer) if (schema.$ref !== undefined) { - return resolveRef(context, newLocation, schema.$ref) + return resolveRef(context, newLocation) } return newLocation } +function getMergedLocation (context, mergedSchemaId) { + const mergedSchema = context.refResolver.getSchema(mergedSchemaId, '#') + return new Location(mergedSchema, mergedSchemaId, '#') +} + function getSchemaId (schema, rootSchemaId) { if (schema.$id && schema.$id.charAt(0) !== '#') { return schema.$id @@ -329,7 +336,7 @@ function buildInnerObject (context, location) { } let propertyLocation = propertiesLocation.getPropertyLocation(key) if (propertyLocation.schema.$ref) { - propertyLocation = resolveRef(context, location, propertyLocation.schema.$ref) + propertyLocation = resolveRef(context, propertyLocation) } const sanitizedKey = JSON.stringify(key) @@ -362,7 +369,7 @@ function buildInnerObject (context, location) { for (const key of Object.keys(schema.properties)) { let propertyLocation = propertiesLocation.getPropertyLocation(key) if (propertyLocation.schema.$ref) { - propertyLocation = resolveRef(context, location, propertyLocation.schema.$ref) + propertyLocation = resolveRef(context, propertyLocation) } const sanitizedKey = JSON.stringify(key) @@ -407,132 +414,154 @@ function buildInnerObject (context, location) { return code } -function mergeAllOfSchema (context, location, schema, mergedSchema) { - const allOfLocation = location.getPropertyLocation('allOf') - - for (let i = 0; i < schema.allOf.length; i++) { - let allOfSchema = schema.allOf[i] - - if (allOfSchema.$ref) { - const allOfSchemaLocation = allOfLocation.getPropertyLocation(i) - allOfSchema = resolveRef(context, allOfSchemaLocation, allOfSchema.$ref).schema +function mergeLocations (context, mergedSchemaId, mergedLocations) { + for (let i = 0; i < mergedLocations.length; i++) { + const location = mergedLocations[i] + const schema = location.schema + if (schema.$ref) { + mergedLocations[i] = resolveRef(context, location) } + } - let allOfSchemaType = allOfSchema.type - if (allOfSchemaType === undefined) { - allOfSchemaType = inferTypeByKeyword(allOfSchema) - } + const mergedSchema = {} + const mergedLocation = new Location(mergedSchema, mergedSchemaId) - if (allOfSchemaType !== undefined) { - if ( - mergedSchema.type !== undefined && - mergedSchema.type !== allOfSchemaType - ) { - throw new Error('allOf schemas have different type values') - } - mergedSchema.type = allOfSchemaType - } + for (const location of mergedLocations) { + const schema = cloneOriginSchema(location.schema, location.schemaId) + for (const key in schema) { + const value = schema[key] - if (allOfSchema.format !== undefined) { - if ( - mergedSchema.format !== undefined && - mergedSchema.format !== allOfSchema.format - ) { - throw new Error('allOf schemas have different format values') + if (key === '$id') continue + if (key === 'allOf') { + if (mergedSchema.allOf === undefined) { + mergedSchema.allOf = [] + } + mergedSchema.allOf.push(...value) + } else if (key === 'anyOf') { + if (mergedSchema.anyOf === undefined) { + mergedSchema.anyOf = [] + } + mergedSchema.anyOf.push(...value) + } else if (key === 'oneOf') { + if (mergedSchema.oneOf === undefined) { + mergedSchema.oneOf = [] + } + mergedSchema.oneOf.push(...value) + } else if (key === 'required') { + if (mergedSchema.required === undefined) { + mergedSchema.required = [] + } + mergedSchema.required.push(...value) + } else if (key === 'properties') { + if (mergedSchema.properties === undefined) { + mergedSchema.properties = {} + } + Object.assign(mergedSchema.properties, value) + } else if (key === 'patternProperties') { + if (mergedSchema.patternProperties === undefined) { + mergedSchema.patternProperties = {} + } + Object.assign(mergedSchema.patternProperties, value) + } else if (key === 'additionalProperties') { + if (mergedSchema.additionalProperties === false || value === false) { + mergedSchema.additionalProperties = false + continue + } + if (mergedSchema.additionalProperties === undefined) { + mergedSchema.additionalProperties = {} + } + Object.assign(mergedSchema.additionalProperties, value) + } else if (key === 'type') { + if (mergedSchema.type !== undefined && mergedSchema.type !== value) { + throw new Error('allOf schemas have different type values') + } + mergedSchema.type = value + } else if (key === 'format') { + if (mergedSchema.format !== undefined && mergedSchema.format !== value) { + throw new Error('allOf schemas have different format values') + } + mergedSchema.format = value + } else if (key === 'nullable') { + if (mergedSchema.nullable !== undefined && mergedSchema.nullable !== value) { + throw new Error('allOf schemas have different nullable values') + } + mergedSchema.nullable = value + } else if (key === 'definitions') { + if (mergedSchema.definitions === undefined) { + mergedSchema.definitions = {} + } + Object.assign(mergedSchema.definitions, value) + } else if (key === 'items') { + if (mergedSchema.items === undefined) { + mergedSchema.items = {} + } + if (Array.isArray(value)) { + mergedSchema.items = value + } else { + Object.assign(mergedSchema.items, value) + } + } else if (key === 'const') { + if (mergedSchema.const !== undefined && mergedSchema.const !== value) { + throw new Error('allOf schemas have different const values') + } + mergedSchema.const = value + } else if (key === 'enum') { + if (mergedSchema.enum === undefined) { + mergedSchema.enum = [] + } + mergedSchema.enum.push(...value) + } else if (key === 'if') { + if (mergedSchema.if !== undefined && mergedSchema.if !== value) { + throw new Error('allOf schemas have different if values') + } + mergedSchema.if = value + } else if (key === 'then') { + if (mergedSchema.then !== undefined && mergedSchema.then !== value) { + throw new Error('allOf schemas have different then values') + } + mergedSchema.then = value + } else if (key === 'else') { + if (mergedSchema.else !== undefined && mergedSchema.else !== value) { + throw new Error('allOf schemas have different else values') + } + mergedSchema.else = value } - mergedSchema.format = allOfSchema.format } + } - if (allOfSchema.nullable !== undefined) { - if ( - mergedSchema.nullable !== undefined && - mergedSchema.nullable !== allOfSchema.nullable - ) { - throw new Error('allOf schemas have different nullable values') - } - mergedSchema.nullable = allOfSchema.nullable - } + context.refResolver.addSchema(mergedSchema, mergedSchemaId) + return mergedLocation +} - if (allOfSchema.properties !== undefined) { - if (mergedSchema.properties === undefined) { - mergedSchema.properties = {} - } - Object.assign(mergedSchema.properties, allOfSchema.properties) - } +function cloneOriginSchema (schema, schemaId) { + const clonedSchema = Array.isArray(schema) ? [] : {} - if (allOfSchema.additionalProperties !== undefined) { - if (mergedSchema.additionalProperties === undefined) { - mergedSchema.additionalProperties = {} - } - Object.assign(mergedSchema.additionalProperties, allOfSchema.additionalProperties) - } + if ( + schema.$id !== undefined && + schema.$id.charAt(0) !== '#' + ) { + schemaId = schema.$id + } - if (allOfSchema.patternProperties !== undefined) { - if (mergedSchema.patternProperties === undefined) { - mergedSchema.patternProperties = {} - } - Object.assign(mergedSchema.patternProperties, allOfSchema.patternProperties) - } + if (schema[mergedSchemaRef]) { + clonedSchema[mergedSchemaRef] = schema[mergedSchemaRef] + } - if (allOfSchema.required !== undefined) { - if (mergedSchema.required === undefined) { - mergedSchema.required = [] - } - mergedSchema.required.push(...allOfSchema.required) - } + for (const key in schema) { + let value = schema[key] - if (allOfSchema.oneOf !== undefined) { - if (mergedSchema.oneOf === undefined) { - mergedSchema.oneOf = [] - } - mergedSchema.oneOf.push(...allOfSchema.oneOf) + if (key === '$ref' && value.charAt(0) === '#') { + value = schemaId + value } - if (allOfSchema.anyOf !== undefined) { - if (mergedSchema.anyOf === undefined) { - mergedSchema.anyOf = [] - } - mergedSchema.anyOf.push(...allOfSchema.anyOf) + if (typeof value === 'object' && value !== null) { + value = cloneOriginSchema(value, schemaId) } - if (allOfSchema.allOf !== undefined) { - mergeAllOfSchema(context, location, allOfSchema, mergedSchema) - } + clonedSchema[key] = value } - delete mergedSchema.allOf - mergedSchema.$id = `__fjs_merged_${schemaIdCounter++}` - context.refResolver.addSchema(mergedSchema) - location.addMergedSchema(mergedSchema, mergedSchema.$id) -} - -function addIfThenElse (context, location, input) { - context.validatorSchemasIds.add(location.getSchemaId()) - - const schema = merge({}, location.schema) - const thenSchema = schema.then - const elseSchema = schema.else || { additionalProperties: true } - - delete schema.if - delete schema.then - delete schema.else - - const ifLocation = location.getPropertyLocation('if') - const ifSchemaRef = ifLocation.getSchemaRef() - - const thenLocation = location.getPropertyLocation('then') - thenLocation.schema = merge(schema, thenSchema) - - const elseLocation = location.getPropertyLocation('else') - elseLocation.schema = merge(schema, elseSchema) - - return ` - if (validator.validate("${ifSchemaRef}", ${input})) { - ${buildValue(context, thenLocation, input)} - } else { - ${buildValue(context, elseLocation, input)} - } - ` + return clonedSchema } function toJSON (variableName) { @@ -579,7 +608,7 @@ function buildArray (context, location) { itemsLocation.schema = itemsLocation.schema || {} if (itemsLocation.schema.$ref) { - itemsLocation = resolveRef(context, itemsLocation, itemsLocation.schema.$ref) + itemsLocation = resolveRef(context, itemsLocation) } const itemsSchema = itemsLocation.schema @@ -848,81 +877,193 @@ function buildConstSerializer (location, input) { return code } -function buildValue (context, location, input) { - let schema = location.schema +function buildAllOf (context, location, input) { + const schema = location.schema - if (typeof schema === 'boolean') { - return `json += JSON.stringify(${input})` + let mergedSchemaId = schema[mergedSchemaRef] + if (mergedSchemaId) { + const mergedLocation = getMergedLocation(context, mergedSchemaId) + return buildValue(context, mergedLocation, input) } - if (schema.$ref) { - location = resolveRef(context, location, schema.$ref) - schema = location.schema + mergedSchemaId = `__fjs_merged_${schemaIdCounter++}` + schema[mergedSchemaRef] = mergedSchemaId + + const { allOf, ...schemaWithoutAllOf } = location.schema + const locations = [ + new Location( + schemaWithoutAllOf, + location.schemaId, + location.jsonPointer + ) + ] + + const allOfsLocation = location.getPropertyLocation('allOf') + for (let i = 0; i < allOf.length; i++) { + locations.push(allOfsLocation.getPropertyLocation(i)) } - if (schema.type === undefined) { - const inferredType = inferTypeByKeyword(schema) - if (inferredType) { - schema.type = inferredType + const mergedLocation = mergeLocations(context, mergedSchemaId, locations) + return buildValue(context, mergedLocation, input) +} + +function buildOneOf (context, location, input) { + context.validatorSchemasIds.add(location.schemaId) + + const schema = location.schema + + const type = schema.anyOf ? 'anyOf' : 'oneOf' + const { [type]: oneOfs, ...schemaWithoutAnyOf } = location.schema + + const locationWithoutOneOf = new Location( + schemaWithoutAnyOf, + location.schemaId, + location.jsonPointer + ) + const oneOfsLocation = location.getPropertyLocation(type) + + let code = '' + + for (let index = 0; index < oneOfs.length; index++) { + const optionLocation = oneOfsLocation.getPropertyLocation(index) + const optionSchema = optionLocation.schema + + let mergedSchemaId = optionSchema[mergedSchemaRef] + let mergedLocation = null + if (mergedSchemaId) { + mergedLocation = getMergedLocation(context, mergedSchemaId) + } else { + mergedSchemaId = `__fjs_merged_${schemaIdCounter++}` + optionSchema[mergedSchemaRef] = mergedSchemaId + + mergedLocation = mergeLocations(context, mergedSchemaId, [ + locationWithoutOneOf, + optionLocation + ]) } + + const nestedResult = buildValue(context, mergedLocation, input) + const schemaRef = optionLocation.getSchemaRef() + code += ` + ${index === 0 ? 'if' : 'else if'}(validator.validate("${schemaRef}", ${input})) + ${nestedResult} + ` } - if (schema.if && schema.then) { - return addIfThenElse(context, location, input) + let schemaRef = location.getSchemaRef() + if (schemaRef.startsWith(context.rootSchemaId)) { + schemaRef = schemaRef.replace(context.rootSchemaId, '') } - if (schema.allOf) { - mergeAllOfSchema(context, location, schema, clone(schema)) - schema = location.schema + code += ` + else throw new TypeError(\`The value of '${schemaRef}' does not match schema definition.\`) + ` + + return code +} + +function buildIfThenElse (context, location, input) { + context.validatorSchemasIds.add(location.schemaId) + + const { + if: ifSchema, + then: thenSchema, + else: elseSchema, + ...schemaWithoutIfThenElse + } = location.schema + + const rootLocation = new Location( + schemaWithoutIfThenElse, + location.schemaId, + location.jsonPointer + ) + + const ifLocation = location.getPropertyLocation('if') + const ifSchemaRef = ifLocation.getSchemaRef() + + const thenLocation = location.getPropertyLocation('then') + let thenMergedSchemaId = thenSchema[mergedSchemaRef] + let thenMergedLocation = null + if (thenMergedSchemaId) { + thenMergedLocation = getMergedLocation(context, thenMergedSchemaId) + } else { + thenMergedSchemaId = `__fjs_merged_${schemaIdCounter++}` + thenSchema[mergedSchemaRef] = thenMergedSchemaId + + thenMergedLocation = mergeLocations(context, thenMergedSchemaId, [ + rootLocation, + thenLocation + ]) } - const type = schema.type + if (!elseSchema) { + return ` + if (validator.validate("${ifSchemaRef}", ${input})) { + ${buildValue(context, thenMergedLocation, input)} + } else { + ${buildValue(context, rootLocation, input)} + } + ` + } - let code = '' + const elseLocation = location.getPropertyLocation('else') + let elseMergedSchemaId = elseSchema[mergedSchemaRef] + let elseMergedLocation = null + if (elseMergedSchemaId) { + elseMergedLocation = getMergedLocation(context, elseMergedSchemaId) + } else { + elseMergedSchemaId = `__fjs_merged_${schemaIdCounter++}` + elseSchema[mergedSchemaRef] = elseMergedSchemaId - if ((type === undefined || type === 'object') && (schema.anyOf || schema.oneOf)) { - context.validatorSchemasIds.add(location.getSchemaId()) + elseMergedLocation = mergeLocations(context, elseMergedSchemaId, [ + rootLocation, + elseLocation + ]) + } - if (schema.type === 'object') { - context.wrapObjects = false - const funcName = buildObject(context, location) - code += ` - json += '{' - json += ${funcName}(${input}) - json += ',' - ` + return ` + if (validator.validate("${ifSchemaRef}", ${input})) { + ${buildValue(context, thenMergedLocation, input)} + } else { + ${buildValue(context, elseMergedLocation, input)} } + ` +} - const type = schema.anyOf ? 'anyOf' : 'oneOf' - const anyOfLocation = location.getPropertyLocation(type) +function buildValue (context, location, input) { + let schema = location.schema - for (let index = 0; index < location.schema[type].length; index++) { - const optionLocation = anyOfLocation.getPropertyLocation(index) - const schemaRef = optionLocation.getSchemaRef() - const nestedResult = buildValue(context, optionLocation, input) - code += ` - ${index === 0 ? 'if' : 'else if'}(validator.validate("${schemaRef}", ${input})) - ${nestedResult} - ` - } + if (typeof schema === 'boolean') { + return `json += JSON.stringify(${input})` + } - let schemaRef = location.getSchemaRef() - if (schemaRef.startsWith(context.rootSchemaId)) { - schemaRef = schemaRef.replace(context.rootSchemaId, '') - } + if (schema.$ref) { + location = resolveRef(context, location) + schema = location.schema + } - code += ` - else throw new TypeError(\`The value of '${schemaRef}' does not match schema definition.\`) - ` - if (schema.type === 'object') { - code += ` - json += '}' - ` - context.wrapObjects = true + if (schema.allOf) { + return buildAllOf(context, location, input) + } + + if (schema.anyOf || schema.oneOf) { + return buildOneOf(context, location, input) + } + + if (schema.if && schema.then) { + return buildIfThenElse(context, location, input) + } + + if (schema.type === undefined) { + const inferredType = inferTypeByKeyword(schema) + if (inferredType) { + schema.type = inferredType } - return code } + let code = '' + + const type = schema.type const nullable = schema.nullable === true if (nullable) { code += ` diff --git a/lib/location.js b/lib/location.js index 1311de81..0d9acb2d 100644 --- a/lib/location.js +++ b/lib/location.js @@ -5,7 +5,6 @@ class Location { this.schema = schema this.schemaId = schemaId this.jsonPointer = jsonPointer - this.mergedSchemaId = null } getPropertyLocation (propertyName) { @@ -14,39 +13,11 @@ class Location { this.schemaId, this.jsonPointer + '/' + propertyName ) - - if (this.mergedSchemaId !== null) { - propertyLocation.addMergedSchema( - this.schema[propertyName], - this.mergedSchemaId, - this.jsonPointer + '/' + propertyName - ) - } - return propertyLocation } - // Use this method to get current schema location. - // Use it when you need to create reference to the current location. - getSchemaId () { - return this.mergedSchemaId || this.schemaId - } - - // Use this method to get original schema id for resolving user schema $refs - // Don't join it with a JSON pointer to get the current location. - getOriginSchemaId () { - return this.schemaId - } - getSchemaRef () { - const schemaId = this.getSchemaId() - return schemaId + this.jsonPointer - } - - addMergedSchema (mergedSchema, schemaId, jsonPointer = '#') { - this.schema = mergedSchema - this.mergedSchemaId = schemaId - this.jsonPointer = jsonPointer + return this.schemaId + this.jsonPointer } } diff --git a/package.json b/package.json index a2ebb852..d027edb0 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,6 @@ "fast-json-stringify": "." }, "dependencies": { - "@fastify/deepmerge": "^1.0.0", "ajv": "^8.10.0", "ajv-formats": "^2.1.1", "fast-deep-equal": "^3.1.3", diff --git a/test/allof.test.js b/test/allof.test.js index d0b8b74f..30d70564 100644 --- a/test/allof.test.js +++ b/test/allof.test.js @@ -553,6 +553,58 @@ test('allof with local anchor reference', (t) => { t.equal(stringify(data), JSON.stringify(data)) }) +test('allOf: multiple nested $ref properties', (t) => { + t.plan(2) + + const externalSchema1 = { + $id: 'externalSchema1', + oneOf: [ + { $ref: '#/definitions/id1' } + ], + definitions: { + id1: { + type: 'object', + properties: { + id1: { + type: 'integer' + } + }, + additionalProperties: false + } + } + } + + const externalSchema2 = { + $id: 'externalSchema2', + oneOf: [ + { $ref: '#/definitions/id2' } + ], + definitions: { + id2: { + type: 'object', + properties: { + id2: { + type: 'integer' + } + }, + additionalProperties: false + } + } + } + + const schema = { + allOf: [ + { $ref: 'externalSchema1' }, + { $ref: 'externalSchema2' } + ] + } + + const stringify = build(schema, { schema: [externalSchema1, externalSchema2] }) + + t.equal(stringify({ id1: 1 }), JSON.stringify({ id1: 1 })) + t.equal(stringify({ id2: 2 }), JSON.stringify({ id2: 2 })) +}) + test('allOf: throw Error if types mismatch ', (t) => { t.plan(1) @@ -600,3 +652,39 @@ test('allOf: throw Error if nullable mismatch /2', (t) => { } t.throws(() => build(schema), new Error('allOf schemas have different nullable values')) }) + +test('recursive nested allOfs', (t) => { + t.plan(1) + + const schema = { + type: 'object', + properties: { + foo: { + additionalProperties: false, + allOf: [{ $ref: '#' }] + } + } + } + + const data = { foo: {} } + const stringify = build(schema) + t.equal(stringify(data), JSON.stringify(data)) +}) + +test('recursive nested allOfs', (t) => { + t.plan(1) + + const schema = { + type: 'object', + properties: { + foo: { + additionalProperties: false, + allOf: [{ allOf: [{ $ref: '#' }] }] + } + } + } + + const data = { foo: {} } + const stringify = build(schema) + t.equal(stringify(data), JSON.stringify(data)) +}) diff --git a/test/anyof.test.js b/test/anyof.test.js index 03483329..1e23753c 100644 --- a/test/anyof.test.js +++ b/test/anyof.test.js @@ -644,3 +644,85 @@ test('object with ref and validated properties', (t) => { const stringify = build(schema, { schema: externalSchemas }) t.equal(stringify({ id: 1, reference: 'hi' }), '{"id":1,"reference":"hi"}') }) + +test('anyOf required props', (t) => { + t.plan(3) + + const schema = { + type: 'object', + properties: { + prop1: { type: 'string' }, + prop2: { type: 'string' }, + prop3: { type: 'string' } + }, + required: ['prop1'], + anyOf: [{ required: ['prop2'] }, { required: ['prop3'] }] + } + const stringify = build(schema) + t.equal(stringify({ prop1: 'test', prop2: 'test2' }), '{"prop1":"test","prop2":"test2"}') + t.equal(stringify({ prop1: 'test', prop3: 'test3' }), '{"prop1":"test","prop3":"test3"}') + t.equal(stringify({ prop1: 'test', prop2: 'test2', prop3: 'test3' }), '{"prop1":"test","prop2":"test2","prop3":"test3"}') +}) + +test('anyOf required props', (t) => { + t.plan(3) + + const schema = { + type: 'object', + properties: { + prop1: { type: 'string' } + }, + anyOf: [ + { + properties: { + prop2: { type: 'string' } + } + }, + { + properties: { + prop3: { type: 'string' } + } + } + ] + } + const stringify = build(schema) + t.equal(stringify({ prop1: 'test1' }), '{"prop1":"test1"}') + t.equal(stringify({ prop2: 'test2' }), '{"prop2":"test2"}') + t.equal(stringify({ prop1: 'test1', prop2: 'test2' }), '{"prop1":"test1","prop2":"test2"}') +}) + +test('recursive nested anyOfs', (t) => { + t.plan(1) + + const schema = { + type: 'object', + properties: { + foo: { + additionalProperties: false, + anyOf: [{ $ref: '#' }] + } + } + } + + const data = { foo: {} } + const stringify = build(schema) + t.equal(stringify(data), JSON.stringify(data)) +}) + +test('recursive nested anyOfs', (t) => { + t.plan(1) + + const schema = { + type: 'object', + properties: { + foo: { + additionalProperties: false, + allOf: [{ allOf: [{ $ref: '#' }] }] + } + } + } + + const data = { foo: {} } + const stringify = build(schema) + t.equal(stringify(data), JSON.stringify(data)) +}) diff --git a/test/if-then-else.test.js b/test/if-then-else.test.js index bab3be7c..714f01ee 100644 --- a/test/if-then-else.test.js +++ b/test/if-then-else.test.js @@ -249,15 +249,7 @@ const alphabetOutput = JSON.stringify({ const deepFoobarOutput = JSON.stringify({ foobar: JSON.parse(foobarOutput) }) -const noElseGreetingOutput = JSON.stringify({ - kind: 'greeting', - foo: 'FOO', - bar: 42, - hi: 'HI', - hello: 45, - a: 'A', - b: 35 -}) +const noElseGreetingOutput = JSON.stringify({}) t.test('if-then-else', t => { const tests = [ From fe65ff40cd64234bddc59a48c7d86fce51f4b20c Mon Sep 17 00:00:00 2001 From: Ivan Tymoshenko Date: Mon, 11 Dec 2023 21:21:48 +0100 Subject: [PATCH 2/7] fix: use proper schema merge for all cases --- index.js | 110 +++----------------------------------- lib/merge-schemas.js | 42 +++++++++++++++ package.json | 3 +- test/allof.test.js | 49 +++++++++++++++-- test/anyof.test.js | 41 +++++++++++++- test/if-then-else.test.js | 57 ++++++++++++++++++++ 6 files changed, 193 insertions(+), 109 deletions(-) create mode 100644 lib/merge-schemas.js diff --git a/index.js b/index.js index a844edbc..85aeacf3 100644 --- a/index.js +++ b/index.js @@ -4,10 +4,11 @@ const { RefResolver } = require('json-schema-ref-resolver') -const validate = require('./lib/schema-validator') const Serializer = require('./lib/serializer') const Validator = require('./lib/validator') const Location = require('./lib/location') +const validate = require('./lib/schema-validator') +const mergeSchemas = require('./lib/merge-schemas') const SINGLE_TICK = /'/g @@ -423,112 +424,17 @@ function mergeLocations (context, mergedSchemaId, mergedLocations) { } } - const mergedSchema = {} - const mergedLocation = new Location(mergedSchema, mergedSchemaId) - + const mergedSchemas = [] for (const location of mergedLocations) { const schema = cloneOriginSchema(location.schema, location.schemaId) - for (const key in schema) { - const value = schema[key] + delete schema.$id - if (key === '$id') continue - if (key === 'allOf') { - if (mergedSchema.allOf === undefined) { - mergedSchema.allOf = [] - } - mergedSchema.allOf.push(...value) - } else if (key === 'anyOf') { - if (mergedSchema.anyOf === undefined) { - mergedSchema.anyOf = [] - } - mergedSchema.anyOf.push(...value) - } else if (key === 'oneOf') { - if (mergedSchema.oneOf === undefined) { - mergedSchema.oneOf = [] - } - mergedSchema.oneOf.push(...value) - } else if (key === 'required') { - if (mergedSchema.required === undefined) { - mergedSchema.required = [] - } - mergedSchema.required.push(...value) - } else if (key === 'properties') { - if (mergedSchema.properties === undefined) { - mergedSchema.properties = {} - } - Object.assign(mergedSchema.properties, value) - } else if (key === 'patternProperties') { - if (mergedSchema.patternProperties === undefined) { - mergedSchema.patternProperties = {} - } - Object.assign(mergedSchema.patternProperties, value) - } else if (key === 'additionalProperties') { - if (mergedSchema.additionalProperties === false || value === false) { - mergedSchema.additionalProperties = false - continue - } - if (mergedSchema.additionalProperties === undefined) { - mergedSchema.additionalProperties = {} - } - Object.assign(mergedSchema.additionalProperties, value) - } else if (key === 'type') { - if (mergedSchema.type !== undefined && mergedSchema.type !== value) { - throw new Error('allOf schemas have different type values') - } - mergedSchema.type = value - } else if (key === 'format') { - if (mergedSchema.format !== undefined && mergedSchema.format !== value) { - throw new Error('allOf schemas have different format values') - } - mergedSchema.format = value - } else if (key === 'nullable') { - if (mergedSchema.nullable !== undefined && mergedSchema.nullable !== value) { - throw new Error('allOf schemas have different nullable values') - } - mergedSchema.nullable = value - } else if (key === 'definitions') { - if (mergedSchema.definitions === undefined) { - mergedSchema.definitions = {} - } - Object.assign(mergedSchema.definitions, value) - } else if (key === 'items') { - if (mergedSchema.items === undefined) { - mergedSchema.items = {} - } - if (Array.isArray(value)) { - mergedSchema.items = value - } else { - Object.assign(mergedSchema.items, value) - } - } else if (key === 'const') { - if (mergedSchema.const !== undefined && mergedSchema.const !== value) { - throw new Error('allOf schemas have different const values') - } - mergedSchema.const = value - } else if (key === 'enum') { - if (mergedSchema.enum === undefined) { - mergedSchema.enum = [] - } - mergedSchema.enum.push(...value) - } else if (key === 'if') { - if (mergedSchema.if !== undefined && mergedSchema.if !== value) { - throw new Error('allOf schemas have different if values') - } - mergedSchema.if = value - } else if (key === 'then') { - if (mergedSchema.then !== undefined && mergedSchema.then !== value) { - throw new Error('allOf schemas have different then values') - } - mergedSchema.then = value - } else if (key === 'else') { - if (mergedSchema.else !== undefined && mergedSchema.else !== value) { - throw new Error('allOf schemas have different else values') - } - mergedSchema.else = value - } - } + mergedSchemas.push(schema) } + const mergedSchema = mergeSchemas(mergedSchemas) + const mergedLocation = new Location(mergedSchema, mergedSchemaId) + context.refResolver.addSchema(mergedSchema, mergedSchemaId) return mergedLocation } diff --git a/lib/merge-schemas.js b/lib/merge-schemas.js new file mode 100644 index 00000000..e8465c3d --- /dev/null +++ b/lib/merge-schemas.js @@ -0,0 +1,42 @@ +'use strict' + +const mergeAllOf = require('json-schema-merge-allof') + +const failOnConflictResolver = mergeAllOf.options.resolvers.type +const pickFirstResolver = mergeAllOf.options.resolvers.title + +const resolvers = { + format: failOnConflictResolver, + nullable: failOnConflictResolver, + defaultResolver: pickFirstResolver +} + +function mergeSchemas (schemas) { + let mergedSchema = null + try { + mergedSchema = mergeAllOf({ allOf: schemas }, { resolvers }) + } catch (error) { + const failedPath = /^Could not resolve values for path:"(.*)"\./.exec(error.message) + /* istanbul ignore else */ + if (failedPath) { + throw new Error(`Failed to merge schemas on "${failedPath[1]}".`) + } else { + throw new Error(`Failed to merge schemas: ${error.message}`) + } + } + + // This is needed because fjs treats `additionalProperties` as false by default + // which is not the case for JSON Schema. + if (mergedSchema.additionalProperties === undefined) { + for (const schema of schemas) { + if (schema.additionalProperties === true) { + mergedSchema.additionalProperties = true + break + } + } + } + + return mergedSchema +} + +module.exports = mergeSchemas diff --git a/package.json b/package.json index d027edb0..f696b298 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ "fast-deep-equal": "^3.1.3", "fast-uri": "^2.1.0", "rfdc": "^1.2.0", - "json-schema-ref-resolver": "^1.0.1" + "json-schema-ref-resolver": "^1.0.1", + "json-schema-merge-allof": "^0.8.1" }, "standard": { "ignore": [ diff --git a/test/allof.test.js b/test/allof.test.js index 30d70564..1f8e2c9a 100644 --- a/test/allof.test.js +++ b/test/allof.test.js @@ -593,7 +593,7 @@ test('allOf: multiple nested $ref properties', (t) => { } const schema = { - allOf: [ + anyOf: [ { $ref: 'externalSchema1' }, { $ref: 'externalSchema2' } ] @@ -614,7 +614,7 @@ test('allOf: throw Error if types mismatch ', (t) => { { type: 'number' } ] } - t.throws(() => build(schema), new Error('allOf schemas have different type values')) + t.throws(() => build(schema), new Error('Failed to merge schemas on "type".')) }) test('allOf: throw Error if format mismatch ', (t) => { @@ -626,7 +626,7 @@ test('allOf: throw Error if format mismatch ', (t) => { { format: 'time' } ] } - t.throws(() => build(schema), new Error('allOf schemas have different format values')) + t.throws(() => build(schema), new Error('Failed to merge schemas on "format".')) }) test('allOf: throw Error if nullable mismatch /1', (t) => { @@ -638,7 +638,7 @@ test('allOf: throw Error if nullable mismatch /1', (t) => { { nullable: false } ] } - t.throws(() => build(schema), new Error('allOf schemas have different nullable values')) + t.throws(() => build(schema), new Error('Failed to merge schemas on "nullable".')) }) test('allOf: throw Error if nullable mismatch /2', (t) => { @@ -650,7 +650,7 @@ test('allOf: throw Error if nullable mismatch /2', (t) => { { nullable: true } ] } - t.throws(() => build(schema), new Error('allOf schemas have different nullable values')) + t.throws(() => build(schema), new Error('Failed to merge schemas on "nullable".')) }) test('recursive nested allOfs', (t) => { @@ -688,3 +688,42 @@ test('recursive nested allOfs', (t) => { const stringify = build(schema) t.equal(stringify(data), JSON.stringify(data)) }) + +test('external recursive allOfs', (t) => { + t.plan(1) + + const externalSchema = { + type: 'object', + properties: { + foo: { + properties: { + bar: { type: 'string' } + }, + allOf: [{ $ref: '#' }] + } + } + } + + const schema = { + type: 'object', + properties: { + a: { $ref: 'externalSchema#/properties/foo' }, + b: { $ref: 'externalSchema#/properties/foo' } + } + } + + const data = { + a: { + foo: {}, + bar: '42', + baz: 42 + }, + b: { + foo: {}, + bar: '42', + baz: 42 + } + } + const stringify = build(schema, { schema: { externalSchema } }) + t.equal(stringify(data), '{"a":{"bar":"42","foo":{}},"b":{"bar":"42","foo":{}}}') +}) diff --git a/test/anyof.test.js b/test/anyof.test.js index 1e23753c..b0d21af6 100644 --- a/test/anyof.test.js +++ b/test/anyof.test.js @@ -717,7 +717,7 @@ test('recursive nested anyOfs', (t) => { properties: { foo: { additionalProperties: false, - allOf: [{ allOf: [{ $ref: '#' }] }] + anyOf: [{ anyOf: [{ $ref: '#' }] }] } } } @@ -726,3 +726,42 @@ test('recursive nested anyOfs', (t) => { const stringify = build(schema) t.equal(stringify(data), JSON.stringify(data)) }) + +test('external recursive anyOfs', (t) => { + t.plan(1) + + const externalSchema = { + type: 'object', + properties: { + foo: { + properties: { + bar: { type: 'string' } + }, + anyOf: [{ $ref: '#' }] + } + } + } + + const schema = { + type: 'object', + properties: { + a: { $ref: 'externalSchema#/properties/foo' }, + b: { $ref: 'externalSchema#/properties/foo' } + } + } + + const data = { + a: { + foo: {}, + bar: '42', + baz: 42 + }, + b: { + foo: {}, + bar: '42', + baz: 42 + } + } + const stringify = build(schema, { schema: { externalSchema } }) + t.equal(stringify(data), '{"a":{"bar":"42","foo":{}},"b":{"bar":"42","foo":{}}}') +}) diff --git a/test/if-then-else.test.js b/test/if-then-else.test.js index 714f01ee..1e93be00 100644 --- a/test/if-then-else.test.js +++ b/test/if-then-else.test.js @@ -411,3 +411,60 @@ t.test('if/else with array', (t) => { t.equal(stringify(['1']), JSON.stringify(['1'])) t.equal(stringify(['1', '2']), JSON.stringify([1, 2])) }) + +t.test('external recursive if/then/else', (t) => { + t.plan(1) + + const externalSchema = { + type: 'object', + properties: { + base: { type: 'string' }, + self: { $ref: 'externalSchema#' } + }, + if: { + type: 'object', + properties: { + foo: { type: 'string', const: '41' } + } + }, + then: { + type: 'object', + properties: { + bar: { type: 'string', const: '42' } + } + }, + else: { + type: 'object', + properties: { + baz: { type: 'string', const: '43' } + } + } + } + + const schema = { + type: 'object', + properties: { + a: { $ref: 'externalSchema#/properties/self' }, + b: { $ref: 'externalSchema#/properties/self' } + } + } + + const data = { + a: { + base: 'a', + foo: '41', + bar: '42', + baz: '43', + ignore: 'ignored' + }, + b: { + base: 'b', + foo: 'not-41', + bar: '42', + baz: '43', + ignore: 'ignored' + } + } + const stringify = build(schema, { schema: { externalSchema } }) + t.equal(stringify(data), '{"a":{"base":"a","bar":"42"},"b":{"base":"b","baz":"43"}}') +}) From b7afc847dbe19553e6aeae91c4ddebc748fecbd0 Mon Sep 17 00:00:00 2001 From: Ivan Tymoshenko Date: Mon, 11 Dec 2023 21:35:01 +0100 Subject: [PATCH 3/7] update --- lib/merge-schemas.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/merge-schemas.js b/lib/merge-schemas.js index e8465c3d..1d59edb1 100644 --- a/lib/merge-schemas.js +++ b/lib/merge-schemas.js @@ -26,7 +26,7 @@ function mergeSchemas (schemas) { } // This is needed because fjs treats `additionalProperties` as false by default - // which is not the case for JSON Schema. + // which is not true for JSON Schema. if (mergedSchema.additionalProperties === undefined) { for (const schema of schemas) { if (schema.additionalProperties === true) { From cd77a4038f40398f7c7d91c863e0ad3ec27148c6 Mon Sep 17 00:00:00 2001 From: Ivan Tymoshenko Date: Mon, 11 Dec 2023 21:39:53 +0100 Subject: [PATCH 4/7] update --- index.js | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/index.js b/index.js index 85aeacf3..5af15287 100644 --- a/index.js +++ b/index.js @@ -93,7 +93,6 @@ function build (schema, options) { functionsCounter: 0, functionsNamesBySchema: new Map(), options, - wrapObjects: true, refResolver: new RefResolver(), rootSchemaId: schema.$id || `__fjs_root_${schemaIdCounter++}`, validatorSchemasIds: new Set() @@ -361,10 +360,8 @@ function buildInnerObject (context, location) { code += ` let addComma = false - let json = '${context.wrapObjects ? '{' : ''}' + let json = '{' ` - const wrapObjects = context.wrapObjects - context.wrapObjects = true if (schema.properties) { for (const key of Object.keys(schema.properties)) { @@ -408,9 +405,8 @@ function buildInnerObject (context, location) { code += buildExtraObjectPropertiesSerializer(context, location) } - context.wrapObjects = wrapObjects code += ` - return json${context.wrapObjects ? ' + \'}\'' : ''} + return json + '}' ` return code } From cfdb917cee00e30d5c5bf39ca24254c3715e9094 Mon Sep 17 00:00:00 2001 From: Ivan Tymoshenko Date: Tue, 16 Jan 2024 13:50:31 +0100 Subject: [PATCH 5/7] deps: use merge-json-schema package --- lib/merge-schemas.js | 37 ++----------------------------------- package.json | 2 +- test/allof.test.js | 42 +++++++++++++++--------------------------- 3 files changed, 18 insertions(+), 63 deletions(-) diff --git a/lib/merge-schemas.js b/lib/merge-schemas.js index 1d59edb1..d0bf1016 100644 --- a/lib/merge-schemas.js +++ b/lib/merge-schemas.js @@ -1,42 +1,9 @@ 'use strict' -const mergeAllOf = require('json-schema-merge-allof') - -const failOnConflictResolver = mergeAllOf.options.resolvers.type -const pickFirstResolver = mergeAllOf.options.resolvers.title - -const resolvers = { - format: failOnConflictResolver, - nullable: failOnConflictResolver, - defaultResolver: pickFirstResolver -} +const { mergeSchemas: _mergeSchemas } = require('merge-json-schemas') function mergeSchemas (schemas) { - let mergedSchema = null - try { - mergedSchema = mergeAllOf({ allOf: schemas }, { resolvers }) - } catch (error) { - const failedPath = /^Could not resolve values for path:"(.*)"\./.exec(error.message) - /* istanbul ignore else */ - if (failedPath) { - throw new Error(`Failed to merge schemas on "${failedPath[1]}".`) - } else { - throw new Error(`Failed to merge schemas: ${error.message}`) - } - } - - // This is needed because fjs treats `additionalProperties` as false by default - // which is not true for JSON Schema. - if (mergedSchema.additionalProperties === undefined) { - for (const schema of schemas) { - if (schema.additionalProperties === true) { - mergedSchema.additionalProperties = true - break - } - } - } - - return mergedSchema + return _mergeSchemas(schemas, { onConflict: 'skip' }) } module.exports = mergeSchemas diff --git a/package.json b/package.json index f696b298..82ecba99 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "fast-uri": "^2.1.0", "rfdc": "^1.2.0", "json-schema-ref-resolver": "^1.0.1", - "json-schema-merge-allof": "^0.8.1" + "merge-json-schemas": "fastify/merge-json-schemas#add-merge-json-schemas-function" }, "standard": { "ignore": [ diff --git a/test/allof.test.js b/test/allof.test.js index 1f8e2c9a..cd9364bd 100644 --- a/test/allof.test.js +++ b/test/allof.test.js @@ -606,7 +606,7 @@ test('allOf: multiple nested $ref properties', (t) => { }) test('allOf: throw Error if types mismatch ', (t) => { - t.plan(1) + t.plan(3) const schema = { allOf: [ @@ -614,11 +614,17 @@ test('allOf: throw Error if types mismatch ', (t) => { { type: 'number' } ] } - t.throws(() => build(schema), new Error('Failed to merge schemas on "type".')) + try { + build(schema) + } catch (error) { + t.ok(error instanceof Error) + t.equal(error.message, 'Failed to merge "type" keyword schemas.') + t.same(error.schemas, [['string'], ['number']]) + } }) test('allOf: throw Error if format mismatch ', (t) => { - t.plan(1) + t.plan(3) const schema = { allOf: [ @@ -626,31 +632,13 @@ test('allOf: throw Error if format mismatch ', (t) => { { format: 'time' } ] } - t.throws(() => build(schema), new Error('Failed to merge schemas on "format".')) -}) - -test('allOf: throw Error if nullable mismatch /1', (t) => { - t.plan(1) - - const schema = { - allOf: [ - { nullable: true }, - { nullable: false } - ] - } - t.throws(() => build(schema), new Error('Failed to merge schemas on "nullable".')) -}) - -test('allOf: throw Error if nullable mismatch /2', (t) => { - t.plan(1) - - const schema = { - allOf: [ - { nullable: false }, - { nullable: true } - ] + try { + build(schema) + } catch (error) { + t.ok(error instanceof Error) + t.equal(error.message, 'Failed to merge "format" keyword schemas.') + t.same(error.schemas, ['date', 'time']) } - t.throws(() => build(schema), new Error('Failed to merge schemas on "nullable".')) }) test('recursive nested allOfs', (t) => { From 69a34a2d3d4670b8641e1ecd9df9458f30819986 Mon Sep 17 00:00:00 2001 From: Ivan Tymoshenko Date: Wed, 17 Jan 2024 15:38:56 +0100 Subject: [PATCH 6/7] deps: use @fastify/merge-json-schemas package --- lib/merge-schemas.js | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/merge-schemas.js b/lib/merge-schemas.js index d0bf1016..bb27a8bf 100644 --- a/lib/merge-schemas.js +++ b/lib/merge-schemas.js @@ -1,6 +1,6 @@ 'use strict' -const { mergeSchemas: _mergeSchemas } = require('merge-json-schemas') +const { mergeSchemas: _mergeSchemas } = require('@fastify/merge-json-schemas') function mergeSchemas (schemas) { return _mergeSchemas(schemas, { onConflict: 'skip' }) diff --git a/package.json b/package.json index 82ecba99..fe06a948 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "fast-uri": "^2.1.0", "rfdc": "^1.2.0", "json-schema-ref-resolver": "^1.0.1", - "merge-json-schemas": "fastify/merge-json-schemas#add-merge-json-schemas-function" + "@fastify/merge-json-schemas": "^0.1.0" }, "standard": { "ignore": [ From 99ba443d12cf8a6bd33800714275f954dd968b3e Mon Sep 17 00:00:00 2001 From: Ivan Tymoshenko Date: Thu, 18 Jan 2024 10:48:23 +0100 Subject: [PATCH 7/7] test: update tests --- test/allof.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/allof.test.js b/test/allof.test.js index cd9364bd..7ec2d0c8 100644 --- a/test/allof.test.js +++ b/test/allof.test.js @@ -616,6 +616,7 @@ test('allOf: throw Error if types mismatch ', (t) => { } try { build(schema) + t.fail('should throw the MergeError') } catch (error) { t.ok(error instanceof Error) t.equal(error.message, 'Failed to merge "type" keyword schemas.') @@ -634,6 +635,7 @@ test('allOf: throw Error if format mismatch ', (t) => { } try { build(schema) + t.fail('should throw the MergeError') } catch (error) { t.ok(error instanceof Error) t.equal(error.message, 'Failed to merge "format" keyword schemas.')