Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5d101df
fix: keep sibling keywords when a referenced schema has a root-level …
RomanHotsiy Jul 24, 2026
4e46cf2
chore: simplify changeset wording
RomanHotsiy Jul 24, 2026
113c97d
Apply suggestion from @RomanHotsiy
RomanHotsiy Jul 24, 2026
420e2e9
fix: follow composed $ref chains in no-required-schema-properties-und…
RomanHotsiy Jul 24, 2026
232e3e3
Apply suggestion from @JLekawa
JLekawa Jul 24, 2026
15453a9
Merge branch 'main' into fix/bundle-root-ref-sibling-keywords
RomanHotsiy Jul 27, 2026
2cb6857
feat: expose composed $ref chains on the resolve result instead of st…
RomanHotsiy Jul 27, 2026
2df7b6e
Merge branch 'fix/bundle-root-ref-sibling-keywords' of github.com:Red…
RomanHotsiy Jul 27, 2026
1c03c85
fix: validate required on inline composed $refs and ignore non-array …
RomanHotsiy Jul 27, 2026
094ff48
fix: walk $ref sibling values that collide with resolved node keys
RomanHotsiy Jul 27, 2026
71b7bc5
test: pin per-level chain independence for multi-hop composed refs
RomanHotsiy Jul 27, 2026
7801c2b
fix: keep the resolve contract for rewritten bundled refs
RomanHotsiy Jul 27, 2026
5d8b082
refactor: carry a Location on chain hops and simplify walker checks
RomanHotsiy Jul 27, 2026
7f9f301
fix: check composed chain hops even when the chain end was already vi…
RomanHotsiy Jul 27, 2026
1093b74
refactor: build the bundled ref registration from the resolve result
RomanHotsiy Jul 28, 2026
eded810
Apply suggestion from @RomanHotsiy
RomanHotsiy Jul 28, 2026
6261d46
Merge branch 'main' into fix/bundle-root-ref-sibling-keywords
RomanHotsiy Jul 28, 2026
64e1a0f
refactor: walk each composed hop in its own frame and split sibling w…
RomanHotsiy Jul 28, 2026
8d88d3a
Merge branch 'fix/bundle-root-ref-sibling-keywords' of github.com:Red…
RomanHotsiy Jul 28, 2026
6fbd859
test: cover discriminator mappings pointing to composed schemas
RomanHotsiy Jul 28, 2026
b704b0b
fix: do not crash on $ref siblings when the ref resolves to null
RomanHotsiy Jul 28, 2026
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/root-ref-sibling-keywords.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@redocly/openapi-core': minor
'@redocly/cli': minor
---

Fixed the `bundle` command losing schema keywords (such as `title`, `properties`, or `required`) written next to a `$ref` when the referenced schemas started with their own `$ref`.
134 changes: 134 additions & 0 deletions packages/core/src/__tests__/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -976,6 +976,140 @@ describe('sibling $ref resolution by spec', () => {
expect(problems).toHaveLength(0);
expect(res.parsed).toMatchSnapshot();
});

it('should keep sibling keywords when an external schema file has a root-level $ref', async () => {
const { bundle: res, problems } = await bundle({
config: await createConfig({}),
ref: path.join(__dirname, 'fixtures/sibling-refs/openapi-root-ref-siblings.yaml'),
});

expect(problems).toHaveLength(0);
expect((res.parsed as any).components.schemas.BadRequest).toEqual({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

wouldn't it be better to use snapshot check?

title: 'Bad request',
type: 'object',
$ref: '#/components/schemas/BaseProblem',
properties: {
status: { type: 'integer', minimum: 400, maximum: 400 },
},
required: ['status'],
});
expect(
(res.parsed as any).paths['/demo'].get.responses['400'].content['application/json'].schema
).toEqual({ $ref: '#/components/schemas/BadRequest' });
});

it('should bundle refs inside $ref siblings that collide with resolved schema keys', async () => {
const { bundle: res, problems } = await bundle({
config: await createConfig({}),
ref: path.join(__dirname, 'fixtures/sibling-refs/openapi-sibling-collision.yaml'),
});

expect(problems).toHaveLength(0);
const schema = (res.parsed as any).paths['/demo'].get.responses['200'].content[
'application/json'
].schema;
expect(schema).toEqual({
$ref: '#/components/schemas/Test',
properties: {
second: { $ref: '#/components/schemas/BaseProblem' },
},
});
expect((res.parsed as any).components.schemas.BaseProblem).toEqual({
type: 'object',
properties: {
title: { type: 'string' },
},
});
});

it('should not crash when a $ref with siblings resolves to null', async () => {
const document = parseYamlToDocument(
outdent`
openapi: 3.0.0
info:
title: Test
version: 1.0.0
paths: {}
components:
schemas:
Nullish:
$ref: '#/components/schemas/Target'
description: composed over null
Target: null
`,
''
);

const { bundle: res, problems } = await bundleDocument({
document,
externalRefResolver: new BaseResolver(),
config: await createConfig({}),
types: Oas3Types,
});

expect(problems).toHaveLength(0);
expect((res.parsed as any).components.schemas.Nullish).toEqual({
$ref: '#/components/schemas/Target',
description: 'composed over null',
});
});

it('should bundle discriminator mappings that point to composed schemas', async () => {
const { bundle: res, problems } = await bundle({
config: await createConfig({}),
ref: path.join(__dirname, 'fixtures/sibling-refs/openapi-discriminator-mapping.yaml'),
});

expect(problems).toHaveLength(0);
const schemas = (res.parsed as any).components.schemas;
expect(schemas.Pet.discriminator.mapping.cat).toEqual('#/components/schemas/Cat');
expect(schemas.Cat).toEqual({
title: 'Cat',
$ref: '#/components/schemas/BaseProblem',
properties: {
toy: { $ref: '#/components/schemas/Toy' },
},
});
expect(schemas.Toy).toEqual({ type: 'string' });
});

it('should keep referenced composed schemas when removing unused components', async () => {
const { bundle: res, problems } = await bundle({
config: await createConfig({}),
ref: path.join(__dirname, 'fixtures/sibling-refs/openapi-root-ref-siblings.yaml'),
removeUnusedComponents: true,
});

expect(problems).toHaveLength(0);
expect(Object.keys((res.parsed as any).components.schemas).sort()).toEqual([
'BadRequest',
'BaseProblem',
]);
});

it('should keep sibling keywords when a referenced component has a root-level $ref', async () => {
const { bundle: res, problems } = await bundle({
config: await createConfig({}),
ref: path.join(__dirname, 'fixtures/sibling-refs/openapi-root-ref-siblings.yaml'),
});

expect(problems).toHaveLength(0);
const schemas = (res.parsed as any).components.schemas;
expect(schemas.Problem).toEqual({
$ref: '#/components/schemas/ProblemAlias',
description: 'Composed problem',
});
expect(schemas.ProblemAlias).toEqual({
$ref: '#/components/schemas/BaseProblem',
title: 'Problem alias',
});
expect(schemas.BaseProblem).toEqual({
type: 'object',
properties: {
title: { type: 'string' },
},
});
});
});

describe('bundle with --component-names-strategy title', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
openapi: 3.1.0
info:
title: Discriminator mapping to a composed schema
version: 1.0.0
paths:
/pets:
get:
responses:
'200':
description: OK
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/Pet'
components:
schemas:
Pet:
type: object
discriminator:
propertyName: type
mapping:
cat: ./schemas/Cat.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
openapi: 3.1.0
info:
title: Root-level $ref with sibling keywords
version: 1.0.0
paths:
/demo:
get:
responses:
'400':
description: Bad request
content:
application/json:
schema:
$ref: ./schemas/BadRequest.yaml
components:
schemas:
BadRequest:
$ref: ./schemas/BadRequest.yaml
ProblemAlias:
$ref: ./schemas/BaseProblem.yaml
title: Problem alias
Problem:
$ref: '#/components/schemas/ProblemAlias'
description: Composed problem
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
openapi: 3.1.0
info:
title: Sibling properties colliding with the referenced schema keys
version: 1.0.0
paths:
/demo:
get:
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Test'
properties:
second:
$ref: ./schemas/BaseProblem.yaml
components:
schemas:
Test:
type: object
title: Test schema
properties:
first:
type: integer
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
title: Bad request
type: object
$ref: ./BaseProblem.yaml
properties:
status:
type: integer
minimum: 400
maximum: 400
required:
- status
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
type: object
properties:
title:
type: string
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
title: Cat
$ref: ./BaseProblem.yaml
properties:
toy:
$ref: ./Toy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
type: string
66 changes: 66 additions & 0 deletions packages/core/src/__tests__/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,72 @@ describe('collect refs', () => {
expect(Array.from(resolvedRefs.values()).pop()!.node).toEqual({ type: 'string' });
});

it('should record a $ref with sibling keys as a chain hop while following transitive refs', async () => {
const rootDocument = parseYamlToDocument(
outdent`
openapi: 3.0.0
info:
$ref: "#/aliased"
aliased:
$ref: '#/target'
description: alias with sibling
target:
contact: {}
`,
'foobar.yaml'
);

const resolvedRefs = await resolveDocument({
rootDocument,
externalRefResolver: new BaseResolver(),
rootType: normalizeTypes(Oas3Types).Root,
});

const aliasedRef = resolvedRefs.get('foobar.yaml::#/aliased')!;
expect(aliasedRef.node).toEqual({ contact: {} });
expect(aliasedRef.chain).toHaveLength(1);
expect(aliasedRef.chain![0].node).toEqual({
$ref: '#/target',
description: 'alias with sibling',
});
expect(aliasedRef.chain![0].location.pointer).toEqual('#/aliased');
expect(resolvedRefs.get('foobar.yaml::#/target')!.node).toEqual({ contact: {} });
});

it('should record each level of a multi-hop composed chain independently', async () => {
const rootDocument = parseYamlToDocument(
outdent`
openapi: 3.0.0
info:
$ref: "#/first"
first:
$ref: '#/second'
description: first hop
second:
$ref: '#/target'
description: second hop
target:
contact: {}
`,
'foobar.yaml'
);

const resolvedRefs = await resolveDocument({
rootDocument,
externalRefResolver: new BaseResolver(),
rootType: normalizeTypes(Oas3Types).Root,
});

const outerRef = resolvedRefs.get('foobar.yaml::#/first')!;
expect(outerRef.node).toEqual({ contact: {} });
expect(outerRef.chain!.map((hop) => hop.location.pointer)).toEqual(['#/first', '#/second']);

// the inner resolution must not be polluted by outer hops
const innerRef = resolvedRefs.get('foobar.yaml::#/second')!;
expect(innerRef.node).toEqual({ contact: {} });
expect(innerRef.chain!.map((hop) => hop.location.pointer)).toEqual(['#/second']);
});

it('should throw error if ref is folder', async () => {
const cwd = path.join(__dirname, 'fixtures/resolve');
const rootDocument = parseYamlToDocument(
Expand Down
Loading
Loading