diff --git a/.changeset/dry-pens-behave.md b/.changeset/dry-pens-behave.md new file mode 100644 index 000000000..6a29d43c3 --- /dev/null +++ b/.changeset/dry-pens-behave.md @@ -0,0 +1,6 @@ +--- +"@gitbook/react-openapi": patch +"gitbook": patch +--- + +Adapt OpenAPI code samples to prefill API key using visitor data diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/PlainCodeBlock.tsx b/packages/gitbook/src/components/DocumentView/CodeBlock/PlainCodeBlock.tsx index 9c50b95a0..f303e5072 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/PlainCodeBlock.tsx +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/PlainCodeBlock.tsx @@ -1,7 +1,8 @@ -import type { DocumentBlockCode, JSONDocument } from '@gitbook/api'; +import type { JSONDocument } from '@gitbook/api'; import { useId } from 'react'; import { CodeBlock } from './CodeBlock'; +import { convertCodeStringToBlock } from './utils'; /** * Plain code block with syntax highlighting. @@ -11,31 +12,7 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) { const { code, syntax } = props; const id = useId(); - const block: DocumentBlockCode = { - key: id, - object: 'block', - type: 'code', - data: { - syntax, - }, - nodes: code.split('\n').map((line) => ({ - object: 'block', - type: 'code-line', - data: {}, - nodes: [ - { - object: 'text', - leaves: [ - { - object: 'leaf', - text: line, - marks: [], - }, - ], - }, - ], - })), - }; + const block = convertCodeStringToBlock({ key: id, code, syntax }); const document: JSONDocument = { object: 'document', diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/utils.test.ts b/packages/gitbook/src/components/DocumentView/CodeBlock/utils.test.ts new file mode 100644 index 000000000..a07172785 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/utils.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from 'bun:test'; +import { convertCodeStringToBlock } from './utils'; + +describe('convertCodeStringToBlock', () => { + it('converts plain code string without placeholders', () => { + const result = convertCodeStringToBlock({ + key: 'test1', + code: 'console.log("hello");', + syntax: 'javascript', + }); + + expect(result).toEqual({ + key: 'test1', + object: 'block', + type: 'code', + data: { syntax: 'javascript' }, + nodes: [ + { + object: 'block', + type: 'code-line', + data: {}, + nodes: [ + { + object: 'text', + leaves: [{ object: 'leaf', text: 'console.log("hello");', marks: [] }], + }, + ], + }, + ], + }); + }); + + it('converts a single placeholder into an expression node', () => { + const result = convertCodeStringToBlock({ + key: 'test2', + code: 'const config = { API_KEY: "$$__X-GITBOOK-PREFILL[visitor.claims.apiKey ?? "YOUR_API_KEY"]__$$" }', + syntax: 'javascript', + }); + + expect(result).toEqual({ + key: 'test2', + object: 'block', + type: 'code', + data: { syntax: 'javascript' }, + nodes: [ + { + object: 'block', + type: 'code-line', + data: {}, + nodes: [ + { + object: 'text', + leaves: [ + { object: 'leaf', text: 'const config = { API_KEY: "', marks: [] }, + ], + }, + { + object: 'inline', + type: 'expression', + data: { expression: 'visitor.claims.apiKey ?? "YOUR_API_KEY"' }, + isVoid: true, + }, + { + object: 'text', + leaves: [{ object: 'leaf', text: '" }', marks: [] }], + }, + ], + }, + ], + }); + }); + + it('handles multiple placeholders in one line', () => { + const result = convertCodeStringToBlock({ + key: 'test3', + code: 'let a = $$__X-GITBOOK-PREFILL[valA]__$$, b = $$__X-GITBOOK-PREFILL[valB]__$$;', + syntax: 'javascript', + }); + + expect(result).toEqual({ + key: 'test3', + object: 'block', + type: 'code', + data: { syntax: 'javascript' }, + nodes: [ + { + object: 'block', + type: 'code-line', + data: {}, + nodes: [ + { + object: 'text', + leaves: [{ object: 'leaf', text: 'let a = ', marks: [] }], + }, + { + object: 'inline', + type: 'expression', + data: { expression: 'valA' }, + isVoid: true, + }, + { + object: 'text', + leaves: [{ object: 'leaf', text: ', b = ', marks: [] }], + }, + { + object: 'inline', + type: 'expression', + data: { expression: 'valB' }, + isVoid: true, + }, + { + object: 'text', + leaves: [{ object: 'leaf', text: ';', marks: [] }], + }, + ], + }, + ], + }); + }); + + it('handles multiple placeholders across different lines', () => { + const result = convertCodeStringToBlock({ + key: 'test4', + code: [ + 'const name = "$$__X-GITBOOK-PREFILL[visitor.claims.userName]__$$";', + 'const age = $$__X-GITBOOK-PREFILL[visitor.claims.userAge]__$$;', + 'console.log(name, age);', + ].join('\n'), + syntax: 'javascript', + }); + + expect(result).toEqual({ + key: 'test4', + object: 'block', + type: 'code', + data: { syntax: 'javascript' }, + nodes: [ + { + object: 'block', + type: 'code-line', + data: {}, + nodes: [ + { + object: 'text', + leaves: [{ object: 'leaf', text: 'const name = "', marks: [] }], + }, + { + object: 'inline', + type: 'expression', + data: { expression: 'visitor.claims.userName' }, + isVoid: true, + }, + { + object: 'text', + leaves: [{ object: 'leaf', text: '";', marks: [] }], + }, + ], + }, + { + object: 'block', + type: 'code-line', + data: {}, + nodes: [ + { + object: 'text', + leaves: [{ object: 'leaf', text: 'const age = ', marks: [] }], + }, + { + object: 'inline', + type: 'expression', + data: { expression: 'visitor.claims.userAge' }, + isVoid: true, + }, + { + object: 'text', + leaves: [{ object: 'leaf', text: ';', marks: [] }], + }, + ], + }, + { + object: 'block', + type: 'code-line', + data: {}, + nodes: [ + { + object: 'text', + leaves: [ + { object: 'leaf', text: 'console.log(name, age);', marks: [] }, + ], + }, + ], + }, + ], + }); + }); + + it('returns an empty code block for empty string', () => { + const result = convertCodeStringToBlock({ + key: 'test5', + code: '', + syntax: 'javascript', + }); + + expect(result).toEqual({ + key: 'test5', + object: 'block', + type: 'code', + data: { syntax: 'javascript' }, + nodes: [ + { + object: 'block', + type: 'code-line', + data: {}, + nodes: [], + }, + ], + }); + }); +}); diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/utils.ts b/packages/gitbook/src/components/DocumentView/CodeBlock/utils.ts new file mode 100644 index 000000000..d59df74f5 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/utils.ts @@ -0,0 +1,67 @@ +import type { DocumentBlockCode, DocumentBlockCodeLine } from '@gitbook/api'; + +const PREFILL_WITH_EXPR_REGEX = /\$\$__X-GITBOOK-PREFILL\[(.+?)\]__\$\$/g; + +/** + * Convert a raw code string into a `DocumentBlockCode` object representation. + * + * Any placeholder of the form `$$__X-GITBOOK-PREFILL[]__$$` inside the code + * string is transformed into a DocumentInlineExpression node with its `data.expression` set to the + * extracted ``. + */ +export function convertCodeStringToBlock(args: { + key: string; + code: string; + syntax: string; +}): DocumentBlockCode { + const { key, code, syntax } = args; + const lines = code.split('\n').map((line) => { + const nodes: DocumentBlockCodeLine['nodes'] = []; + let lastIndex = 0; + + for (const match of line.matchAll(PREFILL_WITH_EXPR_REGEX)) { + const [placeholder, expression] = match; + const start = match.index ?? 0; + + if (start > lastIndex) { + nodes.push({ + object: 'text', + leaves: [{ object: 'leaf', text: line.slice(lastIndex, start), marks: [] }], + }); + } + + if (expression) { + nodes.push({ + object: 'inline', + type: 'expression', + data: { expression }, + isVoid: true, + }); + } + + lastIndex = start + placeholder.length; + } + + if (lastIndex < line.length) { + nodes.push({ + object: 'text', + leaves: [{ object: 'leaf', text: line.slice(lastIndex), marks: [] }], + }); + } + + return { + object: 'block', + type: 'code-line', + data: {}, + nodes, + }; + }); + + return { + key, + object: 'block', + type: 'code', + data: { syntax }, + nodes: lines, + }; +} diff --git a/packages/gitbook/src/lib/visitors.ts b/packages/gitbook/src/lib/visitors.ts index 3dd54e79a..89e96a073 100644 --- a/packages/gitbook/src/lib/visitors.ts +++ b/packages/gitbook/src/lib/visitors.ts @@ -454,7 +454,7 @@ export function serveVisitorClaimsDataRequest(request: NextRequest, siteRequestU }); if (!visitorToken && !Object.keys(unsignedClaims).length) { - return NextResponse.json({}); + return NextResponse.json({ visitor: { claims: { unsigned: {} } } }); } const visitorClaims = { diff --git a/packages/react-openapi/src/OpenAPICodeSample.tsx b/packages/react-openapi/src/OpenAPICodeSample.tsx index 6858ef4d4..aaaef9427 100644 --- a/packages/react-openapi/src/OpenAPICodeSample.tsx +++ b/packages/react-openapi/src/OpenAPICodeSample.tsx @@ -5,12 +5,16 @@ import { } from './OpenAPICodeSampleInteractive'; import { OpenAPICodeSampleBody } from './OpenAPICodeSampleSelector'; import { ScalarApiButton } from './ScalarApiButton'; -import { type CodeSampleGenerator, codeSampleGenerators } from './code-samples'; +import { type CodeSampleGenerator, codeSampleGenerators, parseHostAndPath } from './code-samples'; import { type OpenAPIContext, getOpenAPIClientContext } from './context'; import { generateMediaTypeExamples, generateSchemaExample } from './generateSchemaExample'; import { stringifyOpenAPI } from './stringifyOpenAPI'; import type { OpenAPIOperationData } from './types'; import { getDefaultServerURL } from './util/server'; +import { + resolvePrefillCodePlaceholderFromSecurityScheme, + resolveURLWithPrefillCodePlaceholdersFromServer, +} from './util/tryit-prefill'; import { checkIsReference, extractOperationSecurityInfo } from './utils'; const CUSTOM_CODE_SAMPLES_KEYS = ['x-custom-examples', 'x-code-samples', 'x-codeSamples'] as const; @@ -100,10 +104,15 @@ function generateCodeSamples(props: { ? data.operation.requestBody : undefined; - const url = - getDefaultServerURL(data.servers) + - data.path + - (searchParams.size ? `?${searchParams.toString()}` : ''); + const defaultServerUrl = getDefaultServerURL(data.servers); + let serverUrlPath = defaultServerUrl ? parseHostAndPath(defaultServerUrl).path : ''; + serverUrlPath = serverUrlPath === '/' ? '' : serverUrlPath; + const serverUrl = data.servers[0] + ? resolveURLWithPrefillCodePlaceholdersFromServer(data.servers[0], defaultServerUrl) + : defaultServerUrl; + const serverUrlOrigin = serverUrl.replaceAll(serverUrlPath, ''); + const path = + serverUrlPath + data.path + (searchParams.size ? `?${searchParams.toString()}` : ''); const genericHeaders = { ...getSecurityHeaders({ @@ -124,7 +133,7 @@ function generateCodeSamples(props: { mediaType, element: context.renderCodeBlock({ code: generator.generate({ - url, + url: { origin: serverUrlOrigin, path }, method: data.method, body: undefined, headers: mediaTypeHeaders, @@ -137,7 +146,7 @@ function generateCodeSamples(props: { example, element: context.renderCodeBlock({ code: generator.generate({ - url, + url: { origin: serverUrlOrigin, path }, method: data.method, body: example.value, headers: mediaTypeHeaders, @@ -174,7 +183,7 @@ function generateCodeSamples(props: { label: generator.label, body: context.renderCodeBlock({ code: generator.generate({ - url, + url: { origin: serverUrlOrigin, path }, method: data.method, body: undefined, headers: genericHeaders, @@ -306,13 +315,17 @@ function getSecurityHeaders(args: { switch (security.type) { case 'http': { let scheme = security.scheme; - let format = security.bearerFormat ?? 'YOUR_SECRET_TOKEN'; + const format = resolvePrefillCodePlaceholderFromSecurityScheme({ + security: security, + defaultPlaceholderValue: scheme?.includes('basic') + ? 'username:password' + : 'YOUR_SECRET_TOKEN', + }); if (scheme?.includes('bearer')) { scheme = 'Bearer'; } else if (scheme?.includes('basic')) { scheme = 'Basic'; - format = 'username:password'; } else if (scheme?.includes('token')) { scheme = 'Token'; } @@ -324,14 +337,18 @@ function getSecurityHeaders(args: { if (security.in !== 'header') { break; } - const name = security.name ?? 'Authorization'; - headers[name] = 'YOUR_API_KEY'; - + headers[name] = resolvePrefillCodePlaceholderFromSecurityScheme({ + security: security, + defaultPlaceholderValue: 'YOUR_API_KEY', + }); break; } case 'oauth2': { - headers.Authorization = 'Bearer YOUR_OAUTH2_TOKEN'; + headers.Authorization = `Bearer ${resolvePrefillCodePlaceholderFromSecurityScheme({ + security: security, + defaultPlaceholderValue: 'YOUR_OAUTH2_TOKEN', + })}`; break; } default: { @@ -339,7 +356,6 @@ function getSecurityHeaders(args: { } } } - return headers; } diff --git a/packages/react-openapi/src/OpenAPISecurities.tsx b/packages/react-openapi/src/OpenAPISecurities.tsx index 69aca699e..8223b1588 100644 --- a/packages/react-openapi/src/OpenAPISecurities.tsx +++ b/packages/react-openapi/src/OpenAPISecurities.tsx @@ -5,7 +5,8 @@ import { OpenAPICopyButton } from './OpenAPICopyButton'; import { OpenAPISchemaName } from './OpenAPISchemaName'; import type { OpenAPIClientContext } from './context'; import { t } from './translate'; -import type { OpenAPIOperationData, OpenAPISecurityWithRequired } from './types'; +import type { OpenAPISecuritySchemeWithRequired } from './types'; +import type { OpenAPIOperationData } from './types'; import { createStateKey, extractOperationSecurityInfo, resolveDescription } from './utils'; /** @@ -62,7 +63,10 @@ export function OpenAPISecurities(props: { ); } -function getLabelForType(security: OpenAPISecurityWithRequired, context: OpenAPIClientContext) { +function getLabelForType( + security: OpenAPISecuritySchemeWithRequired, + context: OpenAPIClientContext +) { switch (security.type) { case 'apiKey': return ( diff --git a/packages/react-openapi/src/code-samples.test.ts b/packages/react-openapi/src/code-samples.test.ts index 171df4bcc..a7f616e2a 100644 --- a/packages/react-openapi/src/code-samples.test.ts +++ b/packages/react-openapi/src/code-samples.test.ts @@ -50,6 +50,8 @@ it('should parse host and path on url strings properly', () => { ]); }); +const exampleEndpointUrl = new URL('https://example.com/path'); + describe('curL code sample generator', () => { const generator = codeSampleGenerators.find((g) => g.id === 'curl'); @@ -58,7 +60,7 @@ describe('curL code sample generator', () => { it('should format application/x-www-form-urlencoded body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, @@ -77,7 +79,7 @@ describe('curL code sample generator', () => { it('should format application/json body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/json', }, @@ -96,7 +98,7 @@ describe('curL code sample generator', () => { it('should format application/xml body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/xml', }, @@ -113,7 +115,7 @@ describe('curL code sample generator', () => { it('should convert json to xml body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/xml', }, @@ -130,7 +132,7 @@ describe('curL code sample generator', () => { it('should format application/graphql body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/graphql', }, @@ -147,7 +149,7 @@ describe('curL code sample generator', () => { it('should format text/csv body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'text/csv', }, @@ -164,7 +166,7 @@ describe('curL code sample generator', () => { it('should format application/pdf body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/pdf', }, @@ -181,7 +183,7 @@ describe('curL code sample generator', () => { it('should format text/plain body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'text/plain', }, @@ -198,7 +200,7 @@ describe('curL code sample generator', () => { it('should format multipart/form-data body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'multipart/form-data', }, @@ -223,7 +225,7 @@ describe('javascript code sample generator', () => { it('should format application/x-www-form-urlencoded body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, @@ -242,7 +244,7 @@ describe('javascript code sample generator', () => { it('should format application/json body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/json', }, @@ -261,7 +263,7 @@ describe('javascript code sample generator', () => { it('should format application/xml body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/xml', }, @@ -278,7 +280,7 @@ describe('javascript code sample generator', () => { it('should convert json to xml body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/xml', }, @@ -295,7 +297,7 @@ describe('javascript code sample generator', () => { it('should format application/graphql body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/graphql', }, @@ -312,7 +314,7 @@ describe('javascript code sample generator', () => { it('should format text/csv body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'text/csv', }, @@ -329,7 +331,7 @@ describe('javascript code sample generator', () => { it('should format application/pdf body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/pdf', }, @@ -346,7 +348,7 @@ describe('javascript code sample generator', () => { it('should format text/plain body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'text/plain', }, @@ -363,7 +365,7 @@ describe('javascript code sample generator', () => { it('should format multipart/form-data body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'multipart/form-data', }, @@ -388,7 +390,7 @@ describe('python code sample generator', () => { it('should format application/x-www-form-urlencoded body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, @@ -407,7 +409,7 @@ describe('python code sample generator', () => { it('should format application/json body properly', () => { const input: CodeSampleInput = { method: 'POST', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/json', }, @@ -429,7 +431,7 @@ describe('python code sample generator', () => { it('should format application/xml body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/xml', }, @@ -446,7 +448,7 @@ describe('python code sample generator', () => { it('should convert json to xml body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/xml', }, @@ -463,7 +465,7 @@ describe('python code sample generator', () => { it('should format application/graphql body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/graphql', }, @@ -480,7 +482,7 @@ describe('python code sample generator', () => { it('should format text/csv body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'text/csv', }, @@ -497,7 +499,7 @@ describe('python code sample generator', () => { it('should format application/pdf body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/pdf', }, @@ -514,7 +516,7 @@ describe('python code sample generator', () => { it('should format text/plain body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'text/plain', }, @@ -531,7 +533,7 @@ describe('python code sample generator', () => { it('should format multipart/form-data body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'multipart/form-data', }, @@ -556,7 +558,7 @@ describe('http code sample generator', () => { it('should format application/x-www-form-urlencoded body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, @@ -575,7 +577,7 @@ describe('http code sample generator', () => { it('should format application/json body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/json', }, @@ -594,7 +596,7 @@ describe('http code sample generator', () => { it('should format application/xml body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/xml', }, @@ -611,7 +613,7 @@ describe('http code sample generator', () => { it('should convert json to xml body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/xml', }, @@ -628,7 +630,7 @@ describe('http code sample generator', () => { it('should format application/graphql body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/graphql', }, @@ -645,7 +647,7 @@ describe('http code sample generator', () => { it('should format text/csv body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'text/csv', }, @@ -662,7 +664,7 @@ describe('http code sample generator', () => { it('should format application/pdf body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'application/pdf', }, @@ -679,7 +681,7 @@ describe('http code sample generator', () => { it('should format text/plain body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'text/plain', }, @@ -696,7 +698,7 @@ describe('http code sample generator', () => { it('should format multipart/form-data body properly', () => { const input: CodeSampleInput = { method: 'GET', - url: 'https://example.com/path', + url: { origin: exampleEndpointUrl.origin, path: exampleEndpointUrl.pathname }, headers: { 'Content-Type': 'multipart/form-data', }, diff --git a/packages/react-openapi/src/code-samples.ts b/packages/react-openapi/src/code-samples.ts index e0c5e2055..370204f27 100644 --- a/packages/react-openapi/src/code-samples.ts +++ b/packages/react-openapi/src/code-samples.ts @@ -16,7 +16,10 @@ import { stringifyOpenAPI } from './stringifyOpenAPI'; export interface CodeSampleInput { method: string; - url: string; + url: { + origin: string; + path: string; + }; headers?: Record; body?: any; } @@ -33,9 +36,7 @@ export const codeSampleGenerators: CodeSampleGenerator[] = [ id: 'http', label: 'HTTP', syntax: 'http', - generate: ({ method, url, headers = {}, body }: CodeSampleInput) => { - const { host, path } = parseHostAndPath(url); - + generate: ({ method, url: { origin, path }, headers = {}, body }: CodeSampleInput) => { if (body) { // if we had a body add a content length header const bodyContent = body ? stringifyOpenAPI(body) : ''; @@ -69,7 +70,7 @@ export const codeSampleGenerators: CodeSampleGenerator[] = [ const bodyString = body ? `\n${body}` : ''; const httpRequest = `${method.toUpperCase()} ${decodeURI(path)} HTTP/1.1 -Host: ${host} +Host: ${origin.replaceAll(/https*:\/\//g, '')} ${headerString}${bodyString}`; return httpRequest; @@ -79,7 +80,7 @@ ${headerString}${bodyString}`; id: 'curl', label: 'cURL', syntax: 'bash', - generate: ({ method, url, headers, body }) => { + generate: ({ method, url: { origin, path }, headers, body }) => { const separator = ' \\\n'; const lines: string[] = ['curl -L']; @@ -88,7 +89,7 @@ ${headerString}${bodyString}`; lines.push(`--request ${method.toUpperCase()}`); } - lines.push(`--url '${url}'`); + lines.push(`--url '${origin}${path}'`); if (body) { const bodyContent = BodyGenerators.getCurlBody(body, headers); @@ -120,7 +121,7 @@ ${headerString}${bodyString}`; id: 'javascript', label: 'JavaScript', syntax: 'javascript', - generate: ({ method, url, headers, body }) => { + generate: ({ method, url: { origin, path }, headers, body }) => { let code = ''; if (body) { @@ -134,7 +135,7 @@ ${headerString}${bodyString}`; } } - code += `const response = await fetch('${url}', { + code += `const response = await fetch('${origin}${path}', { method: '${method.toUpperCase()}',\n`; if (headers && Object.keys(headers).length > 0) { @@ -155,7 +156,7 @@ ${headerString}${bodyString}`; id: 'python', label: 'Python', syntax: 'python', - generate: ({ method, url, headers, body }) => { + generate: ({ method, url: { origin, path }, headers, body }) => { const contentType = headers?.['Content-Type']; let code = `${isJSON(contentType) ? 'import json\n' : ''}import requests\n\n`; @@ -171,7 +172,7 @@ ${headerString}${bodyString}`; } code += `response = requests.${method.toLowerCase()}(\n`; - code += indent(`"${url}",\n`, 4); + code += indent(`"${origin}${path}",\n`, 4); if (headers && Object.keys(headers).length > 0) { code += indent(`headers=${stringifyOpenAPI(headers)},\n`, 4); diff --git a/packages/react-openapi/src/types.ts b/packages/react-openapi/src/types.ts index b9dd3f7c9..75446c6cf 100644 --- a/packages/react-openapi/src/types.ts +++ b/packages/react-openapi/src/types.ts @@ -17,7 +17,7 @@ export type OpenAPIServerWithCustomProperties = Omit; /** Securities that should be used for this operation */ - securities: [string, OpenAPISecurityWithRequired][]; + securities: [string, OpenAPISecuritySchemeWithRequired][]; } export interface OpenAPIWebhookData extends OpenAPICustomSpecProperties { diff --git a/packages/react-openapi/src/util/tryit-prefill.test.ts b/packages/react-openapi/src/util/tryit-prefill.test.ts index 2492f6688..0341269ea 100644 --- a/packages/react-openapi/src/util/tryit-prefill.test.ts +++ b/packages/react-openapi/src/util/tryit-prefill.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'bun:test'; import type { PrefillInputContextData } from '../OpenAPIPrefillContextProvider'; import type { OpenAPIOperationData } from '../types'; -import { resolveTryItPrefillForOperation } from './tryit-prefill'; +import { + resolvePrefillCodePlaceholderFromSecurityScheme, + resolveTryItPrefillForOperation, + resolveURLWithPrefillCodePlaceholdersFromServer, +} from './tryit-prefill'; describe('resolveTryItPrefillForOperation', () => { describe('prefill authentication info', () => { @@ -309,3 +313,166 @@ describe('resolveTryItPrefillForOperation', () => { }); }); }); + +describe('resolvePrefillCodePlaceholderFromSecurityScheme (integration style)', () => { + it('should return placeholder for bearer token scheme', () => { + const result = resolvePrefillCodePlaceholderFromSecurityScheme({ + security: { + type: 'http', + scheme: 'bearer', + 'x-gitbook-prefill': '{{ visitor.claims.apiToken }}', + }, + }); + + expect(result).toBe('$$__X-GITBOOK-PREFILL[(visitor.claims.apiToken)]__$$'); + }); + + it('should return placeholder for basic auth scheme', () => { + const result = resolvePrefillCodePlaceholderFromSecurityScheme({ + security: { + type: 'http', + scheme: 'basic', + 'x-gitbook-prefill': '{{ visitor.claims.basicAuth }}', + }, + }); + + expect(result).toBe('$$__X-GITBOOK-PREFILL[(visitor.claims.basicAuth)]__$$'); + }); + + it('should build placeholder for apiKey scheme', () => { + const result = resolvePrefillCodePlaceholderFromSecurityScheme({ + security: { + type: 'apiKey', + in: 'header', + name: 'X-API-KEY', + 'x-gitbook-prefill': '{{ visitor.claims.apiKey }}', + }, + }); + + expect(result).toBe('$$__X-GITBOOK-PREFILL[(visitor.claims.apiKey)]__$$'); + }); + + it('should return placeholder with default value if provided', () => { + const result = resolvePrefillCodePlaceholderFromSecurityScheme({ + security: { + type: 'http', + scheme: 'bearer', + 'x-gitbook-prefill': '{{ visitor.claims.missing }}', + }, + defaultPlaceholderValue: 'YOUR_API_TOKEN', + }); + + expect(result).toBe( + `$$__X-GITBOOK-PREFILL[(visitor.claims.missing) ?? 'YOUR_API_TOKEN']__$$` + ); + }); + + it('should concatenate text and expression in prefill', () => { + const result = resolvePrefillCodePlaceholderFromSecurityScheme({ + security: { + type: 'http', + scheme: 'bearer', + 'x-gitbook-prefill': 'Bearer {{ visitor.claims.apiToken }}', + }, + }); + + expect(result).toBe('$$__X-GITBOOK-PREFILL[("Bearer " + visitor.claims.apiToken)]__$$'); + }); + + it('should handle multiple expressions in prefill', () => { + const result = resolvePrefillCodePlaceholderFromSecurityScheme({ + security: { + type: 'http', + scheme: 'basic', + 'x-gitbook-prefill': '{{ visitor.claims.username }}:{{ visitor.claims.password }}', + }, + }); + + expect(result).toBe( + '$$__X-GITBOOK-PREFILL[(visitor.claims.username + ":" + visitor.claims.password)]__$$' + ); + }); + + it('should return empty default value if no prefill property exists', () => { + const result = resolvePrefillCodePlaceholderFromSecurityScheme({ + security: { + type: 'http', + scheme: 'bearer', + }, + defaultPlaceholderValue: 'YOUR_API_TOKEN', + }); + + expect(result).toBe('YOUR_API_TOKEN'); + }); + + it('should return empty string if no prefill property exists', () => { + const result = resolvePrefillCodePlaceholderFromSecurityScheme({ + security: { + type: 'http', + scheme: 'bearer', + }, + }); + + expect(result).toBe(''); + }); +}); + +describe('resolveURLWithPrefillCodePlaceholdersFromServer', () => { + it('should return a simple URL when no prefills are present', () => { + const result = resolveURLWithPrefillCodePlaceholdersFromServer({ + url: 'https://api.example.com/v1', + }); + + expect(result).toBe('https://api.example.com/v1'); + }); + + it('should replace a variable with its default when no prefill is set', () => { + const result = resolveURLWithPrefillCodePlaceholdersFromServer({ + url: 'https://{region}.example.com', + variables: { + region: { default: 'us-east-1' }, + }, + }); + + expect(result).toBe('https://us-east-1.example.com'); + }); + + it('should return a placeholder for variable-level prefill only', () => { + const result = resolveURLWithPrefillCodePlaceholdersFromServer({ + url: 'https://{region}.example.com', + variables: { + region: { default: 'us-east-1', 'x-gitbook-prefill': '{{ user.region }}' }, + }, + }); + + expect(result).toBe( + `$$__X-GITBOOK-PREFILL[(\`https://\${(user.region ?? 'us-east-1')}.example.com\`)]__$$` + ); + }); + + it('should wrap full URL when URL-level prefill exists', () => { + const result = resolveURLWithPrefillCodePlaceholdersFromServer({ + url: 'https://api.example.com/v1', + 'x-gitbook-prefill': '{{ user.baseUrl }}', + }); + + expect(result).toBe( + "$$__X-GITBOOK-PREFILL[(user.baseUrl ?? 'https://api.example.com/v1')]__$$" + ); + }); + + it('should combine variable-level and URL-level prefills correctly', () => { + const result = resolveURLWithPrefillCodePlaceholdersFromServer({ + url: 'https://{region}.example.com/{version}', + 'x-gitbook-prefill': '{{ user.baseUrl }}', + variables: { + region: { default: 'us-east-1', 'x-gitbook-prefill': '{{ user.region }}' }, + version: { default: 'v1' }, + }, + }); + + expect(result).toBe( + "$$__X-GITBOOK-PREFILL[(user.baseUrl ?? `https://${(user.region ?? 'us-east-1')}.example.com/v1`)]__$$" + ); + }); +}); diff --git a/packages/react-openapi/src/util/tryit-prefill.ts b/packages/react-openapi/src/util/tryit-prefill.ts index ebd9b1e43..ca97e560b 100644 --- a/packages/react-openapi/src/util/tryit-prefill.ts +++ b/packages/react-openapi/src/util/tryit-prefill.ts @@ -1,14 +1,20 @@ -import { ExpressionRuntime, parseTemplate } from '@gitbook/expr'; +import { ExpressionRuntime, type TemplatePart, 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'; +import type { + OpenAPIOperationData, + OpenAPISecuritySchemeWithRequired, + OpenAPIServerWithCustomProperties, +} from '../types'; export interface TryItPrefillConfiguration { authentication?: ApiClientConfiguration['authentication']; servers?: ApiClientConfiguration['servers']; } +export const PREFILL_CUSTOM_PROPERTY = 'x-gitbook-prefill'; + /** * Resolve the Scalar API client prefill configuration for a given OpenAPI operation. */ @@ -68,8 +74,8 @@ function resolveTryItPrefillAuthForOperationSecurities(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']) + const tryitPrefillAuthValue = security[PREFILL_CUSTOM_PROPERTY] + ? resolveTryItPrefillExpression(security[PREFILL_CUSTOM_PROPERTY]) : undefined; if (!tryitPrefillAuthValue) { @@ -121,7 +127,7 @@ function resolveTryItPrefillServersForOperationServers(args: { for (const server of servers) { // Url-level prefill - const tryItPrefillServerUrlExpr = server['x-gitbook-prefill']; + const tryItPrefillServerUrlExpr = server[PREFILL_CUSTOM_PROPERTY]; const tryItPrefillServerUrlValue = tryItPrefillServerUrlExpr ? resolveTryItPrefillExpression(tryItPrefillServerUrlExpr) : undefined; @@ -133,7 +139,8 @@ function resolveTryItPrefillServersForOperationServers(args: { // Variable-level prefill if (server.variables) { for (const [varName, variable] of Object.entries(server.variables)) { - const { 'x-gitbook-prefill': tryItPrefillVarExpr, ...variableProps } = variable; + const { [PREFILL_CUSTOM_PROPERTY]: tryItPrefillVarExpr, ...variableProps } = + variable; const tryItPrefillVarValue = tryItPrefillVarExpr ? resolveTryItPrefillExpression(tryItPrefillVarExpr) @@ -158,3 +165,102 @@ function resolveTryItPrefillServersForOperationServers(args: { return resolvedServers.length > 0 ? resolvedServers : undefined; } + +/** + * Return a X-GITBOOK-PREFILL placeholder based on the prefill custom property in the provided security scheme. + */ +export function resolvePrefillCodePlaceholderFromSecurityScheme(args: { + security: OpenAPISecuritySchemeWithRequired; + defaultPlaceholderValue?: string; +}) { + const { security, defaultPlaceholderValue } = args; + const prefillExprParts = extractPrefillExpressionPartsFromSecurityScheme(security); + + if (prefillExprParts.length === 0) { + return defaultPlaceholderValue ?? ''; + } + const prefillExpr = templatePartsToExpression(prefillExprParts); + + return toPrefillCodePlaceholder(prefillExpr, defaultPlaceholderValue); +} + +function extractPrefillExpressionPartsFromSecurityScheme( + security: OpenAPISecuritySchemeWithRequired +): TemplatePart[] { + const expression = security[PREFILL_CUSTOM_PROPERTY]; + + if (!expression || expression.length === 0) { + return []; + } + + return parseTemplate(expression); +} + +/** + * Return a server URL with X-GITBOOK-PREFILL placeholders based on the prefill custom properties in the provided security scheme. + */ +export function resolveURLWithPrefillCodePlaceholdersFromServer( + server: OpenAPIServerWithCustomProperties, + defaultServerUrl?: string +): string { + const serverVariables = server.variables ?? {}; + const variableExprs: Record = {}; + let hasVariablePrefill = false; + + for (const [name, variable] of Object.entries(serverVariables ?? {})) { + if (variable[PREFILL_CUSTOM_PROPERTY]) { + hasVariablePrefill = true; + const exprString = templatePartsToExpression( + parseTemplate(variable[PREFILL_CUSTOM_PROPERTY]) + ); + variableExprs[name] = `(${exprString} ?? '${variable.default ?? ''}')`; + } else { + variableExprs[name] = String(variable.default) ?? ''; + } + } + + let interpolatedUrl = server.url ?? ''; + interpolatedUrl = interpolatedUrl.replace(/{([^}]+)}/g, (_, varName: string) => { + const expr = variableExprs[varName]; + if (serverVariables[varName]?.[PREFILL_CUSTOM_PROPERTY]) { + return `\${${expr ?? `'${varName}'`}}`; + } + return expr ?? `{${varName}}`; + }); + + const interpolatedUrlTemplate = hasVariablePrefill ? `\`${interpolatedUrl}\`` : interpolatedUrl; + + const urlLevelExpr = server[PREFILL_CUSTOM_PROPERTY]; + if (urlLevelExpr) { + const exprString = templatePartsToExpression(parseTemplate(urlLevelExpr)); + const defaultValue = hasVariablePrefill + ? interpolatedUrlTemplate + : `'${interpolatedUrlTemplate}'`; + return toPrefillCodePlaceholder(`${exprString} ?? ${defaultValue}`, defaultServerUrl); + } + + if (hasVariablePrefill) { + return toPrefillCodePlaceholder(interpolatedUrlTemplate, defaultServerUrl); + } + + return interpolatedUrl; +} + +function templatePartsToExpression(parts: ReturnType) { + return parts + .map((part) => { + switch (part.type) { + case 'text': + return `"${part.value}"`; + case 'expression': + return part.value; + default: + return ''; + } + }) + .join(' + '); +} + +function toPrefillCodePlaceholder(expression: string, defaultValue?: string) { + return `$$__X-GITBOOK-PREFILL[(${expression})${defaultValue ? ` ?? '${defaultValue}'` : ''}]__$$`; +} diff --git a/packages/react-openapi/src/utils.ts b/packages/react-openapi/src/utils.ts index 6ad2128eb..304e1442a 100644 --- a/packages/react-openapi/src/utils.ts +++ b/packages/react-openapi/src/utils.ts @@ -2,7 +2,7 @@ import type { AnyObject, OpenAPIV3, OpenAPIV3_1 } from '@gitbook/openapi-parser' import type { OpenAPIUniversalContext } from './context'; import { stringifyOpenAPI } from './stringifyOpenAPI'; import { tString } from './translate'; -import type { OpenAPIOperationData, OpenAPISecurityWithRequired } from './types'; +import type { OpenAPIOperationData, OpenAPISecuritySchemeWithRequired } from './types'; export function checkIsReference(input: unknown): input is OpenAPIV3.ReferenceObject { return typeof input === 'object' && !!input && '$ref' in input; @@ -258,7 +258,7 @@ export function getSchemaTitle(schema: OpenAPIV3.SchemaObject): string { export type OperationSecurityInfo = { key: string; label: string; - schemes: OpenAPISecurityWithRequired[]; + schemes: OpenAPISecuritySchemeWithRequired[]; }; /**