diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css index e55d75e5dc..c813da2a75 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css @@ -1039,4 +1039,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 6bef0def09..1b36e978fe 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,23 +73,57 @@ export function OpenAPIRequiredScopes(props: { ); } +function OpenAPIScopeAlternatives(props: { + alternatives: OpenAPISecurityScope[][]; + context: OpenAPIClientContext; +}) { + 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[]; + scopes: OpenAPISecurityScope[] | undefined; context: OpenAPIClientContext; isOAuth2?: boolean; + hideDescription?: boolean; }) { - const { scopes, context, isOAuth2 } = props; + const { scopes, context, isOAuth2, hideDescription } = props; return (
-
- {t( - context.translation, - isOAuth2 ? 'available_scopes' : 'required_scopes_description' - )} -
+ {!hideDescription ? ( +
+ {t( + context.translation, + isOAuth2 ? 'available_scopes' : 'required_scopes_description' + )} +
+ ) : null}
    - {scopes.map((scope) => ( + {scopes?.map((scope) => ( ))}
@@ -116,3 +163,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 9a7c1567db..561b61ae9d 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': @@ -176,6 +175,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 72b3ca60b4..3d35d080f8 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,61 @@ 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); + 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]); }