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/ref-utils.ts b/packages/core/src/ref-utils.ts index 14cebd115f..2d5088b76c 100644 --- a/packages/core/src/ref-utils.ts +++ b/packages/core/src/ref-utils.ts @@ -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'; @@ -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 8f7ef2af63..a23fc9c7ea 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'; @@ -353,12 +353,16 @@ export function walkDocument(opts: { propType = { name: 'scalar', properties: {} }; } - if (isRef(node[propName]) && propType?.name === 'scalar') { + if (!isNamedType(propType)) { + continue; + } + + if (nodeIsRef && refHasSibling(node, propName)) { 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..4a44fca274 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 = 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 = 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..9e1221b50d --- /dev/null +++ b/tests/e2e/bundle/ref-siblings/snapshot.txt @@ -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 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..502c2df794 --- /dev/null +++ b/tests/e2e/bundle/ref-siblings/snapshot_2.txt @@ -0,0 +1,75 @@ +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: + - name: Referenced model + in: query + required: false + schema: &ref_0 + type: string + responses: + '200': + description: 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: + description: 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 +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.