OpenAPI: Generate Python objects for code examples

This commit is contained in:
Fuma Nama
2025-10-24 18:58:30 +08:00
parent 978117e030
commit cc179fb4d5
30 changed files with 284 additions and 255 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Generate Python objects for code examples
+1 -1
View File
@@ -1,3 +1,3 @@
export * from './generate-file';
export * from './types';
export type { MediaAdapter } from './media/adapter';
export type { MediaAdapter } from '@/requests/media/adapter';
+2 -1
View File
@@ -48,7 +48,7 @@ import {
CollapsibleTrigger,
} from 'fumadocs-ui/components/ui/collapsible';
import { ChevronDown, LoaderCircle } from 'lucide-react';
import { encodeRequestData, type RequestData } from '@/requests/_shared';
import { encodeRequestData } from '@/requests/media/encode';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { cn } from 'fumadocs-ui/utils/cn';
import {
@@ -70,6 +70,7 @@ import {
} from '@/ui/components/select';
import { labelVariants } from '@/ui/components/input';
import type { ParsedSchema } from '@/utils/schema';
import type { RequestData } from '@/requests/types';
interface FormValues {
path: Record<string, unknown>;
+2 -2
View File
@@ -1,5 +1,5 @@
import type { RequestData } from '@/requests/_shared';
import type { MediaAdapter } from '@/media/adapter';
import type { RequestData } from '@/requests/types';
import type { MediaAdapter } from '@/requests/media/adapter';
export interface FetchOptions extends RequestData {
proxyUrl?: string;
+1 -1
View File
@@ -8,7 +8,7 @@ import {
processDocumentCached,
type ProcessedDocument,
} from '@/utils/process-document';
import { defaultAdapters } from '@/media/adapter';
import { defaultAdapters } from '@/requests/media/adapter';
import type { SharedOpenAPIOptions } from '@/server';
export interface ApiPageProps extends SharedOpenAPIOptions {
@@ -7,12 +7,9 @@ import { CodeExample, CodeExampleProvider } from '@/ui/lazy';
import { getPreferredType, type NoReference } from '@/utils/schema';
import { getRequestData } from '@/render/operation/get-request-data';
import { sample } from 'openapi-sampler';
import {
encodeRequestData,
type RawRequestData,
type RequestData,
} from '@/requests/_shared';
import { defaultSamples } from '@/requests';
import type { RawRequestData, RequestData } from '@/requests/types';
import { defaultSamples } from '@/requests/generators';
import { encodeRequestData } from '@/requests/media/encode';
interface CustomProperty {
'x-codeSamples'?: CodeSample[];
@@ -2,7 +2,7 @@ import type { MethodInformation, RenderContext } from '@/types';
import type { ResolvedSchema } from '@/utils/schema';
import { getPreferredType } from '@/utils/schema';
import { sample } from 'openapi-sampler';
import { type RawRequestData } from '@/requests/_shared';
import type { RawRequestData } from '@/requests/types';
export function getRequestData(
path: string,
@@ -17,7 +17,7 @@ import {
getAPIExamples,
} from '@/render/operation/api-example';
import { MethodLabel } from '@/ui/components/method-label';
import { type SampleGenerator } from '@/requests/_shared';
import { type SampleGenerator } from '@/requests/types';
import { getTypescriptSchema } from '@/utils/get-typescript-schema';
import { CopyResponseTypeScript } from '@/ui/client';
import { SelectTab, SelectTabs, SelectTabTrigger } from '@/ui/select-tabs';
-200
View File
@@ -1,200 +0,0 @@
import type { MediaAdapter } from '@/media/adapter';
import type { NoReference } from '@/utils/schema';
import type { ParameterObject } from '@/types';
export type SampleGenerator<ServerContext = unknown> = (
url: string,
data: RequestData,
context: {
mediaAdapters: Record<string, MediaAdapter>;
server: ServerContext;
},
) => string;
export interface RawRequestData {
method: string;
path: Record<string, unknown>;
query: Record<string, unknown>;
header: Record<string, unknown>;
cookie: Record<string, unknown>;
body?: unknown;
bodyMediaType?: string;
}
interface EncodedParameter {
readonly value: string | string[];
}
export interface RequestData {
method: string;
path: Record<string, EncodedParameter>;
query: Record<string, EncodedParameter>;
header: Record<string, EncodedParameter>;
cookie: Record<string, EncodedParameter>;
body?: unknown;
bodyMediaType?: string;
}
const FormDelimiter = {
spaceDelimited: ' ',
pipeDelimited: '|',
};
const PathPrefix = {
label: '.',
matrix: ';',
};
export function ident(code: string, tab: number = 1) {
return code
.split('\n')
.map((v) => ' '.repeat(tab) + v)
.join('\n');
}
export function encodeRequestData(
from: RawRequestData,
adapters: Record<string, MediaAdapter>,
parameters: NoReference<ParameterObject>[],
): RequestData {
function getMediaEncoder(field: NoReference<ParameterObject>) {
if (!field.content) return;
for (const k in field.content) {
if (k in adapters) {
return (v: unknown) => String(adapters[k].encode({ body: v }));
}
}
}
function writeObject(
parentKey: string,
value: object,
deep: boolean,
output: Record<string, EncodedParameter>,
) {
for (const k in value) {
const prop: unknown = value[k as keyof object];
if (prop == null) continue;
const key = deep ? `${parentKey}[${k}]` : k;
if (!deep || typeof prop !== 'object') {
output[key] = {
value: String(prop),
};
continue;
}
writeObject(key, value, deep, output);
}
}
function write(
key: string,
value: unknown,
output: Record<string, EncodedParameter> = {},
field?: NoReference<ParameterObject>,
): Record<string, EncodedParameter> {
if (!field) {
output[key] = { value: String(value) };
return output;
}
const encoder = getMediaEncoder(field);
if (encoder) {
output[key] = { value: encoder(value) };
return output;
}
const explode = field.explode ?? true;
let prefix = '';
let sep = ',';
if (field.in === 'path') {
const style = field.style ?? 'simple';
if (style in PathPrefix) {
prefix = PathPrefix[style as keyof typeof PathPrefix];
if (explode) sep = prefix;
}
}
if (Array.isArray(value)) {
// header & cookie doesn't support explode for array values
if (explode && field.in !== 'header' && field.in !== 'cookie') {
output[key] = {
value: prefix + value.map(String),
};
return output;
}
if (field.in === 'query') {
const style = field.style ?? 'form';
if (style in FormDelimiter)
sep = FormDelimiter[style as keyof typeof FormDelimiter];
}
output[key] = {
value: prefix + value.map(String).join(sep),
};
return output;
}
if (typeof value === 'object' && value) {
// header & path creates key-value pairs
if (explode && (field.in === 'header' || field.in === 'path')) {
output[key] = {
value:
prefix +
Object.entries(value)
.map(([k, v]) => `${k}=${v}`)
.join(sep),
};
return output;
}
if (explode || field.style === 'deepObject') {
writeObject(key, value, field.style === 'deepObject', output);
return output;
}
output[key] = {
value: prefix + Object.entries(value).flat().join(sep),
};
return output;
}
output[key] = {
value: prefix + String(value),
};
return output;
}
const result: Partial<RequestData> = {
method: from.method,
body: from.body,
bodyMediaType: from.bodyMediaType,
};
for (const type of ['cookie', 'query', 'header', 'path'] as const) {
const out = {};
for (const k in from[type]) {
const value = from[type][k];
if (value == null) continue;
const field = parameters.find((p) => p.name === k && p.in === type);
write(k, value, out, field);
}
result[type] = out;
}
return result as RequestData;
}
@@ -1,5 +1,5 @@
'use client';
import type { SampleGenerator } from '@/requests/_shared';
import type { SampleGenerator } from '@/requests/types';
export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const s: string[] = [];
@@ -1,6 +1,6 @@
'use client';
import { escapeString, inputToString } from '@/utils/input-to-string';
import { ident, type SampleGenerator } from '@/requests/_shared';
import { escapeString, ident, inputToString } from '@/requests/string-utils';
import type { SampleGenerator } from '@/requests/types';
export const generator: SampleGenerator = (url, data) => {
const s: string[] = [];
@@ -1,5 +1,6 @@
'use client';
import { ident, type SampleGenerator } from '@/requests/_shared';
import { ident } from '@/requests/string-utils';
import type { SampleGenerator } from '@/requests/types';
export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const imports = ['fmt', 'net/http', 'io/ioutil'];
@@ -1,10 +1,10 @@
import type { CodeSample } from '@/render/operation';
import * as CURL from '@/requests/curl';
import * as JS from '@/requests/javascript';
import * as Go from '@/requests/go';
import * as Python from '@/requests/python';
import * as Java from '@/requests/java';
import * as CSharp from '@/requests/csharp';
import * as CURL from './curl';
import * as JS from './javascript';
import * as Go from './go';
import * as Python from './python';
import * as Java from './java';
import * as CSharp from './csharp';
export const defaultSamples: CodeSample[] = [
{
@@ -1,5 +1,6 @@
'use client';
import { ident, type SampleGenerator } from '@/requests/_shared';
import { ident } from '@/requests/string-utils';
import type { SampleGenerator } from '@/requests/types';
export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const s: string[] = [];
@@ -1,5 +1,6 @@
'use client';
import { ident, type SampleGenerator } from '@/requests/_shared';
import { ident } from '@/requests/string-utils';
import type { SampleGenerator } from '@/requests/types';
export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const s: string[] = [];
@@ -1,11 +1,15 @@
'use client';
import { type SampleGenerator } from '@/requests/_shared';
import type { SampleGenerator } from '@/requests/types';
import { generatePythonObject } from '@/requests/to-python-object';
export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const headers: Record<string, string> = {};
const imports = new Set<string>();
const params = [`"${data.method}"`, 'url'];
let body: string | undefined;
imports.add('requests');
if (data.body && data.bodyMediaType && data.bodyMediaType in mediaAdapters) {
headers['Content-Type'] = data.bodyMediaType;
@@ -16,9 +20,7 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
},
);
if (body && data.bodyMediaType === 'application/json') {
params.push('json = body');
} else if (body) {
if (body) {
params.push('data = body');
}
}
@@ -28,7 +30,7 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
}
if (Object.keys(headers).length > 0) {
params.push(`headers = ${JSON.stringify(headers, null, 2)}`);
params.push(`headers = ${generatePythonObject(headers, imports)}`);
}
const inputCookies = Object.entries(data.cookie);
@@ -39,10 +41,12 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
cookies[k] = v.value as string;
}
params.push(`cookies = ${JSON.stringify(cookies, null, 2)}`);
params.push(`cookies = ${generatePythonObject(cookies, imports)}`);
}
return `import requests
return `${Array.from(imports)
.map((name) => 'import ' + name)
.join('\n')}
url = ${JSON.stringify(url)}
${body ?? ''}
@@ -1,4 +1,4 @@
import { escapeString, inputToString } from '@/utils/input-to-string';
import { escapeString, inputToString } from '@/requests/string-utils';
// @ts-expect-error -- untyped
import js2xml from 'xml-js/lib/js2xml';
@@ -251,9 +251,6 @@ function str(
}
if (ctx.lang === 'python') {
if (mediaType === 'application/json')
return `body = ${JSON.stringify(init, null, 2)}`;
return `body = ${escapeString(inputToString(init, mediaType), '"""')}`;
}
@@ -0,0 +1,155 @@
import type { MediaAdapter } from '@/requests/media/adapter';
import type { NoReference } from '@/utils/schema';
import type { ParameterObject } from '@/types';
import type { RawRequestData, RequestData } from '@/requests/types';
export interface EncodedParameter {
readonly value: string | string[];
}
const FormDelimiter = {
spaceDelimited: ' ',
pipeDelimited: '|',
};
const PathPrefix = {
label: '.',
matrix: ';',
};
export function encodeRequestData(
from: RawRequestData,
adapters: Record<string, MediaAdapter>,
parameters: NoReference<ParameterObject>[],
): RequestData {
const result: Partial<RequestData> = {
method: from.method,
body: from.body,
bodyMediaType: from.bodyMediaType,
};
for (const type of ['cookie', 'query', 'header', 'path'] as const) {
const output: Record<string, EncodedParameter> = {};
for (const key in from[type]) {
const value = from[type][key];
if (value == null) continue;
const field = parameters.find((p) => p.name === key && p.in === type);
if (!field) {
output[key] = { value: String(value) };
continue;
}
const encoder = getMediaEncoder(field, adapters);
if (encoder) {
output[key] = { value: encoder(value) };
continue;
}
const explode = field.explode ?? true;
let prefix = '';
let sep = ',';
if (field.in === 'path') {
const style = field.style ?? 'simple';
if (style in PathPrefix) {
prefix = PathPrefix[style as keyof typeof PathPrefix];
if (explode) sep = prefix;
}
}
if (Array.isArray(value)) {
// header & cookie doesn't support explode for array values
if (explode && field.in !== 'header' && field.in !== 'cookie') {
output[key] = {
value: prefix + value.map(String),
};
continue;
}
if (field.in === 'query') {
const style = field.style ?? 'form';
if (style in FormDelimiter)
sep = FormDelimiter[style as keyof typeof FormDelimiter];
}
output[key] = {
value: prefix + value.map(String).join(sep),
};
continue;
}
if (typeof value === 'object' && value) {
// header & path creates key-value pairs
if (explode && (field.in === 'header' || field.in === 'path')) {
output[key] = {
value:
prefix +
Object.entries(value)
.map(([k, v]) => `${k}=${v}`)
.join(sep),
};
continue;
}
if (explode || field.style === 'deepObject') {
writeObject(key, value, field.style === 'deepObject', output);
continue;
}
output[key] = {
value: prefix + Object.entries(value).flat().join(sep),
};
continue;
}
output[key] = {
value: prefix + String(value),
};
}
result[type] = output;
}
return result as RequestData;
}
function getMediaEncoder(
field: NoReference<ParameterObject>,
adapters: Record<string, MediaAdapter>,
) {
if (!field.content) return;
for (const k in field.content) {
if (k in adapters) {
return (v: unknown) => String(adapters[k].encode({ body: v }));
}
}
}
function writeObject(
parentKey: string,
value: object,
deep: boolean,
output: Record<string, EncodedParameter>,
) {
for (const k in value) {
const prop: unknown = value[k as keyof object];
if (prop == null) continue;
const key = deep ? `${parentKey}[${k}]` : k;
if (!deep || typeof prop !== 'object') {
output[key] = {
value: String(prop),
};
continue;
}
writeObject(key, value, deep, output);
}
}
@@ -47,3 +47,10 @@ export function escapeString(str: string, delimit?: string): string {
return `${delimit}${str.replaceAll(delimit, `\\${delimit}`)}${delimit}`;
}
export function ident(code: string, tab: number = 1) {
return code
.split('\n')
.map((v) => ' '.repeat(tab) + v)
.join('\n');
}
@@ -0,0 +1,25 @@
export function generatePythonObject(v: unknown, imports = new Set()): string {
if (v === null) {
return 'None';
} else if (typeof v === 'boolean') {
return v ? 'True' : 'False';
} else if (typeof v === 'string') {
return JSON.stringify(v);
} else if (typeof v === 'number') {
return v.toString();
} else if (Array.isArray(v)) {
const items = v.map((item) => generatePythonObject(item, imports));
return `[${items.join(', ')}]`;
} else if (v instanceof Date) {
imports.add('datetime');
return `datetime.datetime(${v.getFullYear()}, ${v.getMonth() + 1}, ${v.getDate()}, ${v.getHours()}, ${v.getMinutes()}, ${v.getSeconds()}, ${v.getMilliseconds()})`;
} else if (typeof v === 'object') {
const entries = Object.entries(v).map(
([key, value]) =>
` ${JSON.stringify(key)}: ${generatePythonObject(value, imports)}`,
);
return `{\n${entries.join(', \n')}\n}`;
} else {
throw new Error(`Unsupported type: ${typeof v}`);
}
}
+35
View File
@@ -0,0 +1,35 @@
import type { MediaAdapter } from '@/requests/media/adapter';
import type { EncodedParameter } from '@/requests/media/encode';
export type SampleGenerator<ServerContext = unknown> = (
url: string,
data: RequestData,
context: {
mediaAdapters: Record<string, MediaAdapter>;
server: ServerContext;
},
) => string;
export interface RawRequestData {
method: string;
path: Record<string, unknown>;
query: Record<string, unknown>;
header: Record<string, unknown>;
cookie: Record<string, unknown>;
body?: unknown;
bodyMediaType?: string;
}
export interface RequestData {
method: string;
path: Record<string, EncodedParameter>;
query: Record<string, EncodedParameter>;
header: Record<string, EncodedParameter>;
cookie: Record<string, EncodedParameter>;
body?: unknown;
bodyMediaType?: string;
}
+1 -1
View File
@@ -7,7 +7,7 @@ import type {
HighlightOptionsCommon,
HighlightOptionsThemes,
} from 'fumadocs-core/highlight';
import type { MediaAdapter } from '@/media/adapter';
import type { MediaAdapter } from '@/requests/media/adapter';
import type { MethodInformation } from '@/types';
import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
import {
+1 -1
View File
@@ -3,7 +3,7 @@ import type { default as Slugger } from 'github-slugger';
import { type Renderer } from '@/render/renderer';
import type { NoReference } from '@/utils/schema';
import type { ProcessedDocument } from '@/utils/process-document';
import type { MediaAdapter } from '@/media/adapter';
import type { MediaAdapter } from '@/requests/media/adapter';
import type { SharedOpenAPIOptions } from '@/server';
export type Document = V3_1.Document;
+1 -1
View File
@@ -8,7 +8,7 @@ import {
useState,
} from 'react';
import type { RenderContext, ServerObject } from '@/types';
import { defaultAdapters, type MediaAdapter } from '@/media/adapter';
import { defaultAdapters, type MediaAdapter } from '@/requests/media/adapter';
export interface ApiProviderProps
extends Omit<ApiContextType, 'mediaAdapters'> {
@@ -26,7 +26,7 @@ import {
resolveServerUrl,
withBase,
} from '@/utils/url';
import type { RawRequestData, RequestData } from '@/requests/_shared';
import type { RawRequestData, RequestData } from '@/requests/types';
type UpdateListener = (data: RawRequestData, encoded: RequestData) => void;
+1 -1
View File
@@ -2,7 +2,7 @@ import type { HTMLAttributes } from 'react';
import type { RootProps } from '@/render/renderer';
import { ApiProvider } from '@/ui/lazy';
import { cn } from 'fumadocs-ui/utils/cn';
import type { MediaAdapter } from '@/media/adapter';
import type { MediaAdapter } from '@/requests/media/adapter';
export function Root({
children,
+1 -1
View File
@@ -1,4 +1,4 @@
import type { RequestData } from '@/requests/_shared';
import type { RequestData } from '@/requests/types';
export function joinURL(base: string, pathname: string): string {
if (pathname.startsWith('/')) pathname = pathname.slice(1);
+6 -6
View File
@@ -1,10 +1,10 @@
import { describe, expect, test } from 'vitest';
import * as Python from '@/requests/python';
import * as Go from '@/requests/go';
import * as Curl from '@/requests/curl';
import * as JS from '@/requests/javascript';
import type { RequestData } from '@/requests/_shared';
import { defaultAdapters } from '@/media/adapter';
import * as Python from '@/requests/generators/python';
import * as Go from '@/requests/generators/go';
import * as Curl from '@/requests/generators/curl';
import * as JS from '@/requests/generators/javascript';
import type { RequestData } from '@/requests/types';
import { defaultAdapters } from '@/requests/media/adapter';
import { resolveRequestData } from '@/utils/url';
describe('Code Sample Generators', () => {
+4 -4
View File
@@ -1,11 +1,11 @@
import requests
url = "http://localhost:8080/hello_world?search=ai"
body = {
body = """{
"id": "id"
}
response = requests.request("GET", url, json = body, headers = {
"Content-Type": "application/json",
}"""
response = requests.request("GET", url, data = body, headers = {
"Content-Type": "application/json",
"authorization": "Bearer"
}, cookies = {
"mode": "light"
+3 -3
View File
@@ -2,11 +2,11 @@ import { describe, expect, test } from 'vitest';
import { combineSchema } from '@/utils/combine-schema';
import {
joinURL,
withBase,
resolveServerUrl,
resolveRequestData,
resolveServerUrl,
withBase,
} from '@/utils/url';
import type { RequestData } from '@/requests/_shared';
import type { RequestData } from '@/requests/types';
describe('Merge object schemas', () => {
test('Merge single object', () => {