mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-17 08:05:19 +00:00
411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
|
|
import {
|
|
OpenAPIMediaTypeExamplesBody,
|
|
OpenAPIMediaTypeExamplesSelector,
|
|
} from './OpenAPICodeSampleInteractive';
|
|
import { OpenAPICodeSampleBody } from './OpenAPICodeSampleSelector';
|
|
import { ScalarApiButton } from './ScalarApiButton';
|
|
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 { mergeHeaders } from './util/headers';
|
|
import {
|
|
extractOrigin,
|
|
getAllServerOrigins,
|
|
getDefaultServerURL,
|
|
hasValidServerHost,
|
|
} 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;
|
|
|
|
/**
|
|
* Display code samples to execute the operation.
|
|
* It supports the Redocly custom syntax as well (https://redocly.com/docs/api-reference-docs/specification-extensions/x-code-samples/)
|
|
*/
|
|
export function OpenAPICodeSample(props: {
|
|
data: OpenAPIOperationData;
|
|
context: OpenAPIContext;
|
|
}) {
|
|
const { data, context } = props;
|
|
|
|
// If code samples are disabled at operation level, we don't display the code samples.
|
|
if (data.operation['x-codeSamples'] === false) {
|
|
return null;
|
|
}
|
|
|
|
const customCodeSamples = getCustomCodeSamples(props);
|
|
|
|
// If code samples are disabled at the top-level and not custom code samples are defined,
|
|
// we don't display the code samples.
|
|
if (data['x-codeSamples'] === false && !customCodeSamples) {
|
|
return null;
|
|
}
|
|
|
|
const samples = customCodeSamples ?? generateCodeSamples(props);
|
|
|
|
if (samples.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<OpenAPICodeSampleBody
|
|
context={getOpenAPIClientContext(context)}
|
|
data={data}
|
|
items={samples}
|
|
selectIcon={context.icons.chevronDown}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Generate code samples for the operation.
|
|
*/
|
|
function generateCodeSamples(props: {
|
|
data: OpenAPIOperationData;
|
|
context: OpenAPIContext;
|
|
}) {
|
|
const { data, context } = props;
|
|
|
|
const searchParams = new URLSearchParams();
|
|
const headersObject: { [k: string]: string } = {};
|
|
|
|
// The parser can sometimes returns invalid parameters (an object instead of an array).
|
|
// It should get fixed in scalar, but in the meantime we just ignore the parameters in that case.
|
|
const params = Array.isArray(data.operation.parameters) ? data.operation.parameters : [];
|
|
|
|
params.forEach((param) => {
|
|
if (!param) {
|
|
return;
|
|
}
|
|
|
|
if (param.in === 'header' && param.required) {
|
|
const example = param.schema
|
|
? generateSchemaExample(param.schema, { mode: 'write' })
|
|
: undefined;
|
|
if (example !== undefined && param.name) {
|
|
headersObject[param.name] =
|
|
typeof example !== 'string' ? stringifyOpenAPI(example) : example;
|
|
}
|
|
} else if (param.in === 'query' && param.required) {
|
|
const example = param.schema
|
|
? generateSchemaExample(param.schema, { mode: 'write' })
|
|
: undefined;
|
|
if (example !== undefined && param.name) {
|
|
searchParams.append(
|
|
param.name,
|
|
String(Array.isArray(example) ? example[0] : example)
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
const requestBody = !checkIsReference(data.operation.requestBody)
|
|
? data.operation.requestBody
|
|
: undefined;
|
|
|
|
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({
|
|
securityRequirement: data.operation.security,
|
|
securities: data.securities,
|
|
}),
|
|
...headersObject,
|
|
};
|
|
|
|
const mediaTypeRendererFactories = Object.entries(requestBody?.content ?? {}).map(
|
|
([mediaType, mediaTypeObject]) => {
|
|
return (generator: CodeSampleGenerator) => {
|
|
const mediaTypeHeaders = mergeHeaders(genericHeaders, {
|
|
'Content-Type': mediaType,
|
|
});
|
|
return {
|
|
mediaType,
|
|
element: context.renderCodeBlock({
|
|
code: generator.generate({
|
|
url: { origin: serverUrlOrigin, path },
|
|
method: data.method,
|
|
body: undefined,
|
|
headers: mediaTypeHeaders,
|
|
}),
|
|
syntax: generator.syntax,
|
|
}),
|
|
examples: generateMediaTypeExamples(mediaTypeObject, {
|
|
mode: 'write',
|
|
}).map((example) => ({
|
|
example,
|
|
element: context.renderCodeBlock({
|
|
code: generator.generate({
|
|
url: { origin: serverUrlOrigin, path },
|
|
method: data.method,
|
|
body: example.value,
|
|
headers: mediaTypeHeaders,
|
|
}),
|
|
syntax: generator.syntax,
|
|
}),
|
|
})),
|
|
} satisfies MediaTypeRenderer;
|
|
};
|
|
}
|
|
);
|
|
|
|
return codeSampleGenerators.map((generator) => {
|
|
if (mediaTypeRendererFactories.length > 0) {
|
|
const renderers = mediaTypeRendererFactories.map((generate) => generate(generator));
|
|
return {
|
|
key: `default-${generator.id}`,
|
|
label: generator.label,
|
|
body: (
|
|
<OpenAPIMediaTypeExamplesBody
|
|
method={data.method}
|
|
path={data.path}
|
|
renderers={renderers}
|
|
blockKey={context.blockKey}
|
|
/>
|
|
),
|
|
footer: (
|
|
<OpenAPICodeSampleFooter renderers={renderers} data={data} context={context} />
|
|
),
|
|
};
|
|
}
|
|
return {
|
|
key: `default-${generator.id}`,
|
|
label: generator.label,
|
|
body: context.renderCodeBlock({
|
|
code: generator.generate({
|
|
url: { origin: serverUrlOrigin, path },
|
|
method: data.method,
|
|
body: undefined,
|
|
headers: genericHeaders,
|
|
}),
|
|
syntax: generator.syntax,
|
|
}),
|
|
footer: <OpenAPICodeSampleFooter data={data} renderers={[]} context={context} />,
|
|
};
|
|
});
|
|
}
|
|
|
|
export interface MediaTypeRenderer {
|
|
mediaType: string;
|
|
element: React.ReactNode;
|
|
examples: Array<{
|
|
example: OpenAPIV3.ExampleObject;
|
|
element: React.ReactNode;
|
|
}>;
|
|
}
|
|
|
|
function OpenAPICodeSampleFooter(props: {
|
|
data: OpenAPIOperationData;
|
|
renderers: MediaTypeRenderer[];
|
|
context: OpenAPIContext;
|
|
}) {
|
|
const { data, context, renderers } = props;
|
|
const { method, path, securities, servers } = data;
|
|
const { specUrl } = context;
|
|
const hideTryItPanel = data['x-hideTryItPanel'] || data.operation['x-hideTryItPanel'];
|
|
const hasMultipleMediaTypes =
|
|
renderers.length >= 2 || renderers.some((renderer) => renderer.examples.length >= 2);
|
|
|
|
// Check if any server has a host that can be used in an HTTP request
|
|
const hasValidHost = hasValidServerHost(servers);
|
|
|
|
if (hideTryItPanel && !hasMultipleMediaTypes) {
|
|
return null;
|
|
}
|
|
|
|
if (!validateHttpMethod(method) || (!hasMultipleMediaTypes && !hasValidHost)) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="openapi-codesample-footer">
|
|
{hasMultipleMediaTypes ? (
|
|
<OpenAPIMediaTypeExamplesSelector
|
|
method={data.method}
|
|
path={data.path}
|
|
renderers={renderers}
|
|
selectIcon={context.icons.chevronDown}
|
|
blockKey={context.blockKey}
|
|
/>
|
|
) : (
|
|
<span />
|
|
)}
|
|
{!hideTryItPanel && hasValidHost && specUrl && (
|
|
<ScalarApiButton
|
|
context={resolveScalarClientContext(context, servers, specUrl)}
|
|
withProxy={Boolean(data.operation['x-enable-proxy'] ?? data['x-enable-proxy'])}
|
|
method={method}
|
|
path={path}
|
|
securities={securities}
|
|
servers={servers}
|
|
specUrl={specUrl}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Build the client context for ScalarApiButton, resolving the signed proxy URL
|
|
* with the allowed server hosts for SSRF protection.
|
|
*/
|
|
function resolveScalarClientContext(
|
|
context: OpenAPIContext,
|
|
servers: OpenAPIOperationData['servers'],
|
|
specUrl: string
|
|
) {
|
|
const clientContext = getOpenAPIClientContext(context);
|
|
|
|
if (context.resolveProxyUrl) {
|
|
// Collect all possible host+path entries from spec servers
|
|
const origins = getAllServerOrigins(servers);
|
|
|
|
// Add the spec URL so the proxy can resolve it
|
|
const specOrigin = extractOrigin(specUrl);
|
|
if (specOrigin) {
|
|
origins.push(specOrigin);
|
|
}
|
|
|
|
clientContext.proxyUrl = context.resolveProxyUrl(origins) ?? undefined;
|
|
}
|
|
|
|
return clientContext;
|
|
}
|
|
|
|
/**
|
|
* Get custom code samples for the operation.
|
|
*/
|
|
function getCustomCodeSamples(props: {
|
|
data: OpenAPIOperationData;
|
|
context: OpenAPIContext;
|
|
}) {
|
|
const { data, context } = props;
|
|
|
|
let customCodeSamples: null | Array<{
|
|
key: string;
|
|
label: string;
|
|
body: React.ReactNode;
|
|
}> = null;
|
|
|
|
CUSTOM_CODE_SAMPLES_KEYS.forEach((key) => {
|
|
const customSamples = data.operation[key];
|
|
if (customSamples && Array.isArray(customSamples)) {
|
|
customCodeSamples = customSamples
|
|
.filter((sample) => {
|
|
return typeof sample.source === 'string' && typeof sample.lang === 'string';
|
|
})
|
|
.map((sample, index) => ({
|
|
key: `custom-sample-${sample.lang}-${index}`,
|
|
label: sample.label || sample.lang,
|
|
body: context.renderCodeBlock({
|
|
code: sample.source,
|
|
syntax: sample.lang,
|
|
}),
|
|
footer: (
|
|
<OpenAPICodeSampleFooter renderers={[]} data={data} context={context} />
|
|
),
|
|
}));
|
|
}
|
|
});
|
|
|
|
return customCodeSamples;
|
|
}
|
|
|
|
export function getSecurityHeaders(args: {
|
|
securityRequirement: OpenAPIV3.OperationObject['security'];
|
|
securities: OpenAPIOperationData['securities'];
|
|
}): {
|
|
[key: string]: string;
|
|
} {
|
|
const { securityRequirement, securities } = args;
|
|
const operationSecurityInfo = extractOperationSecurityInfo({ securityRequirement, securities });
|
|
|
|
if (operationSecurityInfo.length === 0) {
|
|
return {};
|
|
}
|
|
|
|
const selectedSecurity = operationSecurityInfo.at(0);
|
|
|
|
if (!selectedSecurity) {
|
|
return {};
|
|
}
|
|
|
|
const headers: { [key: string]: string } = {};
|
|
|
|
for (const security of selectedSecurity.schemes) {
|
|
switch (security.type) {
|
|
case 'http': {
|
|
// We do not use x-gitbook-prefix for http schemes to avoid confusion with the standard.
|
|
let scheme = security.scheme;
|
|
const defaultPlaceholderValue = scheme?.toLowerCase()?.includes('basic')
|
|
? 'username:password'
|
|
: 'YOUR_SECRET_TOKEN';
|
|
const format = resolvePrefillCodePlaceholderFromSecurityScheme({
|
|
security: security,
|
|
defaultPlaceholderValue,
|
|
});
|
|
|
|
if (scheme?.includes('bearer')) {
|
|
scheme = 'Bearer';
|
|
} else if (scheme?.includes('basic')) {
|
|
scheme = 'Basic';
|
|
} else if (scheme?.includes('token')) {
|
|
scheme = 'Token';
|
|
} else {
|
|
scheme = scheme ?? '';
|
|
}
|
|
|
|
headers.Authorization = `${scheme} ${format}`;
|
|
break;
|
|
}
|
|
case 'apiKey': {
|
|
if (security.in !== 'header') {
|
|
break;
|
|
}
|
|
const name = security.name ?? 'Authorization';
|
|
const placeholder = resolvePrefillCodePlaceholderFromSecurityScheme({
|
|
security: security,
|
|
defaultPlaceholderValue: 'YOUR_API_KEY',
|
|
});
|
|
// Use x-gitbook-prefix if provided for apiKey schemes
|
|
const prefix = security['x-gitbook-prefix'];
|
|
headers[name] = prefix ? `${prefix} ${placeholder}` : placeholder;
|
|
break;
|
|
}
|
|
case 'oauth2': {
|
|
const prefix = security['x-gitbook-prefix'] ?? 'Bearer';
|
|
headers.Authorization = `${prefix} ${resolvePrefillCodePlaceholderFromSecurityScheme(
|
|
{
|
|
security: security,
|
|
defaultPlaceholderValue: 'YOUR_OAUTH2_TOKEN',
|
|
}
|
|
)}`;
|
|
break;
|
|
}
|
|
default: {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
function validateHttpMethod(method: string): method is OpenAPIV3.HttpMethods {
|
|
return ['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'trace'].includes(method);
|
|
}
|