diff --git a/.changeset/clean-queens-train.md b/.changeset/clean-queens-train.md new file mode 100644 index 000000000..fbb604355 --- /dev/null +++ b/.changeset/clean-queens-train.md @@ -0,0 +1,7 @@ +--- +"@gitbook/react-openapi": minor +"gitbook": minor +"@gitbook/openapi-parser": patch +--- + +Adapt OpenAPI blocks to eval adaptive exprs & prefill TryIt config diff --git a/bun.lock b/bun.lock index 8c985036a..e87f93103 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 34edb47e1..6c9b0ba50 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -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:*", diff --git a/packages/gitbook/src/components/Adaptive/AdaptiveVisitorContextProvider.tsx b/packages/gitbook/src/components/Adaptive/AdaptiveVisitorContextProvider.tsx new file mode 100644 index 000000000..917913f50 --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/AdaptiveVisitorContextProvider.tsx @@ -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 & { unsigned: Record }; + }; +}; + +/** + * In-memory cache of visitor claim readers keyed by contextId. + */ +const adaptiveVisitorReaderCache = new Map< + string, + ReturnType> +>(); + +function createResourceReader(promise: Promise) { + 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(); + } catch { + return null; + } + })(); + + reader = createResourceReader(promise); + adaptiveVisitorReaderCache.set(contextId, reader); + } + return reader; +} + +export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaimsData | null; + +const AdaptiveVisitorContext = createContext(() => 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 ( + + + {children} + + + ); +} + +/** + * Hook that returns a suspensable getter for adaptive visitor claims data. + */ +export function useAdaptiveVisitor(): AdaptiveVisitorContextValue { + return useContext(AdaptiveVisitorContext); +} diff --git a/packages/gitbook/src/components/Adaptive/index.ts b/packages/gitbook/src/components/Adaptive/index.ts new file mode 100644 index 000000000..0a6192d4c --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/index.ts @@ -0,0 +1 @@ +export * from './AdaptiveVisitorContextProvider'; diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index 3ef342015..4c4738608 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -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 ( - - - - {children} - - - + + + {children} + + + + ); } diff --git a/packages/gitbook/src/lib/visitors.ts b/packages/gitbook/src/lib/visitors.ts index 540b758c3..3dd54e79a 100644 --- a/packages/gitbook/src/lib/visitors.ts +++ b/packages/gitbook/src/lib/visitors.ts @@ -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); +} diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index 6c0acebbb..372d865fc 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -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 // diff --git a/packages/openapi-parser/src/types.ts b/packages/openapi-parser/src/types.ts index 79a0b66ed..a1af691ec 100644 --- a/packages/openapi-parser/src/types.ts +++ b/packages/openapi-parser/src/types.ts @@ -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'; /** diff --git a/packages/react-openapi/package.json b/packages/react-openapi/package.json index f52ad6c2d..d80d93624 100644 --- a/packages/react-openapi/package.json +++ b/packages/react-openapi/package.json @@ -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", diff --git a/packages/react-openapi/src/OpenAPICodeSample.tsx b/packages/react-openapi/src/OpenAPICodeSample.tsx index 4e51d26ee..7120debd5 100644 --- a/packages/react-openapi/src/OpenAPICodeSample.tsx +++ b/packages/react-openapi/src/OpenAPICodeSample.tsx @@ -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} /> )} diff --git a/packages/react-openapi/src/OpenAPIPrefillContextProvider.tsx b/packages/react-openapi/src/OpenAPIPrefillContextProvider.tsx new file mode 100644 index 000000000..afe498ba0 --- /dev/null +++ b/packages/react-openapi/src/OpenAPIPrefillContextProvider.tsx @@ -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; + +/** + * Context value is function that returns prefill data. + */ +type PrefillContextValue = () => PrefillInputContextData | null; + +const OpenAPIPrefillContext = React.createContext(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 ( + + {children} + + ); +} + +/** + * Hook to access the prefill context function. + */ +export function useOpenAPIPrefillContext(): PrefillContextValue { + return React.useContext(OpenAPIPrefillContext) ?? (() => null); +} diff --git a/packages/react-openapi/src/ScalarApiButton.tsx b/packages/react-openapi/src/ScalarApiButton.tsx index 59024d202..cc7551655 100644 --- a/packages/react-openapi/src/ScalarApiButton.tsx +++ b/packages/react-openapi/src/ScalarApiButton.tsx @@ -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(null); + return (
@@ -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; }) { - 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 ( - + ); @@ -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 }); }; } diff --git a/packages/react-openapi/src/index.ts b/packages/react-openapi/src/index.ts index f097eab6a..f04fca524 100644 --- a/packages/react-openapi/src/index.ts +++ b/packages/react-openapi/src/index.ts @@ -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'; diff --git a/packages/react-openapi/src/types.ts b/packages/react-openapi/src/types.ts index 625bdd18b..b9dd3f7c9 100644 --- a/packages/react-openapi/src/types.ts +++ b/packages/react-openapi/src/types.ts @@ -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 & { + 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; diff --git a/packages/react-openapi/src/util/tryit-prefill.test.ts b/packages/react-openapi/src/util/tryit-prefill.test.ts new file mode 100644 index 000000000..2492f6688 --- /dev/null +++ b/packages/react-openapi/src/util/tryit-prefill.test.ts @@ -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//spaces/', + 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//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//spaces/', + 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//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//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//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//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' }, + }, + }, + ], + }); + }); + }); +}); diff --git a/packages/react-openapi/src/util/tryit-prefill.ts b/packages/react-openapi/src/util/tryit-prefill.ts new file mode 100644 index 000000000..723c583a8 --- /dev/null +++ b/packages/react-openapi/src/util/tryit-prefill.ts @@ -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; + /** + * 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; +}