OpenAPI: Implement multipart form data (experimental)

This commit is contained in:
fuma-nama
2024-07-31 16:49:01 +08:00
parent b53efac931
commit dfcc61f9a9
9 changed files with 146 additions and 27 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': minor
---
Implement multipart form data
+7 -4
View File
@@ -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,
},
);
+26 -8
View File
@@ -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,
+19 -8
View File
@@ -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';
+45 -1
View File
@@ -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,
),
),
);
}
}
/**
+26 -1
View File
@@ -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
+9 -2
View File
@@ -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,
+1
View File
@@ -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);
}
+8 -3
View File
@@ -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];
}
/**