Add OpenAPI servers selection (#3726)

This commit is contained in:
Nolann B.
2025-10-16 14:27:42 +02:00
committed by GitHub
parent 8eae6e970b
commit 8ce73229c3
15 changed files with 311 additions and 127 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---
Add OpenAPI servers selection
@@ -39,6 +39,8 @@ export function getOpenAPIContext(args: {
chevronDown: <Icon icon="chevron-down" />,
chevronRight: <Icon icon="chevron-right" />,
plus: <Icon icon="plus" />,
copy: <Icon icon="copy" />,
check: <Icon icon="check" />,
},
renderCodeBlock: (codeProps) => <PlainCodeBlock {...codeProps} />,
renderDocument: (documentProps) => (
@@ -478,17 +478,22 @@
}
.openapi-path-server {
@apply text-tint hidden md:inline;
@apply text-tint inline;
}
.openapi-path .openapi-method {
@apply m-0 mt-0.5 items-center flex px-1;
.openapi-summary .openapi-path .openapi-method {
@apply m-0 items-center flex px-2 py-1 h-6;
}
.openapi-path-title {
@apply flex-1 relative font-normal text-left font-mono text-tint-strong/10;
@apply py-0.5 px-1 rounded hover:bg-tint transition-colors;
@apply flex-1 relative font-normal text-left overflow-x-auto font-mono text-tint-strong/10;
@apply whitespace-nowrap md:whitespace-normal;
scrollbar-width: none;
-ms-overflow-style: none;
}
.openapi-path-title-row{
@apply flex flex-row items-center;
}
.openapi-path-title[data-deprecated="true"] {
@@ -592,18 +597,30 @@ body:has(.openapi-select-popover) {
}
.openapi-select > button {
@apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 text-tint-strong rounded text-xs p-1.5 leading-none border border-tint-subtle bg-tint;
@apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 p-1.5 border border-tint-subtle text-tint-strong rounded leading-none;
@apply hover:bg-tint-hover transition-all;
}
.openapi-select:not(.openapi-select-unstyled) > button {
@apply border border-tint-subtle bg-tint text-xs;
}
.openapi-select-unstyled > button {
@apply p-1;
}
.openapi-select > button[data-focused="true"] {
@apply outline-primary -outline-offset-1 outline outline-1;
@apply outline-primary -outline-offset-1 outline;
}
.openapi-select > button > span.react-aria-SelectValue {
@apply shrink truncate flex items-center;
}
.openapi-select > button > .react-aria-SelectValue [slot="description"] {
display: none;
}
.openapi-select > button .openapi-markdown {
@apply *:leading-none;
}
@@ -630,6 +647,14 @@ body:has(.openapi-select-popover) {
@apply hover:bg-tint-hover hover:theme-gradient:bg-tint-12/1 hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-inset contrast-more:hover:ring-current;
}
.openapi-select-item.openapi-select-item-column {
@apply flex flex-col gap-1 justify-start items-start;
}
.openapi-select-item [slot="description"] {
@apply text-xs text-tint-subtle;
}
.openapi-select button .openapi-markdown,
.openapi-select-item .openapi-markdown {
@apply text-[0.813rem] *:truncate *:!p-0 *:!m-0 [&>*:not(:first-child)]:hidden;
@@ -966,3 +991,20 @@ body:has(.openapi-select-popover) {
.openapi-copy-button[data-disabled="true"] {
@apply cursor-default;
}
.openapi-path-copy-button {
@apply p-1 flex rounded-md;
@apply hover:bg-tint;
}
.openapi-path-copy-button-icon {
@apply size-6 flex opacity-0 transition-all;
}
.openapi-path:hover .openapi-path-copy-button-icon {
@apply opacity-100;
}
.openapi-path-copy-button-icon svg {
@apply text-tint size-4;
}
@@ -116,7 +116,7 @@ export function InteractiveSection(props: {
<OpenAPISelect
stateKey={stateKey}
items={tabs}
onSelectionChange={() => {
onChange={() => {
state.expand();
}}
icon={selectIcon}
@@ -1,5 +1,4 @@
'use client';
import clsx from 'classnames';
import type { MediaTypeRenderer } from './OpenAPICodeSample';
import { OpenAPISelect, OpenAPISelectItem, useSelectState } from './OpenAPISelect';
import { createStateKey } from './utils';
@@ -49,16 +48,7 @@ function MediaTypeSelector(props: {
}));
return (
<OpenAPISelect
className={clsx('openapi-select')}
items={renderers.map((renderer) => ({
key: renderer.mediaType,
label: renderer.mediaType,
}))}
icon={selectIcon}
stateKey={stateKey}
placement="bottom start"
>
<OpenAPISelect items={items} icon={selectIcon} stateKey={stateKey} placement="bottom start">
{items.map((item) => (
<OpenAPISelectItem key={item.key} id={item.key} value={item}>
{item.label}
@@ -1,7 +1,8 @@
'use client';
import { useState } from 'react';
import { Button, type ButtonProps, Tooltip, TooltipTrigger } from 'react-aria-components';
import { Button, type ButtonProps } from 'react-aria-components';
import { OpenAPITooltip } from './OpenAPITooltip';
import type { OpenAPIClientContext } from './context';
import { t } from './translate';
@@ -36,13 +37,7 @@ export function OpenAPICopyButton(
};
return (
<TooltipTrigger
isOpen={isOpen}
onOpenChange={setIsOpen}
isDisabled={!withTooltip}
closeDelay={200}
delay={200}
>
<OpenAPITooltip isDisabled={!withTooltip} isOpen={isOpen} onOpenChange={setIsOpen}>
<Button
type="button"
preventFocusOnPress
@@ -56,17 +51,16 @@ export function OpenAPICopyButton(
{children}
</Button>
<Tooltip
isOpen={isOpen}
onOpenChange={setIsOpen}
placement="top"
offset={4}
className="openapi-tooltip"
>
{copied
? t(context.translation, 'copied')
: label || t(context.translation, 'copy_to_clipboard')}
</Tooltip>
</TooltipTrigger>
<OpenAPITooltip.Content isOpen={isOpen} onOpenChange={setIsOpen}>
{copied ? (
<>
{context.icons.check}
{t(context.translation, 'copied')}
</>
) : (
label || t(context.translation, 'copy_to_clipboard')
)}
</OpenAPITooltip.Content>
</OpenAPITooltip>
);
}
@@ -128,7 +128,7 @@ function DisclosureItem(props: {
<OpenAPISelect
icon={selectIcon}
stateKey={selectStateKey}
onSelectionChange={() => {
onChange={() => {
state.expand();
}}
items={group.tabs}
+20 -68
View File
@@ -1,14 +1,12 @@
import { OpenAPICopyButton } from './OpenAPICopyButton';
import { OpenAPIPathItem } from './OpenAPIPathItem';
import { OpenAPIPathMultipleServers } from './OpenAPIPathMultipleServers';
import { type OpenAPIUniversalContext, getOpenAPIClientContext } from './context';
import { formatPath } from './formatPath';
import type { OpenAPIOperationData } from './types';
import { getDefaultServerURL } from './util/server';
/**
* Display the path of an operation.
*/
export function OpenAPIPath(props: {
export type OpenAPIPathProps = {
data: OpenAPIOperationData;
context: OpenAPIUniversalContext;
/** Whether to show the server URL.
* @default true
*/
@@ -18,73 +16,27 @@ export function OpenAPIPath(props: {
* @default true
*/
canCopy?: boolean;
}) {
const { data, context, withServer = true, canCopy = true } = props;
const { method, path, operation } = data;
const server = getDefaultServerURL(data.servers);
const formattedPath = formatPath(path);
const element = (() => {
return (
<>
{withServer ? <span className="openapi-path-server">{server}</span> : null}
{formattedPath}
</>
);
})();
return (
<div className="openapi-path">
<div className={`openapi-method openapi-method-${method}`}>{method}</div>
<OpenAPICopyButton
value={`${withServer ? server : ''}${path}`}
className="openapi-path-title"
data-deprecated={operation.deprecated}
isDisabled={!canCopy}
context={getOpenAPIClientContext(context)}
>
{element}
</OpenAPICopyButton>
</div>
);
}
};
/**
* Format the path by wrapping placeholders in <span> tags.
* Display the path of an operation.
*/
function formatPath(path: string) {
// Matches placeholders like {id}, {userId}, etc.
const regex = /\{\s*(\w+)\s*\}|:\w+/g;
export function OpenAPIPath(props: OpenAPIPathProps & { context: OpenAPIUniversalContext }) {
const { data, withServer = true, context } = props;
const { path } = data;
const clientContext = getOpenAPIClientContext(context);
const parts: (string | React.JSX.Element)[] = [];
let lastIndex = 0;
//Wrap the variables in <span> tags and maintain either {variable} or :variable
path.replace(regex, (match, _, offset) => {
if (offset > lastIndex) {
parts.push(path.slice(lastIndex, offset));
}
parts.push(
<span key={`offset-${offset}`} className="openapi-path-variable">
{match}
</span>
);
lastIndex = offset + match.length;
return match;
});
if (lastIndex < path.length) {
parts.push(path.slice(lastIndex));
if (withServer && data.servers.length > 1) {
return <OpenAPIPathMultipleServers {...props} context={clientContext} />;
}
const formattedPath = parts.map((part, index) => {
if (typeof part === 'string') {
return <span key={`part-${index}`}>{part}</span>;
}
return part;
});
const formattedPath = formatPath(path);
const defaultServer = getDefaultServerURL(data.servers);
return formattedPath;
return (
<OpenAPIPathItem {...props} value={`${defaultServer}${path}`} context={clientContext}>
{withServer ? <span className="openapi-path-server">{defaultServer}</span> : null}
{formattedPath}
</OpenAPIPathItem>
);
}
@@ -0,0 +1,51 @@
import { OpenAPICopyButton } from './OpenAPICopyButton';
import type { OpenAPIPathProps } from './OpenAPIPath';
import type { OpenAPIClientContext } from './context';
export function OpenAPIPathItem(
props: OpenAPIPathProps & {
value?: string;
children: React.ReactNode;
copyType?: 'button' | 'children';
context: OpenAPIClientContext;
}
) {
const { value, canCopy = true, context, children, data, copyType = 'children' } = props;
const { operation, method } = data;
const title = <span className="openapi-path-title">{children}</span>;
return (
<div className="openapi-path">
<div className={`openapi-method openapi-method-${method}`}>{method}</div>
{canCopy && value ? (
copyType === 'children' ? (
<OpenAPICopyButton
value={value}
data-deprecated={operation.deprecated}
isDisabled={!canCopy}
context={context}
className="openapi-path-copy-button"
>
{title}
</OpenAPICopyButton>
) : (
<>
{title}
<OpenAPICopyButton
value={value}
data-deprecated={operation.deprecated}
isDisabled={!canCopy}
context={context}
className="openapi-path-copy-button openapi-path-copy-button-icon"
>
{context.icons.copy}
</OpenAPICopyButton>
</>
)
) : (
title
)}
</div>
);
}
@@ -0,0 +1,77 @@
'use client';
import { Text } from 'react-aria-components';
import type { OpenAPIPathProps } from './OpenAPIPath';
import { OpenAPIPathItem } from './OpenAPIPathItem';
import { OpenAPISelect, OpenAPISelectItem, useSelectState } from './OpenAPISelect';
import { OpenAPITooltip } from './OpenAPITooltip';
import type { OpenAPIClientContext } from './context';
import { formatPath } from './formatPath';
import type { OpenAPIServerWithCustomProperties } from './types';
import { getDefaultServerURL } from './util/server';
import { createStateKey } from './utils';
export const serversStateKey = createStateKey('servers');
/**
* Display the path of an operation.
*/
export function OpenAPIPathMultipleServers(
props: OpenAPIPathProps & { context: OpenAPIClientContext }
) {
const { data, withServer = true, context } = props;
const { path, servers } = data;
const defaultServer = getDefaultServerURL(servers);
const { key, setKey } = useSelectState(serversStateKey, defaultServer);
const formattedPath = formatPath(path);
const items = servers
.filter(
(server): server is OpenAPIServerWithCustomProperties & { url: string } => !!server.url
)
.map((server) => ({
key: server.url,
label: server.url,
description: server.description,
}));
return (
<OpenAPIPathItem
copyType="button"
{...props}
value={`${withServer ? key : ''}${path}`}
context={context}
>
{withServer ? (
<OpenAPITooltip>
<OpenAPISelect
className="openapi-select openapi-select-unstyled"
items={items}
stateKey={serversStateKey}
placement="bottom start"
icon={context.icons.chevronDown}
defaultValue={defaultServer}
onChange={setKey}
>
{items.map((item) => (
<OpenAPISelectItem
textValue={item.label}
key={item.key}
id={item.key}
value={item}
className="openapi-select-item-column"
>
<Text slot="label">{item.label}</Text>
{item.description ? (
<Text slot="description">{item.description}</Text>
) : null}
</OpenAPISelectItem>
))}
</OpenAPISelect>
<OpenAPITooltip.Content>Click to select a server</OpenAPITooltip.Content>
</OpenAPITooltip>
) : null}
{formattedPath}
</OpenAPIPathItem>
);
}
+15 -19
View File
@@ -30,7 +30,7 @@ interface OpenAPISelectProps<T extends OpenAPISelectItem> extends Omit<SelectPro
/**
* Icon to display in the select button.
*/
icon?: React.ReactNode;
icon?: React.ReactNode | null;
}
export function useSelectState(stateKey = 'select-state', initialKey: Key = 'default') {
@@ -42,18 +42,10 @@ export function useSelectState(stateKey = 'select-state', initialKey: Key = 'def
}
export function OpenAPISelect<T extends OpenAPISelectItem>(props: OpenAPISelectProps<T>) {
const {
icon = '▼',
items,
children,
className,
placement,
stateKey,
selectedKey,
onSelectionChange,
} = props;
const { icon, items, children, className, placement, stateKey, value, onChange, defaultValue } =
props;
const state = useSelectState(stateKey, items[0]?.key);
const state = useSelectState(stateKey, defaultValue ?? items[0]?.key);
const selected = items.find((item) => item.key === state.key) || items[0];
@@ -61,16 +53,16 @@ export function OpenAPISelect<T extends OpenAPISelectItem>(props: OpenAPISelectP
<Select
aria-label="OpenAPI Select"
{...props}
value={selectedKey || selected?.key}
value={value ?? selected?.key}
onChange={(key) => {
onSelectionChange?.(key);
onChange?.(key);
state.setKey(key);
}}
className={clsx('openapi-select', className)}
>
<Button>
<SelectValue />
{icon}
{icon !== null ? icon || '▼' : null}
</Button>
<Popover placement={placement} className="openapi-select-popover">
<ListBox className="openapi-select-listbox" items={items}>
@@ -86,10 +78,14 @@ export function OpenAPISelectItem(props: ListBoxItemProps) {
<ListBoxItem
{...props}
className={({ isFocused, isSelected }) =>
clsx('openapi-select-item', {
'openapi-select-item-focused': isFocused,
'openapi-select-item-selected': isSelected,
})
clsx(
'openapi-select-item',
{
'openapi-select-item-focused': isFocused,
'openapi-select-item-selected': isSelected,
},
props.className
)
}
/>
);
@@ -0,0 +1,34 @@
'use client';
import classNames from 'classnames';
import type { TooltipTriggerProps } from 'react-aria';
import { Tooltip, type TooltipProps, TooltipTrigger } from 'react-aria-components';
export function OpenAPITooltip(
props: TooltipTriggerProps & {
children: React.ReactNode;
}
) {
const { children, ...rest } = props;
return (
<TooltipTrigger {...rest} closeDelay={200} delay={200}>
{children}
</TooltipTrigger>
);
}
function OpenAPITooltipContent(props: TooltipProps) {
const { children, placement = 'top', offset = 4, className, ...rest } = props;
return (
<Tooltip
{...rest}
placement={placement}
offset={offset}
className={classNames('openapi-tooltip', className)}
>
{children}
</Tooltip>
);
}
OpenAPITooltip.Content = OpenAPITooltipContent;
+2
View File
@@ -13,6 +13,8 @@ export interface OpenAPIClientContext {
chevronDown: React.ReactNode;
chevronRight: React.ReactNode;
plus: React.ReactNode;
copy: React.ReactNode;
check: React.ReactNode;
};
/**
+37
View File
@@ -0,0 +1,37 @@
/**
* Format the path by wrapping placeholders in <span> tags.
*/
export function formatPath(path: string) {
// Matches placeholders like {id}, {userId}, etc.
const regex = /\{\s*(\w+)\s*\}|:\w+/g;
const parts: (string | React.JSX.Element)[] = [];
let lastIndex = 0;
//Wrap the variables in <span> tags and maintain either {variable} or :variable
path.replace(regex, (match, _, offset) => {
if (offset > lastIndex) {
parts.push(path.slice(lastIndex, offset));
}
parts.push(
<span key={`offset-${offset}`} className="openapi-path-variable">
{match}
</span>
);
lastIndex = offset + match.length;
return match;
});
if (lastIndex < path.length) {
parts.push(path.slice(lastIndex));
}
const formattedPath = parts.map((part, index) => {
if (typeof part === 'string') {
return <span key={`part-${index}`}>{part}</span>;
}
return part;
});
return formattedPath;
}
+1
View File
@@ -12,6 +12,7 @@ export type OpenAPIServerVariableWithCustomProperties = OpenAPIV3.ServerVariable
* OpenAPI ServerObject type extended to provide x-gitbook prefill custom properties at the variable level.
*/
export type OpenAPIServerWithCustomProperties = Omit<OpenAPIV3.ServerObject, 'variables'> & {
name?: string;
variables?: {
[variable: string]: OpenAPIServerVariableWithCustomProperties;
};