Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update OpenAPI code examples to support multiple content-type #2843

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
5 changes: 5 additions & 0 deletions .changeset/big-lemons-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---

Update OpenAPI code examples to support multiple content-type
6 changes: 1 addition & 5 deletions packages/react-openapi/src/OpenAPICodeSample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,7 @@ export function OpenAPICodeSample(props: {
data.path +
(searchParams.size ? `?${searchParams.toString()}` : ''),
method: data.method,
body: requestBodyContent
? generateMediaTypeExample(requestBodyContent[1], {
omitEmptyAndOptionalProperties: true,
})
: undefined,
body: requestBodyContent ? generateMediaTypeExample(requestBodyContent[1]) : undefined,
headers: {
...getSecurityHeaders(data.securities),
...headersObject,
Expand Down
20 changes: 14 additions & 6 deletions packages/react-openapi/src/OpenAPISchema.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -252,12 +252,7 @@ export function OpenAPISchemaPresentation(props: OpenAPISchemaPropertyEntry) {
) : null}
{shouldDisplayExample(schema) ? (
<div className="openapi-schema-example">
Example:{' '}
<code>
{typeof schema.example === 'string'
? schema.example
: stringifyOpenAPI(schema.example)}
</code>
Example: <code>{formatExample(schema.example)}</code>
</div>
) : null}
{schema.pattern ? (
Expand Down Expand Up @@ -437,3 +432,16 @@ export function getSchemaTitle(

return type;
}

function formatExample(example: any): string {
if (typeof example === 'string') {
return example
.replace(/\n/g, ' ') // Replace newlines with spaces
.replace(/\s+/g, ' ') // Collapse multiple spaces/newlines into a single space
.replace(/([\{\}:,])\s+/g, '$1 ') // Ensure a space after {, }, :, and ,
.replace(/\s+([\{\}:,])/g, ' $1') // Ensure a space before {, }, :, and ,
.trim();
}

return stringifyOpenAPI(example);
}
10 changes: 8 additions & 2 deletions packages/react-openapi/src/OpenAPITabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,17 @@ export function OpenAPITabs(
const tabFromState = syncedTabs.get(stateKey);

if (!items.some((item) => item.key === tabFromState?.key)) {
return;
return setSelectedTab(defaultTab);
}

if (tabFromState && tabFromState?.key !== selectedTab?.key) {
setSelectedTab(tabFromState);
const tabFromItems = items.find((item) => item.key === tabFromState.key);

if (!tabFromItems) {
return;
}

setSelectedTab(tabFromItems);
}
}
}, [isVisible, stateKey, syncedTabs, selectedTabKey]);
Expand Down
241 changes: 234 additions & 7 deletions packages/react-openapi/src/code-samples.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
import {
isFormData,
isPDF,
isFormUrlEncoded,
isText,
isXML,
isCSV,
isGraphQL,
isPlainObject,
} from './contentTypeChecks';
import { stringifyOpenAPI } from './stringifyOpenAPI';

export interface CodeSampleInput {
Expand Down Expand Up @@ -30,14 +40,29 @@ export const codeSampleGenerators: CodeSampleGenerator[] = [

lines.push(`--url '${url}'`);

if (body) {
const bodyContent = BodyGenerators.getCurlBody(body, headers);

if (!bodyContent) {
return '';
}

body = bodyContent.body;
headers = bodyContent.headers;
}

if (headers) {
Object.entries(headers).forEach(([key, value]) => {
lines.push(`--header '${key}: ${value}'`);
});
}

if (body && Object.keys(body).length > 0) {
lines.push(`--data '${stringifyOpenAPI(body)}'`);
if (body) {
if (Array.isArray(body)) {
lines.push(...body);
} else {
lines.push(body);
}
}

return lines.map((line, index) => (index > 0 ? indent(line, 2) : line)).join(separator);
Expand All @@ -50,6 +75,19 @@ export const codeSampleGenerators: CodeSampleGenerator[] = [
generate: ({ method, url, headers, body }) => {
let code = '';

if (body) {
const lines = BodyGenerators.getJavaScriptBody(body, headers);

if (!lines) {
return '';
}

// add the generated code to the top
code += lines.code;
body = lines.body;
headers = lines.headers;
}

code += `const response = await fetch('${url}', {
method: '${method.toUpperCase()}',\n`;

Expand All @@ -58,10 +96,10 @@ export const codeSampleGenerators: CodeSampleGenerator[] = [
}

if (body) {
code += indent(`body: JSON.stringify(${stringifyOpenAPI(body, null, 2)}),\n`, 4);
code += indent(`body: ${body}\n`, 4);
}

code += `});\n`;
code += `});\n\n`;
code += `const data = await response.json();`;

return code;
Expand All @@ -73,15 +111,36 @@ export const codeSampleGenerators: CodeSampleGenerator[] = [
syntax: 'python',
generate: ({ method, url, headers, body }) => {
let code = 'import requests\n\n';

if (body) {
const lines = BodyGenerators.getPythonBody(body, headers);

if (!lines) {
return '';
}

// add the generated code to the top
code += lines.code;
body = lines.body;
headers = lines.headers;
}

code += `response = requests.${method.toLowerCase()}(\n`;
code += indent(`"${url}",\n`, 4);

if (headers) {
code += indent(`headers=${stringifyOpenAPI(headers)},\n`, 4);
}

if (body) {
code += indent(`json=${stringifyOpenAPI(body)}\n`, 4);
if (body === 'files') {
code += indent(`files=${body}\n`, 4);
} else {
code += indent(`data=${stringifyOpenAPI(body)}\n`, 4);
}
}
code += ')\n';

code += ')\n\n';
code += `data = response.json()`;
return code;
},
Expand All @@ -99,6 +158,12 @@ export const codeSampleGenerators: CodeSampleGenerator[] = [
// handle unicode chars with a text encoder
const encoder = new TextEncoder();

const bodyString = BodyGenerators.getHTTPBody(body, headers);

if (bodyString) {
body = bodyString;
}

headers = {
...headers,
'Content-Length': encoder.encode(bodyContent).length.toString(),
Expand All @@ -117,7 +182,7 @@ export const codeSampleGenerators: CodeSampleGenerator[] = [
.join('\n') + '\n'
: '';

const bodyString = body ? `\n${stringifyOpenAPI(body, null, 2)}` : '';
const bodyString = body ? `\n${body}` : '';

const httpRequest = `${method.toUpperCase()} ${decodeURI(path)} HTTP/1.1
Host: ${host}
Expand Down Expand Up @@ -157,3 +222,165 @@ export function parseHostAndPath(url: string) {
return { host, path };
}
}

// Body Generators
const BodyGenerators = {
getCurlBody(body: any, headers?: Record<string, string>) {
if (!body || !headers) return undefined;

// Copy headers to avoid mutating the original object
const headersCopy = { ...headers };
const contentType: string = headersCopy['Content-Type'] || '';

if (isFormData(contentType)) {
body = isPlainObject(body)
? Object.entries(body).map(([key, value]) => `--form '${key}=${String(value)}'`)
: `--form 'file=@${body}'`;
} else if (isFormUrlEncoded(contentType)) {
body = isPlainObject(body)
? `--data '${Object.entries(body)
.map(([key, value]) => `${key}=${String(value)}`)
.join('&')}'`
: String(body);
} else if (isText(contentType)) {
body = `--data '${String(body).replace(/"/g, '')}'`;
} else if (isXML(contentType) || isCSV(contentType)) {
// We use --data-binary to avoid cURL converting newlines to \r\n
body = `--data-binary $'${stringifyOpenAPI(body).replace(/"/g, '')}'`;
} else if (isGraphQL(contentType)) {
body = `--data '${stringifyOpenAPI(body)}'`;
// Set Content-Type to application/json for GraphQL, recommended by GraphQL spec
headersCopy['Content-Type'] = 'application/json';
} else if (isPDF(contentType)) {
// We use --data-binary to avoid cURL converting newlines to \r\n
body = `--data-binary '@${String(body)}'`;
} else {
body = `--data '${stringifyOpenAPI(body, null, 2)}'`;
}

return {
body,
headers: headersCopy,
};
},
getJavaScriptBody: (body: any, headers?: Record<string, string>) => {
if (!body || !headers) return;

let code = '';

// Copy headers to avoid mutating the original object
const headersCopy = { ...headers };
const contentType: string = headersCopy['Content-Type'] || '';

// Use FormData for file uploads
if (isFormData(contentType)) {
code += 'const formData = new FormData();\n\n';
if (isPlainObject(body)) {
Object.entries(body).forEach(([key, value]) => {
code += `formData.append("${key}", "${String(value)}");\n`;
});
} else if (typeof body === 'string') {
code += `formData.append("file", "${body}");\n`;
}
code += '\n';
body = 'formData';
} else if (isFormUrlEncoded(contentType)) {
// Use URLSearchParams for form-urlencoded data
code += 'const params = new URLSearchParams();\n\n';
if (isPlainObject(body)) {
Object.entries(body).forEach(([key, value]) => {
code += `params.append("${key}", "${String(value)}");\n`;
});
}
code += '\n';
body = 'params.toString()';
} else if (isGraphQL(contentType)) {
if (isPlainObject(body)) {
Object.entries(body).forEach(([key, value]) => {
code += `const ${key} = \`\n${indent(String(value), 4)}\`;\n\n`;
});
body = `JSON.stringify({ ${Object.keys(body).join(', ')} })`;
// Set Content-Type to application/json for GraphQL, recommended by GraphQL spec
headersCopy['Content-Type'] = 'application/json';
} else {
code += `const query = \`\n${indent(String(body), 4)}\`;\n\n`;
body = 'JSON.stringify(query)';
}
} else if (isCSV(contentType)) {
code += 'const csv = `\n';
code += indent(String(body), 4);
code += '`;\n\n';
body = 'csv';
} else if (isPDF(contentType)) {
// Use FormData to upload PDF files
code += 'const formData = new FormData();\n\n';
code += `formData.append("file", "${body}");\n\n`;
body = 'formData';
} else if (isXML(contentType)) {
code += 'const xml = `\n';
code += indent(String(body), 4);
code += '`;\n\n';
body = 'xml';
} else if (isText(contentType)) {
body = stringifyOpenAPI(body, null, 2);
} else {
body = `JSON.stringify(${stringifyOpenAPI(body, null, 2)})`;
}

return { body, code, headers: headersCopy };
},
getPythonBody: (body: any, headers?: Record<string, string>) => {
if (!body || !headers) return;
let code = '';
const contentType: string = headers['Content-Type'] || '';

if (isFormData(contentType)) {
code += 'files = {\n';
if (isPlainObject(body)) {
Object.entries(body).forEach(([key, value]) => {
code += indent(`"${key}": "${String(value)}",`, 4) + '\n';
});
}
code += '}\n\n';
body = 'files';
}

if (isPDF(contentType)) {
code += 'files = {\n';
code += indent(`"file": "${body}",`, 4) + '\n';
code += '}\n\n';
body = 'files';
}

return { body, code, headers };
},
getHTTPBody: (body: any, headers?: Record<string, string>) => {
if (!body || !headers) return undefined;

const contentType: string = headers['Content-Type'] || '';

const typeHandlers = {
pdf: () => `${stringifyOpenAPI(body, null, 2)}`,
formUrlEncoded: () => {
const encoded = isPlainObject(body)
? Object.entries(body)
.map(([key, value]) => `${key}=${String(value)}`)
.join('&')
: String(body);
return `"${encoded}"`;
},
text: () => `"${String(body)}"`,
xmlOrCsv: () => `"${stringifyOpenAPI(body).replace(/"/g, '')}"`,
default: () => `${stringifyOpenAPI(body, null, 2)}`,
};

if (isPDF(contentType)) return typeHandlers.pdf();
if (isFormUrlEncoded(contentType)) return typeHandlers.formUrlEncoded();
if (isText(contentType)) return typeHandlers.text();
if (isXML(contentType) || isCSV(contentType)) {
return typeHandlers.xmlOrCsv();
}

return typeHandlers.default();
},
};
Loading
Loading