Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/curvy-ghosts-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@redocly/cli': patch
---

Fixed the `join` command to apply root-level `security` from each joined API to operations that do not define their own `security`.
16 changes: 14 additions & 2 deletions packages/cli/src/commands/join/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
writeToFileByExtension,
} from '../../utils/miscellaneous.js';
import type { CommandArgs } from '../../wrapper.js';
import { COMPONENTS } from '../split/constants.js';
// import { COMPONENTS } from '../split/constants.js';
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Commented-out import instead of removal

Low Severity

The COMPONENTS import is commented out (// import { COMPONENTS } from ...) rather than deleted. This constant is no longer used in index.ts because the new getEffectiveComponentsPrefix utility handles it internally. The commented-out line is dead code that appears to be left over from development.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b647fd9. Configure here.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's remove comments.

import type { JoinArgv, AnyOas3Definition } from './types.js';
import {
replace$Refs,
Expand All @@ -39,6 +39,8 @@ import {
collectComponents,
collectWebhooks,
addInfoSectionAndSpecVersion,
collectDuplicateSecuritySchemeNames,
getEffectiveComponentsPrefix,
} from './utils/index.js';

export async function handleJoin({
Expand Down Expand Up @@ -174,6 +176,10 @@ export async function handleJoin({
joinedDef.servers = first.parsed.servers;
}

const duplicateSecuritySchemeNames = collectDuplicateSecuritySchemeNames(
documents as Document<AnyOas3Definition>[]
);

for (const document of documents) {
const openapi = isPlainObject<AnyOas3Definition>(document.parsed)
? document.parsed
Expand All @@ -184,7 +190,12 @@ export async function handleJoin({
const tagsPrefix = prefixTagsWithFilename
? apiFilename
: getInfoPrefix(info, prefixTagsWithInfoProp, 'tags');
const componentsPrefix = getInfoPrefix(info, prefixComponentsWithInfoProp, COMPONENTS);
const componentsPrefix = getEffectiveComponentsPrefix({
info,
apiFilename,
prefixComponentsWithInfoProp,
duplicateSecuritySchemeNames,
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Info description prefix diverges from main component prefix

Low Severity

addInfoSectionAndSpecVersion computes its componentsPrefix via getInfoPrefix, which only returns a prefix when prefixComponentsWithInfoProp is explicitly provided. The main loop now uses getEffectiveComponentsPrefix, which can auto-generate a prefix when duplicate security scheme names exist. This means component references in the first API's info.description text won't be rewritten to match the auto-prefixed component names, leaving stale references.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 162da39. Configure here.


if (openapi.hasOwnProperty('x-tagGroups')) {
logger.warn(`warning: x-tagGroups at ${blue(api)} will be skipped \n`);
Expand All @@ -203,6 +214,7 @@ export async function handleJoin({
if (tags) {
populateTags({ joinedDef, withoutXTagGroups, context });
}

collectExternalDocs({ joinedDef, openapi, context });
collectPaths({ joinedDef, withoutXTagGroups, openapi, context, serversAreTheSame });
collectComponents({ joinedDef, openapi, context });
Expand Down
23 changes: 11 additions & 12 deletions packages/cli/src/commands/join/utils/collect-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ import { OPENAPI3_METHOD_NAMES } from '../../split/oas/constants.js';
import { type Oas3Method } from '../../split/types.js';
import type { AnyOas3Definition, JoinDocumentContext } from '../types.js';
import { addPrefix } from './add-prefix.js';
import { addSecurityPrefix } from './add-security-prefix.js';
import { formatTags } from './format-tags.js';
import { populateTags } from './populate-tags.js';
import { resolveOperationSecurity } from './resolve-operation-security.js';

export function collectPaths({
joinedDef,
Expand Down Expand Up @@ -181,7 +181,7 @@ export function collectPaths({
];
}

const { tags, security } = joinedDef.paths[path][operation];
const { tags } = joinedDef.paths[path][operation];

if (tags) {
joinedDef.paths[path][operation].tags = tags.map((tag: string) => addPrefix(tag, tagsPrefix));
Expand Down Expand Up @@ -216,16 +216,15 @@ export function collectPaths({
},
});
}
if (!security && openapi.hasOwnProperty('security')) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why the previous solution was not working? I see it refers to the root security already.

joinedDef.paths[path][operation]['security'] = addSecurityPrefix(
openapi.security,
componentsPrefix!
);
} else if (pathOperation.security) {
joinedDef.paths[path][operation].security = addSecurityPrefix(
pathOperation.security,
componentsPrefix!
);
const operationSecurity = resolveOperationSecurity({
pathItem,
pathOperation,
openapi,
componentsPrefix,
});

if (operationSecurity) {
joinedDef.paths[path][operation].security = operationSecurity;
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { type Document, type Oas3PathItem, isPlainObject, keysOf } from '@redocly/openapi-core';

import { COMPONENTS } from '../../split/constants.js';
import { OPENAPI3_METHOD_NAMES } from '../../split/oas/constants.js';
import { type Oas3Method } from '../../split/types.js';
import type { AnyOas3Definition } from '../types.js';
import { getInfoPrefix } from './get-info-prefix.js';

const operationsSet = new Set(OPENAPI3_METHOD_NAMES);

function collectSecuritySchemeNamesFromApi(openapi: AnyOas3Definition): Set<string> {
const names = new Set<string>();

const addFromSecurity = (security: AnyOas3Definition['security']) => {
if (!security) {
return;
}
for (const requirement of security) {
for (const schemeName of Object.keys(requirement)) {
names.add(schemeName);
}
}
};

addFromSecurity(openapi.security);

for (const pathItem of Object.values(openapi.paths ?? {})) {
if (!pathItem || !isPlainObject(pathItem)) {
continue;
}
Comment thread
cursor[bot] marked this conversation as resolved.

const pathItemObject = pathItem as Oas3PathItem;

addFromSecurity(
(pathItemObject as Oas3PathItem & { security?: AnyOas3Definition['security'] }).security
);

for (const field of keysOf(pathItemObject)) {
if (operationsSet.has(field as Oas3Method)) {
addFromSecurity(pathItemObject[field as Oas3Method]?.security);
}
}
}

for (const schemeName of Object.keys(openapi.components?.securitySchemes ?? {})) {
names.add(schemeName);
}

return names;
}

export function collectDuplicateSecuritySchemeNames(
documents: Document<AnyOas3Definition>[]
): Set<string> {
const apisPerScheme = new Map<string, number>();

for (const document of documents) {
const schemeNames = collectSecuritySchemeNamesFromApi(document.parsed);

for (const name of schemeNames) {
apisPerScheme.set(name, (apisPerScheme.get(name) ?? 0) + 1);
}
}

return new Set(
[...apisPerScheme.entries()].filter(([, apiCount]) => apiCount > 1).map(([name]) => name)
);
}

export function getEffectiveComponentsPrefix({
info,
apiFilename,
prefixComponentsWithInfoProp,
duplicateSecuritySchemeNames,
}: {
info: AnyOas3Definition['info'];
apiFilename: string;
prefixComponentsWithInfoProp?: string;
duplicateSecuritySchemeNames: Set<string>;
}) {
const explicitPrefix = getInfoPrefix(info, prefixComponentsWithInfoProp, COMPONENTS);
if (explicitPrefix) {
return explicitPrefix;
}

if (!duplicateSecuritySchemeNames.size) {
return '';
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Auto-prefixing applies to all APIs, not just conflicting ones

Medium Severity

getEffectiveComponentsPrefix checks duplicateSecuritySchemeNames.size which is a global set of all duplicate scheme names across all APIs. When any pair of APIs shares a security scheme name, every API in the join gets auto-prefixed — including APIs that have no conflicting scheme names at all. For example, joining three APIs where A and B both define oauth2 but C only defines a unique apiKey, API C's components and $refs would be unnecessarily rewritten with a prefix. The check needs to verify whether the current API's scheme names intersect with duplicateSecuritySchemeNames, not just whether the global set is non-empty.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 162da39. Configure here.


const raw = info?.title ?? apiFilename;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nullish coalescing fails for empty title string

Low Severity

info?.title ?? apiFilename uses nullish coalescing (??), which only falls back to apiFilename when title is null or undefined. If info.title is an empty string '', the fallback is skipped and the function returns '' — a falsy prefix that silently disables all conflict resolution for duplicate security scheme names. Using || instead would correctly fall back to apiFilename for empty strings.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5bc9605. Configure here.

return raw.replaceAll(/\s/g, '_');
}
2 changes: 2 additions & 0 deletions packages/cli/src/commands/join/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export * from './replace-$-refs.js';
export * from './get-info-prefix.js';
export * from './add-security-prefix.js';
export * from './resolve-operation-security.js';
export * from './get-effective-components-prefix.js';
export * from './add-prefix.js';
export * from './add-components-prefix.js';
export * from './format-tags.js';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { Oas3PathItem } from '@redocly/openapi-core';

import { type Oas3Method } from '../../split/types.js';
import type { AnyOas3Definition } from '../types.js';
import { addSecurityPrefix } from './add-security-prefix.js';

export function resolveOperationSecurity({
pathItem,
pathOperation,
openapi,
componentsPrefix,
}: {
pathItem: Oas3PathItem;
pathOperation: NonNullable<Oas3PathItem[Oas3Method]>;
openapi: AnyOas3Definition;
componentsPrefix: string | undefined;
}) {
if (pathOperation.hasOwnProperty('security')) {
return addSecurityPrefix(pathOperation.security, componentsPrefix!);
}

if (pathItem.hasOwnProperty('security')) {
return addSecurityPrefix(
(pathItem as Oas3PathItem & { security: AnyOas3Definition['security'] }).security,
componentsPrefix!
);
}

if (openapi.hasOwnProperty('security')) {
return addSecurityPrefix(openapi.security, componentsPrefix!);
}

return undefined;
}
25 changes: 25 additions & 0 deletions tests/e2e/join/duplicate-scheme-operation-level/bar.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
openapi: 3.1.0
info:
title: spec2
version: 1.0.0
servers:
- url: https://api.example.com
components:
securitySchemes:
oauth2:
type: oauth2
flows:
authorizationCode:
authorizationUrl: https://example.com/oauth/authorize2
tokenUrl: https://example.com/oauth/token2
scopes: {}
paths:
/orders:
get:
summary: Orders from spec2
operationId: getOrdersSpec2
security:
- oauth2: []
responses:
'200':
description: OK
25 changes: 25 additions & 0 deletions tests/e2e/join/duplicate-scheme-operation-level/foo.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
openapi: 3.1.0
info:
title: spec1
version: 1.0.0
servers:
- url: https://api.example.com
components:
securitySchemes:
oauth2:
type: oauth2
flows:
authorizationCode:
authorizationUrl: https://example.com/oauth/authorize1
tokenUrl: https://example.com/oauth/token1
scopes: {}
paths:
/pets:
get:
summary: Pets from spec1
operationId: getPetsSpec1
security:
- oauth2: []
responses:
'200':
description: OK
61 changes: 61 additions & 0 deletions tests/e2e/join/duplicate-scheme-operation-level/snapshot.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@

openapi: 3.1.0
info:
title: spec1
version: 1.0.0
servers:
- url: https://api.example.com
tags:
- name: foo_other
x-displayName: other
- name: bar_other
x-displayName: other
paths:
/pets:
get:
summary: Pets from spec1
operationId: getPetsSpec1
security:
- spec1_oauth2: []
responses:
'200':
description: OK
tags:
- foo_other
/orders:
get:
summary: Orders from spec2
operationId: getOrdersSpec2
security:
- spec2_oauth2: []
responses:
'200':
description: OK
tags:
- bar_other
components:
securitySchemes:
spec1_oauth2:
type: oauth2
flows:
authorizationCode:
authorizationUrl: https://example.com/oauth/authorize1
tokenUrl: https://example.com/oauth/token1
scopes: {}
spec2_oauth2:
type: oauth2
flows:
authorizationCode:
authorizationUrl: https://example.com/oauth/authorize2
tokenUrl: https://example.com/oauth/token2
scopes: {}
x-tagGroups:
- name: spec1
tags:
- foo_other
- name: spec2
tags:
- bar_other

openapi.yaml: join processed in <test>ms

25 changes: 25 additions & 0 deletions tests/e2e/join/duplicate-scheme-root-security/bar.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
openapi: 3.1.0
info:
title: spec2
version: 1.0.0
servers:
- url: https://api.example.com
security:
- oauth2: []
components:
securitySchemes:
oauth2:
type: oauth2
flows:
authorizationCode:
authorizationUrl: https://example.com/oauth/authorize2
tokenUrl: https://example.com/oauth/token2
scopes: {}
paths:
/orders:
get:
summary: Orders from spec2
operationId: getOrdersSpec2
responses:
'200':
description: OK
Loading
Loading