Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
90 changes: 76 additions & 14 deletions packages/react-openapi/src/OpenAPIRequiredScopes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -51,7 +59,12 @@ export function OpenAPIRequiredScopes(props: {
{
key: 'scopes',
label: '',
body: <OpenAPISchemaScopes scopes={scopes} context={context} />,
body: (
<OpenAPIScopeAlternatives
alternatives={resolvedAlternatives}
context={context}
/>
),
},
],
},
Expand All @@ -60,23 +73,57 @@ export function OpenAPIRequiredScopes(props: {
);
}

function OpenAPIScopeAlternatives(props: {
alternatives: OpenAPISecurityScope[][];
context: OpenAPIClientContext;
}) {
const { alternatives, context } = props;

if (alternatives.length === 1) {
return <OpenAPISchemaScopes scopes={alternatives[0]} context={context} />;
}

return (
<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[];
scopes: OpenAPISecurityScope[] | undefined;
context: OpenAPIClientContext;
isOAuth2?: boolean;
hideDescription?: boolean;
}) {
const { scopes, context, isOAuth2 } = props;
const { scopes, context, isOAuth2, hideDescription } = props;

return (
<div className="openapi-securities-scopes openapi-markdown">
<div className="openapi-required-scopes-description">
{t(
context.translation,
isOAuth2 ? 'available_scopes' : 'required_scopes_description'
)}
</div>
{!hideDescription ? (
<div className="openapi-required-scopes-description">
{t(
context.translation,
isOAuth2 ? 'available_scopes' : 'required_scopes_description'
)}
</div>
) : null}
<ul>
{scopes.map((scope) => (
{scopes?.map((scope) => (
<OpenAPIScopeItem key={scope[0]} scope={scope} context={context} />
))}
</ul>
Expand Down Expand Up @@ -116,3 +163,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;
}
44 changes: 34 additions & 10 deletions packages/react-openapi/src/resolveOpenAPIOperation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
});
}
}

Expand All @@ -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':
Expand Down Expand Up @@ -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<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.
*/
Expand Down
68 changes: 58 additions & 10 deletions packages/react-openapi/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -327,6 +331,7 @@ export type OperationSecurityInfo = {
key: string;
label: string;
schemes: OpenAPICustomSecurityScheme[];
scopeAlternatives: OpenAPISecurityScope[][];
};

/**
Expand All @@ -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<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);
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]);
}
Loading