From 8621c7f571c609716769b2e80bc4b18cefbf2a93 Mon Sep 17 00:00:00 2001 From: "Nolann B." <100787331+nolannbiron@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:04:31 +0100 Subject: [PATCH] Support alternative security scope groups (#3976) --- .../components/DocumentView/OpenAPI/style.css | 8 ++ .../src/OpenAPIRequiredScopes.tsx | 83 ++++++++++++++++--- .../src/resolveOpenAPIOperation.ts | 44 +++++++--- packages/react-openapi/src/utils.ts | 71 +++++++++++++--- 4 files changed, 176 insertions(+), 30 deletions(-) diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css index 3c5b52e4a..c0d3da3d2 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css @@ -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; } \ No newline at end of file diff --git a/packages/react-openapi/src/OpenAPIRequiredScopes.tsx b/packages/react-openapi/src/OpenAPIRequiredScopes.tsx index 86fc84ba6..a2261ffa8 100644 --- a/packages/react-openapi/src/OpenAPIRequiredScopes.tsx +++ b/packages/react-openapi/src/OpenAPIRequiredScopes.tsx @@ -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: , + body: ( + + ), }, ], }, @@ -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 ; + } return ( -
+
{t(context.translation, 'required_scopes_description')}
+
+ {alternatives.map((scopes, index) => ( +
+ + {index < alternatives.length - 1 ? ( + + {t(context.translation, 'or')} + + ) : null} +
+ ))} +
+
+ ); +} + +export function OpenAPISchemaScopes(props: { + scopes: OpenAPISecurityScope[] | undefined; + context: OpenAPIClientContext; + isOAuth2?: boolean; + hideDescription?: boolean; +}) { + const { scopes, context, hideDescription } = props; + + return ( +
+ {!hideDescription ? ( +
+ {t(context.translation, 'required_scopes_description')} +
+ ) : null}
    - {scopes.map((scope) => ( + {scopes?.map((scope) => ( ))}
@@ -112,3 +160,18 @@ function OpenAPIScopeItemKey(props: { ); } + +function dedupeScopes(scopes: OpenAPISecurityScope[]) { + const seen = new Set(); + const deduped: OpenAPISecurityScope[] = []; + + for (const scope of scopes) { + if (seen.has(scope[0])) { + continue; + } + seen.add(scope[0]); + deduped.push(scope); + } + + return deduped; +} diff --git a/packages/react-openapi/src/resolveOpenAPIOperation.ts b/packages/react-openapi/src/resolveOpenAPIOperation.ts index 48d93652f..4918a694a 100644 --- a/packages/react-openapi/src/resolveOpenAPIOperation.ts +++ b/packages/react-openapi/src/resolveOpenAPIOperation.ts @@ -50,7 +50,7 @@ export async function resolveOpenAPIOperation( const flatSecurities = flattenSecurities(security); // Resolve securities - const securities: OpenAPIOperationData['securities'] = []; + const securitiesMap = new Map(); 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(); + 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. */ diff --git a/packages/react-openapi/src/utils.ts b/packages/react-openapi/src/utils.ts index 72b3ca60b..ff294ec26 100644 --- a/packages/react-openapi/src/utils.ts +++ b/packages/react-openapi/src/utils.ts @@ -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(); - 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]); }