diff --git a/bun.lockb b/bun.lockb index cfd1fe63c..6469f28a4 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/react-openapi/package.json b/packages/react-openapi/package.json index d10c570c7..432128cae 100644 --- a/packages/react-openapi/package.json +++ b/packages/react-openapi/package.json @@ -2,10 +2,12 @@ "name": "@gitbook/react-openapi", "exports": "./src/index.ts", "dependencies": { - "openapi-types": "^12.1.3", + "@scalar/api-client-react": "^0.2.4", + "@scalar/oas-utils": "0.1.1", "classnames": "^2.5.1", + "flatted": "^3.2.9", "json-pointer": "^0.6.2", - "flatted": "^3.2.9" + "openapi-types": "^12.1.3" }, "devDependencies": { "@types/json-pointer": "^1.0.34" diff --git a/packages/react-openapi/src/InteractiveSection.tsx b/packages/react-openapi/src/InteractiveSection.tsx index a0050c47b..7761deeae 100644 --- a/packages/react-openapi/src/InteractiveSection.tsx +++ b/packages/react-openapi/src/InteractiveSection.tsx @@ -30,6 +30,8 @@ export function InteractiveSection(props: { header: React.ReactNode; /** Body of the section */ children?: React.ReactNode; + /** Children to display within the container */ + overlay?: React.ReactNode; }) { const { id, @@ -40,6 +42,7 @@ export function InteractiveSection(props: { defaultTab = tabs[0]?.key, header, children, + overlay, toggleOpenIcon = '▶', toggleCloseIcon = '▼', } = props; @@ -120,6 +123,7 @@ export function InteractiveSection(props: { {tabBody} ) : null} + {overlay} ); } diff --git a/packages/react-openapi/src/OpenAPICodeSample.tsx b/packages/react-openapi/src/OpenAPICodeSample.tsx index 0489ac543..d7afb222f 100644 --- a/packages/react-openapi/src/OpenAPICodeSample.tsx +++ b/packages/react-openapi/src/OpenAPICodeSample.tsx @@ -1,9 +1,11 @@ import { OpenAPIV3 } from 'openapi-types'; + +import { CodeSampleInput, codeSampleGenerators } from './code-samples'; +import { OpenAPIOperationData, toJSON } from './fetchOpenAPIOperation'; +import { generateMediaTypeExample } from './generateSchemaExample'; import { InteractiveSection } from './InteractiveSection'; import { getServersURL } from './OpenAPIServerURL'; -import { CodeSampleInput, codeSampleGenerators } from './code-samples'; -import { OpenAPIOperationData } from './fetchOpenAPIOperation'; -import { generateMediaTypeExample } from './generateSchemaExample'; +import { ScalarApiButton } from './ScalarApiButton'; import { OpenAPIContextProps } from './types'; import { noReference } from './utils'; @@ -72,7 +74,19 @@ export function OpenAPICodeSample(props: { } }); - return ; + async function fetchOperationData() { + 'use server'; + return toJSON(data); + } + + return ( + } + /> + ); } function getSecurityHeaders(securities: OpenAPIOperationData['securities']): { diff --git a/packages/react-openapi/src/OpenAPIOperation.tsx b/packages/react-openapi/src/OpenAPIOperation.tsx index 493341b59..7787e5c67 100644 --- a/packages/react-openapi/src/OpenAPIOperation.tsx +++ b/packages/react-openapi/src/OpenAPIOperation.tsx @@ -1,12 +1,13 @@ import classNames from 'classnames'; import { OpenAPIOperationData, toJSON } from './fetchOpenAPIOperation'; -import { OpenAPIServerURL } from './OpenAPIServerURL'; -import { OpenAPIClientContext, OpenAPIContextProps } from './types'; -import { OpenAPICodeSample } from './OpenAPICodeSample'; -import { OpenAPISpec } from './OpenAPISpec'; -import { OpenAPIResponseExample } from './OpenAPIResponseExample'; import { Markdown } from './Markdown'; +import { OpenAPICodeSample } from './OpenAPICodeSample'; +import { OpenAPIResponseExample } from './OpenAPIResponseExample'; +import { OpenAPIServerURL } from './OpenAPIServerURL'; +import { OpenAPISpec } from './OpenAPISpec'; +import { ScalarApiClient } from './ScalarApiButton'; +import { OpenAPIClientContext, OpenAPIContextProps } from './types'; /** * Display an interactive OpenAPI operation. @@ -25,39 +26,40 @@ export function OpenAPIOperation(props: { }; return ( -
-
-

{operation.summary}

- {operation.description ? ( - - ) : null} -
- - {method.toUpperCase()} - - - - {path} - + +
+
+

{operation.summary}

+ {operation.description ? ( + + ) : null} +
+ + {method.toUpperCase()} + + + + {path} + +
-
- -
-
- -
-
-
- - +
+
+ +
+
+
+ + +
-
+ ); } diff --git a/packages/react-openapi/src/OpenAPISpec.tsx b/packages/react-openapi/src/OpenAPISpec.tsx index d8d0f9bd9..8ee8e31b7 100644 --- a/packages/react-openapi/src/OpenAPISpec.tsx +++ b/packages/react-openapi/src/OpenAPISpec.tsx @@ -1,14 +1,15 @@ 'use client'; import { OpenAPIV3 } from 'openapi-types'; + +import { OpenAPIOperationData, fromJSON } from './fetchOpenAPIOperation'; import { InteractiveSection } from './InteractiveSection'; import { OpenAPIRequestBody } from './OpenAPIRequestBody'; import { OpenAPIResponses } from './OpenAPIResponses'; import { OpenAPISchemaProperties } from './OpenAPISchema'; -import { OpenAPIOperationData, fromJSON } from './fetchOpenAPIOperation'; +import { OpenAPISecurities } from './OpenAPISecurities'; import { OpenAPIClientContext } from './types'; import { noReference } from './utils'; -import { OpenAPISecurities } from './OpenAPISecurities'; /** * Client component to render the spec for the request and response. @@ -18,7 +19,9 @@ import { OpenAPISecurities } from './OpenAPISecurities'; */ export function OpenAPISpec(props: { rawData: any; context: OpenAPIClientContext }) { const { rawData, context } = props; - const { operation, securities } = fromJSON(rawData) as OpenAPIOperationData; + + const parsedData = fromJSON(rawData) as OpenAPIOperationData; + const { operation, securities } = parsedData; const parameterGroups = groupParameters((operation.parameters || []).map(noReference)); @@ -55,7 +58,6 @@ export function OpenAPISpec(props: { rawData: any; context: OpenAPIClientContext context={context} /> ) : null} - {operation.responses ? ( ) : null} diff --git a/packages/react-openapi/src/ScalarApiButton.tsx b/packages/react-openapi/src/ScalarApiButton.tsx new file mode 100644 index 000000000..11d0ea4db --- /dev/null +++ b/packages/react-openapi/src/ScalarApiButton.tsx @@ -0,0 +1,163 @@ +'use client'; +import { + Cookie, + getHarRequest, + getParametersFromOperation, + type TransformedOperation, + getRequestFromOperation, + Query, + Header, +} from '@scalar/oas-utils'; +import React from 'react'; + +import { OpenAPIOperationData, fromJSON } from './fetchOpenAPIOperation'; + +const ApiClientReact = React.lazy(async () => { + const mod = await import('@scalar/api-client-react'); + return { default: mod.ApiClientReact }; +}); + +const ScalarContext = React.createContext< + (fetchOperationData: () => Promise) => void +>(() => {}); + +/** + * Button which launches the Scalar API Client + */ +export function ScalarApiButton(props: { + fetchOperationData: () => Promise; +}) { + const { fetchOperationData } = props; + const open = React.useContext(ScalarContext); + + return ( +
+ +
+ ); +} + +/** + * Wrap the rendering with a context to open the scalar modal. + */ +export function ScalarApiClient(props: { children: React.ReactNode }) { + const { children } = props; + + const [active, setActive] = React.useState(null); + + const proxy = '/~scalar/proxy'; + + const open = React.useCallback( + async (fetchOperationData: () => Promise) => { + setActive({ operationData: null }); + const operationData = fromJSON(await fetchOperationData()); + setActive({ operationData }); + }, + [], + ); + + const onClose = React.useCallback(() => { + setActive(null); + }, []); + + const request = React.useMemo(() => { + const operationData = active?.operationData; + + if (!operationData) { + return null; + } + + const operationId = + operationData.operation.operationId ?? operationData.method + operationData.path; + + const operation = { + ...operationData, + httpVerb: operationData.method, + pathParameters: operationData.operation.parameters, + } as TransformedOperation; + + const variables = getParametersFromOperation(operation, 'path', false); + + const request = getHarRequest( + { + url: operationData.path, + }, + getRequestFromOperation(operation, { requiredOnly: false }), + ); + + return { + id: operationId, + type: operationData.method, + path: operationData.path, + variables, + cookies: request.cookies.map((cookie: Cookie) => { + return { ...cookie, enabled: true }; + }), + query: request.queryString.map((queryString: Query) => { + const query: typeof queryString & { required?: boolean } = queryString; + return { ...queryString, enabled: query.required ?? true }; + }), + headers: request.headers.map((header: Header) => { + return { ...header, enabled: true }; + }), + url: operationData.servers[0]?.url, + body: request.postData?.text, + }; + }, [active]); + + return ( + + {children} + {active ? ( +
+
+
+ + {request ? ( + }> + + + ) : ( + + )} +
+
onClose()} className="scalar-app-exit">
+
+
+ ) : null} +
+ ); +} + +function ScalarLoading() { + return
Loading...
; +} diff --git a/src/app/~scalar/proxy/route.ts b/src/app/~scalar/proxy/route.ts new file mode 100644 index 000000000..269820fb4 --- /dev/null +++ b/src/app/~scalar/proxy/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from 'next/server'; + +type ProxyRequest = { + url: string; + method: string; + headers: Record; + data: Record; +}; + +export const runtime = 'edge'; + +/** + * Taken from https://github.com/scalar/scalar/tree/main/packages/api-client-proxy + */ +export async function POST(req: NextRequest) { + const requestBody: ProxyRequest = await req.json(); + + const isGetOrHeadRequest = ['get', 'head'].includes(requestBody.method.trim().toLowerCase()); + const body = isGetOrHeadRequest + ? null + : requestBody.data + ? JSON.stringify(requestBody.data) + : null; + + // Default options are marked with * + try { + const response = await fetch(requestBody.url.trim(), { + // *GET, POST, PUT, DELETE, etc. + method: requestBody.method.trim(), + // no-cors, *cors, same-origin + // mode: 'cors', // Not supported on Cloudflare Workers + // *default, no-cache, reload, force-cache, only-if-cached + cache: 'no-cache', + // include, *same-origin, omit + // credentials: 'include', // Not supported on Cloudflare Workers + headers: requestBody.headers, + // manual, *follow, error + redirect: 'follow', + // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url + // referrerPolicy: 'no-referrer', // Not supported on Cloudflare Workers + // body data type must match "Content-Type" header + body, + }); + + const headers: Record = {}; + + const proxyHeaders = [...response.headers]; + proxyHeaders.forEach(([key, value]) => { + if (['access-control-allow-origin'].includes(key.toLowerCase())) { + return; + } + + headers[key] = value; + }); + + const text = await response.text(); + + return NextResponse.json({ + statusCode: response.status, + // TODO: Do we need body? + // body: … + data: text, + headers: { + ...headers, + 'X-API-Client-Content-Length': text.length, + }, + // TODO: transform cookie data + cookies: response.headers.get('cookies'), + }); + } catch (error) { + console.error( + 'Scalar API Client Proxy Error', + (error as Error).stack ?? (error as Error).message ?? error, + ); + return NextResponse.json({ + data: 'Scalar API Client Proxy Error', + }); + } +} diff --git a/src/components/DocumentView/OpenAPI/OpenAPI.tsx b/src/components/DocumentView/OpenAPI/OpenAPI.tsx index 9111e6079..a5220dd9b 100644 --- a/src/components/DocumentView/OpenAPI/OpenAPI.tsx +++ b/src/components/DocumentView/OpenAPI/OpenAPI.tsx @@ -12,6 +12,7 @@ import { BlockProps } from '../Block'; import { PlainCodeBlock } from '../CodeBlock'; import './style.css'; +import './scalar.css'; /** * Render an OpenAPI block. diff --git a/src/components/DocumentView/OpenAPI/scalar.css b/src/components/DocumentView/OpenAPI/scalar.css new file mode 100644 index 000000000..17e934494 --- /dev/null +++ b/src/components/DocumentView/OpenAPI/scalar.css @@ -0,0 +1,325 @@ +.light .scalar-modal-layout, +.light .scalar { + --theme-color-1: color-mix( + in srgb, + rgb(var(--primary-base-300, 180 180 180)), + rgb(var(--dark-base, 23 23 23)) 96% + ); + --theme-color-2: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.72)); + --theme-color-3: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.4)); + --theme-color-accent: #007d9c; + + --theme-background-1: rgb(var(--light-base, 255 255 255)); + --theme-background-2: color-mix( + in srgb, + rgb(var(--primary-base-800, 30 30 30)), + var(--theme-background-1) 96% + ); + --theme-background-3: color-mix( + in srgb, + rgb(var(--primary-base-800, 30 30 30)), + var(--theme-background-1) 90% + ); + --theme-background-accent: #007d9c1f; + --theme-code-language-color-supersede: var(--theme-color-1); + --theme-code-languages-background-supersede: var(--theme-background-1); + --theme-border-color: color-mix( + in srgb, + var(--theme-color-1), + transparent calc(100% - 100% * 0.08) + ); + + --theme-color-green: #0a6355; + --theme-color-red: #dc1b19; + --theme-color-yellow: #ffc90d; + --theme-color-blue: rgb(var(--primary-color-500, 52 109 219)); + --theme-color-orange: #ff8d4d; + --theme-color-purple: #8250df; + + --theme-scrollbar-color: rgba(255, 255, 255, 0.24); + --theme-scrollbar-color-active: rgba(255, 255, 255, 0.48); +} +.dark .scalar-modal-layout, +.dark .scalar { + --theme-color-1: color-mix( + in srgb, + rgb(var(--primary-base-700, 70 70 70)), + rgb(var(--light-base, 255 255 255)) 100% + ); + --theme-color-2: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.64)); + --theme-color-3: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.4)); + --theme-color-accent: #50b7e0; + + --theme-background-1: rgb(var(--dark-base, 22 22 22)); + --theme-background-2: color-mix( + in srgb, + rgb(var(--primary-base-200, 200 200 200)), + var(--theme-background-1) 92% + ); + --theme-background-3: color-mix( + in srgb, + rgb(var(--primary-base-200, 200 200 200)), + var(--theme-background-1) 88% + ); + --theme-background-accent: #8ab4f81f; + --theme-code-languages-background-supersede: var(--theme-background-1); + --theme-border-color: color-mix( + in srgb, + var(--theme-color-1), + transparent calc(100% - 100% * 0.08) + ); + + --theme-color-green: #56b6c2; + --theme-color-red: rgb(245 124 97); + --theme-color-yellow: #edbe20; + --theme-color-blue: rgb(var(--primary-color-400, 93 138 226)); + --theme-color-orange: #d19a66; + --theme-color-purple: #5203d1; + + --theme-scrollbar-color: rgba(0, 0, 0, 0.18); + --theme-scrollbar-color-active: rgba(0, 0, 0, 0.36); +} +.scalar-modal-layout, +.scalar { + --theme-font: initial; + --theme-font-code: var(--font-mono); + + --theme-paragraph: 16px; + --theme-small: 14px; + --theme-mini: 13px; + --theme-micro: 12px; + + --theme-bold: 600; + --theme-semibold: 500; + --theme-regular: 400; + + /* Font sizes for interactive applications (not rendered text content) */ + --theme-font-size-1: 24px; + --theme-font-size-2: 16px; + --theme-font-size-3: 14px; + --theme-font-size-4: 13px; + --theme-font-size-5: 12px; + + --theme-line-height-1: 32px; + --theme-line-height-2: 24px; + --theme-line-height-3: 20px; + --theme-line-height-4: 18px; + --theme-line-height-5: 16px; + + --scalar-app-header-height: 35px; +} +.scalar input::placeholder { + color: var(--theme-color-3); +} +.scalar .scalar-app-header { + width: 100%; + z-index: 1000; + padding: 6px 12px 6px 12px; + border-radius: 0.25rem 0.25rem 0 0; + font-size: 14px; + height: var(--scalar-app-header-height); + display: flex; + align-items: center; + flex-shrink: 0; + gap: 6px; +} +.scalar .scalar-api-client { + max-height: calc(100dvh - (100px + var(--scalar-app-header-height))) !important; + border-radius: 8px; +} +.scalar-api-client__close { + appearance: none; + border: none; + outline: none; + display: flex; + align-items: center; + background: transparent; + color: var(--theme-color-1); + font-size: var(--theme-small); + font-weight: var(--theme-semibold); +} +.scalar-api-client__close:hover { + cursor: pointer; +} +.scalar .scalar-app { + background: var(--theme-background-3); + height: calc(100dvh - 100px); + max-width: 1280px; + width: 100%; + margin: auto; + opacity: 0; + animation: scalarapiclientfadein 0.35s forwards; + z-index: 1002; + position: relative; + overflow: hidden; + border-radius: 8px; + display: flex; + flex-direction: column; +} +@keyframes scalarapiclientfadein { + from { + transform: translate3d(0, 20px, 0) scale(0.985); + opacity: 0; + } + to { + transform: translate3d(0, 0, 0) scale(1); + opacity: 1; + } +} +.scalar .scalar-app-exit { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.62); + transition: all 0.3s ease-in-out; + z-index: 1000; + cursor: pointer; + animation: scalardrawerexitfadein 0.35s forwards; +} +.scalar .scalar-app-exit:before { + content: '\00d7'; + font-family: sans-serif; + position: absolute; + top: 0; + right: 0; + font-size: 30px; + font-weight: 100; + line-height: 50px; + right: 12px; + text-align: center; + color: white; + opacity: 0.6; +} +.scalar .scalar-app-exit:hover:before { + opacity: 1; +} +@keyframes scalardrawerexitfadein { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +.scalar-container { + overflow: hidden; + visibility: visible; + position: fixed; + bottom: 0; + left: 0; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1001; + display: flex; + align-items: center; + justify-content: center; +} + +.scalar .url-form-input { + min-height: auto !important; +} + +.scalar .scalar-container { + line-height: normal; +} +.scalar .scalar-app-header span { + color: var(--theme-color-3); +} +.scalar .scalar-app-header a { + color: var(--theme-color-1); +} +.scalar .scalar-app-header a:hover { + text-decoration: underline; +} +.scalar-activate { + width: fit-content; + margin: 0px 0.75rem 0.75rem auto; + line-height: 24px; + font-size: 0.75rem; + cursor: pointer; + font-size: 0.875rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; +} +.scalar-activate-button { + display: flex; + gap: 6px; + align-items: center; + color: var(--theme-color-blue); + appearance: none; + outline: none; + border: none; + background: transparent; +} +.scalar-activate-button { + padding: 0 0.5rem; +} +.scalar-activate:hover .scalar-activate-button { + background: var(--theme-background-3); + border-radius: 3px; +} + +.scalar-app-loading { + flex: 1; + display: flex; + align-items: center; + justify-content: center; +} +.scalar .request-method { + white-space: nowrap; +} +/* Use :where to lower specificity to 0 */ +.scalar .custom-scroll { + overflow-y: auto; + scrollbar-color: transparent transparent; + scrollbar-width: thin; + -webkit-overflow-scrolling: touch; +} +@supports (-moz-appearance: none) { + .scalar .custom-scroll { + padding-right: 12px; + } +} +.scalar .custom-scroll:hover { + scrollbar-color: rgba(0, 0, 0, 0.24) transparent; +} +.dark .scalar .custom-scroll:hover { + scrollbar-color: rgba(255, 255, 255, 0.24) transparent; +} +.scalar .custom-scroll:hover::-webkit-scrollbar-thumb { + background: var(--theme-scrollbar-color, var(--default-theme-scrollbar-color)); + background-clip: content-box; + border: 3px solid transparent; +} +.scalar .custom-scroll::-webkit-scrollbar-thumb:active { + background: var(--theme-scrollbar-color-active, var(--default-theme-scrollbar-color-active)); + background-clip: content-box; + border: 3px solid transparent; +} +.scalar .custom-scroll::-webkit-scrollbar-corner { + background: transparent; +} +.scalar .custom-scroll::-webkit-scrollbar { + height: 12px; + width: 12px; +} +.scalar .custom-scroll::-webkit-scrollbar-track { + background: transparent; +} +.scalar .custom-scroll::-webkit-scrollbar-thumb { + border-radius: 20px; + background: transparent; + background-clip: content-box; + border: 3px solid transparent; +} +@media (pointer: coarse) { + .scalar .custom-scroll { + padding-right: 12px; + } +} diff --git a/src/middleware.ts b/src/middleware.ts index 9c5b6277b..c159ee8c4 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -29,7 +29,7 @@ import { waitUntil } from './lib/waitUntil'; export const config = { matcher: - '/((?!_next/static|_next/image|~gitbook/revalidate|~gitbook/image|~gitbook/monitoring|~gitbook/static).*)', + '/((?!_next/static|_next/image|~gitbook/revalidate|~gitbook/image|~gitbook/monitoring|~gitbook/static|~scalar/proxy).*)', skipTrailingSlashRedirect: true, };