Support alternative security scope groups (#3976)

This commit is contained in:
Nolann B.
2026-03-02 17:04:31 +01:00
committed by GitHub
parent 4a6f91a19d
commit 8621c7f571
4 changed files with 176 additions and 30 deletions
@@ -1047,4 +1047,12 @@ body:has(.openapi-select-popover) {
.openapi-required-scopes .openapi-required-scopes-description {
@apply text-xs !text-tint font-normal mb-2;
}
.openapi-schema-alternatives .openapi-securities-scopes {
@apply ml-0 pl-0;
}
.openapi-scopes-alternatives .openapi-schema-alternatives {
@apply flex flex-col gap-2;
}
@@ -24,11 +24,19 @@ export function OpenAPIRequiredScopes(props: {
return null;
}
const scopes = selectedSecurity.schemes.flatMap((scheme) => {
return scheme.scopes ?? [];
});
const scopeAlternatives =
selectedSecurity.scopeAlternatives.length > 0
? selectedSecurity.scopeAlternatives
: [
selectedSecurity.schemes.flatMap((scheme) => {
return scheme.scopes ?? [];
}),
];
const resolvedAlternatives = scopeAlternatives
.map((scopes) => dedupeScopes(scopes))
.filter((scopes) => scopes.length > 0);
if (!scopes.length) {
if (!resolvedAlternatives.length) {
return null;
}
@@ -51,7 +59,12 @@ export function OpenAPIRequiredScopes(props: {
{
key: 'scopes',
label: '',
body: <OpenAPISchemaScopes scopes={scopes} context={context} />,
body: (
<OpenAPIScopeAlternatives
alternatives={resolvedAlternatives}
context={context}
/>
),
},
],
},
@@ -60,19 +73,54 @@ export function OpenAPIRequiredScopes(props: {
);
}
export function OpenAPISchemaScopes(props: {
scopes: OpenAPISecurityScope[];
function OpenAPIScopeAlternatives(props: {
alternatives: OpenAPISecurityScope[][];
context: OpenAPIClientContext;
}) {
const { scopes, context } = props;
const { alternatives, context } = props;
if (alternatives.length === 1) {
return <OpenAPISchemaScopes scopes={alternatives[0]} context={context} />;
}
return (
<div className="openapi-securities-scopes openapi-markdown">
<div className="openapi-scopes-alternatives">
<div className="openapi-required-scopes-description">
{t(context.translation, 'required_scopes_description')}
</div>
<div className="openapi-schema-alternatives">
{alternatives.map((scopes, index) => (
<div key={index} className="openapi-schema-alternative">
<OpenAPISchemaScopes scopes={scopes} context={context} hideDescription />
{index < alternatives.length - 1 ? (
<span className="openapi-schema-alternative-separator">
{t(context.translation, 'or')}
</span>
) : null}
</div>
))}
</div>
</div>
);
}
export function OpenAPISchemaScopes(props: {
scopes: OpenAPISecurityScope[] | undefined;
context: OpenAPIClientContext;
isOAuth2?: boolean;
hideDescription?: boolean;
}) {
const { scopes, context, hideDescription } = props;
return (
<div className="openapi-securities-scopes openapi-markdown">
{!hideDescription ? (
<div className="openapi-required-scopes-description">
{t(context.translation, 'required_scopes_description')}
</div>
) : null}
<ul>
{scopes.map((scope) => (
{scopes?.map((scope) => (
<OpenAPIScopeItem key={scope[0]} scope={scope} context={context} />
))}
</ul>
@@ -112,3 +160,18 @@ function OpenAPIScopeItemKey(props: {
</OpenAPICopyButton>
);
}
function dedupeScopes(scopes: OpenAPISecurityScope[]) {
const seen = new Set<string>();
const deduped: OpenAPISecurityScope[] = [];
for (const scope of scopes) {
if (seen.has(scope[0])) {
continue;
}
seen.add(scope[0]);
deduped.push(scope);
}
return deduped;
}
@@ -50,7 +50,7 @@ export async function resolveOpenAPIOperation(
const flatSecurities = flattenSecurities(security);
// Resolve securities
const securities: OpenAPIOperationData['securities'] = [];
const securitiesMap = new Map<string, OpenAPIOperationData['securities'][number][1]>();
for (const entry of flatSecurities) {
const [securityKey, operationScopes] = Object.entries(entry)[0] ?? [];
if (securityKey) {
@@ -59,14 +59,13 @@ export async function resolveOpenAPIOperation(
securityScheme,
operationScopes,
});
securities.push([
securityKey,
{
...securityScheme,
required: !isOptionalSecurity,
scopes,
},
]);
const existing = securitiesMap.get(securityKey);
const mergedScopes = mergeSecurityScopes(existing?.scopes ?? null, scopes);
securitiesMap.set(securityKey, {
...securityScheme,
required: !isOptionalSecurity,
scopes: mergedScopes,
});
}
}
@@ -75,7 +74,7 @@ export async function resolveOpenAPIOperation(
operation: { ...operation, security },
method,
path,
securities,
securities: Array.from(securitiesMap.entries()),
'x-codeSamples':
typeof schema['x-codeSamples'] === 'boolean' ? schema['x-codeSamples'] : undefined,
'x-hideTryItPanel':
@@ -199,6 +198,31 @@ function resolveSecurityScopes({
return operationScopes.map((scope) => [scope, undefined]);
}
function mergeSecurityScopes(
existing: OpenAPISecurityScope[] | null,
incoming: OpenAPISecurityScope[] | null
): OpenAPISecurityScope[] | null {
if (!existing?.length) {
return incoming;
}
if (!incoming?.length) {
return existing;
}
const seen = new Set<string>();
const merged: OpenAPISecurityScope[] = [];
for (const scope of [...existing, ...incoming]) {
if (seen.has(scope[0])) {
continue;
}
seen.add(scope[0]);
merged.push(scope);
}
return merged;
}
/**
* Check if a security scheme is an OAuth or OpenID Connect security scheme.
*/
+61 -10
View File
@@ -2,7 +2,11 @@ import type { AnyObject, OpenAPIV3, OpenAPIV3_1 } from '@gitbook/openapi-parser'
import type { OpenAPIUniversalContext } from './context';
import { stringifyOpenAPI } from './stringifyOpenAPI';
import { tString } from './translate';
import type { OpenAPICustomSecurityScheme, OpenAPIOperationData } from './types';
import type {
OpenAPICustomSecurityScheme,
OpenAPIOperationData,
OpenAPISecurityScope,
} from './types';
export function checkIsReference(input: unknown): input is OpenAPIV3.ReferenceObject {
return typeof input === 'object' && !!input && '$ref' in input;
@@ -327,6 +331,7 @@ export type OperationSecurityInfo = {
key: string;
label: string;
schemes: OpenAPICustomSecurityScheme[];
scopeAlternatives: OpenAPISecurityScope[][];
};
/**
@@ -345,18 +350,64 @@ export function extractOperationSecurityInfo(args: {
key,
label: key,
schemes: [security],
scopeAlternatives: security.scopes?.length ? [security.scopes] : [],
}));
}
return securityRequirement.map((requirement, idx) => {
const schemeKeys = Object.keys(requirement);
const grouped = new Map<string, OperationSecurityInfo>();
return {
key: `security-${idx}`,
label: schemeKeys.join(' & '),
schemes: schemeKeys
.map((schemeKey) => securitiesMap.get(schemeKey))
.filter((s): s is OpenAPICustomSecurityScheme => s !== undefined),
};
securityRequirement.forEach((requirement) => {
const schemeKeys = Object.keys(requirement).sort();
if (schemeKeys.length === 0) {
return;
}
const label = schemeKeys.join(' & ');
const existing = grouped.get(label);
const schemes = schemeKeys
.map((schemeKey) => securitiesMap.get(schemeKey))
.filter((s): s is OpenAPICustomSecurityScheme => s !== undefined);
const scopesForRequirement = schemeKeys.flatMap((schemeKey) =>
resolveRequiredScopesForScheme(securitiesMap.get(schemeKey), requirement[schemeKey])
);
if (existing) {
existing.scopeAlternatives.push(scopesForRequirement);
if (!existing.schemes.length && schemes.length) {
existing.schemes = schemes;
}
} else {
grouped.set(label, {
key: `security-${grouped.size}`,
label,
schemes,
scopeAlternatives: [scopesForRequirement],
});
}
});
return Array.from(grouped.values());
}
function resolveRequiredScopesForScheme(
security: OpenAPICustomSecurityScheme | undefined,
operationScopes: string[] | undefined
): OpenAPISecurityScope[] {
if (!security || !operationScopes?.length) {
return [];
}
if (security.type === 'oauth2') {
const flows = security.flows ? Object.entries(security.flows) : [];
const resolved = flows.flatMap(([_, flow]) => {
return Object.entries(flow.scopes ?? {}).filter(([scope]) =>
operationScopes.includes(scope)
);
});
if (resolved.length) {
return resolved;
}
}
return operationScopes.map((scope) => [scope, undefined]);
}