mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
OpenAPI: Implement multipart form data (experimental)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-openapi': minor
|
||||
---
|
||||
|
||||
Implement multipart form data
|
||||
@@ -4,7 +4,7 @@ import { getExampleResponse } from '@/utils/generate-response';
|
||||
import * as CURL from '@/requests/curl';
|
||||
import * as JS from '@/requests/javascript';
|
||||
import { type MethodInformation, type RenderContext } from '@/types';
|
||||
import { noRef, getPreferredMedia } from '@/utils/schema';
|
||||
import { noRef, getPreferredMedia, getPreferredType } from '@/utils/schema';
|
||||
import { getTypescriptSchema } from '@/utils/get-typescript-schema';
|
||||
import { getScheme } from '@/utils/get-security';
|
||||
import { renderPlayground } from '@/render/playground';
|
||||
@@ -54,18 +54,20 @@ export async function renderOperation(
|
||||
}
|
||||
|
||||
if (body) {
|
||||
const bodySchema = getPreferredMedia(body.content)?.schema;
|
||||
if (!bodySchema) throw new Error();
|
||||
const type = getPreferredType(body.content);
|
||||
if (!type)
|
||||
throw new Error(`No supported media type for body content: ${path}`);
|
||||
|
||||
info.push(
|
||||
heading(level, `Request Body ${!body.required ? '(Optional)' : ''}`),
|
||||
p(body.description),
|
||||
schemaElement('body', noRef(bodySchema), {
|
||||
schemaElement('body', noRef(body.content[type].schema ?? {}), {
|
||||
parseObject: true,
|
||||
readOnly: method.method === 'GET',
|
||||
writeOnly: method.method !== 'GET',
|
||||
required: body.required ?? false,
|
||||
render: ctx,
|
||||
allowFile: type === 'multipart/form-data',
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -93,6 +95,7 @@ export async function renderOperation(
|
||||
writeOnly: method.method !== 'GET',
|
||||
required: param.required ?? false,
|
||||
render: ctx,
|
||||
allowFile: false,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { OpenAPIV3 as OpenAPI } from 'openapi-types';
|
||||
import type { MethodInformation, RenderContext } from '@/types';
|
||||
import { getPreferredMedia, noRef } from '@/utils/schema';
|
||||
import { getPreferredType, noRef } from '@/utils/schema';
|
||||
import { getScheme } from '@/utils/get-security';
|
||||
|
||||
interface BaseRequestField {
|
||||
@@ -33,6 +33,10 @@ interface ArraySchema extends BaseSchema {
|
||||
items: string | RequestSchema;
|
||||
}
|
||||
|
||||
interface FileSchema extends BaseSchema {
|
||||
type: 'file';
|
||||
}
|
||||
|
||||
interface ObjectSchema extends BaseSchema {
|
||||
type: 'object';
|
||||
properties: Record<string, ReferenceSchema>;
|
||||
@@ -57,9 +61,11 @@ export type RequestSchema =
|
||||
| ArraySchema
|
||||
| ObjectSchema
|
||||
| SwitcherSchema
|
||||
| NullSchema;
|
||||
| NullSchema
|
||||
| FileSchema;
|
||||
|
||||
interface Context {
|
||||
allowFile: boolean;
|
||||
schema: Record<string, RequestSchema>;
|
||||
registered: WeakMap<OpenAPI.SchemaObject, string>;
|
||||
nextId: () => string;
|
||||
@@ -68,6 +74,7 @@ interface Context {
|
||||
export interface APIPlaygroundProps {
|
||||
route: string;
|
||||
method: string;
|
||||
bodyType: 'json' | 'form-data';
|
||||
authorization?: PrimitiveRequestField;
|
||||
path?: PrimitiveRequestField[];
|
||||
query?: PrimitiveRequestField[];
|
||||
@@ -82,21 +89,23 @@ export function renderPlayground(
|
||||
ctx: RenderContext,
|
||||
): string {
|
||||
let currentId = 0;
|
||||
const bodyContent = noRef(method.requestBody)?.content;
|
||||
const mediaType = bodyContent ? getPreferredType(bodyContent) : undefined;
|
||||
|
||||
const context: Context = {
|
||||
allowFile: mediaType === 'multipart/form-data',
|
||||
schema: {},
|
||||
nextId() {
|
||||
return String(currentId++);
|
||||
},
|
||||
registered: new WeakMap(),
|
||||
};
|
||||
const body = method.requestBody
|
||||
? getPreferredMedia(noRef(method.requestBody).content)
|
||||
: undefined;
|
||||
|
||||
return ctx.renderer.APIPlayground({
|
||||
authorization: getAuthorizationField(method, ctx),
|
||||
method: method.method,
|
||||
route: path,
|
||||
bodyType: mediaType === 'multipart/form-data' ? 'form-data' : 'json',
|
||||
path: method.parameters
|
||||
.filter((v) => v.in === 'path')
|
||||
.map((v) => parameterToField(v, context)),
|
||||
@@ -106,9 +115,10 @@ export function renderPlayground(
|
||||
header: method.parameters
|
||||
.filter((v) => v.in === 'header')
|
||||
.map((v) => parameterToField(v, context)),
|
||||
body: body?.schema
|
||||
? toSchema(noRef(body.schema), true, context)
|
||||
: undefined,
|
||||
body:
|
||||
bodyContent && mediaType && bodyContent[mediaType].schema
|
||||
? toSchema(noRef(bodyContent[mediaType].schema), true, context)
|
||||
: undefined,
|
||||
schemas: context.schema,
|
||||
});
|
||||
}
|
||||
@@ -273,6 +283,14 @@ function toSchema(
|
||||
};
|
||||
}
|
||||
|
||||
if (ctx.allowFile && schema.type === 'string' && schema.format === 'binary') {
|
||||
return {
|
||||
type: 'file',
|
||||
isRequired: required,
|
||||
description: schema.description ?? schema.title,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: schema.type === 'integer' ? 'number' : schema.type,
|
||||
defaultValue: (schema.example ?? '') as string,
|
||||
|
||||
@@ -25,6 +25,11 @@ interface Context {
|
||||
/** Render the full object */
|
||||
parseObject: boolean;
|
||||
|
||||
/**
|
||||
* Parse binary format string to be files
|
||||
*/
|
||||
allowFile: boolean;
|
||||
|
||||
stack: OpenAPI.SchemaObject[];
|
||||
|
||||
render: RenderContext;
|
||||
@@ -166,7 +171,7 @@ function render(
|
||||
return renderer.Property(
|
||||
{
|
||||
name,
|
||||
type: getSchemaType(schema),
|
||||
type: getSchemaType(schema, ctx),
|
||||
required: ctx.required,
|
||||
deprecated: schema.deprecated,
|
||||
},
|
||||
@@ -218,9 +223,9 @@ function isComplexType(schema: OpenAPI.SchemaObject): boolean {
|
||||
return isObject(schema) || schema.type === 'array';
|
||||
}
|
||||
|
||||
function getSchemaType(schema: OpenAPI.SchemaObject): string {
|
||||
function getSchemaType(schema: OpenAPI.SchemaObject, ctx: Context): string {
|
||||
if (schema.nullable) {
|
||||
const type = getSchemaType({ ...schema, nullable: false });
|
||||
const type = getSchemaType({ ...schema, nullable: false }, ctx);
|
||||
|
||||
// null if schema only contains `nullable`
|
||||
return type === 'unknown' ? 'null' : `${type} | null`;
|
||||
@@ -229,22 +234,28 @@ function getSchemaType(schema: OpenAPI.SchemaObject): string {
|
||||
if (schema.title) return schema.title;
|
||||
|
||||
if (schema.type === 'array')
|
||||
return `array<${getSchemaType(noRef(schema.items))}>`;
|
||||
return `array<${getSchemaType(noRef(schema.items), ctx)}>`;
|
||||
|
||||
if (schema.oneOf)
|
||||
return schema.oneOf.map((one) => getSchemaType(noRef(one))).join(' | ');
|
||||
return schema.oneOf
|
||||
.map((one) => getSchemaType(noRef(one), ctx))
|
||||
.join(' | ');
|
||||
|
||||
if (schema.allOf)
|
||||
return schema.allOf.map((one) => getSchemaType(noRef(one))).join(' & ');
|
||||
return schema.allOf
|
||||
.map((one) => getSchemaType(noRef(one), ctx))
|
||||
.join(' & ');
|
||||
|
||||
if (schema.not) return `not ${getSchemaType(noRef(schema.not))}`;
|
||||
if (schema.not) return `not ${getSchemaType(noRef(schema.not), ctx)}`;
|
||||
|
||||
if (schema.anyOf) {
|
||||
return `Any properties in ${schema.anyOf
|
||||
.map((one) => getSchemaType(noRef(one)))
|
||||
.map((one) => getSchemaType(noRef(one), ctx))
|
||||
.join(', ')}`;
|
||||
}
|
||||
|
||||
if (schema.type === 'string' && schema.format === 'binary' && ctx.allowFile)
|
||||
return 'File';
|
||||
if (schema.type) return schema.type;
|
||||
|
||||
if (isObject(schema)) return 'object';
|
||||
|
||||
@@ -7,12 +7,56 @@ import { type DynamicField } from '@/ui/contexts/schema';
|
||||
* Create request body from value
|
||||
*/
|
||||
export function createBodyFromValue(
|
||||
type: 'json' | 'form-data',
|
||||
value: unknown,
|
||||
schema: RequestSchema,
|
||||
references: Record<string, RequestSchema>,
|
||||
dynamic: Map<string, DynamicField>,
|
||||
): unknown {
|
||||
return convertValue('body', value, schema, references, dynamic);
|
||||
if (type === 'json') {
|
||||
return convertValue('body', value, schema, references, dynamic);
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
if (schema.type !== 'object' || typeof value !== 'object' || !value) {
|
||||
throw new Error(
|
||||
`Unsupported body schema type: ${schema.type}, expected: object`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!(key in schema)) return;
|
||||
|
||||
const prop = resolve(schema.properties[key], references);
|
||||
|
||||
if (prop.type === 'file') {
|
||||
formData.set(key, value[key as keyof object]);
|
||||
}
|
||||
|
||||
if (
|
||||
prop.type === 'array' &&
|
||||
Array.isArray(value) &&
|
||||
resolve(prop.items, references).type === 'file'
|
||||
) {
|
||||
for (const item of value) {
|
||||
formData.append(key, item as Blob);
|
||||
}
|
||||
}
|
||||
|
||||
formData.set(
|
||||
key,
|
||||
JSON.stringify(
|
||||
convertValue(
|
||||
`body.${key}`,
|
||||
value[key as keyof object],
|
||||
resolve(schema.properties[key], references),
|
||||
references,
|
||||
dynamic,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -425,11 +425,36 @@ function NormalInput({
|
||||
header,
|
||||
field,
|
||||
...props
|
||||
}: InputProps<'string' | 'boolean' | 'number'> & {
|
||||
}: InputProps<'string' | 'boolean' | 'number' | 'file'> & {
|
||||
header?: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
const { control } = useFormContext();
|
||||
|
||||
if (field.type === 'file') {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={fieldName}
|
||||
render={({ field: { value, onChange, ...restField } }) => (
|
||||
<FormItem {...props}>
|
||||
{header}
|
||||
<p>{(value as File | undefined)?.name}</p>
|
||||
<FormControl>
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => {
|
||||
if (!e.target.files) return [];
|
||||
onChange(e.target.files.item(0));
|
||||
}}
|
||||
{...restField}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'boolean') {
|
||||
return (
|
||||
<FormField
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface CustomField<TName extends FieldPath<FormValues>, Info> {
|
||||
export function APIPlayground({
|
||||
route,
|
||||
method = 'GET',
|
||||
bodyType,
|
||||
authorization,
|
||||
path = [],
|
||||
header = [],
|
||||
@@ -81,7 +82,7 @@ export function APIPlayground({
|
||||
});
|
||||
|
||||
const testQuery = useSWRImmutable(
|
||||
input ? [baseUrl, route, method, input] : null,
|
||||
input ? [baseUrl, route, method, input, bodyType] : null,
|
||||
async () => {
|
||||
if (!input) return;
|
||||
|
||||
@@ -116,7 +117,13 @@ export function APIPlayground({
|
||||
|
||||
const bodyValue =
|
||||
body && input.body && Object.keys(input.body).length > 0
|
||||
? createBodyFromValue(input.body, body, schemas, dynamicRef.current)
|
||||
? createBodyFromValue(
|
||||
bodyType,
|
||||
input.body,
|
||||
body,
|
||||
schemas,
|
||||
dynamicRef.current,
|
||||
)
|
||||
: undefined;
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
|
||||
@@ -61,6 +61,7 @@ export function getDefaultValue(
|
||||
resolve(Object.values(item.items)[0], references),
|
||||
references,
|
||||
);
|
||||
if (item.type === 'file') return undefined;
|
||||
|
||||
return String(item.defaultValue);
|
||||
}
|
||||
|
||||
@@ -7,11 +7,16 @@ export function noRef<T>(v: T): NoReference<T> {
|
||||
}
|
||||
|
||||
export function getPreferredMedia<T>(body: Record<string, T>): T | undefined {
|
||||
if (Object.keys(body).length === 0) return undefined;
|
||||
const type = getPreferredType(body);
|
||||
if (type) return body[type];
|
||||
}
|
||||
|
||||
if ('application/json' in body) return body['application/json'];
|
||||
export function getPreferredType<B extends Record<string, unknown>>(
|
||||
body: B,
|
||||
): keyof B | undefined {
|
||||
if ('application/json' in body) return 'application/json';
|
||||
|
||||
return Object.values(body)[0];
|
||||
return Object.keys(body)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user