From 8c24a7a47b1c5a16d427b34389b11b4ccc890ce4 Mon Sep 17 00:00:00 2001 From: unkizhang Date: Sat, 15 Aug 2026 03:23:31 +0800 Subject: [PATCH] fix: apply sibling keywords when dereferencing $ref (#866) buildValue resolved $ref by replacing the whole schema, discarding sibling keywords like required. A property such as { $ref: 'Foo', required: ['extra'] } serialized objects missing 'extra' without raising, letting incomplete data pass silently. Resolve $ref with siblings by merging the resolved reference with the sibling keywords (same mergeLocations path used by allOf), keeping the direct-dereference fast path for schemas containing $ref alone. All call sites that eagerly resolve $ref now share resolveRefLocation so properties, array items and nested object properties behave the same. --- index.js | 40 +++++++++++++++++++++++++------- test/ref.test.js | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/index.js b/index.js index 3e3d7549..a980d5c8 100644 --- a/index.js +++ b/index.js @@ -87,6 +87,31 @@ function getMergedLocation (context, mergedSchemaId) { return new Location(mergedSchema, mergedSchemaId, '#') } +// Resolves `location.schema.$ref`, applying sibling keywords on top of the +// resolved schema instead of discarding them (#866). Schemas containing +// `$ref` alone keep the direct-dereference fast path. +function resolveRefLocation (context, location) { + const schema = location.schema + if (schema.$ref === undefined || Object.keys(schema).length === 1) { + return resolveRef(context, location) + } + + let mergedSchemaId = context.mergedSchemasIds.get(schema) + if (mergedSchemaId) { + return getMergedLocation(context, mergedSchemaId) + } + + mergedSchemaId = `__fjs_merged_${schemaIdCounter++}` + context.mergedSchemasIds.set(schema, mergedSchemaId) + + const { $ref, ...schemaWithoutRef } = schema + const locations = [ + new Location(schemaWithoutRef, location.schemaId, location.jsonPointer), + new Location({ $ref }, location.schemaId, location.jsonPointer) + ] + return mergeLocations(context, mergedSchemaId, locations) +} + function getSchemaId (schema, rootSchemaId) { if (schema.$id && schema.$id.charAt(0) !== '#') { return schema.$id @@ -439,7 +464,7 @@ function buildInnerObject (context, location, objVar) { let resolvedLocation = propertyLocation if (propertyLocation.schema.$ref) { - resolvedLocation = resolveRef(context, propertyLocation) + resolvedLocation = resolveRefLocation(context, propertyLocation) } const sanitizedKey = JSON.stringify(key) @@ -487,7 +512,7 @@ function buildInnerObject (context, location, objVar) { for (const key of propertiesKeys) { let propertyLocation = propertiesLocation.getPropertyLocation(key) if (propertyLocation.schema.$ref) { - propertyLocation = resolveRef(context, propertyLocation) + propertyLocation = resolveRefLocation(context, propertyLocation) } const sanitizedKey = JSON.stringify(key) @@ -651,7 +676,7 @@ function buildArray (context, location, input) { itemsLocation.schema = itemsLocation.schema || {} if (itemsLocation.schema.$ref) { - itemsLocation = resolveRef(context, itemsLocation) + itemsLocation = resolveRefLocation(context, itemsLocation) } const itemsSchema = itemsLocation.schema @@ -708,7 +733,7 @@ function buildArray (context, location, input) { let item = itemsSchema[i] let itemLocation = itemsLocation.getPropertyLocation(i) if (itemLocation.schema.$ref) { - itemLocation = resolveRef(context, itemLocation) + itemLocation = resolveRefLocation(context, itemLocation) item = itemLocation.schema } const value = `value_${i}` @@ -795,7 +820,7 @@ function buildArray (context, location, input) { let item = itemsSchema[i] let itemLocation = itemsLocation.getPropertyLocation(i) if (itemLocation.schema.$ref) { - itemLocation = resolveRef(context, itemLocation) + itemLocation = resolveRefLocation(context, itemLocation) item = itemLocation.schema } const value = `value_${i}_${context.uid++}` @@ -1291,15 +1316,14 @@ function buildIfThenElse (context, location, input) { } function buildValue (context, location, input) { - let schema = location.schema + const schema = location.schema if (typeof schema === 'boolean') { return `json += JSON.stringify(${input})` } if (schema.$ref) { - location = resolveRef(context, location) - schema = location.schema + return buildValue(context, resolveRefLocation(context, location), input) } if (schema.allOf) { diff --git a/test/ref.test.js b/test/ref.test.js index 2f736445..ff705ee9 100644 --- a/test/ref.test.js +++ b/test/ref.test.js @@ -2075,3 +2075,62 @@ test('ref nested', (t) => { t.assert.doesNotThrow(() => JSON.parse(output)) t.assert.equal(output, '{"str":"test"}') }) + +test('ref external with sibling required keyword keeps both constraints', (t) => { + t.plan(3) + + const externalSchema = { + $id: 'Foo', + type: 'object', + properties: { + value: { type: 'number' } + }, + required: ['value'] + } + + const stringify = build({ + type: 'object', + properties: { + x: { + $ref: 'Foo', + required: ['extra'] + } + } + }, { schema: { Foo: externalSchema } }) + + const output = stringify({ x: { value: 1, extra: 'x' } }) + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"x":{"value":1}}') + + // sibling `required` must not be discarded: missing `extra` throws + t.assert.throws(() => stringify({ x: { value: 1 } }), /"extra" is required!/) +}) + +test('ref external with sibling required keyword keeps ref constraints', (t) => { + t.plan(2) + + const externalSchema = { + $id: 'Foo', + type: 'object', + properties: { + value: { type: 'number' } + }, + required: ['value'] + } + + const stringify = build({ + type: 'object', + properties: { + x: { + $ref: 'Foo', + required: ['extra'] + } + } + }, { schema: { Foo: externalSchema } }) + + // the ref target `required` must not be discarded either: missing `value` throws + t.assert.throws(() => stringify({ x: { extra: 'x' } }), /"value" is required!/) + + const output = stringify({ x: { value: 1, extra: 'x' } }) + t.assert.equal(output, '{"x":{"value":1}}') +})