Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/plain-eagles-fry.md
Original file line number Diff line number Diff line change
@@ -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.
65 changes: 65 additions & 0 deletions packages/core/src/__tests__/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
});
});
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Second:
title: Referenced model
type: string
8 changes: 8 additions & 0 deletions packages/core/src/ref-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as path from 'node:path';

import type { Source } from './resolve.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';
Expand All @@ -19,6 +20,13 @@ export function isExternalValue(node: unknown): node is Oas3Example & { external
return isPlainObject(node) && typeof node.externalValue === 'string';
}

export function refHasSibling<Prop extends string>(
node: OasRef,
propName: Prop
): node is OasRef & Record<Prop, unknown> {
return getOwn(node, propName) !== undefined;
}

export class Location {
constructor(
public source: Source,
Expand Down
10 changes: 7 additions & 3 deletions packages/core/src/walk.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -353,12 +353,16 @@ export function walkDocument<T extends BaseVisitor>(opts: {
propType = { name: 'scalar', properties: {} };
}

if (isRef(node[propName]) && propType?.name === 'scalar') {
if (!isNamedType(propType)) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to avoid duplication in the two checks below

continue;
}

if (nodeIsRef && refHasSibling(node, propName)) {
walkNode(node[propName], propType, location.child([propName]), node, propName);
continue;
}

if (!isNamedType(propType) || (propType.name === 'scalar' && !isRef(value))) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sibling walked with propType derived from resolved value

Medium Severity

When a property exists in both the resolved $ref target and as a sibling, value is set to resolvedNode[propName] (line 326) and propType is computed from that resolved value (line 337). But when the sibling check at line 360 fires, node[propName] (the sibling) is walked with this propType. For function-based type resolvers like additionalProperties (returns { type: 'boolean' } for false vs 'Schema' for objects) or items (returns listOf('Schema') for arrays vs 'Schema' for objects), the sibling value can end up walked with the wrong type. This is visible in the E2E snapshot where additionalProperties: {$ref: reference.yaml} is inlined while identical refs at second and third.items are bundled as component references — an inconsistency caused by the sibling being walked as scalar instead of Schema.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3fee16d. Configure here.

if (propType.name === 'scalar' && !isRef(value)) {
continue;
}

Expand Down
18 changes: 18 additions & 0 deletions tests/e2e/bundle/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = getCommandOutput(args, { testPath });
await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt'));
Comment thread
vadyvas marked this conversation as resolved.
});

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 = getCommandOutput(args, { testPath });
await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot_2.txt'));
});
});
66 changes: 66 additions & 0 deletions tests/e2e/bundle/ref-siblings/openapi.yaml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions tests/e2e/bundle/ref-siblings/redocly.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
apis:
main:
root: ./openapi.yaml
2 changes: 2 additions & 0 deletions tests/e2e/bundle/ref-siblings/reference.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Referenced model
type: string
70 changes: 70 additions & 0 deletions tests/e2e/bundle/ref-siblings/snapshot.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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: Referenced model
responses:
'200':
description: 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:
description: requestBody $ref with sibling description
operationId: postSubmit
requestBody:
$ref: '#/components/requestBodies/SampleBody'
description: Referenced model
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 <test>ms.
Loading
Loading