diff --git a/.changeset/long-shrimps-judge.md b/.changeset/long-shrimps-judge.md new file mode 100644 index 0000000000..edae3be683 --- /dev/null +++ b/.changeset/long-shrimps-judge.md @@ -0,0 +1,6 @@ +--- +'@gitbook/react-openapi': minor +'gitbook': minor +--- + +Allow selection of server url diff --git a/packages/gitbook/src/app/(site)/(content)/[[...pathname]]/page.tsx b/packages/gitbook/src/app/(site)/(content)/[[...pathname]]/page.tsx index c36214e341..78729ef755 100644 --- a/packages/gitbook/src/app/(site)/(content)/[[...pathname]]/page.tsx +++ b/packages/gitbook/src/app/(site)/(content)/[[...pathname]]/page.tsx @@ -3,6 +3,7 @@ import { Metadata, Viewport } from 'next'; import { notFound, redirect } from 'next/navigation'; import React from 'react'; +import { serverUrlCache } from '@/components/DocumentView/OpenAPI/ServerUrlCache'; import { PageAside } from '@/components/PageAside'; import { PageBody, PageCover } from '@/components/PageBody'; import { PageHrefContext, absoluteHref, pageHref } from '@/lib/links'; @@ -26,6 +27,8 @@ export default async function Page(props: { }) { const { params, searchParams } = props; + serverUrlCache.parse(searchParams); + const { content: contentPointer, contentTarget, diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPI.tsx b/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPI.tsx index 014a531931..57838ae205 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPI.tsx +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPI.tsx @@ -7,6 +7,8 @@ import { LoadingPane } from '@/components/primitives'; import { fetchOpenAPIBlock } from '@/lib/openapi'; import { tcls } from '@/lib/tailwind'; +import OpenAPIClientStateContainer from './OpenAPIClientStateContainer'; +import { serverUrlCache } from './ServerUrlCache'; import { BlockProps } from '../Block'; import { PlainCodeBlock } from '../CodeBlock'; @@ -17,7 +19,7 @@ import './scalar.css'; * Render an OpenAPI block. */ export async function OpenAPI(props: BlockProps) { - const { block, style } = props; + const { style } = props; return (
}> @@ -45,21 +47,30 @@ async function OpenAPIBody(props: BlockProps) { return null; } + // To update the code sample we need to re-render the server component + // so reading the cached value from search params + const serverUrl = serverUrlCache.get('serverUrl'); + return ( - , - chevronRight: , - }, - CodeBlock: PlainCodeBlock, - defaultInteractiveOpened: context.mode === 'print', - id: block.meta?.id, - blockKey: block.key, - }} - className="openapi-block" - /> + + , + chevronRight: , + edit: , + editDone: , + }, + CodeBlock: PlainCodeBlock, + defaultInteractiveOpened: context.mode === 'print', + id: block.meta?.id, + blockKey: block.key, + serverUrl, + }} + className="openapi-block" + /> + ); } diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPIClientStateContainer.tsx b/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPIClientStateContainer.tsx new file mode 100644 index 0000000000..9dde1881f8 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPIClientStateContainer.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { DocumentBlock } from '@gitbook/api'; +import { OpenAPIClientState } from '@gitbook/react-openapi/client'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { OpenAPIV3 } from 'openapi-types'; +import * as React from 'react'; + +/** + * Client component that wraps `OpenAPIClientState` so we can + * use some hooks (e.g. useRouter) in the `onUpdate` callback. + */ +export default function OpenAPIClientStateContainer(props: { + children: React.ReactNode; + block: DocumentBlock; + servers: OpenAPIV3.ServerObject[]; +}) { + const { block, children, servers } = props; + const [isPending, startTransition] = React.useTransition(); + const router = useRouter(); + const searchParams = useSearchParams(); + + return ( + { + startTransition(() => { + const queryParams = new URLSearchParams(params ?? ''); + router.replace(`?${queryParams}`, { scroll: false }); + }); + }} + > + {children} + + ); +} diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/ServerUrlCache.tsx b/packages/gitbook/src/components/DocumentView/OpenAPI/ServerUrlCache.tsx new file mode 100644 index 0000000000..47ae56d22a --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/ServerUrlCache.tsx @@ -0,0 +1,5 @@ +import { createSearchParamsCache, parseAsString } from 'nuqs/server'; + +export const serverUrlCache = createSearchParamsCache({ + serverUrl: parseAsString, +}); diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css index cc7f3f69e0..56e2295bb9 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css @@ -57,7 +57,7 @@ /** URL */ .openapi-url { - @apply font-mono text-sm text-dark/8 dark:text-light/8; + @apply flex items-center font-mono text-sm text-dark/8 dark:text-light/8; } .openapi-url-var { @@ -364,3 +364,24 @@ .openapi-markdown > *:last-child { @apply mb-0; } + +.openapi-select-button, +.openapi-edit-button { + @apply p-0.5 inline-flex text-dark/6 dark:text-light/6 hover:opacity-8; +} + +.openapi-edit-button { + @apply p-0.5 ml-4 size-4; +} + +.openapi-edit-button > * { + @apply size-full; +} + +.openapi-select-button { + @apply leading-[1cap] disabled:opacity-5; +} + +.openapi-pending { + @apply opacity-5; +} diff --git a/packages/gitbook/src/components/PageBody/PageBody.tsx b/packages/gitbook/src/components/PageBody/PageBody.tsx index 9af6471c16..f89dc31571 100644 --- a/packages/gitbook/src/components/PageBody/PageBody.tsx +++ b/packages/gitbook/src/components/PageBody/PageBody.tsx @@ -50,7 +50,6 @@ export function PageBody(props: { const language = getSpaceLanguage(customization); const updatedAt = page.updatedAt ?? page.createdAt; const shouldHighlightCode = createHighlightingContext(); - return ( <>
| null; + /** + * Callback for when the client state is updated + */ + onUpdate: (params: Record | null) => void; +}; + +const OpenAPIClientStateContext = React.createContext(null); + +export function useOpenAPIClientState() { + return React.useContext(OpenAPIClientStateContext); +} + +/** + * Control client state for an OpenAPI operation + */ +export function OpenAPIClientState(props: { + children: React.ReactNode; + servers: OpenAPIV3.ServerObject[]; + isPending?: boolean; + params?: Record; + onUpdate: OpenAPIClientStateContextProps['onUpdate']; +}) { + const { children, servers, isPending, params, onUpdate } = props; + + const clientState = React.useMemo(() => { + if (!params) { + return null; + } + return parseClientStateModifiers(servers, params); + }, [servers, params]); + const serverUrl = getServersURL(servers, clientState ?? undefined); + + return ( + + {children} + + ); +} + +function parseClientStateModifiers( + servers: OpenAPIV3.ServerObject[], + params: Record, +) { + if (!servers) { + return null; + } + const serverQueryParam = params['server']; + const serverIndex = + serverQueryParam && !isNaN(Number(serverQueryParam)) + ? Math.max(0, Math.min(Number(serverQueryParam), servers.length - 1)) + : 0; + const server = servers[serverIndex]; + return server + ? Object.keys(server.variables ?? {}).reduce>( + (result, key) => { + const selection = params[key]; + if (!isNaN(Number(selection))) { + result[key] = selection; + } + return result; + }, + { server: `${serverIndex}`, edit: params['edit'] }, + ) + : null; +} diff --git a/packages/react-openapi/src/OpenAPICodeSample.tsx b/packages/react-openapi/src/OpenAPICodeSample.tsx index 265d57207c..6345dfe299 100644 --- a/packages/react-openapi/src/OpenAPICodeSample.tsx +++ b/packages/react-openapi/src/OpenAPICodeSample.tsx @@ -4,10 +4,9 @@ import { CodeSampleInput, codeSampleGenerators } from './code-samples'; import { OpenAPIOperationData, toJSON } from './fetchOpenAPIOperation'; import { generateMediaTypeExample, generateSchemaExample } from './generateSchemaExample'; import { InteractiveSection } from './InteractiveSection'; -import { getServersURL } from './OpenAPIServerURL'; import { ScalarApiButton } from './ScalarApiButton'; import { OpenAPIContextProps } from './types'; -import { noReference } from './utils'; +import { getServersURL, noReference } from './utils'; /** * Display code samples to execute the operation. @@ -49,14 +48,11 @@ export function OpenAPICodeSample(props: { } }); + const serverUrl = context.serverUrl ?? getServersURL(data.servers); const requestBody = noReference(data.operation.requestBody); const requestBodyContent = requestBody ? Object.entries(requestBody.content)[0] : undefined; - const input: CodeSampleInput = { - url: - getServersURL(data.servers) + - data.path + - (searchParams.size ? `?${searchParams.toString()}` : ''), + url: serverUrl + data.path + (searchParams.size ? `?${searchParams.toString()}` : ''), method: data.method, body: requestBodyContent ? generateMediaTypeExample(requestBodyContent[1], { onlyRequired: true }) diff --git a/packages/react-openapi/src/OpenAPIOperation.tsx b/packages/react-openapi/src/OpenAPIOperation.tsx index 365cede518..a37e336143 100644 --- a/packages/react-openapi/src/OpenAPIOperation.tsx +++ b/packages/react-openapi/src/OpenAPIOperation.tsx @@ -37,7 +37,7 @@ export function OpenAPIOperation(props: { {operation.description ? ( ) : null} -
+
- - {path} +
diff --git a/packages/react-openapi/src/OpenAPIServerURL.tsx b/packages/react-openapi/src/OpenAPIServerURL.tsx index e81fb47ba4..1d54e4ac3a 100644 --- a/packages/react-openapi/src/OpenAPIServerURL.tsx +++ b/packages/react-openapi/src/OpenAPIServerURL.tsx @@ -1,66 +1,62 @@ +'use client'; + import * as React from 'react'; import { OpenAPIV3 } from 'openapi-types'; +import classNames from 'classnames'; + import { OpenAPIServerURLVariable } from './OpenAPIServerURLVariable'; +import { OpenAPIClientContext } from './types'; +import { ServerURLForm } from './OpenAPIServerURLForm'; +import { useOpenAPIClientState } from './OpenAPIClientStateContext'; +import { parseServerURL } from './utils'; /** - * Show the url of the server with variables replaced by their default values. + * Show the url of the server, where there are variables they are replaced by their default values. + * If a stateContext is provided with an `onUpdate` callback then the variables can be edited and + * if there are multiple servers available they can be selected. */ -export function OpenAPIServerURL(props: { servers: OpenAPIV3.ServerObject[] }) { - const { servers } = props; - const server = servers[0]; +export function OpenAPIServerURL(props: { + servers: OpenAPIV3.ServerObject[]; + context: OpenAPIClientContext; + path?: string; +}) { + const { path, servers, context } = props; + const stateContext = useOpenAPIClientState(); + const serverIndex = !isNaN(Number(stateContext?.state?.server)) + ? Number(stateContext?.state?.server) + : 0; + const server = servers[serverIndex]; const parts = parseServerURL(server?.url ?? ''); + if (!server) { + return null; + } + return ( - - {parts.map((part, i) => { - if (part.kind === 'text') { - return {part.text}; - } else { - if (!server.variables?.[part.name]) { - return {`{${part.name}}`}; - } + + + {parts.map((part, i) => { + if (part.kind === 'text') { + return {part.text}; + } else { + if (!server.variables?.[part.name]) { + return {`{${part.name}}`}; + } - return ( - - ); - } - })} - + return ( + + ); + } + })} + {path} + + ); } - -/** - * Get the default URL for the server. - */ -export function getServersURL(servers: OpenAPIV3.ServerObject[]): string { - const server = servers[0]; - const parts = parseServerURL(server?.url ?? ''); - - return parts - .map((part) => { - if (part.kind === 'text') { - return part.text; - } else { - return server.variables?.[part.name]?.default ?? `{${part.name}}`; - } - }) - .join(''); -} - -function parseServerURL(url: string) { - const parts = url.split(/{([^}]+)}/g); - const result: Array<{ kind: 'variable'; name: string } | { kind: 'text'; text: string }> = []; - for (let i = 0; i < parts.length; i++) { - if (i % 2 === 0) { - result.push({ kind: 'text', text: parts[i] }); - } else { - result.push({ kind: 'variable', name: parts[i] }); - } - } - return result; -} diff --git a/packages/react-openapi/src/OpenAPIServerURLForm.tsx b/packages/react-openapi/src/OpenAPIServerURLForm.tsx new file mode 100644 index 0000000000..a65823ab04 --- /dev/null +++ b/packages/react-openapi/src/OpenAPIServerURLForm.tsx @@ -0,0 +1,101 @@ +'use client'; + +import * as React from 'react'; +import { OpenAPIClientContext } from './types'; +import { OpenAPIV3 } from 'openapi-types'; +import { ServerSelector } from './ServerSelector'; +import { useOpenAPIClientState } from './OpenAPIClientStateContext'; +import { getServersURL } from './utils'; + +export function ServerURLForm(props: { + children: React.ReactNode; + context: OpenAPIClientContext; + servers: OpenAPIV3.ServerObject[]; + serverIndex: number; +}) { + const { children, context, servers, serverIndex } = props; + const stateContext = useOpenAPIClientState(); + const server = servers[serverIndex]; + const formRef = React.useRef(null); + + function switchServer(index: number) { + if (index !== serverIndex) { + update({ + server: index ? `${index}` : '0', + ...(stateContext?.state?.edit ? { edit: 'true' } : undefined), + }); + } + } + + function updateServerVariables(formData: FormData) { + const variableKeys = Object.keys(server.variables ?? {}); + const variables: Record = {}; + for (const pair of formData) { + if (variableKeys.includes(pair[0]) && !isNaN(Number(pair[1]))) { + variables[pair[0]] = `${pair[1]}`; + } + } + update({ + server: formData.has('server') ? `${formData.get('server')}` : '0', + ...variables, + ...(stateContext?.state?.edit ? { edit: 'true' } : undefined), + }); + } + + function update(variables?: Record) { + if (!context.blockKey) { + return; + } + stateContext?.onUpdate({ + block: context.blockKey, + ...variables, + }); + } + + // Only make the server url editable if there is some onUpdate callback + // and if there are variations on the server url (e.g. an array of servers or url variables). + const isEditable = stateContext?.onUpdate && (servers.length > 1 || server.variables); + const isEditing = isEditable && stateContext?.state?.edit; + return ( +
{ + e.preventDefault(); + updateServerVariables(new FormData(e.currentTarget)); + }} + className="contents" + > +
+ + {children} + {isEditing && servers.length > 1 ? ( + + ) : null} + {isEditable ? ( + + ) : null} +
+
+ ); +} diff --git a/packages/react-openapi/src/OpenAPIServerURLVariable.tsx b/packages/react-openapi/src/OpenAPIServerURLVariable.tsx index 408c841179..27e82a00ca 100644 --- a/packages/react-openapi/src/OpenAPIServerURLVariable.tsx +++ b/packages/react-openapi/src/OpenAPIServerURLVariable.tsx @@ -1,5 +1,4 @@ 'use client'; - import * as React from 'react'; import classNames from 'classnames'; import { OpenAPIV3 } from 'openapi-types'; @@ -10,7 +9,64 @@ import { OpenAPIV3 } from 'openapi-types'; export function OpenAPIServerURLVariable(props: { name: string; variable: OpenAPIV3.ServerVariableObject; + selectionIndex?: number; + selectable: boolean; }) { - const { variable } = props; + const { selectable, selectionIndex, name, variable } = props; + + if (variable.enum && variable.enum.length > 0) { + if (!selectable) { + return ( + + {!isNaN(Number(selectionIndex)) + ? variable.enum[Number(selectionIndex)] + : variable.default} + + ); + } + + return ( + v === variable.default) + } + /> + ); + } + return {variable.default}; } + +/** + * Render a select if there is an enum for a Server URL variable + */ +function VariableSelector(props: { + value?: number; + name: string; + variable: OpenAPIV3.ServerVariableObject; +}) { + const { value, name, variable } = props; + return ( + + ); +} diff --git a/packages/react-openapi/src/ScalarApiButton.tsx b/packages/react-openapi/src/ScalarApiButton.tsx index 02d9ffaa18..ca0a9869c5 100644 --- a/packages/react-openapi/src/ScalarApiButton.tsx +++ b/packages/react-openapi/src/ScalarApiButton.tsx @@ -12,6 +12,7 @@ import { import React from 'react'; import { OpenAPIOperationData, fromJSON } from './fetchOpenAPIOperation'; +import { useOpenAPIClientState } from './OpenAPIClientStateContext'; const ApiClientReact = React.lazy(async () => { const mod = await import('@scalar/api-client-react'); @@ -58,6 +59,8 @@ export function ScalarApiButton(props: { export function ScalarApiClient(props: { children: React.ReactNode }) { const { children } = props; + const stateCtx = useOpenAPIClientState(); + const [active, setActive] = React.useState(null); @@ -125,12 +128,12 @@ export function ScalarApiClient(props: { children: React.ReactNode }) { headers: request.headers.map((header: Header) => { return { ...header, enabled: true }; }), - url: operationData.servers[0]?.url, + url: stateCtx?.serverUrl ?? operationData.servers[0]?.url, body: request.postData?.text, }; return data; - }, [active]); + }, [active, stateCtx?.serverUrl]); return ( diff --git a/packages/react-openapi/src/ServerSelector.tsx b/packages/react-openapi/src/ServerSelector.tsx new file mode 100644 index 0000000000..17037ffbfa --- /dev/null +++ b/packages/react-openapi/src/ServerSelector.tsx @@ -0,0 +1,48 @@ +'use client'; + +import * as React from 'react'; + +export function ServerSelector(props: { + currentIndex: number; + lastIndex: number; + onChange: (value: number) => void; +}) { + const { currentIndex, onChange, lastIndex } = props; + const [index, setIndex] = React.useState(currentIndex); + + React.useEffect(() => { + onChange(index); + }, [index]); + + return ( + + + + + + ); +} diff --git a/packages/react-openapi/src/client.ts b/packages/react-openapi/src/client.ts new file mode 100644 index 0000000000..32e5f16d46 --- /dev/null +++ b/packages/react-openapi/src/client.ts @@ -0,0 +1,2 @@ +'use client'; +export { OpenAPIClientState } from './OpenAPIClientStateContext'; diff --git a/packages/react-openapi/src/types.ts b/packages/react-openapi/src/types.ts index 156a28ac05..7be2deb50e 100644 --- a/packages/react-openapi/src/types.ts +++ b/packages/react-openapi/src/types.ts @@ -8,6 +8,8 @@ export interface OpenAPIClientContext { icons: { chevronDown: React.ReactNode; chevronRight: React.ReactNode; + edit: React.ReactNode; + editDone: React.ReactNode; }; /** @@ -15,12 +17,20 @@ export interface OpenAPIClientContext { * @default false */ defaultInteractiveOpened?: boolean; + /** * The key of the block */ blockKey?: string; - /** Optional id attached to the OpenAPI Operation heading and used as an anchor */ + + /** + * Optional id attached to the OpenAPI Operation heading and used as an anchor + */ id?: string; + /** + * Optional serverUrl to use with OpenAPI operations + */ + serverUrl?: string | null; } export interface OpenAPIFetcher { diff --git a/packages/react-openapi/src/utils.ts b/packages/react-openapi/src/utils.ts index 5bc3ee0922..d2d66e2be6 100644 --- a/packages/react-openapi/src/utils.ts +++ b/packages/react-openapi/src/utils.ts @@ -11,3 +11,41 @@ export function noReference(input: T | OpenAPIV3.ReferenceObject): T { export function createStateKey(key: string, scope?: string) { return scope ? `${scope}_${key}` : key; } + +/** + * Get the default URL for the server. + */ +export function getServersURL( + servers: OpenAPIV3.ServerObject[], + selectors?: Record, +): string { + const serverIndex = + selectors && !isNaN(Number(selectors.server)) ? Number(selectors.server) : 0; + const server = servers[serverIndex]; + const parts = parseServerURL(server?.url ?? ''); + + return parts + .map((part) => { + if (part.kind === 'text') { + return part.text; + } else { + return selectors && !isNaN(Number(selectors[part.name])) + ? server.variables?.[part.name]?.enum?.[Number(selectors[part.name])] + : (server.variables?.[part.name]?.default ?? `{${part.name}}`); + } + }) + .join(''); +} + +export function parseServerURL(url: string) { + const parts = url.split(/{([^}]+)}/g); + const result: Array<{ kind: 'variable'; name: string } | { kind: 'text'; text: string }> = []; + for (let i = 0; i < parts.length; i++) { + if (i % 2 === 0) { + result.push({ kind: 'text', text: parts[i] }); + } else { + result.push({ kind: 'variable', name: parts[i] }); + } + } + return result; +}