Adapt OpenAPI code samples to prefill API key using visitor data (#3666)

This commit is contained in:
spastorelli
2025-09-30 11:09:05 +02:00
committed by GitHub
parent 8e99871004
commit f3e40410c4
13 changed files with 667 additions and 102 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/react-openapi": patch
"gitbook": patch
---
Adapt OpenAPI code samples to prefill API key using visitor data
@@ -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',
@@ -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: [],
},
],
});
});
});
@@ -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[<expression>]__$$` inside the code
* string is transformed into a DocumentInlineExpression node with its `data.expression` set to the
* extracted `<expression>`.
*/
export function convertCodeStringToBlock(args: {
key: string;
code: string;
syntax: string;
}): DocumentBlockCode {
const { key, code, syntax } = args;
const lines = code.split('\n').map<DocumentBlockCodeLine>((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,
};
}
+1 -1
View File
@@ -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 = {
@@ -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;
}
@@ -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 (
+38 -36
View File
@@ -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',
},
+12 -11
View File
@@ -16,7 +16,10 @@ import { stringifyOpenAPI } from './stringifyOpenAPI';
export interface CodeSampleInput {
method: string;
url: string;
url: {
origin: string;
path: string;
};
headers?: Record<string, string>;
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);
+2 -2
View File
@@ -17,7 +17,7 @@ export type OpenAPIServerWithCustomProperties = Omit<OpenAPIV3.ServerObject, 'va
};
} & OpenAPICustomPrefillProperties;
export type OpenAPISecurityWithRequired = OpenAPIV3.SecuritySchemeObject &
export type OpenAPISecuritySchemeWithRequired = OpenAPIV3.SecuritySchemeObject &
OpenAPICustomPrefillProperties & { required?: boolean };
export interface OpenAPIOperationData extends OpenAPICustomSpecProperties {
@@ -31,7 +31,7 @@ export interface OpenAPIOperationData extends OpenAPICustomSpecProperties {
operation: OpenAPIV3.OperationObject<OpenAPICustomOperationProperties>;
/** Securities that should be used for this operation */
securities: [string, OpenAPISecurityWithRequired][];
securities: [string, OpenAPISecuritySchemeWithRequired][];
}
export interface OpenAPIWebhookData extends OpenAPICustomSpecProperties {
@@ -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`)]__$$"
);
});
});
@@ -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<string, string> = {};
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<typeof parseTemplate>) {
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}'` : ''}]__$$`;
}
+2 -2
View File
@@ -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[];
};
/**