From c1e98c57bc37091b0e1065d0462d540e37734957 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 | 54 ++++++++++++++++++++++++- test/ref.test.js | 103 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index 63372eaa..3f84ff4a 100644 --- a/index.js +++ b/index.js @@ -66,6 +66,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 getMaxDepth (options) { @@ -191,10 +204,12 @@ function isValidSchema (schema, name) { function resolveRef (context, location) { const seen = new Set() + const siblingLocations = [] let depth = 0 while (location.schema.$ref !== undefined) { - const ref = location.schema.$ref + const refSchema = location.schema + const ref = refSchema.$ref const locationRef = location.getSchemaRef() if (seen.has(locationRef)) { @@ -219,10 +234,44 @@ function resolveRef (context, location) { throw new Error(`Cannot find reference "${ref}"`) } + const siblingSchema = {} + for (const key in refSchema) { + if (!IGNORED_REF_SIBLING_KEYWORDS.has(key)) { + siblingSchema[key] = refSchema[key] + } + } + + if (Object.keys(siblingSchema).length !== 0) { + siblingLocations.unshift(new Location( + cloneOriginSchema(context, siblingSchema, location.schemaId), + location.schemaId, + location.jsonPointer + )) + } + location = new Location(schema, schemaId, jsonPointer) } - return location + if (siblingLocations.length === 0) { + return location + } + + // 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 = location.getSchemaRef() + JSON.stringify(siblingLocations.map((l) => l.schema)) + + let mergedSchemaId = context.mergedRefsIds.get(mergedSchemaKey) + if (mergedSchemaId === undefined) { + mergedSchemaId = `__fjs_merged_${schemaIdCounter++}` + try { + mergeLocations(context, mergedSchemaId, [location, ...siblingLocations]) + } catch { + return location + } + context.mergedRefsIds.set(mergedSchemaKey, mergedSchemaId) + } + + return getMergedLocation(context, mergedSchemaId) } function getSchemaDependencies (refResolver, schemaId) { @@ -341,6 +390,7 @@ function build (schema, options) { validatorSchemasIds: new Set(), validatorSchemaRefs: new Set(), mergedSchemasIds: new Map(), + mergedRefsIds: new Map(), maxDepth, recursiveSchemas: new Set(), recursivePaths: 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 638a125977a32f802e96426b81a3b91741c02dc5 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 3f84ff4a..668f4a40 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') @@ -265,7 +266,8 @@ function resolveRef (context, location) { mergedSchemaId = `__fjs_merged_${schemaIdCounter++}` try { mergeLocations(context, mergedSchemaId, [location, ...siblingLocations]) - } catch { + } catch (err) { + if (!(err instanceof MergeError)) throw err return location } context.mergedRefsIds.set(mergedSchemaKey, mergedSchemaId) From d4e3bcf15170142a4bee7a1b163c55b9a1e76bef 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 27cbcc60add42866da346e1eedf830985fc41f6c 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"/ } + ) +})