Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/dull-adults-punch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@redocly/cli': patch
---

Fixed an issue where the `join` command silently dropped path-level `x-*` extensions with non-string values.
102 changes: 102 additions & 0 deletions packages/cli/src/__tests__/commands/join.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
detectSpec,
getTotals,
loadConfig,
logger,
type Document,
BaseResolver,
} from '@redocly/openapi-core';
Expand All @@ -24,6 +25,10 @@ import {
thirdDocument,
serverAndPaths,
anotherServerAndPaths,
pathWithObjectExtension,
anotherPathWithSameObjectExtension,
anotherPathWithDifferentObjectExtension,
anotherPathWithDifferentKeyObjectExtension,
} from '../fixtures/join/documents.js';

describe('handleJoin', () => {
Expand Down Expand Up @@ -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 () => {
Comment thread
kanoru3101 marked this conversation as resolved.
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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What if the object had different keys though? Like a.yaml has owner and b.yaml has team?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

A warning log is being output.
I think we'll just have to handle this the same way we do other standard items.

If this part overlaps, it means the paths also overlap.

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.

@nonchan7720 Please add a test for this case { owner: 'team-a' } and { team: 'x' }.

} 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 = {
Expand Down
75 changes: 75 additions & 0 deletions packages/cli/src/__tests__/fixtures/join/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
},
},
};
4 changes: 2 additions & 2 deletions packages/cli/src/commands/join/utils/collect-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand All @@ -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;
Expand Down
Loading