OpenAPI: Support code usage generator registry

This commit is contained in:
Fuma Nama
2026-02-27 21:22:15 +08:00
parent 7ea32fa757
commit 146bb4bbc1
22 changed files with 607 additions and 465 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Support code usage generator registry
@@ -29,40 +29,58 @@ export default defineClientConfig({
});
```
Installing the types packages is highly recommended for advanced customisations:
### Generate Code Usages
```package-install
openapi-types json-schema-typed -D
Generate code usage examples for each API endpoint.
```ts title="lib/openapi/code-usage.ts" twoslash
import {
createCodeUsageGeneratorRegistry,
type CodeUsageGenerator,
} from 'fumadocs-openapi/requests/generators';
import { registerDefault } from 'fumadocs-openapi/requests/generators/all';
export const codeUsages = createCodeUsageGeneratorRegistry();
// include defaults
registerDefault(codeUsages);
// add custom generators
codeUsages.add('custom-id', {
label: 'My Example',
lang: 'js',
generate(url, data, { mediaAdapters }) {
// request data
console.log(url, data);
return 'const response = "hello world";';
},
});
```
It gives you a higher level of type-safety.
Pass the registry to both client & server configs:
### Generate Code Samples
Generate custom code samples for each API endpoint.
```ts
```tsx tab="components/api-page.tsx"
import { openapi } from '@/lib/openapi';
import { createAPIPage } from 'fumadocs-openapi/ui';
import { codeUsages } from '@/lib/openapi/code-usage';
import client from './api-page.client';
export const APIPage = createAPIPage(openapi, {
generateCodeSamples(endpoint) {
return [
{
id: 'js',
lang: 'js',
label: 'JavaScript SDK',
source: "console.log('hello')",
},
// or to disable the default code samples
// set `source: false`
{
id: 'curl',
lang: 'bash',
source: false,
},
];
},
client,
// [!code ++]
codeUsages,
});
```
```tsx tab="components/api-page.client.tsx"
'use client';
import { defineClientConfig } from 'fumadocs-openapi/ui/client';
import { codeUsages } from '@/lib/openapi/code-usage';
export default defineClientConfig({
// [!code ++]
codeUsages,
});
```
@@ -89,7 +107,7 @@ You can create a media adapter to support other media types in API pages, a medi
Put your media adapters in a separate file.
```ts title="lib/media-adapters.ts" twoslash
```ts title="lib/openapi/media.ts" twoslash
import type { MediaAdapter } from 'fumadocs-openapi';
export const mediaAdapters: Record<string, MediaAdapter> = {
@@ -123,7 +141,7 @@ Pass the adapter to both client & server configs.
```tsx tab="components/api-page.tsx"
import { openapi } from '@/lib/openapi';
import { createAPIPage } from 'fumadocs-openapi/ui';
import { mediaAdapters } from '@/lib/media-adapters';
import { mediaAdapters } from '@/lib/openapi/media';
import client from './api-page.client';
export const APIPage = createAPIPage(openapi, {
@@ -136,7 +154,7 @@ export const APIPage = createAPIPage(openapi, {
```tsx tab="components/api-page.client.tsx"
'use client';
import { defineClientConfig } from 'fumadocs-openapi/ui/client';
import { mediaAdapters } from '@/lib/media-adapters';
import { mediaAdapters } from '@/lib/openapi/media';
export default defineClientConfig({
// [!code ++]
+19 -33
View File
@@ -16,41 +16,27 @@
],
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./css/*": "./css/*",
"./ui": {
"types": "./dist/ui/index.d.ts",
"import": "./dist/ui/index.js"
},
"./ui/base": {
"types": "./dist/ui/base.d.ts",
"import": "./dist/ui/base.js"
},
"./ui/client": {
"types": "./dist/ui/client/index.d.ts",
"import": "./dist/ui/client/index.js"
},
"./playground": {
"types": "./dist/playground/index.d.ts",
"import": "./dist/playground/index.js"
},
"./playground/client": {
"types": "./dist/playground/client.d.ts",
"import": "./dist/playground/client.js"
},
"./scalar": {
"types": "./dist/scalar/index.d.ts",
"import": "./dist/scalar/index.js"
},
"./server": {
"types": "./dist/server/index.d.ts",
"import": "./dist/server/index.js"
}
".": "./dist/index.js",
"./playground": "./dist/playground/index.js",
"./playground/client": "./dist/playground/client.js",
"./requests/generators": "./dist/requests/generators/index.js",
"./requests/generators/all": "./dist/requests/generators/all.js",
"./requests/generators/csharp": "./dist/requests/generators/csharp.js",
"./requests/generators/curl": "./dist/requests/generators/curl.js",
"./requests/generators/go": "./dist/requests/generators/go.js",
"./requests/generators/java": "./dist/requests/generators/java.js",
"./requests/generators/javascript": "./dist/requests/generators/javascript.js",
"./requests/generators/python": "./dist/requests/generators/python.js",
"./scalar": "./dist/scalar/index.js",
"./server": "./dist/server/index.js",
"./ui": "./dist/ui/index.js",
"./ui/base": "./dist/ui/base.js",
"./ui/client": "./dist/ui/client/index.js",
"./package.json": "./package.json",
"./css/*": "./css/*"
},
"publishConfig": {
"access": "public"
@@ -0,0 +1,16 @@
import type { CodeUsageGeneratorRegistry } from '.';
import { csharp } from './csharp';
import { curl } from './curl';
import { go } from './go';
import { java } from './java';
import { javascript } from './javascript';
import { python } from './python';
export function registerDefault(registry: CodeUsageGeneratorRegistry) {
registry.add('curl', curl);
registry.add('js', javascript);
registry.add('go', go);
registry.add('python', python);
registry.add('java', java);
registry.add('csharp', csharp);
}
@@ -1,66 +1,69 @@
'use client';
import type { SampleGenerator } from '@/requests/types';
import type { CodeUsageGenerator } from '@/requests/generators';
import { resolveMediaAdapter } from '@/requests/media/adapter';
export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const s: string[] = [];
const imports = new Set<string>(['System', 'System.Net.Http', 'System.Text']);
const headers = { ...data.header };
export const csharp: CodeUsageGenerator = {
label: 'C#',
lang: 'csharp',
generate(url, data, { mediaAdapters }) {
const s: string[] = [];
const imports = new Set<string>(['System', 'System.Net.Http', 'System.Text']);
const headers = { ...data.header };
// Handle request body
let body: string | undefined;
if (data.body && data.bodyMediaType) {
const adapter = resolveMediaAdapter(data.bodyMediaType, mediaAdapters);
body = adapter?.generateExample(data as { body: unknown }, {
lang: 'csharp',
addImport(from) {
imports.add(from);
},
});
}
// Handle request body
let body: string | undefined;
if (data.body && data.bodyMediaType) {
const adapter = resolveMediaAdapter(data.bodyMediaType, mediaAdapters);
body = adapter?.generateExample(data as { body: unknown }, {
lang: 'csharp',
addImport(from) {
imports.add(from);
},
});
}
for (const specifier of imports) {
s.push(`using ${specifier};`);
}
for (const specifier of imports) {
s.push(`using ${specifier};`);
}
s.push('');
s.push('');
if (body) {
s.push(body, '');
}
if (body) {
s.push(body, '');
}
s.push('var client = new HttpClient();');
const headerLines: string[] = [];
s.push('var client = new HttpClient();');
const headerLines: string[] = [];
function addHeader(key: string, value: string) {
headerLines.push(`client.DefaultRequestHeaders.Add("${key}", ${JSON.stringify(value)});`);
}
for (const k in headers) {
addHeader(k, headers[k].value);
}
function addHeader(key: string, value: string) {
headerLines.push(`client.DefaultRequestHeaders.Add("${key}", ${JSON.stringify(value)});`);
}
for (const k in headers) {
addHeader(k, headers[k].value);
}
// Add cookie header if cookies are present
if (Object.keys(data.cookie).length > 0) {
const cookie = Object.entries(data.cookie)
.map(([key, param]) => `${key}=${param.value}`)
.join('; ');
// Add cookie header if cookies are present
if (Object.keys(data.cookie).length > 0) {
const cookie = Object.entries(data.cookie)
.map(([key, param]) => `${key}=${param.value}`)
.join('; ');
addHeader('cookie', cookie);
}
addHeader('cookie', cookie);
}
s.push(...headerLines);
s.push(...headerLines);
// Build the request
const method = data.method[0].toUpperCase() + data.method.slice(1).toLowerCase() + 'Async';
// Build the request
const method = data.method[0].toUpperCase() + data.method.slice(1).toLowerCase() + 'Async';
if (body) {
s.push(`var response = await client.${method}("${url}", body);`);
} else {
s.push(`var response = await client.${method}("${url}");`);
}
if (body) {
s.push(`var response = await client.${method}("${url}", body);`);
} else {
s.push(`var response = await client.${method}("${url}");`);
}
// Add response handling
s.push('var responseBody = await response.Content.ReadAsStringAsync();');
// Add response handling
s.push('var responseBody = await response.Content.ReadAsStringAsync();');
return s.join('\n');
return s.join('\n');
},
};
@@ -1,42 +1,45 @@
'use client';
import { escapeString, ident, inputToString } from '@/requests/string-utils';
import type { SampleGenerator } from '@/requests/types';
import type { CodeUsageGenerator } from '@/requests/generators';
export const generator: SampleGenerator = (url, data) => {
const s: string[] = [];
s.push(`curl -X ${data.method} "${url}"`);
export const curl: CodeUsageGenerator = {
label: 'cURL',
lang: 'bash',
generate(url, data) {
const s: string[] = [];
s.push(`curl -X ${data.method} "${url}"`);
for (const header in data.header) {
const value = `${header}: ${data.header[header].value}`;
for (const header in data.header) {
const value = `${header}: ${data.header[header].value}`;
s.push(`-H "${value}"`);
}
for (const k in data.cookie) {
const param = data.cookie[k];
s.push(`--cookie ${JSON.stringify(`${k}=${param.value}`)}`);
}
if (data.body && data.bodyMediaType === 'multipart/form-data') {
if (typeof data.body !== 'object') throw new Error('[CURL] request body must be an object.');
for (const [key, value] of Object.entries(data.body)) {
s.push(`-F ${key}=${JSON.stringify(inputToString(value))}`);
s.push(`-H "${value}"`);
}
} else if (data.body && data.bodyMediaType) {
const escaped = escapeString(
inputToString(
data.body,
// @ts-expect-error -- assume the body media type is supported
data.bodyMediaType,
),
"'",
);
s.push(`-H "Content-Type: ${data.bodyMediaType}"`);
s.push(`-d ${escaped}`);
}
for (const k in data.cookie) {
const param = data.cookie[k];
return s.flatMap((v, i) => ident(v, i > 0 ? 1 : 0)).join(' \\\n');
s.push(`--cookie ${JSON.stringify(`${k}=${param.value}`)}`);
}
if (data.body && data.bodyMediaType === 'multipart/form-data') {
if (typeof data.body !== 'object') throw new Error('[CURL] request body must be an object.');
for (const [key, value] of Object.entries(data.body)) {
s.push(`-F ${key}=${JSON.stringify(inputToString(value))}`);
}
} else if (data.body && data.bodyMediaType) {
const escaped = escapeString(
inputToString(
data.body,
// @ts-expect-error -- assume the body media type is supported
data.bodyMediaType,
),
"'",
);
s.push(`-H "Content-Type: ${data.bodyMediaType}"`);
s.push(`-d ${escaped}`);
}
return s.flatMap((v, i) => ident(v, i > 0 ? 1 : 0)).join(' \\\n');
},
};
+32 -29
View File
@@ -1,40 +1,42 @@
'use client';
import { ident } from '@/requests/string-utils';
import type { SampleGenerator } from '@/requests/types';
import type { CodeUsageGenerator } from '@/requests/generators';
import { resolveMediaAdapter } from '@/requests/media/adapter';
export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const imports = ['fmt', 'net/http', 'io/ioutil'];
const headers = new Map<string, string>();
const variables = new Map<string, string>();
variables.set('url', JSON.stringify(url));
export const go: CodeUsageGenerator = {
label: 'Go',
lang: 'go',
generate(url, data, { mediaAdapters }) {
const imports = ['fmt', 'net/http', 'io/ioutil'];
const headers = new Map<string, string>();
const variables = new Map<string, string>();
variables.set('url', JSON.stringify(url));
for (const header in data.header) {
headers.set(header, JSON.stringify(data.header[header].value));
}
for (const header in data.header) {
headers.set(header, JSON.stringify(data.header[header].value));
}
const cookies = Object.entries(data.cookie);
if (cookies.length > 0) {
headers.set(
'Cookie',
JSON.stringify(cookies.map(([k, param]) => `${k}=${param.value}`).join('; ')),
);
}
const cookies = Object.entries(data.cookie);
if (cookies.length > 0) {
headers.set(
'Cookie',
JSON.stringify(cookies.map(([k, param]) => `${k}=${param.value}`).join('; ')),
);
}
let body: string | undefined;
let body: string | undefined;
if (data.body && data.bodyMediaType) {
const adapter = resolveMediaAdapter(data.bodyMediaType, mediaAdapters);
headers.set('Content-Type', `"${data.bodyMediaType}"`);
body = adapter?.generateExample(data as { body: unknown }, {
lang: 'go',
addImport(from) {
imports.push(from);
},
});
}
if (data.body && data.bodyMediaType) {
const adapter = resolveMediaAdapter(data.bodyMediaType, mediaAdapters);
headers.set('Content-Type', `"${data.bodyMediaType}"`);
body = adapter?.generateExample(data as { body: unknown }, {
lang: 'go',
addImport(from) {
imports.push(from);
},
});
}
return `package main
return `package main
import (
${ident(imports.map((v) => `"${v}"`).join('\n'))}
@@ -58,4 +60,5 @@ ${ident(
fmt.Println(res)
fmt.Println(string(body))
}`;
},
};
@@ -1,46 +1,101 @@
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';
import type { CodeUsageGenerator } from '@/ui/operation/usage-tabs';
import type { MediaAdapter } from '../media/adapter';
import type { RequestData } from '../types';
export const defaultSamples: CodeUsageGenerator[] = [
{
id: 'curl',
label: 'cURL',
source: CURL.generator,
lang: 'bash',
export interface CodeUsageGeneratorRegistry {
add: (id: string, generator: CodeUsageGenerator) => void;
get: (id: string) => CodeUsageGenerator | undefined;
addInline: (generator: InlineCodeUsageGenerator) => void;
/**
* @returns if the generator is removed
*/
remove: (id: string) => boolean;
map: () => Map<string, CodeUsageGenerator>;
}
/**
* Generate code example for given programming language
*/
export interface CodeUsageGenerator {
generate: CodeUsageGeneratorFn;
lang: string;
label?: string;
/**
* for inline generators passed from server, this stores info available for client (e.g. forwarded from "use client").
*/
_client?: {
generate: string | CodeUsageGeneratorFn;
serverContext?: unknown;
};
}
/**
* Generate code example for given programming language
*/
export interface InlineCodeUsageGenerator<T = unknown> {
id?: string;
lang: string;
label?: string;
/**
* either:
* - code
* - a function imported from a file with "use client" directive
* - false (disabled)
*/
source?: string | CodeUsageGeneratorFn<T> | false;
/**
* Pass extra context to client-side source generator
*/
serverContext?: T;
}
export function createCodeUsageGeneratorRegistry(
ihherit?: CodeUsageGeneratorRegistry,
): CodeUsageGeneratorRegistry {
const registry = new Map<string, CodeUsageGenerator>(ihherit?.map());
return {
add(id, generator) {
registry.set(id, generator);
},
get(id) {
return registry.get(id);
},
addInline(generator) {
const source = generator.source;
const id = generator.id ?? generator.lang;
if (!source) {
this.remove(id);
return;
}
registry.set(id, {
lang: generator.lang,
label: generator.label,
generate() {
return typeof source === 'string' ? source : '';
},
_client: {
generate: source,
serverContext: generator.serverContext,
},
});
},
remove(id) {
return registry.delete(id);
},
map() {
return registry;
},
};
}
export type CodeUsageGeneratorFn<ServerContext = unknown> = (
url: string,
data: RequestData,
context: {
mediaAdapters: Record<string, MediaAdapter>;
server: ServerContext;
},
{
id: 'js',
label: 'JavaScript',
source: JS.generator,
lang: 'js',
},
{
id: 'go',
label: 'Go',
source: Go.generator,
lang: 'go',
},
{
id: 'python',
label: 'Python',
source: Python.generator,
lang: 'python',
},
{
id: 'java',
label: 'Java',
source: Java.generator,
lang: 'java',
},
{
id: 'csharp',
label: 'C#',
source: CSharp.generator,
lang: 'csharp',
},
];
) => string;
@@ -1,86 +1,89 @@
'use client';
import { ident } from '@/requests/string-utils';
import type { SampleGenerator } from '@/requests/types';
import type { CodeUsageGenerator } from '@/requests/generators';
import { resolveMediaAdapter } from '@/requests/media/adapter';
export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const s: string[] = [];
const headers = { ...data.header };
const imports = new Set<string>([
'java.net.URI',
'java.net.http.HttpClient',
'java.net.http.HttpRequest',
'java.net.http.HttpResponse',
'java.net.http.HttpResponse.BodyHandlers',
'java.time.Duration',
]);
export const java: CodeUsageGenerator = {
label: 'Java',
lang: 'java',
generate(url, data, { mediaAdapters }) {
const s: string[] = [];
const headers = { ...data.header };
const imports = new Set<string>([
'java.net.URI',
'java.net.http.HttpClient',
'java.net.http.HttpRequest',
'java.net.http.HttpResponse',
'java.net.http.HttpResponse.BodyHandlers',
'java.time.Duration',
]);
// Handle body if present
let body: string | undefined;
if (data.body && data.bodyMediaType) {
const adapter = resolveMediaAdapter(data.bodyMediaType, mediaAdapters);
body = adapter?.generateExample(data as { body: unknown }, {
lang: 'java',
addImport(specifier) {
imports.add(specifier);
},
});
}
// Handle body if present
let body: string | undefined;
if (data.body && data.bodyMediaType) {
const adapter = resolveMediaAdapter(data.bodyMediaType, mediaAdapters);
body = adapter?.generateExample(data as { body: unknown }, {
lang: 'java',
addImport(specifier) {
imports.add(specifier);
},
});
}
for (const value of imports.values()) {
s.push(`import ${value};`);
}
s.push('');
for (const value of imports.values()) {
s.push(`import ${value};`);
}
s.push('');
if (body) {
s.push(body);
}
if (body) {
s.push(body);
}
// Create HttpClient
s.push('HttpClient client = HttpClient.newBuilder()');
s.push(ident('.connectTimeout(Duration.ofSeconds(10))'));
s.push(ident('.build();'));
s.push('');
// Create HttpClient
s.push('HttpClient client = HttpClient.newBuilder()');
s.push(ident('.connectTimeout(Duration.ofSeconds(10))'));
s.push(ident('.build();'));
s.push('');
// Build request
s.push('HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()');
s.push(ident(`.uri(URI.create(${JSON.stringify(url)}))`));
// Build request
s.push('HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()');
s.push(ident(`.uri(URI.create(${JSON.stringify(url)}))`));
// Add headers
for (const [key, param] of Object.entries(headers)) {
s.push(ident(`.header(${JSON.stringify(key)}, ${JSON.stringify(param.value)})`));
}
// Add headers
for (const [key, param] of Object.entries(headers)) {
s.push(ident(`.header(${JSON.stringify(key)}, ${JSON.stringify(param.value)})`));
}
if (data.bodyMediaType) {
s.push(ident(`.header("Content-Type", "${data.bodyMediaType}")`));
}
if (data.bodyMediaType) {
s.push(ident(`.header("Content-Type", "${data.bodyMediaType}")`));
}
// Add cookies if present
const cookies = Object.entries(data.cookie);
// Add cookies if present
const cookies = Object.entries(data.cookie);
if (cookies.length > 0) {
const cookieString = cookies.map(([key, param]) => `${key}=${param.value}`).join('; ');
if (cookies.length > 0) {
const cookieString = cookies.map(([key, param]) => `${key}=${param.value}`).join('; ');
s.push(ident(`.header("Cookie", ${JSON.stringify(cookieString)})`));
}
s.push(ident(`.header("Cookie", ${JSON.stringify(cookieString)})`));
}
const arg = body ? 'body' : '';
s.push(ident(`.${data.method.toUpperCase()}(${arg})`));
s.push(ident('.build();'));
s.push('');
const arg = body ? 'body' : '';
s.push(ident(`.${data.method.toUpperCase()}(${arg})`));
s.push(ident('.build();'));
s.push('');
// Add response handling
s.push('try {');
s.push(
ident(
'HttpResponse<String> response = client.send(requestBuilder.build(), BodyHandlers.ofString());',
),
);
s.push(ident('System.out.println("Status code: " + response.statusCode());'));
s.push(ident('System.out.println("Response body: " + response.body());'));
s.push('} catch (Exception e) {');
s.push(ident('e.printStackTrace();'));
s.push('}');
// Add response handling
s.push('try {');
s.push(
ident(
'HttpResponse<String> response = client.send(requestBuilder.build(), BodyHandlers.ofString());',
),
);
s.push(ident('System.out.println("Status code: " + response.statusCode());'));
s.push(ident('System.out.println("Response body: " + response.body());'));
s.push('} catch (Exception e) {');
s.push(ident('e.printStackTrace();'));
s.push('}');
return s.join('\n');
return s.join('\n');
},
};
@@ -1,57 +1,60 @@
'use client';
import { ident } from '@/requests/string-utils';
import type { SampleGenerator } from '@/requests/types';
import type { CodeUsageGenerator } from '@/requests/generators';
import { resolveMediaAdapter } from '@/requests/media/adapter';
export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const s: string[] = [];
const options = new Map<string, string>();
const headers: Record<string, string> = {};
export const javascript: CodeUsageGenerator = {
label: 'JavaScript',
lang: 'js',
generate(url, data, { mediaAdapters }) {
const s: string[] = [];
const options = new Map<string, string>();
const headers: Record<string, string> = {};
options.set('method', JSON.stringify(data.method));
if (data.bodyMediaType) {
headers['Content-Type'] = data.bodyMediaType;
}
options.set('method', JSON.stringify(data.method));
if (data.bodyMediaType) {
headers['Content-Type'] = data.bodyMediaType;
}
for (const [k, v] of Object.entries(data.header)) {
headers[k] = v.value;
}
for (const [k, v] of Object.entries(data.header)) {
headers[k] = v.value;
}
const cookies = Object.entries(data.cookie);
if (cookies.length > 0) {
headers['cookie'] = cookies.map(([key, param]) => `${key}=${param.value}`).join('; ');
}
const cookies = Object.entries(data.cookie);
if (cookies.length > 0) {
headers['cookie'] = cookies.map(([key, param]) => `${key}=${param.value}`).join('; ');
}
if (Object.keys(headers).length > 0) {
options.set('headers', JSON.stringify(headers, null, 2));
}
if (Object.keys(headers).length > 0) {
options.set('headers', JSON.stringify(headers, null, 2));
}
let body: string | undefined;
if (data.body && data.bodyMediaType) {
const adapter = resolveMediaAdapter(data.bodyMediaType, mediaAdapters);
body = adapter?.generateExample(data as { body: unknown }, {
lang: 'js',
addImport(from, name) {
s.unshift(`import { ${name} } from "${from}"`);
},
});
}
let body: string | undefined;
if (data.body && data.bodyMediaType) {
const adapter = resolveMediaAdapter(data.bodyMediaType, mediaAdapters);
body = adapter?.generateExample(data as { body: unknown }, {
lang: 'js',
addImport(from, name) {
s.unshift(`import { ${name} } from "${from}"`);
},
});
}
if (body) {
s.push(body);
options.set('body', 'body');
}
if (body) {
s.push(body);
options.set('body', 'body');
}
const params = [JSON.stringify(url)];
if (options.size > 0) {
const str = Array.from(options.entries())
.map(([k, v]) => ident(k === v ? k : `${k}: ${v}`))
.join(',\n');
const params = [JSON.stringify(url)];
if (options.size > 0) {
const str = Array.from(options.entries())
.map(([k, v]) => ident(k === v ? k : `${k}: ${v}`))
.join(',\n');
params.push(`{\n${str}\n}`);
}
params.push(`{\n${str}\n}`);
}
s.push(`fetch(${params.join(', ')})`);
s.push(`fetch(${params.join(', ')})`);
return s.join('\n\n');
return s.join('\n\n');
},
};
@@ -1,55 +1,58 @@
'use client';
import type { SampleGenerator } from '@/requests/types';
import type { CodeUsageGenerator } from '@/requests/generators';
import { generatePythonObject } from '@/requests/to-python-object';
import { resolveMediaAdapter } from '@/requests/media/adapter';
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;
export const python: CodeUsageGenerator = {
label: 'Python',
lang: 'python',
generate(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');
imports.add('requests');
if (data.body && data.bodyMediaType) {
const adapter = resolveMediaAdapter(data.bodyMediaType, mediaAdapters);
headers['Content-Type'] = data.bodyMediaType;
if (data.body && data.bodyMediaType) {
const adapter = resolveMediaAdapter(data.bodyMediaType, mediaAdapters);
headers['Content-Type'] = data.bodyMediaType;
body = adapter?.generateExample(data as { body: unknown }, {
lang: 'python',
});
body = adapter?.generateExample(data as { body: unknown }, {
lang: 'python',
});
if (body) {
params.push('data = body');
}
}
for (const [k, v] of Object.entries(data.header)) {
headers[k] = v.value;
}
if (Object.keys(headers).length > 0) {
params.push(`headers = ${generatePythonObject(headers, imports)}`);
}
const inputCookies = Object.entries(data.cookie);
if (inputCookies.length > 0) {
const cookies: Record<string, string> = {};
for (const [k, v] of inputCookies) {
cookies[k] = v.value;
if (body) {
params.push('data = body');
}
}
params.push(`cookies = ${generatePythonObject(cookies, imports)}`);
}
for (const [k, v] of Object.entries(data.header)) {
headers[k] = v.value;
}
return `${Array.from(imports)
.map((name) => 'import ' + name)
.join('\n')}
if (Object.keys(headers).length > 0) {
params.push(`headers = ${generatePythonObject(headers, imports)}`);
}
const inputCookies = Object.entries(data.cookie);
if (inputCookies.length > 0) {
const cookies: Record<string, string> = {};
for (const [k, v] of inputCookies) {
cookies[k] = v.value;
}
params.push(`cookies = ${generatePythonObject(cookies, imports)}`);
}
return `${Array.from(imports)
.map((name) => 'import ' + name)
.join('\n')}
url = ${JSON.stringify(url)}
${body ?? ''}
response = requests.request(${params.join(', ')})
print(response.text)`;
},
};
-10
View File
@@ -1,15 +1,5 @@
import type { MediaAdapter } from '@/requests/media/adapter';
import type { EncodedParameter, EncodedParameterMultiple } 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;
+8 -5
View File
@@ -1,7 +1,7 @@
import { createProxy } from '@/server/proxy';
import { processDocument, type ProcessedDocument } from '@/utils/process-document';
import type { CodeUsageGenerator } from '@/ui/operation/usage-tabs';
import type { Document } from '@/types';
import type { InlineCodeUsageGenerator } from '@/requests/generators';
/**
* schema id -> file path, URL, or downloaded schema object
@@ -78,8 +78,11 @@ export function createOpenAPI(options: OpenAPIOptions = {}): OpenAPIServer {
};
}
export function createCodeSample<T>(options: Partial<CodeUsageGenerator<T>>): CodeUsageGenerator {
const { lang = 'unknown', id = lang, ...rest } = options as Partial<CodeUsageGenerator>;
return { id, lang, ...rest };
/**
* @deprecated
*/
export function createCodeSample<T>(
options: InlineCodeUsageGenerator<T>,
): InlineCodeUsageGenerator<T> {
return options;
}
+2 -2
View File
@@ -5,8 +5,8 @@ import type { ProcessedDocument } from '@/utils/process-document';
import type { MediaAdapter } from '@/requests/media/adapter';
import type { OpenAPIOptions } from '@/server';
import type { CreateAPIPageOptions } from './ui/base';
import type { CodeUsageGenerator } from './ui/operation/usage-tabs';
import type { HTMLAttributes, ReactNode } from 'react';
import type { InlineCodeUsageGenerator } from './requests/generators';
export type Document = OpenAPIV3_2.Document;
export type OperationObject = OpenAPIV3_2.OperationObject;
@@ -27,7 +27,7 @@ export type RequestBodyObject = OpenAPIV3_2.RequestBodyObject;
export type MethodInformation = NoReference<OperationObject> & {
method: string;
'x-codeSamples'?: Omit<CodeUsageGenerator, 'id'>[];
'x-codeSamples'?: InlineCodeUsageGenerator[];
'x-selectedCodeSample'?: string;
'x-exclusiveCodeSample'?: string;
};
+8 -3
View File
@@ -8,7 +8,6 @@ import type { FC, ReactNode } from 'react';
import { highlight, type CoreHighlightOptions } from 'fumadocs-core/highlight/core';
import type { OpenAPIServer } from '@/server';
import type { APIPageClientOptions } from './client';
import type { CodeUsageGenerator } from './operation/usage-tabs';
import { Heading } from 'fumadocs-ui/components/heading';
import { createRehypeCode } from 'fumadocs-core/mdx-plugins/rehype-code.core';
import { remarkGfm } from 'fumadocs-core/mdx-plugins/remark-gfm';
@@ -23,6 +22,7 @@ import type { ResponseTab } from './operation/response-tabs';
import type { ExampleRequestItem } from './operation/request-tabs';
import type { ResolvedShikiConfig } from 'fumadocs-core/highlight/config';
import { APIPage, type ApiPageProps, type OperationItem, type WebhookItem } from './api-page';
import type { CodeUsageGeneratorRegistry, InlineCodeUsageGenerator } from '@/requests/generators';
export interface CreateAPIPageOptions {
/**
@@ -45,7 +45,12 @@ export interface CreateAPIPageOptions {
/**
* Generate example code usage for endpoints.
*/
generateCodeSamples?: (method: MethodInformation) => Awaitable<CodeUsageGenerator[]>;
codeUsages?: CodeUsageGeneratorRegistry;
/**
* Generate example code usage for endpoints.
*/
generateCodeSamples?: (method: MethodInformation) => Awaitable<InlineCodeUsageGenerator[]>;
shiki: ResolvedShikiConfig;
renderMarkdown?: (md: string) => Awaitable<ReactNode>;
@@ -90,7 +95,7 @@ export interface CreateAPIPageOptions {
* @param generators - codegens for API example usages
*/
renderAPIExampleUsageTabs?: (
generators: CodeUsageGenerator[],
generators: CodeUsageGeneratorRegistry,
ctx: RenderContext,
) => Awaitable<ReactNode>;
+6
View File
@@ -3,6 +3,7 @@ import type { PlaygroundClientOptions } from '@/playground/client';
import type { MediaAdapter } from '@/requests/media/adapter';
import type { FC } from 'react';
import type { ExampleRequestItem } from '../operation/request-tabs';
import type { CodeUsageGeneratorRegistry } from '@/requests/generators';
export interface APIPageClientOptions {
playground?: PlaygroundClientOptions;
@@ -21,6 +22,11 @@ export interface APIPageClientOptions {
* Support other media types (for client-side serialization)
*/
mediaAdapters?: Record<string, MediaAdapter>;
/**
* generate code usage examples
*/
codeUsages?: CodeUsageGeneratorRegistry;
}
export interface OperationClientOptions {
+19 -5
View File
@@ -14,6 +14,11 @@ import { defaultAdapters, type MediaAdapter } from '@/requests/media/adapter';
import type { NoReference } from '@/utils/schema';
import { useStorageKey } from '../client/storage-key';
import type { APIPageClientOptions } from '../client';
import {
type CodeUsageGeneratorRegistry,
createCodeUsageGeneratorRegistry,
} from '@/requests/generators';
import { registerDefault } from '@/requests/generators/all';
interface InheritFromContext extends Pick<RenderContext, 'shikiOptions'> {
client: APIPageClientOptions;
@@ -47,6 +52,7 @@ export interface SelectedServer {
interface ApiContextType extends InheritFromContext {
mediaAdapters: Record<string, MediaAdapter>;
codeUsages: CodeUsageGeneratorRegistry;
}
interface ServerSelectType {
@@ -83,17 +89,25 @@ export function ApiProvider({
}: ApiProviderProps & { children: ReactNode }) {
return (
<ApiContext
value={useMemo(
() => ({
value={useMemo(() => {
let codeUsages: CodeUsageGeneratorRegistry;
if (client.codeUsages) {
codeUsages = createCodeUsageGeneratorRegistry(client.codeUsages);
} else {
codeUsages = createCodeUsageGeneratorRegistry();
registerDefault(codeUsages);
}
return {
shikiOptions,
client,
codeUsages,
mediaAdapters: {
...defaultAdapters,
...client.mediaAdapters,
},
}),
[client, shikiOptions],
)}
};
}, [client, shikiOptions])}
>
{children}
</ApiContext>
@@ -10,9 +10,9 @@ import {
} from '@/ui/components/select';
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock.core';
import { useState, useEffect, useMemo, createContext, ReactNode, useRef, use } from 'react';
import type { CodeUsageGenerator } from '.';
import type { ExampleRequestItem } from '../request-tabs';
import type { RawRequestData, RequestData } from '@/requests/types';
import type { CodeUsageGenerator } from '@/requests/generators';
export type ExampleUpdateListener = (data: RawRequestData, encoded: RequestData) => void;
@@ -130,8 +130,12 @@ export function UsageTabsSelector() {
);
}
export function UsageTab(sample: CodeUsageGenerator) {
const { shikiOptions, mediaAdapters } = useApiContext();
export function UsageTab({
id,
lang,
_client,
}: Pick<CodeUsageGenerator, 'lang' | '_client'> & { id: string }) {
const { shikiOptions, mediaAdapters, codeUsages } = useApiContext();
const {
examples,
example: selectedExampleId,
@@ -154,26 +158,33 @@ export function UsageTab(sample: CodeUsageGenerator) {
}, [addListener, removeListener]);
const code = useMemo(() => {
if (!sample.source || !data) return;
if (typeof sample.source === 'string') return sample.source;
return sample.source(
joinURL(
withBase(
server ? resolveServerUrl(server.url, server.variables) : '/',
typeof window !== 'undefined' ? window.location.origin : 'https://loading',
),
resolveRequestData(route, data),
if (!data) return;
const url = joinURL(
withBase(
server ? resolveServerUrl(server.url, server.variables) : '/',
typeof window !== 'undefined' ? window.location.origin : 'https://loading',
),
data,
{
server: sample.serverContext,
mediaAdapters,
},
resolveRequestData(route, data),
);
}, [mediaAdapters, sample, server, route, data]);
if (!code || !sample) return null;
if (_client) {
const { generate, serverContext } = _client;
if (typeof generate === 'string') return generate;
return generate(url, data, {
mediaAdapters,
server: serverContext,
});
}
return <DynamicCodeBlock lang={sample.lang} code={code} options={shikiOptions} />;
const codegen = codeUsages.get(id);
if (!codegen) return;
return codegen.generate(url, data, {
mediaAdapters,
server: null,
});
}, [data, server, route, _client, codeUsages, id, mediaAdapters]);
if (!code) return null;
return <DynamicCodeBlock lang={lang} code={code} options={shikiOptions} />;
}
@@ -1,6 +1,9 @@
import type { MethodInformation, RenderContext } from '@/types';
import type { SampleGenerator } from '@/requests/types';
import { defaultSamples } from '@/requests/generators';
import type { CodeUsageGeneratorFn } from '@/requests/generators';
import {
type CodeUsageGeneratorRegistry,
createCodeUsageGeneratorRegistry,
} from '@/requests/generators';
import {
CodeBlockTab,
CodeBlockTabs,
@@ -9,6 +12,7 @@ import {
} from 'fumadocs-ui/components/codeblock';
import { UsageTabsSelectorLazy, UsageTabLazy } from './lazy';
import { ResponseTabs } from '../response-tabs';
import { registerDefault } from '@/requests/generators/all';
/**
* Generate code example for given programming language
@@ -23,7 +27,7 @@ export interface CodeUsageGenerator<T = unknown> {
* - a function imported from a file with "use client" directive
* - false (disabled)
*/
source?: string | SampleGenerator<T> | false;
source?: string | CodeUsageGeneratorFn<T> | false;
/**
* Pass extra context to client-side source generator
@@ -50,70 +54,52 @@ export async function UsageTabs({
);
};
renderAPIExampleUsageTabs ??= (generators) => {
if (generators.length === 0) return null;
renderAPIExampleUsageTabs ??= (registry) => {
const map = Array.from(registry.map().entries());
if (map.length === 0) return null;
return (
<CodeBlockTabs groupId="fumadocs_openapi_requests" defaultValue={generators[0].id}>
<CodeBlockTabs groupId="fumadocs_openapi_requests" defaultValue={map[0][0]}>
<CodeBlockTabsList>
{generators.map((item) => (
<CodeBlockTabsTrigger key={item.id} value={item.id}>
{map.map(([id, item]) => (
<CodeBlockTabsTrigger key={id} value={id}>
{item.label ?? item.lang}
</CodeBlockTabsTrigger>
))}
</CodeBlockTabsList>
{generators.map((item) => (
<CodeBlockTab key={item.id} value={item.id}>
<UsageTabLazy {...item} />
{map.map(([id, item]) => (
<CodeBlockTab key={id} value={id}>
<UsageTabLazy id={id} lang={item.lang} _client={item._client} />
</CodeBlockTab>
))}
</CodeBlockTabs>
);
};
let generators: CodeUsageGenerator[] = [...defaultSamples];
if (ctx.generateCodeSamples) {
generators.push(...(await ctx.generateCodeSamples(method)));
let registry: CodeUsageGeneratorRegistry;
if (ctx.codeUsages) {
registry = createCodeUsageGeneratorRegistry(ctx.codeUsages);
} else {
registry = createCodeUsageGeneratorRegistry();
registerDefault(registry);
}
for (const gen of (await ctx.generateCodeSamples?.(method)) ?? []) {
registry.addInline(gen);
}
if (method['x-codeSamples']) {
for (const sample of method['x-codeSamples']) {
generators.push(
'id' in sample && typeof sample.id === 'string'
? (sample as CodeUsageGenerator)
: {
id: sample.lang,
...sample,
},
);
registry.addInline(sample);
}
}
generators = dedupe(generators);
return renderAPIExampleLayout(
{
selector: method['x-exclusiveCodeSample'] ? null : <UsageTabsSelectorLazy />,
usageTabs: await renderAPIExampleUsageTabs(generators, ctx),
usageTabs: await renderAPIExampleUsageTabs(registry, ctx),
responseTabs: <ResponseTabs operation={method} ctx={ctx} />,
},
ctx,
);
}
/**
* Remove duplicated ids
*/
function dedupe(samples: CodeUsageGenerator[]): CodeUsageGenerator[] {
const set = new Set<string>();
const out: CodeUsageGenerator[] = [];
for (let i = samples.length - 1; i >= 0; i--) {
const item = samples[i];
if (set.has(item.id)) continue;
set.add(item.id);
if (item.source !== false) out.unshift(item);
}
return out;
}
+13 -8
View File
@@ -1,11 +1,12 @@
import { describe, expect, test } from 'vitest';
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';
import { go } from '@/requests/generators/go';
import { curl } from '@/requests/generators/curl';
import { python } from '@/requests/generators/python';
import { javascript } from '@/requests/generators/javascript';
import { csharp } from '@/requests/generators/csharp';
describe('Code Sample Generators', () => {
const data: RequestData = {
@@ -35,18 +36,22 @@ describe('Code Sample Generators', () => {
};
test(`Go`, async () => {
await expect(Go.generator(url, data, context)).toMatchFileSnapshot(`./out/samples/1.go`);
await expect(go.generate(url, data, context)).toMatchFileSnapshot(`./out/samples/1.go`);
});
test(`Curl`, async () => {
await expect(Curl.generator(url, data, context)).toMatchFileSnapshot(`./out/samples/1.bash`);
await expect(curl.generate(url, data, context)).toMatchFileSnapshot(`./out/samples/1.bash`);
});
test(`Python`, async () => {
await expect(Python.generator(url, data, context)).toMatchFileSnapshot(`./out/samples/1.py`);
await expect(python.generate(url, data, context)).toMatchFileSnapshot(`./out/samples/1.py`);
});
test(`JavaScript`, async () => {
await expect(JS.generator(url, data, context)).toMatchFileSnapshot(`./out/samples/1.js`);
await expect(javascript.generate(url, data, context)).toMatchFileSnapshot(`./out/samples/1.js`);
});
test(`C#`, async () => {
await expect(csharp.generate(url, data, context)).toMatchFileSnapshot(`./out/samples/1.cs`);
});
});
+15
View File
@@ -0,0 +1,15 @@
using System;
using System.Net.Http;
using System.Text;
var body = new StringContent("""
{
"id": "id"
}
""", Encoding.UTF8, "application/json");
var client = new HttpClient();
client.DefaultRequestHeaders.Add("authorization", "Bearer");
client.DefaultRequestHeaders.Add("cookie", "mode=light");
var response = await client.GetAsync("http://localhost:8080/hello_world?search=ai", body);
var responseBody = await response.Content.ReadAsStringAsync();
+9
View File
@@ -12,6 +12,7 @@ export default defineConfig({
'./src/playground/{index,client}.tsx',
'./src/scalar/index.tsx',
'./src/server/index.ts',
'./src/requests/generators/*.ts',
],
fixedExtension: false,
unbundle: true,
@@ -22,4 +23,12 @@ export default defineConfig({
await compileInline();
},
inlineOnly: [],
exports: {
enabled: true,
customExports(v) {
v['./css/*'] = './css/*';
return v;
},
legacy: true,
},
});