Adapt OpenAPI to eval adaptive exprs & prefill TryIt config (#3607)

This commit is contained in:
spastorelli
2025-09-05 13:39:12 +02:00
committed by GitHub
parent 45207288a1
commit 12c9d765ff
17 changed files with 788 additions and 37 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@gitbook/react-openapi": minor
"gitbook": minor
"@gitbook/openapi-parser": patch
---
Adapt OpenAPI blocks to eval adaptive exprs & prefill TryIt config
+23 -2
View File
@@ -99,6 +99,7 @@
"@gitbook/cache-tags": "workspace:*",
"@gitbook/colors": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/expr": "workspace:*",
"@gitbook/fonts": "workspace:*",
"@gitbook/icons": "workspace:*",
"@gitbook/openapi-parser": "workspace:*",
@@ -262,9 +263,11 @@
"name": "@gitbook/react-openapi",
"version": "1.3.6",
"dependencies": {
"@gitbook/expr": "workspace:*",
"@gitbook/openapi-parser": "workspace:*",
"@scalar/api-client-react": "^1.3.16",
"@scalar/oas-utils": "^0.2.130",
"@scalar/types": "^0.1.9",
"clsx": "^2.1.1",
"flatted": "^3.2.9",
"js-yaml": "^4.1.0",
@@ -1173,7 +1176,7 @@
"@scalar/themes": ["@scalar/themes@0.9.86", "", { "dependencies": { "@scalar/types": "0.1.7" } }, "sha512-QUHo9g5oSWi+0Lm1vJY9TaMZRau8LHg+vte7q5BVTBnu6NuQfigCaN+ouQ73FqIVd96TwMO6Db+dilK1B+9row=="],
"@scalar/types": ["@scalar/types@0.2.3", "", { "dependencies": { "@scalar/openapi-types": "0.3.3", "nanoid": "^5.1.5", "zod": "3.24.1" } }, "sha512-K1/Vp5xaQ8TEGDYwHg88OBuQWJRWrE3ToZ0Z2LJwskcY1Eg+uDiRQXKF4QPngZzu5rrWgRErAq4yte4Y7omqgA=="],
"@scalar/types": ["@scalar/types@0.1.16", "", { "dependencies": { "@scalar/openapi-types": "0.2.3", "nanoid": "^5.1.5", "zod": "3.24.1" } }, "sha512-v1L96F8Inn27NUw5Xcl0iifw3SOX0R9WBV6GeswR23s8i1SJz7UiLEi8rLOqOB5DiLtC8hDK0kNl4TKn7MNJWQ=="],
"@scalar/use-codemirror": ["@scalar/use-codemirror@0.12.13", "", { "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.8", "@codemirror/lang-json": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.10.7", "@codemirror/lint": "^6.8.4", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", "@lezer/common": "^1.2.3", "@lezer/highlight": "^1.2.1", "@replit/codemirror-css-color-picker": "^6.3.0", "@scalar/components": "0.14.12", "codemirror": "^6.0.0", "vue": "^3.5.12" } }, "sha512-XOXCUT3b1l0gkp4CY51e7xMgR6IP/AMpYIZ97fBOOjnrUIJ8B/J+U/BMS6+lPKiN0hexaSALRWlVBf90txdT8w=="],
@@ -3963,10 +3966,14 @@
"@scalar/api-client/@scalar/themes": ["@scalar/themes@0.13.4", "", { "dependencies": { "@scalar/types": "0.2.3", "nanoid": "^5.1.5" } }, "sha512-PMiSqX+MZF29u32ogEjTVA5c/LBd5cE/QPF7lK0EJeemXRy2r/nWbvrXtqD9Y7Sw54g1caVNjK8yJcCYJdB6hw=="],
"@scalar/api-client/@scalar/types": ["@scalar/types@0.2.3", "", { "dependencies": { "@scalar/openapi-types": "0.3.3", "nanoid": "^5.1.5", "zod": "3.24.1" } }, "sha512-K1/Vp5xaQ8TEGDYwHg88OBuQWJRWrE3ToZ0Z2LJwskcY1Eg+uDiRQXKF4QPngZzu5rrWgRErAq4yte4Y7omqgA=="],
"@scalar/api-client/pretty-ms": ["pretty-ms@8.0.0", "", { "dependencies": { "parse-ms": "^3.0.0" } }, "sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q=="],
"@scalar/api-client/zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="],
"@scalar/api-client-react/@scalar/types": ["@scalar/types@0.2.3", "", { "dependencies": { "@scalar/openapi-types": "0.3.3", "nanoid": "^5.1.5", "zod": "3.24.1" } }, "sha512-K1/Vp5xaQ8TEGDYwHg88OBuQWJRWrE3ToZ0Z2LJwskcY1Eg+uDiRQXKF4QPngZzu5rrWgRErAq4yte4Y7omqgA=="],
"@scalar/components/@scalar/oas-utils": ["@scalar/oas-utils@0.4.7", "", { "dependencies": { "@hyperjump/browser": "^1.1.0", "@hyperjump/json-schema": "^1.9.6", "@scalar/helpers": "0.0.4", "@scalar/object-utils": "1.2.1", "@scalar/openapi-types": "0.3.3", "@scalar/themes": "0.13.4", "@scalar/types": "0.2.3", "@types/har-format": "^1.2.15", "flatted": "^3.3.1", "microdiff": "^1.4.0", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "yaml": "^2.4.5", "zod": "3.24.1" } }, "sha512-Vp8iZZCjNKXcPpL0Yr6dmy+VOSN1LZ5CdlGAHtp+pxuQTeahV6FxiVjMc9kdFve77fTNyut4seVAD6d7rDUDcA=="],
"@scalar/components/@scalar/themes": ["@scalar/themes@0.13.4", "", { "dependencies": { "@scalar/types": "0.2.3", "nanoid": "^5.1.5" } }, "sha512-PMiSqX+MZF29u32ogEjTVA5c/LBd5cE/QPF7lK0EJeemXRy2r/nWbvrXtqD9Y7Sw54g1caVNjK8yJcCYJdB6hw=="],
@@ -3983,7 +3990,7 @@
"@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, "sha512-irIDYzTQG2KLvFbuTI8k2Pz/R4JR+zUUSykVTbEMatkzMmVFnn1VzNSMlODbadycwZunbnL2tA27AXed9URVjw=="],
"@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.3.3", "", { "dependencies": { "zod": "3.24.1" } }, "sha512-mr3OvbCyvuzSDgv9gR9D3pJAsl82BRNBp1P8ldgRgKF4Wiswfa7nQeKibpX0kHSGXLgiyvxWJULUFwgdWMXNNA=="],
"@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.2.3", "", { "dependencies": { "zod": "3.24.1" } }, "sha512-O1GwqLpcRc3GKXTbeBZ5E12fXR2ltpqGWk4RfhoN4ebKZsPVknV5at5425G97E1SwMy12BporRvn90k1Z+MruQ=="],
"@scalar/types/zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="],
@@ -5081,18 +5088,28 @@
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
"@scalar/api-client-react/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.3.3", "", { "dependencies": { "zod": "3.24.1" } }, "sha512-mr3OvbCyvuzSDgv9gR9D3pJAsl82BRNBp1P8ldgRgKF4Wiswfa7nQeKibpX0kHSGXLgiyvxWJULUFwgdWMXNNA=="],
"@scalar/api-client-react/@scalar/types/zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="],
"@scalar/api-client/pretty-ms/parse-ms": ["parse-ms@3.0.0", "", {}, "sha512-Tpb8Z7r7XbbtBTrM9UhpkzzaMrqA2VXMT3YChzYltwV3P3pM6t8wl7TvpMnSTosz1aQAdVib7kdoys7vYOPerw=="],
"@scalar/components/@scalar/oas-utils/@scalar/object-utils": ["@scalar/object-utils@1.2.1", "", { "dependencies": { "flatted": "^3.3.1", "just-clone": "^6.2.0", "ts-deepmerge": "^7.0.1", "type-fest": "^4.20.0" } }, "sha512-2P/0In6XSrV1Ye8yU3LyOWJKWkzXpxf0thHmTfJriqGBZz41s06td2KKqoqHSEEi+FAsl3O1ngmOOzLM3QUkkw=="],
"@scalar/components/@scalar/oas-utils/@scalar/openapi-types": ["@scalar/openapi-types@0.3.3", "", { "dependencies": { "zod": "3.24.1" } }, "sha512-mr3OvbCyvuzSDgv9gR9D3pJAsl82BRNBp1P8ldgRgKF4Wiswfa7nQeKibpX0kHSGXLgiyvxWJULUFwgdWMXNNA=="],
"@scalar/components/@scalar/oas-utils/@scalar/types": ["@scalar/types@0.2.3", "", { "dependencies": { "@scalar/openapi-types": "0.3.3", "nanoid": "^5.1.5", "zod": "3.24.1" } }, "sha512-K1/Vp5xaQ8TEGDYwHg88OBuQWJRWrE3ToZ0Z2LJwskcY1Eg+uDiRQXKF4QPngZzu5rrWgRErAq4yte4Y7omqgA=="],
"@scalar/components/@scalar/oas-utils/zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="],
"@scalar/components/@scalar/themes/@scalar/types": ["@scalar/types@0.2.3", "", { "dependencies": { "@scalar/openapi-types": "0.3.3", "nanoid": "^5.1.5", "zod": "3.24.1" } }, "sha512-K1/Vp5xaQ8TEGDYwHg88OBuQWJRWrE3ToZ0Z2LJwskcY1Eg+uDiRQXKF4QPngZzu5rrWgRErAq4yte4Y7omqgA=="],
"@scalar/postman-to-openapi/@scalar/oas-utils/@scalar/object-utils": ["@scalar/object-utils@1.2.1", "", { "dependencies": { "flatted": "^3.3.1", "just-clone": "^6.2.0", "ts-deepmerge": "^7.0.1", "type-fest": "^4.20.0" } }, "sha512-2P/0In6XSrV1Ye8yU3LyOWJKWkzXpxf0thHmTfJriqGBZz41s06td2KKqoqHSEEi+FAsl3O1ngmOOzLM3QUkkw=="],
"@scalar/postman-to-openapi/@scalar/oas-utils/@scalar/themes": ["@scalar/themes@0.13.4", "", { "dependencies": { "@scalar/types": "0.2.3", "nanoid": "^5.1.5" } }, "sha512-PMiSqX+MZF29u32ogEjTVA5c/LBd5cE/QPF7lK0EJeemXRy2r/nWbvrXtqD9Y7Sw54g1caVNjK8yJcCYJdB6hw=="],
"@scalar/postman-to-openapi/@scalar/oas-utils/@scalar/types": ["@scalar/types@0.2.3", "", { "dependencies": { "@scalar/openapi-types": "0.3.3", "nanoid": "^5.1.5", "zod": "3.24.1" } }, "sha512-K1/Vp5xaQ8TEGDYwHg88OBuQWJRWrE3ToZ0Z2LJwskcY1Eg+uDiRQXKF4QPngZzu5rrWgRErAq4yte4Y7omqgA=="],
"@scalar/postman-to-openapi/@scalar/oas-utils/zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="],
"@scalar/postman-to-openapi/@scalar/openapi-types/zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="],
@@ -5439,6 +5456,10 @@
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw=="],
"@scalar/components/@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.3.3", "", { "dependencies": { "zod": "3.24.1" } }, "sha512-mr3OvbCyvuzSDgv9gR9D3pJAsl82BRNBp1P8ldgRgKF4Wiswfa7nQeKibpX0kHSGXLgiyvxWJULUFwgdWMXNNA=="],
"@scalar/components/@scalar/themes/@scalar/types/zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="],
"@smithy/core/@smithy/util-stream/@smithy/fetch-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.0.1", "", { "dependencies": { "@smithy/types": "^4.1.0", "@smithy/util-uri-escape": "^4.0.0", "tslib": "^2.6.2" } }, "sha512-wU87iWZoCbcqrwszsOewEIuq+SU2mSoBE2CcsLwE0I19m0B2gOJr1MVjxWcDQYOzHbR1xCk7AcOBbGFUYOKvdg=="],
"@smithy/core/@smithy/util-stream/@smithy/node-http-handler/@smithy/abort-controller": ["@smithy/abort-controller@4.0.1", "", { "dependencies": { "@smithy/types": "^4.1.0", "tslib": "^2.6.2" } }, "sha512-fiUIYgIgRjMWznk6iLJz35K2YxSLHzLBA/RC6lBrKfQ8fHbPfvk7Pk9UvpKoHgJjI18MnbPuEju53zcVy6KF1g=="],
+1
View File
@@ -7,6 +7,7 @@
"@gitbook/browser-types": "workspace:*",
"@gitbook/cache-tags": "workspace:*",
"@gitbook/colors": "workspace:*",
"@gitbook/expr": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/fonts": "workspace:*",
"@gitbook/icons": "workspace:*",
@@ -0,0 +1,103 @@
'use client';
import type { GitBookSiteContext } from '@/lib/context';
import { OpenAPIPrefillContextProvider } from '@gitbook/react-openapi';
import * as React from 'react';
import { createContext, useContext } from 'react';
export type AdaptiveVisitorClaimsData = {
visitor: {
claims: Record<string, unknown> & { unsigned: Record<string, unknown> };
};
};
/**
* In-memory cache of visitor claim readers keyed by contextId.
*/
const adaptiveVisitorReaderCache = new Map<
string,
ReturnType<typeof createResourceReader<AdaptiveVisitorClaimsData | null>>
>();
function createResourceReader<T>(promise: Promise<T>) {
let result: T | null | undefined;
const suspender = (async () => {
try {
result = await promise;
} catch {
result = null;
}
})();
return {
read() {
if (result === undefined) {
throw suspender;
}
return result;
},
};
}
/**
* Return an adaptive visitor claims cached reader for a given endpoint URL and contextId.
*/
function getAdaptiveVisitorClaimsReader(url: string, contextId: string) {
let reader = adaptiveVisitorReaderCache.get(contextId);
if (!reader) {
const promise = (async () => {
try {
const res = await fetch(url);
if (!res.ok) {
return null;
}
return await res.json<AdaptiveVisitorClaimsData>();
} catch {
return null;
}
})();
reader = createResourceReader(promise);
adaptiveVisitorReaderCache.set(contextId, reader);
}
return reader;
}
export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaimsData | null;
const AdaptiveVisitorContext = createContext<AdaptiveVisitorContextValue>(() => null);
/**
* Provide context to adapt site based on visitor claims.
*/
export function AdaptiveVisitorContextProvider(
props: React.PropsWithChildren<{
visitorClaimsURL: string;
contextId: GitBookSiteContext['contextId'] | undefined;
}>
) {
const { visitorClaimsURL, contextId, children } = props;
const getAdaptiveVisitorClaims = React.useCallback(() => {
if (!contextId) {
return null;
}
return getAdaptiveVisitorClaimsReader(visitorClaimsURL, contextId).read();
}, [visitorClaimsURL, contextId]);
return (
<AdaptiveVisitorContext.Provider value={getAdaptiveVisitorClaims}>
<OpenAPIPrefillContextProvider getPrefillInputContextData={getAdaptiveVisitorClaims}>
{children}
</OpenAPIPrefillContextProvider>
</AdaptiveVisitorContext.Provider>
);
}
/**
* Hook that returns a suspensable getter for adaptive visitor claims data.
*/
export function useAdaptiveVisitor(): AdaptiveVisitorContextValue {
return useContext(AdaptiveVisitorContext);
}
@@ -0,0 +1 @@
export * from './AdaptiveVisitorContextProvider';
@@ -17,6 +17,7 @@ import { GITBOOK_APP_URL } from '@/lib/env';
import { AIChatProvider } from '../AI';
import type { RenderAIMessageOptions } from '../AI';
import { AIChat } from '../AIChat';
import { AdaptiveVisitorContextProvider } from '../Adaptive';
import { Announcement } from '../Announcement';
import { SpacesDropdown } from '../Header/SpacesDropdown';
import { InsightsProvider } from '../Insights';
@@ -56,29 +57,38 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
eventUrl.searchParams.set('o', context.organizationId);
eventUrl.searchParams.set('s', context.site.id);
const getVisitorClaimsUrl = context.linker.toAbsoluteURL(
context.linker.toPathInSite('/~gitbook/visitor')
);
return (
<SpaceLayoutContextProvider basePath={context.linker.toPathInSpace('')}>
<CurrentContentProvider
organizationId={context.organizationId}
siteId={context.site.id}
siteSectionId={context.sections?.current?.id ?? null}
siteSpaceId={context.siteSpace.id}
siteShareKey={context.shareKey ?? null}
spaceId={context.space.id}
revisionId={context.revisionId}
visitorAuthClaims={visitorAuthClaims}
<AdaptiveVisitorContextProvider
contextId={context.contextId}
visitorClaimsURL={getVisitorClaimsUrl}
>
<InsightsProvider
enabled={withTracking}
appURL={GITBOOK_APP_URL}
eventUrl={eventUrl.toString()}
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
<CurrentContentProvider
organizationId={context.organizationId}
siteId={context.site.id}
siteSectionId={context.sections?.current?.id ?? null}
siteSpaceId={context.siteSpace.id}
siteShareKey={context.shareKey ?? null}
spaceId={context.space.id}
revisionId={context.revisionId}
visitorAuthClaims={visitorAuthClaims}
>
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
{children}
</AIChatProvider>
</InsightsProvider>
</CurrentContentProvider>
<InsightsProvider
enabled={withTracking}
appURL={GITBOOK_APP_URL}
eventUrl={eventUrl.toString()}
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
>
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
{children}
</AIChatProvider>
</InsightsProvider>
</CurrentContentProvider>
</AdaptiveVisitorContextProvider>
</SpaceLayoutContextProvider>
);
}
+43 -1
View File
@@ -1,5 +1,5 @@
import { type JwtPayload, jwtDecode } from 'jwt-decode';
import type { NextRequest } from 'next/server';
import { type NextRequest, NextResponse } from 'next/server';
import hash from 'object-hash';
const VISITOR_AUTH_PARAM = 'jwt_token';
@@ -443,3 +443,45 @@ export function getVisitorAuthCookieMaxAge(decoded: JwtPayload): number {
return defaultMaxAge;
}
/**
* Handler for the /~gitbook/visitor middleware route to expose visitor data.
*/
export function serveVisitorClaimsDataRequest(request: NextRequest, siteRequestURL: URL) {
const { visitorToken, unsignedClaims } = getVisitorData({
cookies: request.cookies.getAll(),
url: siteRequestURL,
});
if (!visitorToken && !Object.keys(unsignedClaims).length) {
return NextResponse.json({});
}
const visitorClaims = {
visitor: {
claims: {
unsigned: unsignedClaims,
},
},
};
if (!visitorToken) {
return NextResponse.json(visitorClaims);
}
try {
const decodedJwtPayload = jwtDecode(visitorToken.token);
return NextResponse.json({
visitor: {
claims: {
...visitorClaims.visitor.claims,
...decodedJwtPayload,
},
},
});
} catch (error) {
console.warn('Error decoding visitor token', error);
}
return NextResponse.json(visitorClaims);
}
+6 -1
View File
@@ -22,6 +22,7 @@ import {
getResponseCookiesForVisitorAuth,
getVisitorData,
normalizeVisitorURL,
serveVisitorClaimsDataRequest,
} from '@/lib/visitors';
import { serveResizedImage } from '@/routes/image';
import { cookies } from 'next/headers';
@@ -126,7 +127,6 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
const { url: siteRequestURL, mode } = match;
const imagesContextId = getImageResizingContextId(siteRequestURL);
/**
* Serve image resizing requests (all requests containing `/~gitbook/image`).
* All URLs containing `/~gitbook/image` are rewritten to `/~gitbook/image`
@@ -154,6 +154,11 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
});
}
// Handler that returns visitor data for the app to consume.
if (siteRequestURL.pathname.endsWith('/~gitbook/visitor')) {
return serveVisitorClaimsDataRequest(request, siteRequestURL);
}
//
// Detect and extract the visitor authentication token from the request
//
+7
View File
@@ -70,6 +70,13 @@ export interface OpenAPICustomOperationProperties {
'x-stability'?: OpenAPIStability;
}
/**
* Custom properties that can be defined to enable prefilling for OpenAPI blocks (e.g TryIt functionality).
*/
export interface OpenAPICustomPrefillProperties {
'x-gitbook-prefill'?: string;
}
export type OpenAPIStability = 'experimental' | 'alpha' | 'beta';
/**
+2
View File
@@ -11,9 +11,11 @@
"version": "1.3.6",
"sideEffects": false,
"dependencies": {
"@gitbook/expr": "workspace:*",
"@gitbook/openapi-parser": "workspace:*",
"@scalar/api-client-react": "^1.3.16",
"@scalar/oas-utils": "^0.2.130",
"@scalar/types": "^0.1.9",
"clsx": "^2.1.1",
"flatted": "^3.2.9",
"json-xml-parse": "^1.3.0",
@@ -194,7 +194,7 @@ function OpenAPICodeSampleFooter(props: {
context: OpenAPIContext;
}) {
const { data, context, renderers } = props;
const { method, path } = data;
const { method, path, securities, servers } = data;
const { specUrl } = context;
const hideTryItPanel = data['x-hideTryItPanel'] || data.operation['x-hideTryItPanel'];
const hasMultipleMediaTypes =
@@ -226,6 +226,8 @@ function OpenAPICodeSampleFooter(props: {
context={getOpenAPIClientContext(context)}
method={method}
path={path}
securities={securities}
servers={servers}
specUrl={specUrl}
/>
)}
@@ -0,0 +1,40 @@
'use client';
import * as React from 'react';
/**
* Prefill data that can be used to dynamically inject info into OpenAPI operation blocks.
*
* This is typically dynamic input context, such as visitor data or environment info.
*/
export type PrefillInputContextData = Record<string, unknown>;
/**
* Context value is function that returns prefill data.
*/
type PrefillContextValue = () => PrefillInputContextData | null;
const OpenAPIPrefillContext = React.createContext<PrefillContextValue | null>(null);
/**
* Provide context to help prefill dynamic info like visitor data in OpenAPI blocks.
*/
export function OpenAPIPrefillContextProvider(
props: React.PropsWithChildren<{
getPrefillInputContextData: () => PrefillInputContextData | null;
}>
) {
const { getPrefillInputContextData, children } = props;
return (
<OpenAPIPrefillContext.Provider value={getPrefillInputContextData}>
{children}
</OpenAPIPrefillContext.Provider>
);
}
/**
* Hook to access the prefill context function.
*/
export function useOpenAPIPrefillContext(): PrefillContextValue {
return React.useContext(OpenAPIPrefillContext) ?? (() => null);
}
+39 -11
View File
@@ -1,13 +1,16 @@
'use client';
import { ApiClientModalProvider, useApiClientModal } from '@scalar/api-client-react';
import { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { Suspense, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { OpenAPIV3_1 } from '@gitbook/openapi-parser';
import { useOpenAPIOperationContext } from './OpenAPIOperationContext';
import { useOpenAPIPrefillContext } from './OpenAPIPrefillContextProvider';
import type { OpenAPIClientContext } from './context';
import { t } from './translate';
import type { OpenAPIOperationData } from './types';
import { resolveTryItPrefillForOperation } from './util/tryit-prefill';
/**
* Button which launches the Scalar API Client
@@ -15,12 +18,15 @@ import { t } from './translate';
export function ScalarApiButton(props: {
method: OpenAPIV3_1.HttpMethods;
path: string;
securities: OpenAPIOperationData['securities'];
servers: OpenAPIOperationData['servers'];
specUrl: string;
context: OpenAPIClientContext;
}) {
const { method, path, specUrl, context } = props;
const { method, path, securities, servers, specUrl, context } = props;
const [isOpen, setIsOpen] = useState(false);
const controllerRef = useRef<ScalarModalControllerRef>(null);
return (
<div className="scalar scalar-activate">
<button
@@ -42,12 +48,16 @@ export function ScalarApiButton(props: {
{isOpen &&
createPortal(
<ScalarModal
controllerRef={controllerRef}
method={method}
path={path}
specUrl={specUrl}
/>,
<Suspense fallback={null}>
<ScalarModal
controllerRef={controllerRef}
method={method}
path={path}
securities={securities}
servers={servers}
specUrl={specUrl}
/>
</Suspense>,
document.body
)}
</div>
@@ -57,12 +67,26 @@ export function ScalarApiButton(props: {
function ScalarModal(props: {
method: OpenAPIV3_1.HttpMethods;
path: string;
securities: OpenAPIOperationData['securities'];
servers: OpenAPIOperationData['servers'];
specUrl: string;
controllerRef: React.Ref<ScalarModalControllerRef>;
}) {
const { method, path, specUrl, controllerRef } = props;
const { method, path, securities, servers, specUrl, controllerRef } = props;
const getPrefillInputContextData = useOpenAPIPrefillContext();
const prefillInputContext = getPrefillInputContextData();
const prefillConfig = resolveTryItPrefillForOperation({
operation: { securities, servers },
prefillInputContext,
});
return (
<ApiClientModalProvider configuration={{ url: specUrl }} initialRequest={{ method, path }}>
<ApiClientModalProvider
configuration={{ url: specUrl, ...prefillConfig }}
initialRequest={{ method, path }}
>
<ScalarModalController method={method} path={path} controllerRef={controllerRef} />
</ApiClientModalProvider>
);
@@ -84,7 +108,11 @@ function ScalarModalController(props: {
const openClient = useMemo(() => {
if (openScalarClient) {
return () => {
openScalarClient({ method, path, _source: 'gitbook' });
openScalarClient({
method,
path,
_source: 'gitbook',
});
trackClientOpening({ method, path });
};
}
+1
View File
@@ -2,6 +2,7 @@ export * from './schemas';
export * from './OpenAPIOperation';
export * from './OpenAPIWebhook';
export * from './OpenAPIOperationContext';
export * from './OpenAPIPrefillContextProvider';
export * from './resolveOpenAPIOperation';
export * from './resolveOpenAPIWebhook';
export type { OpenAPIOperationData, OpenAPIWebhookData } from './types';
+16 -2
View File
@@ -1,17 +1,31 @@
import type {
OpenAPICustomOperationProperties,
OpenAPICustomPrefillProperties,
OpenAPICustomSpecProperties,
OpenAPIV3,
} from '@gitbook/openapi-parser';
export type OpenAPISecurityWithRequired = OpenAPIV3.SecuritySchemeObject & { required?: boolean };
export type OpenAPIServerVariableWithCustomProperties = OpenAPIV3.ServerVariableObject &
OpenAPICustomPrefillProperties;
/**
* OpenAPI ServerObject type extended to provide x-gitbook prefill custom properties at the variable level.
*/
export type OpenAPIServerWithCustomProperties = Omit<OpenAPIV3.ServerObject, 'variables'> & {
variables?: {
[variable: string]: OpenAPIServerVariableWithCustomProperties;
};
} & OpenAPICustomPrefillProperties;
export type OpenAPISecurityWithRequired = OpenAPIV3.SecuritySchemeObject &
OpenAPICustomPrefillProperties & { required?: boolean };
export interface OpenAPIOperationData extends OpenAPICustomSpecProperties {
path: string;
method: string;
/** Servers to be used for this operation */
servers: OpenAPIV3.ServerObject[];
servers: OpenAPIServerWithCustomProperties[];
/** Spec of the operation */
operation: OpenAPIV3.OperationObject<OpenAPICustomOperationProperties>;
@@ -0,0 +1,311 @@
import { describe, expect, it } from 'bun:test';
import type { PrefillInputContextData } from '../OpenAPIPrefillContextProvider';
import type { OpenAPIOperationData } from '../types';
import { resolveTryItPrefillForOperation } from './tryit-prefill';
describe('resolveTryItPrefillForOperation', () => {
describe('prefill authentication info', () => {
it('should resolve prefill for bearer token scheme', () => {
const operation: OpenAPIOperationData = {
path: '/orgs/<orgId>/spaces/<spaceId>',
method: 'GET',
operation: { summary: 'Get space by ID' },
servers: [{ url: 'https://api.gitbook.com/v1/' }],
securities: [
[
'apiTokenScheme',
{
type: 'http',
scheme: 'bearer',
'x-gitbook-prefill': '{{ visitor.claims.apiToken }}',
},
],
],
};
const prefillInputContext: PrefillInputContextData = {
visitor: { claims: { apiToken: 'gb_api_testToken' } },
};
const result = resolveTryItPrefillForOperation({
operation,
prefillInputContext,
});
expect(result).toEqual({
authentication: {
securitySchemes: {
apiTokenScheme: { token: 'gb_api_testToken' },
},
},
});
});
it('should resolve prefill for basic auth scheme', () => {
const operation: OpenAPIOperationData = {
path: '/orgs/<orgId>/spaces',
method: 'PUT',
operation: { summary: 'Create space in an org' },
servers: [{ url: 'https://api.gitbook.com/v1/' }],
securities: [
[
'basicAuthScheme',
{
type: 'http',
scheme: 'basic',
'x-gitbook-prefill': '{{ visitor.claims.basicAuth }}',
},
],
],
};
const prefillInputContext: PrefillInputContextData = {
visitor: { claims: { basicAuth: 'testuser:testpassword' } },
};
const result = resolveTryItPrefillForOperation({
operation,
prefillInputContext,
});
expect(result).toEqual({
authentication: {
securitySchemes: {
basicAuthScheme: { username: 'testuser', password: 'testpassword' },
},
},
});
});
it('should resolve prefill for apiKey scheme', () => {
const operation: OpenAPIOperationData = {
path: '/orgs/<orgId>/spaces/<spaceId>',
method: 'POST',
operation: { summary: 'Update space by ID' },
servers: [{ url: 'https://api.gitbook.com/v1/' }],
securities: [
[
'apiKeyHeader',
{
type: 'apiKey',
in: 'header',
name: 'X-API-KEY',
'x-gitbook-prefill': '{{ visitor.claims.apiKey }}',
},
],
],
};
const prefillInputContext: PrefillInputContextData = {
visitor: { claims: { apiKey: 'key-123' } },
};
const result = resolveTryItPrefillForOperation({
operation,
prefillInputContext,
});
expect(result).toEqual({
authentication: {
securitySchemes: {
apiKeyHeader: { name: 'X-API-KEY', in: 'header', value: 'key-123' },
},
},
});
});
it('should return empty object if no visitor data matches prefill expression', () => {
const operation: OpenAPIOperationData = {
path: '/orgs/<orgId>/spaces',
method: 'GET',
operation: { summary: 'List all spaces in an org' },
servers: [{ url: 'https://api.gitbook.com/v1/' }],
securities: [
[
'bearer',
{
type: 'http',
scheme: 'bearer',
'x-gitbook-prefill': '{{ visitor.claims.missing }}',
},
],
],
};
const prefillInputContext: PrefillInputContextData = {
visitor: { claims: {} },
};
const result = resolveTryItPrefillForOperation({
operation,
prefillInputContext,
});
expect(result).toEqual({});
});
});
describe('prefill server info', () => {
it('should prefill server at url level', () => {
const operation: OpenAPIOperationData = {
path: '/orgs/<orgId>/spaces',
method: 'GET',
operation: { summary: 'List spaces in an org' },
servers: [
{
url: 'https://api.gitbook.com/v1/',
description: 'GitBook API endpoint',
'x-gitbook-prefill': '{{ visitor.claims.api.endpointUrl }}',
},
],
securities: [],
};
const prefillInputContext: PrefillInputContextData = {
visitor: {
claims: { api: { endpointUrl: 'https://api.gitbook-staging.com/v1/' } },
},
};
const result = resolveTryItPrefillForOperation({
operation,
prefillInputContext,
});
expect(result).toEqual({
servers: [
{
url: 'https://api.gitbook-staging.com/v1/',
description: 'GitBook API endpoint',
},
],
});
});
it('should prefill server at variables level', () => {
const operation: OpenAPIOperationData = {
path: '/orgs/<orgId>/spaces',
method: 'GET',
operation: { summary: 'List spaces in an org' },
servers: [
{
url: 'https://api.{domain}/{version}/',
description: 'Versioned API endpoint by environment',
variables: {
domain: {
default: 'gitbook.com',
'x-gitbook-prefill':
'{{ visitor.claims.api.env === "staging" ? "gitbook-staging.com" : "gitbook.com" }}',
},
version: {
default: 'v1',
'x-gitbook-prefill': '{{ visitor.claims.api.version }}',
},
},
},
],
securities: [],
};
// Override env
const overrideEnvResult = resolveTryItPrefillForOperation({
operation,
prefillInputContext: { visitor: { claims: { api: { env: 'staging' } } } },
});
expect(overrideEnvResult).toEqual({
servers: [
{
url: 'https://api.{domain}/{version}/',
description: 'Versioned API endpoint by environment',
variables: {
domain: { default: 'gitbook-staging.com' },
version: { default: 'v1' },
},
},
],
});
// Override version
const overrideVersionResult = resolveTryItPrefillForOperation({
operation,
prefillInputContext: { visitor: { claims: { api: { version: 'v2' } } } },
});
expect(overrideVersionResult).toEqual({
servers: [
{
url: 'https://api.{domain}/{version}/',
description: 'Versioned API endpoint by environment',
variables: {
domain: { default: 'gitbook.com' },
version: { default: 'v2' },
},
},
],
});
// Override both
const overrideBoth = resolveTryItPrefillForOperation({
operation,
prefillInputContext: {
visitor: { claims: { api: { env: 'staging', version: 'v2' } } },
},
});
expect(overrideBoth).toEqual({
servers: [
{
url: 'https://api.{domain}/{version}/',
description: 'Versioned API endpoint by environment',
variables: {
domain: { default: 'gitbook-staging.com' },
version: { default: 'v2' },
},
},
],
});
});
it('should ignore server prefill when no matching visitor data', () => {
const operation: OpenAPIOperationData = {
path: '/orgs/<orgId>/spaces',
method: 'GET',
operation: { summary: 'List spaces in an org' },
servers: [
{
url: 'https://api.{domain}/{version}/',
description: 'Versioned API endpoint by environment',
variables: {
domain: {
default: 'gitbook.com',
'x-gitbook-prefill':
'{{ visitor.claims.api.env === "staging" ? "gitbook-staging.com" : "gitbook.com" }}',
},
version: {
default: 'v1',
'x-gitbook-prefill': '{{ visitor.claims.api.version }}',
},
},
},
],
securities: [],
};
const result = resolveTryItPrefillForOperation({
operation,
prefillInputContext: { visitor: { claims: { isBetaUser: true } } },
});
expect(result).toEqual({
servers: [
{
url: 'https://api.{domain}/{version}/',
description: 'Versioned API endpoint by environment',
variables: {
domain: { default: 'gitbook.com' },
version: { default: 'v1' },
},
},
],
});
});
});
});
@@ -0,0 +1,156 @@
import { ExpressionRuntime, parseTemplate } from '@gitbook/expr';
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import type { ApiClientConfiguration } from '@scalar/types';
import type { PrefillInputContextData } from '../OpenAPIPrefillContextProvider';
import type { OpenAPIOperationData } from '../types';
export interface TryItPrefillConfiguration {
authentication?: ApiClientConfiguration['authentication'];
servers?: ApiClientConfiguration['servers'];
}
/**
* Resolve the Scalar API client prefill configuration for a given OpenAPI operation.
*/
export function resolveTryItPrefillForOperation(args: {
/**
* The parsed OpenAPI operation.
*/
operation: Pick<OpenAPIOperationData, 'securities' | 'servers'>;
/**
* Prefill input context data.
*/
prefillInputContext: PrefillInputContextData | null;
}): TryItPrefillConfiguration {
const {
operation: { securities, servers },
prefillInputContext,
} = args;
// Fixed ExpressionRuntime and resolveTryItPrefillExpression function
const runtime = new ExpressionRuntime();
const resolveTryItPrefillExpression = (expr: string) => {
if (!prefillInputContext) return undefined;
const parts = parseTemplate(expr);
if (!parts.length) return undefined;
return runtime.evaluateTemplate(expr, prefillInputContext);
};
const prefillAuth = securities
? resolveTryItPrefillAuthForOperationSecurities({
securities,
resolveTryItPrefillExpression,
})
: undefined;
const prefillServers = servers
? resolveTryItPrefillServersForOperationServers({ servers, resolveTryItPrefillExpression })
: [];
return {
...(prefillAuth ? { authentication: prefillAuth } : {}),
...(prefillServers ? { servers: prefillServers } : {}),
};
}
/**
* Resolve prefill authentication configuration for the security schemes defined for an operation.
*/
function resolveTryItPrefillAuthForOperationSecurities(args: {
securities: OpenAPIOperationData['securities'];
resolveTryItPrefillExpression: (expr: string) => string | undefined;
}): ApiClientConfiguration['authentication'] | undefined {
const { securities, resolveTryItPrefillExpression } = args;
const prefillAuthConfig: ApiClientConfiguration['authentication']['securitySchemes'] = {};
for (const [schemeName, security] of Object.values(securities)) {
const tryitPrefillAuthValue = security['x-gitbook-prefill']
? resolveTryItPrefillExpression(security['x-gitbook-prefill'])
: undefined;
if (!tryitPrefillAuthValue) {
continue;
}
switch (security.type) {
case 'http': {
if (security.scheme?.includes('bearer')) {
prefillAuthConfig[schemeName] = { token: tryitPrefillAuthValue };
} else if (
security.scheme?.includes('basic') &&
tryitPrefillAuthValue.includes(':')
) {
const [username, password] = tryitPrefillAuthValue.split(':', 2);
prefillAuthConfig[schemeName] = { username, password };
}
break;
}
case 'apiKey': {
prefillAuthConfig[schemeName] = {
name: security.name,
in: security.in,
value: tryitPrefillAuthValue,
};
break;
}
case 'oauth2':
case 'openIdConnect': {
break;
}
}
}
return Object.keys(prefillAuthConfig).length > 0
? { securitySchemes: prefillAuthConfig }
: undefined;
}
/**
* Resolve prefill server configuration for the servers defined for an operation.
*/
function resolveTryItPrefillServersForOperationServers(args: {
servers: OpenAPIOperationData['servers'];
resolveTryItPrefillExpression: (expr: string) => string | undefined;
}): ApiClientConfiguration['servers'] | undefined {
const { servers, resolveTryItPrefillExpression } = args;
const resolvedServers: ApiClientConfiguration['servers'] = [];
for (const server of servers) {
// Url-level prefill
const tryItPrefillServerUrlExpr = server['x-gitbook-prefill'];
const tryItPrefillServerUrlValue = tryItPrefillServerUrlExpr
? resolveTryItPrefillExpression(tryItPrefillServerUrlExpr)
: undefined;
const variables: { [variable: string]: OpenAPIV3.ServerVariableObject } = server.variables
? { ...server.variables }
: {};
// Variable-level prefill
if (server.variables) {
for (const [varName, variable] of Object.entries(server.variables)) {
const { 'x-gitbook-prefill': tryItPrefillVarExpr, ...variableProps } = variable;
const tryItPrefillVarValue = tryItPrefillVarExpr
? resolveTryItPrefillExpression(tryItPrefillVarExpr)
: undefined;
variables[varName] = {
...variableProps,
...(tryItPrefillVarValue ? { default: String(tryItPrefillVarValue) } : {}),
};
}
}
const hasServerVariables = Object.keys(variables).length > 0;
if (server.url && (tryItPrefillServerUrlValue || hasServerVariables)) {
const resolvedServer: OpenAPIV3.ServerObject = {
url: tryItPrefillServerUrlValue ?? server.url,
...(server.description ? { description: server.description } : {}),
...(hasServerVariables ? { variables } : {}),
};
resolvedServers.push(resolvedServer);
}
}
return resolvedServers.length > 0 ? resolvedServers : undefined;
}