Skip to content

Commit 8621c7f

Browse files
authored
Support alternative security scope groups (#3976)
1 parent 4a6f91a commit 8621c7f

4 files changed

Lines changed: 176 additions & 30 deletions

File tree

packages/gitbook/src/components/DocumentView/OpenAPI/style.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,4 +1047,12 @@ body:has(.openapi-select-popover) {
10471047

10481048
.openapi-required-scopes .openapi-required-scopes-description {
10491049
@apply text-xs !text-tint font-normal mb-2;
1050+
}
1051+
1052+
.openapi-schema-alternatives .openapi-securities-scopes {
1053+
@apply ml-0 pl-0;
1054+
}
1055+
1056+
.openapi-scopes-alternatives .openapi-schema-alternatives {
1057+
@apply flex flex-col gap-2;
10501058
}

packages/react-openapi/src/OpenAPIRequiredScopes.tsx

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,19 @@ export function OpenAPIRequiredScopes(props: {
2424
return null;
2525
}
2626

27-
const scopes = selectedSecurity.schemes.flatMap((scheme) => {
28-
return scheme.scopes ?? [];
29-
});
27+
const scopeAlternatives =
28+
selectedSecurity.scopeAlternatives.length > 0
29+
? selectedSecurity.scopeAlternatives
30+
: [
31+
selectedSecurity.schemes.flatMap((scheme) => {
32+
return scheme.scopes ?? [];
33+
}),
34+
];
35+
const resolvedAlternatives = scopeAlternatives
36+
.map((scopes) => dedupeScopes(scopes))
37+
.filter((scopes) => scopes.length > 0);
3038

31-
if (!scopes.length) {
39+
if (!resolvedAlternatives.length) {
3240
return null;
3341
}
3442

@@ -51,7 +59,12 @@ export function OpenAPIRequiredScopes(props: {
5159
{
5260
key: 'scopes',
5361
label: '',
54-
body: <OpenAPISchemaScopes scopes={scopes} context={context} />,
62+
body: (
63+
<OpenAPIScopeAlternatives
64+
alternatives={resolvedAlternatives}
65+
context={context}
66+
/>
67+
),
5568
},
5669
],
5770
},
@@ -60,19 +73,54 @@ export function OpenAPIRequiredScopes(props: {
6073
);
6174
}
6275

63-
export function OpenAPISchemaScopes(props: {
64-
scopes: OpenAPISecurityScope[];
76+
function OpenAPIScopeAlternatives(props: {
77+
alternatives: OpenAPISecurityScope[][];
6578
context: OpenAPIClientContext;
6679
}) {
67-
const { scopes, context } = props;
80+
const { alternatives, context } = props;
81+
82+
if (alternatives.length === 1) {
83+
return <OpenAPISchemaScopes scopes={alternatives[0]} context={context} />;
84+
}
6885

6986
return (
70-
<div className="openapi-securities-scopes openapi-markdown">
87+
<div className="openapi-scopes-alternatives">
7188
<div className="openapi-required-scopes-description">
7289
{t(context.translation, 'required_scopes_description')}
7390
</div>
91+
<div className="openapi-schema-alternatives">
92+
{alternatives.map((scopes, index) => (
93+
<div key={index} className="openapi-schema-alternative">
94+
<OpenAPISchemaScopes scopes={scopes} context={context} hideDescription />
95+
{index < alternatives.length - 1 ? (
96+
<span className="openapi-schema-alternative-separator">
97+
{t(context.translation, 'or')}
98+
</span>
99+
) : null}
100+
</div>
101+
))}
102+
</div>
103+
</div>
104+
);
105+
}
106+
107+
export function OpenAPISchemaScopes(props: {
108+
scopes: OpenAPISecurityScope[] | undefined;
109+
context: OpenAPIClientContext;
110+
isOAuth2?: boolean;
111+
hideDescription?: boolean;
112+
}) {
113+
const { scopes, context, hideDescription } = props;
114+
115+
return (
116+
<div className="openapi-securities-scopes openapi-markdown">
117+
{!hideDescription ? (
118+
<div className="openapi-required-scopes-description">
119+
{t(context.translation, 'required_scopes_description')}
120+
</div>
121+
) : null}
74122
<ul>
75-
{scopes.map((scope) => (
123+
{scopes?.map((scope) => (
76124
<OpenAPIScopeItem key={scope[0]} scope={scope} context={context} />
77125
))}
78126
</ul>
@@ -112,3 +160,18 @@ function OpenAPIScopeItemKey(props: {
112160
</OpenAPICopyButton>
113161
);
114162
}
163+
164+
function dedupeScopes(scopes: OpenAPISecurityScope[]) {
165+
const seen = new Set<string>();
166+
const deduped: OpenAPISecurityScope[] = [];
167+
168+
for (const scope of scopes) {
169+
if (seen.has(scope[0])) {
170+
continue;
171+
}
172+
seen.add(scope[0]);
173+
deduped.push(scope);
174+
}
175+
176+
return deduped;
177+
}

packages/react-openapi/src/resolveOpenAPIOperation.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export async function resolveOpenAPIOperation(
5050
const flatSecurities = flattenSecurities(security);
5151

5252
// Resolve securities
53-
const securities: OpenAPIOperationData['securities'] = [];
53+
const securitiesMap = new Map<string, OpenAPIOperationData['securities'][number][1]>();
5454
for (const entry of flatSecurities) {
5555
const [securityKey, operationScopes] = Object.entries(entry)[0] ?? [];
5656
if (securityKey) {
@@ -59,14 +59,13 @@ export async function resolveOpenAPIOperation(
5959
securityScheme,
6060
operationScopes,
6161
});
62-
securities.push([
63-
securityKey,
64-
{
65-
...securityScheme,
66-
required: !isOptionalSecurity,
67-
scopes,
68-
},
69-
]);
62+
const existing = securitiesMap.get(securityKey);
63+
const mergedScopes = mergeSecurityScopes(existing?.scopes ?? null, scopes);
64+
securitiesMap.set(securityKey, {
65+
...securityScheme,
66+
required: !isOptionalSecurity,
67+
scopes: mergedScopes,
68+
});
7069
}
7170
}
7271

@@ -75,7 +74,7 @@ export async function resolveOpenAPIOperation(
7574
operation: { ...operation, security },
7675
method,
7776
path,
78-
securities,
77+
securities: Array.from(securitiesMap.entries()),
7978
'x-codeSamples':
8079
typeof schema['x-codeSamples'] === 'boolean' ? schema['x-codeSamples'] : undefined,
8180
'x-hideTryItPanel':
@@ -199,6 +198,31 @@ function resolveSecurityScopes({
199198
return operationScopes.map((scope) => [scope, undefined]);
200199
}
201200

201+
function mergeSecurityScopes(
202+
existing: OpenAPISecurityScope[] | null,
203+
incoming: OpenAPISecurityScope[] | null
204+
): OpenAPISecurityScope[] | null {
205+
if (!existing?.length) {
206+
return incoming;
207+
}
208+
if (!incoming?.length) {
209+
return existing;
210+
}
211+
212+
const seen = new Set<string>();
213+
const merged: OpenAPISecurityScope[] = [];
214+
215+
for (const scope of [...existing, ...incoming]) {
216+
if (seen.has(scope[0])) {
217+
continue;
218+
}
219+
seen.add(scope[0]);
220+
merged.push(scope);
221+
}
222+
223+
return merged;
224+
}
225+
202226
/**
203227
* Check if a security scheme is an OAuth or OpenID Connect security scheme.
204228
*/

packages/react-openapi/src/utils.ts

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import type { AnyObject, OpenAPIV3, OpenAPIV3_1 } from '@gitbook/openapi-parser'
22
import type { OpenAPIUniversalContext } from './context';
33
import { stringifyOpenAPI } from './stringifyOpenAPI';
44
import { tString } from './translate';
5-
import type { OpenAPICustomSecurityScheme, OpenAPIOperationData } from './types';
5+
import type {
6+
OpenAPICustomSecurityScheme,
7+
OpenAPIOperationData,
8+
OpenAPISecurityScope,
9+
} from './types';
610

711
export function checkIsReference(input: unknown): input is OpenAPIV3.ReferenceObject {
812
return typeof input === 'object' && !!input && '$ref' in input;
@@ -327,6 +331,7 @@ export type OperationSecurityInfo = {
327331
key: string;
328332
label: string;
329333
schemes: OpenAPICustomSecurityScheme[];
334+
scopeAlternatives: OpenAPISecurityScope[][];
330335
};
331336

332337
/**
@@ -345,18 +350,64 @@ export function extractOperationSecurityInfo(args: {
345350
key,
346351
label: key,
347352
schemes: [security],
353+
scopeAlternatives: security.scopes?.length ? [security.scopes] : [],
348354
}));
349355
}
350356

351-
return securityRequirement.map((requirement, idx) => {
352-
const schemeKeys = Object.keys(requirement);
357+
const grouped = new Map<string, OperationSecurityInfo>();
353358

354-
return {
355-
key: `security-${idx}`,
356-
label: schemeKeys.join(' & '),
357-
schemes: schemeKeys
358-
.map((schemeKey) => securitiesMap.get(schemeKey))
359-
.filter((s): s is OpenAPICustomSecurityScheme => s !== undefined),
360-
};
359+
securityRequirement.forEach((requirement) => {
360+
const schemeKeys = Object.keys(requirement).sort();
361+
if (schemeKeys.length === 0) {
362+
return;
363+
}
364+
const label = schemeKeys.join(' & ');
365+
const existing = grouped.get(label);
366+
const schemes = schemeKeys
367+
.map((schemeKey) => securitiesMap.get(schemeKey))
368+
.filter((s): s is OpenAPICustomSecurityScheme => s !== undefined);
369+
const scopesForRequirement = schemeKeys.flatMap((schemeKey) =>
370+
resolveRequiredScopesForScheme(securitiesMap.get(schemeKey), requirement[schemeKey])
371+
);
372+
373+
if (existing) {
374+
existing.scopeAlternatives.push(scopesForRequirement);
375+
if (!existing.schemes.length && schemes.length) {
376+
existing.schemes = schemes;
377+
}
378+
} else {
379+
grouped.set(label, {
380+
key: `security-${grouped.size}`,
381+
label,
382+
schemes,
383+
scopeAlternatives: [scopesForRequirement],
384+
});
385+
}
361386
});
387+
388+
return Array.from(grouped.values());
389+
}
390+
391+
function resolveRequiredScopesForScheme(
392+
security: OpenAPICustomSecurityScheme | undefined,
393+
operationScopes: string[] | undefined
394+
): OpenAPISecurityScope[] {
395+
if (!security || !operationScopes?.length) {
396+
return [];
397+
}
398+
399+
if (security.type === 'oauth2') {
400+
const flows = security.flows ? Object.entries(security.flows) : [];
401+
const resolved = flows.flatMap(([_, flow]) => {
402+
return Object.entries(flow.scopes ?? {}).filter(([scope]) =>
403+
operationScopes.includes(scope)
404+
);
405+
});
406+
407+
if (resolved.length) {
408+
return resolved;
409+
}
410+
}
411+
412+
return operationScopes.map((scope) => [scope, undefined]);
362413
}

0 commit comments

Comments
 (0)