From b39d04f901cb19f0203221f317c1aac205be9666 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:02:19 +0300 Subject: [PATCH 1/6] fix: resolve $ref siblings during bundling --- .changeset/plain-eagles-fry.md | 6 ++ packages/core/src/__tests__/bundle.test.ts | 65 +++++++++++++++ ...pi-schema-ref-sibling-nested-external.yaml | 26 ++++++ .../schema-ref-sibling-external-value.yaml | 3 + packages/core/src/walk.ts | 13 ++- tests/e2e/bundle/bundle.test.ts | 18 +++++ tests/e2e/bundle/ref-siblings/openapi.yaml | 66 +++++++++++++++ tests/e2e/bundle/ref-siblings/redocly.yaml | 3 + tests/e2e/bundle/ref-siblings/reference.yaml | 2 + tests/e2e/bundle/ref-siblings/snapshot.txt | 76 +++++++++++++++++ tests/e2e/bundle/ref-siblings/snapshot_2.txt | 81 +++++++++++++++++++ 11 files changed, 352 insertions(+), 7 deletions(-) create mode 100644 .changeset/plain-eagles-fry.md create mode 100644 packages/core/src/__tests__/fixtures/sibling-refs/openapi-schema-ref-sibling-nested-external.yaml create mode 100644 packages/core/src/__tests__/fixtures/sibling-refs/schema-ref-sibling-external-value.yaml create mode 100644 tests/e2e/bundle/ref-siblings/openapi.yaml create mode 100644 tests/e2e/bundle/ref-siblings/redocly.yaml create mode 100644 tests/e2e/bundle/ref-siblings/reference.yaml create mode 100644 tests/e2e/bundle/ref-siblings/snapshot.txt create mode 100644 tests/e2e/bundle/ref-siblings/snapshot_2.txt diff --git a/.changeset/plain-eagles-fry.md b/.changeset/plain-eagles-fry.md new file mode 100644 index 0000000000..83731d0d71 --- /dev/null +++ b/.changeset/plain-eagles-fry.md @@ -0,0 +1,6 @@ +--- +"@redocly/openapi-core": patch +"@redocly/cli": patch +--- + +Fixed an issue where sibling properties next to `$ref` were not resolved during bundling. diff --git a/packages/core/src/__tests__/bundle.test.ts b/packages/core/src/__tests__/bundle.test.ts index d385cb442f..3dbc2cd735 100644 --- a/packages/core/src/__tests__/bundle.test.ts +++ b/packages/core/src/__tests__/bundle.test.ts @@ -843,4 +843,69 @@ describe('sibling $ref resolution by spec', () => { expect(problems).toHaveLength(0); expect(res.parsed).toMatchSnapshot(); }); + + it('should bundle nested external refs inside Schema $ref sibling properties', async () => { + const { bundle: res, problems } = await bundle({ + config: await createConfig({}), + ref: path.join( + __dirname, + 'fixtures/sibling-refs/openapi-schema-ref-sibling-nested-external.yaml' + ), + }); + + const parsed = res.parsed as any; + const nestedSiblingRef = + parsed.paths['/sample'].get.responses['200'].content['application/json'].schema.properties + .second.$ref; + + expect(problems).toHaveLength(0); + expect(nestedSiblingRef).toBe('#/components/schemas/Second'); + expect(parsed.components.schemas.Second).toMatchObject({ + title: 'Referenced model', + type: 'string', + }); + }); + + it('should keep sibling fields defined next to a schema $ref wrapper', async () => { + const { + bundle: { parsed }, + problems, + } = await bundleFromString({ + source: outdent` + openapi: 3.1.0 + info: + title: Sibling fields on schema $ref wrapper + version: 1.0.0 + paths: + /sample: + get: + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/IdReadOnly' + components: + schemas: + Id: + type: integer + description: A generic id + IdReadOnly: + $ref: '#/components/schemas/Id' + readOnly: true + description: A generic id that is specifically read only + `, + config: await createConfig({}), + }); + + const wrappedSchema = (parsed as any).components.schemas.IdReadOnly; + + expect(problems).toHaveLength(0); + expect(wrappedSchema).toMatchObject({ + $ref: '#/components/schemas/Id', + readOnly: true, + description: 'A generic id that is specifically read only', + }); + }); }); diff --git a/packages/core/src/__tests__/fixtures/sibling-refs/openapi-schema-ref-sibling-nested-external.yaml b/packages/core/src/__tests__/fixtures/sibling-refs/openapi-schema-ref-sibling-nested-external.yaml new file mode 100644 index 0000000000..293fbc3a2d --- /dev/null +++ b/packages/core/src/__tests__/fixtures/sibling-refs/openapi-schema-ref-sibling-nested-external.yaml @@ -0,0 +1,26 @@ +openapi: 3.1.0 +info: + title: Nested external refs in schema $ref siblings + version: 1.0.0 +paths: + /sample: + get: + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Base' + properties: + second: + $ref: ./schema-ref-sibling-external-value.yaml#/Second +components: + schemas: + Base: + type: object + properties: + first: + type: integer + second: + type: integer diff --git a/packages/core/src/__tests__/fixtures/sibling-refs/schema-ref-sibling-external-value.yaml b/packages/core/src/__tests__/fixtures/sibling-refs/schema-ref-sibling-external-value.yaml new file mode 100644 index 0000000000..6be77099c2 --- /dev/null +++ b/packages/core/src/__tests__/fixtures/sibling-refs/schema-ref-sibling-external-value.yaml @@ -0,0 +1,3 @@ +Second: + title: Referenced model + type: string diff --git a/packages/core/src/walk.ts b/packages/core/src/walk.ts index 8f7ef2af63..dbe10a3206 100644 --- a/packages/core/src/walk.ts +++ b/packages/core/src/walk.ts @@ -327,11 +327,6 @@ export function walkDocument(opts: { let loc = resolvedLocation; - if (value === undefined) { - value = node[propName]; - loc = location; // properties on the same level as $ref should resolve against original location, not target - } - let propType = getOwn(type.properties, propName); if (propType === undefined) propType = type.additionalProperties; if (typeof propType === 'function') propType = propType(value, propName); @@ -353,12 +348,16 @@ export function walkDocument(opts: { propType = { name: 'scalar', properties: {} }; } - if (isRef(node[propName]) && propType?.name === 'scalar') { + if (!isNamedType(propType)) { + continue; + } + + if (resolvedNode !== node && node[propName] !== undefined) { walkNode(node[propName], propType, location.child([propName]), node, propName); continue; } - if (!isNamedType(propType) || (propType.name === 'scalar' && !isRef(value))) { + if (propType.name === 'scalar' && !isRef(value)) { continue; } diff --git a/tests/e2e/bundle/bundle.test.ts b/tests/e2e/bundle/bundle.test.ts index 0188c55411..c6cded1539 100644 --- a/tests/e2e/bundle/bundle.test.ts +++ b/tests/e2e/bundle/bundle.test.ts @@ -200,3 +200,21 @@ describe('bundle with long description', () => { await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot_2.txt')); }); }); + +describe('bundle with ref siblings', () => { + it('should resolve schema ref siblings', async () => { + const testPath = join(__dirname, `ref-siblings`); + const args = getParams(indexEntryPoint, ['bundle', 'openapi.yaml']); + + const result = cleanupOutput(getCommandOutput(args, { testPath })); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); + }); + + it('should resolve schema ref siblings in dereferenced output', async () => { + const testPath = join(__dirname, `ref-siblings`); + const args = getParams(indexEntryPoint, ['bundle', 'openapi.yaml', '--dereferenced']); + + const result = cleanupOutput(getCommandOutput(args, { testPath })); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot_2.txt')); + }); +}); diff --git a/tests/e2e/bundle/ref-siblings/openapi.yaml b/tests/e2e/bundle/ref-siblings/openapi.yaml new file mode 100644 index 0000000000..95f71ec980 --- /dev/null +++ b/tests/e2e/bundle/ref-siblings/openapi.yaml @@ -0,0 +1,66 @@ +openapi: 3.1.0 +info: + title: E2E bundle - ref with sibling keys + version: 1.0.0 +servers: + - url: https://api.redocly.com +security: [] +paths: + /sample: + get: + operationId: getSample + description: parameter $ref with sibling name + parameters: + - $ref: '#/components/parameters/id' + name: + $ref: reference.yaml#/title + responses: + '200': + description: JSON schema using $ref and sibling properties + content: + application/json: + schema: + $ref: '#/components/schemas/test' + properties: + second: + $ref: reference.yaml + third: + type: array + items: + $ref: reference.yaml + additionalProperties: + $ref: reference.yaml + /submit: + post: + description: requestBody $ref with sibling description + operationId: postSubmit + requestBody: + $ref: '#/components/requestBodies/SampleBody' + description: + $ref: reference.yaml#/title + +components: + parameters: + id: + name: id + in: query + required: false + schema: + type: string + schemas: + test: + type: object + properties: + first: + type: integer + additionalProperties: false + requestBodies: + SampleBody: + required: true + content: + application/json: + schema: + type: object + properties: + value: + type: string diff --git a/tests/e2e/bundle/ref-siblings/redocly.yaml b/tests/e2e/bundle/ref-siblings/redocly.yaml new file mode 100644 index 0000000000..f6f9f27544 --- /dev/null +++ b/tests/e2e/bundle/ref-siblings/redocly.yaml @@ -0,0 +1,3 @@ +apis: + main: + root: ./openapi.yaml diff --git a/tests/e2e/bundle/ref-siblings/reference.yaml b/tests/e2e/bundle/ref-siblings/reference.yaml new file mode 100644 index 0000000000..801ac986e8 --- /dev/null +++ b/tests/e2e/bundle/ref-siblings/reference.yaml @@ -0,0 +1,2 @@ +title: Referenced model +type: string diff --git a/tests/e2e/bundle/ref-siblings/snapshot.txt b/tests/e2e/bundle/ref-siblings/snapshot.txt new file mode 100644 index 0000000000..1a6cd1f467 --- /dev/null +++ b/tests/e2e/bundle/ref-siblings/snapshot.txt @@ -0,0 +1,76 @@ +openapi: 3.1.0 +info: + title: E2E bundle - ref with sibling keys + version: 1.0.0 + license: + name: MIT + identifier: MIT +servers: + - url: https://api.redocly.com +security: [] +paths: + /sample: + get: + summary: Sample GET (parameter $ref with sibling name) + operationId: getSample + parameters: + - $ref: '#/components/parameters/id' + name: Referenced model + responses: + '200': + description: 200 response with JSON schema using $ref and sibling properties + content: + application/json: + schema: + $ref: '#/components/schemas/test' + properties: + second: + $ref: '#/components/schemas/reference' + third: + type: array + items: + $ref: '#/components/schemas/reference' + additionalProperties: + title: Referenced model + type: string + /submit: + post: + summary: Sample POST (requestBody $ref with sibling description) + operationId: postSubmit + requestBody: + $ref: '#/components/requestBodies/SampleBody' + description: Referenced model + responses: + '204': + description: No content +components: + parameters: + id: + name: id + in: query + required: false + schema: + type: string + schemas: + test: + type: object + properties: + first: + type: integer + additionalProperties: false + reference: + title: Referenced model + type: string + requestBodies: + SampleBody: + required: true + content: + application/json: + schema: + type: object + properties: + value: + type: string + +bundling openapi.yaml using configuration for api 'main'... +📦 Created a bundle for openapi.yaml at stdout ms. diff --git a/tests/e2e/bundle/ref-siblings/snapshot_2.txt b/tests/e2e/bundle/ref-siblings/snapshot_2.txt new file mode 100644 index 0000000000..36fa3be001 --- /dev/null +++ b/tests/e2e/bundle/ref-siblings/snapshot_2.txt @@ -0,0 +1,81 @@ +openapi: 3.1.0 +info: + title: E2E bundle - ref with sibling keys + version: 1.0.0 + license: + name: MIT + identifier: MIT +servers: + - url: https://api.redocly.com +security: [] +paths: + /sample: + get: + summary: Sample GET (parameter $ref with sibling name) + operationId: getSample + parameters: + - name: Referenced model + in: query + required: false + schema: &ref_0 + type: string + responses: + '200': + description: 200 response with JSON schema using $ref and sibling properties + content: + application/json: + schema: + properties: + second: + title: Referenced model + type: string + third: + type: array + items: + title: Referenced model + type: string + additionalProperties: + title: Referenced model + type: string + type: object + /submit: + post: + summary: Sample POST (requestBody $ref with sibling description) + operationId: postSubmit + requestBody: + description: Referenced model + required: true + content: &ref_1 + application/json: + schema: + type: object + properties: + value: + type: string + responses: + '204': + description: No content +components: + parameters: + id: + name: id + in: query + required: false + schema: *ref_0 + schemas: + test: + type: object + properties: + first: + type: integer + additionalProperties: false + reference: + title: Referenced model + type: string + requestBodies: + SampleBody: + required: true + content: *ref_1 + +bundling openapi.yaml using configuration for api 'main'... +📦 Created a bundle for openapi.yaml at stdout ms. From 81a63b9f5eee7fec5182382acf54ed42f341b64c Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:27:48 +0300 Subject: [PATCH 2/6] tests: update snapshots --- tests/e2e/bundle/ref-siblings/snapshot.txt | 12 +++--------- tests/e2e/bundle/ref-siblings/snapshot_2.txt | 12 +++--------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/tests/e2e/bundle/ref-siblings/snapshot.txt b/tests/e2e/bundle/ref-siblings/snapshot.txt index 1a6cd1f467..9e1221b50d 100644 --- a/tests/e2e/bundle/ref-siblings/snapshot.txt +++ b/tests/e2e/bundle/ref-siblings/snapshot.txt @@ -2,23 +2,20 @@ openapi: 3.1.0 info: title: E2E bundle - ref with sibling keys version: 1.0.0 - license: - name: MIT - identifier: MIT servers: - url: https://api.redocly.com security: [] paths: /sample: get: - summary: Sample GET (parameter $ref with sibling name) operationId: getSample + description: parameter $ref with sibling name parameters: - $ref: '#/components/parameters/id' name: Referenced model responses: '200': - description: 200 response with JSON schema using $ref and sibling properties + description: JSON schema using $ref and sibling properties content: application/json: schema: @@ -35,14 +32,11 @@ paths: type: string /submit: post: - summary: Sample POST (requestBody $ref with sibling description) + description: requestBody $ref with sibling description operationId: postSubmit requestBody: $ref: '#/components/requestBodies/SampleBody' description: Referenced model - responses: - '204': - description: No content components: parameters: id: diff --git a/tests/e2e/bundle/ref-siblings/snapshot_2.txt b/tests/e2e/bundle/ref-siblings/snapshot_2.txt index 36fa3be001..502c2df794 100644 --- a/tests/e2e/bundle/ref-siblings/snapshot_2.txt +++ b/tests/e2e/bundle/ref-siblings/snapshot_2.txt @@ -2,17 +2,14 @@ openapi: 3.1.0 info: title: E2E bundle - ref with sibling keys version: 1.0.0 - license: - name: MIT - identifier: MIT servers: - url: https://api.redocly.com security: [] paths: /sample: get: - summary: Sample GET (parameter $ref with sibling name) operationId: getSample + description: parameter $ref with sibling name parameters: - name: Referenced model in: query @@ -21,7 +18,7 @@ paths: type: string responses: '200': - description: 200 response with JSON schema using $ref and sibling properties + description: JSON schema using $ref and sibling properties content: application/json: schema: @@ -40,7 +37,7 @@ paths: type: object /submit: post: - summary: Sample POST (requestBody $ref with sibling description) + description: requestBody $ref with sibling description operationId: postSubmit requestBody: description: Referenced model @@ -52,9 +49,6 @@ paths: properties: value: type: string - responses: - '204': - description: No content components: parameters: id: From b8066fbc479d179b538af6fd2034740082c3b20a Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:59:09 +0300 Subject: [PATCH 3/6] chore: remove double cleanupOutput --- tests/e2e/bundle/bundle.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/bundle/bundle.test.ts b/tests/e2e/bundle/bundle.test.ts index c6cded1539..4a44fca274 100644 --- a/tests/e2e/bundle/bundle.test.ts +++ b/tests/e2e/bundle/bundle.test.ts @@ -206,7 +206,7 @@ describe('bundle with ref siblings', () => { const testPath = join(__dirname, `ref-siblings`); const args = getParams(indexEntryPoint, ['bundle', 'openapi.yaml']); - const result = cleanupOutput(getCommandOutput(args, { testPath })); + const result = getCommandOutput(args, { testPath }); await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); }); @@ -214,7 +214,7 @@ describe('bundle with ref siblings', () => { const testPath = join(__dirname, `ref-siblings`); const args = getParams(indexEntryPoint, ['bundle', 'openapi.yaml', '--dereferenced']); - const result = cleanupOutput(getCommandOutput(args, { testPath })); + const result = getCommandOutput(args, { testPath }); await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot_2.txt')); }); }); From e41e843d225279081a57aaf29ed0767064b4e53a Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Mon, 4 May 2026 21:08:21 +0300 Subject: [PATCH 4/6] chore: refactor prop check --- packages/core/src/ref-utils.ts | 10 +++++++++- packages/core/src/walk.ts | 4 ++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/core/src/ref-utils.ts b/packages/core/src/ref-utils.ts index 14cebd115f..1a1af7315b 100644 --- a/packages/core/src/ref-utils.ts +++ b/packages/core/src/ref-utils.ts @@ -1,7 +1,8 @@ import * as path from 'node:path'; import type { Source } from './resolve.js'; -import type { Oas3Example, OasRef } from './typings/openapi.js'; +import type { Oas3Example,OasRef } from './typings/openapi.js'; +import { getOwn } from './utils/get-own.js'; import { isPlainObject } from './utils/is-plain-object.js'; import { isTruthy } from './utils/is-truthy.js'; import type { ResolveResult, UserContext } from './walk.js'; @@ -19,6 +20,13 @@ export function isExternalValue(node: unknown): node is Oas3Example & { external return isPlainObject(node) && typeof node.externalValue === 'string'; } +export function refHasSibling( + node: OasRef, + propName: Prop +): node is OasRef & Record { + return getOwn(node, propName) !== undefined; +} + export class Location { constructor( public source: Source, diff --git a/packages/core/src/walk.ts b/packages/core/src/walk.ts index dbe10a3206..61a2235ff2 100644 --- a/packages/core/src/walk.ts +++ b/packages/core/src/walk.ts @@ -1,7 +1,7 @@ import type { Config, RuleSeverity } from './config/index.js'; import { YamlParseError } from './errors/yaml-parse-error.js'; import type { SpecVersion } from './oas-types.js'; -import { Location, isRef } from './ref-utils.js'; +import { Location, isRef, refHasSibling } from './ref-utils.js'; import type { ResolveError, Source, ResolvedRefMap, Document } from './resolve.js'; import { isNamedType, SpecExtension, type NormalizedNodeType } from './types/index.js'; import type { Referenced } from './typings/openapi.js'; @@ -352,7 +352,7 @@ export function walkDocument(opts: { continue; } - if (resolvedNode !== node && node[propName] !== undefined) { + if (nodeIsRef && refHasSibling(node, propName)) { walkNode(node[propName], propType, location.child([propName]), node, propName); continue; } From 3bad21bed2ae8c07b4f49f34106346cfe21a0f01 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Mon, 4 May 2026 21:13:25 +0300 Subject: [PATCH 5/6] chore: fix import indent --- packages/core/src/ref-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/ref-utils.ts b/packages/core/src/ref-utils.ts index 1a1af7315b..2d5088b76c 100644 --- a/packages/core/src/ref-utils.ts +++ b/packages/core/src/ref-utils.ts @@ -1,7 +1,7 @@ import * as path from 'node:path'; import type { Source } from './resolve.js'; -import type { Oas3Example,OasRef } from './typings/openapi.js'; +import type { Oas3Example, OasRef } from './typings/openapi.js'; import { getOwn } from './utils/get-own.js'; import { isPlainObject } from './utils/is-plain-object.js'; import { isTruthy } from './utils/is-truthy.js'; From 3fee16d18c3d8b2835e514997abe26d2c95d3578 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Tue, 12 May 2026 14:03:14 +0300 Subject: [PATCH 6/6] fix: revert removal of sibling value fallback in walk --- packages/core/src/walk.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/core/src/walk.ts b/packages/core/src/walk.ts index 61a2235ff2..a23fc9c7ea 100644 --- a/packages/core/src/walk.ts +++ b/packages/core/src/walk.ts @@ -327,6 +327,11 @@ export function walkDocument(opts: { let loc = resolvedLocation; + if (value === undefined) { + value = node[propName]; + loc = location; // properties on the same level as $ref should resolve against original location, not target + } + let propType = getOwn(type.properties, propName); if (propType === undefined) propType = type.additionalProperties; if (typeof propType === 'function') propType = propType(value, propName);