diff --git a/.changeset/dull-adults-punch.md b/.changeset/dull-adults-punch.md new file mode 100644 index 0000000000..5dd3637b26 --- /dev/null +++ b/.changeset/dull-adults-punch.md @@ -0,0 +1,5 @@ +--- +'@redocly/cli': patch +--- + +Fixed an issue where the `join` command silently dropped path-level `x-*` extensions with non-string values. diff --git a/packages/cli/src/__tests__/commands/join.test.ts b/packages/cli/src/__tests__/commands/join.test.ts index bf8dea26bc..94301d08bd 100644 --- a/packages/cli/src/__tests__/commands/join.test.ts +++ b/packages/cli/src/__tests__/commands/join.test.ts @@ -3,6 +3,7 @@ import { detectSpec, getTotals, loadConfig, + logger, type Document, BaseResolver, } from '@redocly/openapi-core'; @@ -24,6 +25,10 @@ import { thirdDocument, serverAndPaths, anotherServerAndPaths, + pathWithObjectExtension, + anotherPathWithSameObjectExtension, + anotherPathWithDifferentObjectExtension, + anotherPathWithDifferentKeyObjectExtension, } from '../fixtures/join/documents.js'; describe('handleJoin', () => { @@ -466,6 +471,103 @@ describe('handleJoin', () => { }); }); + describe('path extensions', () => { + it('should merge non-string x-* path extension when values are the same', async () => { + vi.mocked(detectSpec).mockReturnValue('oas3_0'); + const warnSpy = vi.spyOn(logger, 'warn'); + vi.spyOn(BaseResolver.prototype, 'resolveDocument') + .mockReset() + .mockImplementationOnce(() => + Promise.resolve({ + source: { absoluteRef: 'ref-a' }, + parsed: pathWithObjectExtension, + } as Document) + ) + .mockImplementationOnce(() => + Promise.resolve({ + source: { absoluteRef: 'ref-b' }, + parsed: anotherPathWithSameObjectExtension, + } as Document) + ); + + await handleJoin({ + argv: { + apis: ['a.yaml', 'b.yaml'], + }, + config: configFixture, + version: 'cli-version', + }); + + const joinedDef = writeToFileByExtensionSpy.mock.calls[0][0]; + expect(joinedDef.paths['/foo']['x-metadata']).toEqual({ owner: 'team-a' }); + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('different x-metadata values') + ); + }); + + it('should warn when non-string x-* path extension values differ', async () => { + vi.mocked(detectSpec).mockReturnValue('oas3_0'); + const warnSpy = vi.spyOn(logger, 'warn'); + vi.spyOn(BaseResolver.prototype, 'resolveDocument') + .mockReset() + .mockImplementationOnce(() => + Promise.resolve({ + source: { absoluteRef: 'ref-a' }, + parsed: pathWithObjectExtension, + } as Document) + ) + .mockImplementationOnce(() => + Promise.resolve({ + source: { absoluteRef: 'ref-b' }, + parsed: anotherPathWithDifferentObjectExtension, + } as Document) + ); + + await handleJoin({ + argv: { + apis: ['a.yaml', 'b.yaml'], + }, + config: configFixture, + version: 'cli-version', + }); + + const joinedDef = writeToFileByExtensionSpy.mock.calls[0][0]; + expect(joinedDef.paths['/foo']['x-metadata']).toEqual({ owner: 'team-a' }); + expect(warnSpy).toHaveBeenCalledWith('warning: different x-metadata values in /foo\n'); + }); + + it('should warn when non-string x-* path extension objects have different keys', async () => { + vi.mocked(detectSpec).mockReturnValue('oas3_0'); + const warnSpy = vi.spyOn(logger, 'warn'); + vi.spyOn(BaseResolver.prototype, 'resolveDocument') + .mockReset() + .mockImplementationOnce(() => + Promise.resolve({ + source: { absoluteRef: 'ref-a' }, + parsed: pathWithObjectExtension, + } as Document) + ) + .mockImplementationOnce(() => + Promise.resolve({ + source: { absoluteRef: 'ref-b' }, + parsed: anotherPathWithDifferentKeyObjectExtension, + } as Document) + ); + + await handleJoin({ + argv: { + apis: ['a.yaml', 'b.yaml'], + }, + config: configFixture, + version: 'cli-version', + }); + + const joinedDef = writeToFileByExtensionSpy.mock.calls[0][0]; + expect(joinedDef.paths['/foo']['x-metadata']).toEqual({ owner: 'team-a' }); + expect(warnSpy).toHaveBeenCalledWith('warning: different x-metadata values in /foo\n'); + }); + }); + describe('replace$Refs', () => { it('should prefix discriminator mapping refs when schema name contains prefix substring', () => { const doc = { diff --git a/packages/cli/src/__tests__/fixtures/join/documents.ts b/packages/cli/src/__tests__/fixtures/join/documents.ts index 0da63a113d..40d076aee8 100644 --- a/packages/cli/src/__tests__/fixtures/join/documents.ts +++ b/packages/cli/src/__tests__/fixtures/join/documents.ts @@ -162,3 +162,78 @@ export const anotherServerAndPaths = { }, }, }; + +export const pathWithObjectExtension = { + openapi: '3.0.0', + info: { + title: 'First API', + version: '1.0.0', + }, + servers: [ + { + url: 'https://foo.com/api/v1/first', + }, + ], + paths: { + '/foo': { + 'x-metadata': { owner: 'team-a' }, + get: { + summary: 'Get Foo', + }, + }, + }, +}; + +export const anotherPathWithSameObjectExtension = { + openapi: '3.0.0', + info: { + title: 'Second API', + version: '1.0.0', + }, + servers: [ + { + url: 'https://foo.com/api/v1/second', + }, + ], + paths: { + '/foo': { + 'x-metadata': { owner: 'team-a' }, + }, + }, +}; + +export const anotherPathWithDifferentObjectExtension = { + openapi: '3.0.0', + info: { + title: 'Second API', + version: '1.0.0', + }, + servers: [ + { + url: 'https://foo.com/api/v1/second', + }, + ], + paths: { + '/foo': { + 'x-metadata': { owner: 'team-b' }, + }, + }, +}; + +export const anotherPathWithDifferentKeyObjectExtension = { + openapi: '3.0.0', + info: { + title: 'Second API', + version: '1.0.0', + }, + servers: [ + { + url: 'https://foo.com/api/v1/second', + }, + ], + paths: { + '/foo': { + 'x-metadata': { team: 'x' }, + }, + }, +}; diff --git a/packages/cli/src/commands/join/utils/collect-paths.ts b/packages/cli/src/commands/join/utils/collect-paths.ts index 3a7ef2d4d6..923d4c0956 100644 --- a/packages/cli/src/commands/join/utils/collect-paths.ts +++ b/packages/cli/src/commands/join/utils/collect-paths.ts @@ -68,7 +68,7 @@ export function collectPaths({ if (field === 'parameters') { collectPathParameters(pathItem, path); } - if (typeof pathItem[field] === 'string') { + if (typeof pathItem[field] === 'string' || field.startsWith('x-')) { collectPathStringFields(pathItem, path, field); } } @@ -83,7 +83,7 @@ export function collectPaths({ const fieldValue = pathItem[field]; if ( joinedDef.paths[path].hasOwnProperty(field) && - joinedDef.paths[path][field] !== fieldValue + !dequal(joinedDef.paths[path][field], fieldValue) ) { logger.warn(`warning: different ${field} values in ${path}\n`); return;