From ed8b59a6919b237eb52ef06dd04b8b4fb3d327e8 Mon Sep 17 00:00:00 2001 From: lazerg Date: Sat, 8 Aug 2026 10:55:00 +0500 Subject: [PATCH 1/4] fix: apply sibling keywords when a schema resolves a $ref --- index.js | 55 +++++++++++++++++++++++-- test/ref.test.js | 103 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 4 deletions(-) diff --git a/index.js b/index.js index 3e3d7549..a38c3ff2 100644 --- a/index.js +++ b/index.js @@ -42,6 +42,19 @@ const validLargeArrayMechanisms = new Set([ 'json-stringify' ]) +// Keywords that do not change the output, so a $ref carrying only these +// can still be dereferenced directly instead of merged with its target. +const IGNORED_REF_SIBLING_KEYWORDS = new Set([ + '$ref', + '$comment', + 'title', + 'description', + 'examples', + 'deprecated', + 'readOnly', + 'writeOnly' +]) + let schemaIdCounter = 0 function isValidSchema (schema, name) { @@ -59,7 +72,8 @@ function isValidSchema (schema, name) { } function resolveRef (context, location) { - const ref = location.schema.$ref + const refSchema = location.schema + const ref = refSchema.$ref let hashIndex = ref.indexOf('#') if (hashIndex === -1) { @@ -74,12 +88,44 @@ function resolveRef (context, location) { throw new Error(`Cannot find reference "${ref}"`) } - const newLocation = new Location(schema, schemaId, jsonPointer) + let newLocation = new Location(schema, schemaId, jsonPointer) if (schema.$ref !== undefined) { - return resolveRef(context, newLocation) + newLocation = resolveRef(context, newLocation) + } + + const siblingSchema = {} + for (const key in refSchema) { + if (!IGNORED_REF_SIBLING_KEYWORDS.has(key)) { + siblingSchema[key] = refSchema[key] + } + } + + if (Object.keys(siblingSchema).length === 0) { + return newLocation + } + + const siblingLocation = new Location( + cloneOriginSchema(context, siblingSchema, location.schemaId), + location.schemaId, + location.jsonPointer + ) + + // Keyed by content, not by identity: a merged schema is cloned on every + // merge, so a recursive $ref would otherwise be merged again on each level. + const mergedSchemaKey = newLocation.getSchemaRef() + JSON.stringify(siblingLocation.schema) + + let mergedSchemaId = context.mergedRefsIds.get(mergedSchemaKey) + if (mergedSchemaId === undefined) { + mergedSchemaId = `__fjs_merged_${schemaIdCounter++}` + try { + mergeLocations(context, mergedSchemaId, [newLocation, siblingLocation]) + } catch { + return newLocation + } + context.mergedRefsIds.set(mergedSchemaKey, mergedSchemaId) } - return newLocation + return getMergedLocation(context, mergedSchemaId) } function getMergedLocation (context, mergedSchemaId) { @@ -161,6 +207,7 @@ function build (schema, options) { rootSchemaId: schema.$id || `__fjs_root_${schemaIdCounter++}`, validatorSchemasIds: new Set(), mergedSchemasIds: new Map(), + mergedRefsIds: new Map(), recursiveSchemas: new Set(), recursivePaths: new Set(), buildingSet: new Set(), diff --git a/test/ref.test.js b/test/ref.test.js index 2f736445..fdc60403 100644 --- a/test/ref.test.js +++ b/test/ref.test.js @@ -2075,3 +2075,106 @@ test('ref nested', (t) => { t.assert.doesNotThrow(() => JSON.parse(output)) t.assert.equal(output, '{"str":"test"}') }) + +test('ref internal - sibling keywords', (t) => { + t.plan(3) + + const schema = { + definitions: { + def: { + type: 'object', + properties: { + str: { + type: 'string' + }, + num: { + type: 'integer' + } + }, + required: ['str'] + } + }, + type: 'object', + properties: { + obj: { + $ref: '#/definitions/def', + required: ['num'] + } + } + } + + const object = { + obj: { + str: 'test', + num: 42 + } + } + + const stringify = build(schema) + const output = stringify(object) + + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"obj":{"str":"test","num":42}}') + + t.assert.throws(() => { + stringify({ + obj: { + str: 'test' + } + }) + }, { message: '"num" is required!' }) +}) + +test('ref external - sibling keywords', (t) => { + t.plan(3) + + const externalSchema = { + external: { + definitions: { + def: { + type: 'object', + properties: { + str: { + type: 'string' + }, + num: { + type: 'integer' + } + }, + required: ['str'] + } + } + } + } + + const schema = { + type: 'object', + properties: { + obj: { + $ref: 'external#/definitions/def', + required: ['num'] + } + } + } + + const object = { + obj: { + str: 'test', + num: 42 + } + } + + const stringify = build(schema, { schema: externalSchema }) + const output = stringify(object) + + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"obj":{"str":"test","num":42}}') + + t.assert.throws(() => { + stringify({ + obj: { + str: 'test' + } + }) + }, { message: '"num" is required!' }) +}) From 84e2bead5c7547da09e47ee9ad9497094ec337c7 Mon Sep 17 00:00:00 2001 From: lazerg Date: Fri, 11 Sep 2026 18:24:25 +0500 Subject: [PATCH 2/4] fix: rethrow non-merge errors when merging $ref siblings --- index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index a38c3ff2..53991740 100644 --- a/index.js +++ b/index.js @@ -3,6 +3,7 @@ /* eslint no-prototype-builtins: 0 */ const { RefResolver } = require('json-schema-ref-resolver') +const { MergeError } = require('@fastify/merge-json-schemas') const Serializer = require('./lib/serializer') const Validator = require('./lib/validator') @@ -119,7 +120,8 @@ function resolveRef (context, location) { mergedSchemaId = `__fjs_merged_${schemaIdCounter++}` try { mergeLocations(context, mergedSchemaId, [newLocation, siblingLocation]) - } catch { + } catch (err) { + if (!(err instanceof MergeError)) throw err return newLocation } context.mergedRefsIds.set(mergedSchemaKey, mergedSchemaId) From 73d0a225ad4b08e0021b692fe56cca025023e737 Mon Sep 17 00:00:00 2001 From: lazerg Date: Fri, 11 Sep 2026 18:24:25 +0500 Subject: [PATCH 3/4] test: cover recursive $ref with sibling keywords --- test/ref.test.js | 54 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/test/ref.test.js b/test/ref.test.js index fdc60403..a8f900d4 100644 --- a/test/ref.test.js +++ b/test/ref.test.js @@ -2178,3 +2178,57 @@ test('ref external - sibling keywords', (t) => { }) }, { message: '"num" is required!' }) }) + +test('ref external - recursive sibling keywords', (t) => { + t.plan(3) + + const externalSchema = { + node: { + $id: 'node', + type: 'object', + properties: { + str: { + type: 'string' + }, + next: { + $ref: 'node#', + required: ['str'] + } + } + } + } + + const schema = { + type: 'object', + properties: { + root: { + $ref: 'node#', + required: ['str'] + } + } + } + + const object = { + root: { + str: 'test', + next: { + str: 'nested' + } + } + } + + const stringify = build(schema, { schema: externalSchema }) + const output = stringify(object) + + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"root":{"str":"test","next":{"str":"nested"}}}') + + t.assert.throws(() => { + stringify({ + root: { + str: 'test', + next: {} + } + }) + }, { message: '"str" is required!' }) +}) From b19f087d9a7acfe37766dd1ccb92899de74d4646 Mon Sep 17 00:00:00 2001 From: lazerg Date: Sat, 12 Sep 2026 00:13:09 +0500 Subject: [PATCH 4/4] test: cover the $ref sibling merge failure paths --- test/ref.test.js | 70 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/test/ref.test.js b/test/ref.test.js index a8f900d4..79b046f2 100644 --- a/test/ref.test.js +++ b/test/ref.test.js @@ -2232,3 +2232,73 @@ test('ref external - recursive sibling keywords', (t) => { }) }, { message: '"str" is required!' }) }) + +test('ref internal - conflicting sibling keywords', (t) => { + t.plan(2) + + const schema = { + definitions: { + def: { + type: 'string' + } + }, + type: 'object', + properties: { + value: { + $ref: '#/definitions/def', + type: 'integer' + } + } + } + + const object = { + value: 42 + } + + const stringify = build(schema) + const output = stringify(object) + + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"value":"42"}') +}) + +test('ref external - sibling keywords with a duplicated anchor', (t) => { + t.plan(1) + + const externalSchema = { + external: { + $id: 'external', + definitions: { + def: { + type: 'object', + properties: { + str: { + $id: '#anchor', + type: 'string' + } + } + } + } + } + } + + const schema = { + type: 'object', + properties: { + obj: { + $ref: 'external#/definitions/def', + properties: { + num: { + $id: '#anchor', + type: 'integer' + } + } + } + } + } + + t.assert.throws( + () => build(schema, { schema: externalSchema }), + { message: /There is already another anchor "#anchor"/ } + ) +})