OpenAPI: Fix handling of primitive types in anyOf/allOf

This commit is contained in:
Fuma Nama
2025-07-09 17:09:12 +08:00
parent 045a8d8d66
commit dd94271fba
7 changed files with 1321 additions and 1123 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Fix handling of primitive types in `anyOf`/`allOf`
File diff suppressed because it is too large Load Diff
+89 -83
View File
@@ -403,57 +403,64 @@ function SecurityTabs({
return result; return result;
} }
const paramNames = ['Headers', 'Cookies', 'Query', 'Path'] as const; const ParamTypes = ['path', 'header', 'cookie', 'query'] as const;
const paramTypes = ['header', 'cookie', 'query', 'path'] as const;
function FormBody({ function FormBody({
parameters = [], parameters = [],
fields = {}, fields = {},
body, body,
}: Pick<ClientProps, 'parameters' | 'body' | 'fields'>) { }: Pick<ClientProps, 'parameters' | 'body' | 'fields'>) {
const params = useMemo(() => { const panels = useMemo(() => {
return paramTypes.map((param) => parameters.filter((v) => v.in === param)); return ParamTypes.map((type) => {
}, [parameters]); const items = parameters.filter((v) => v.in === type);
if (items.length === 0) return;
return (
<CollapsiblePanel
key={type}
title={
{
header: 'Header',
cookie: 'Cookies',
query: 'Query',
path: 'Path',
}[type]
}
>
{items.map((field) => {
const fieldName = `${type}.${field.name}` as const;
const schema = (
field.content
? field.content[Object.keys(field.content)[0]].schema
: field.schema
) as ParsedSchema;
if (fields?.parameter) {
return renderCustomField(
fieldName,
schema,
fields.parameter,
field.name,
);
}
return (
<FieldSet
key={fieldName}
name={field.name}
fieldName={fieldName}
field={schema}
/>
);
})}
</CollapsiblePanel>
);
});
}, [fields.parameter, parameters]);
return ( return (
<> <>
{params.map((param, i) => { {panels}
if (param.length === 0) return;
const name = paramNames[i];
const type = paramTypes[i];
return (
<CollapsiblePanel key={name} title={name}>
{param.map((field) => {
const fieldName = `${type}.${field.name}` as const;
const schema = (
field.content
? field.content[Object.keys(field.content)[0]].schema
: field.schema
) as ParsedSchema;
if (fields?.parameter) {
return renderCustomField(
fieldName,
schema,
fields.parameter,
field.name,
);
}
return (
<FieldSet
key={fieldName}
name={field.name}
fieldName={fieldName}
field={schema}
/>
);
})}
</CollapsiblePanel>
);
})}
{body && ( {body && (
<CollapsiblePanel title="Body"> <CollapsiblePanel title="Body">
{fields.body ? ( {fields.body ? (
@@ -474,48 +481,47 @@ function BodyInput({ field: _field }: { field: RequestSchema }) {
if (field.format === 'binary') if (field.format === 'binary')
return <FieldSet field={field} fieldName="body" />; return <FieldSet field={field} fieldName="body" />;
if (isJson)
return (
<>
<button
className={cn(
buttonVariants({
color: 'secondary',
size: 'sm',
className: 'w-fit font-mono p-2',
}),
)}
onClick={() => setIsJson(false)}
type="button"
>
Close JSON Editor
</button>
<JsonInput fieldName="body" />
</>
);
return ( return (
<> <FieldSet
{isJson ? ( field={field}
<> fieldName="body"
<button collapsible={false}
className={cn( name={
buttonVariants({ <button
color: 'secondary', type="button"
size: 'sm', className={cn(
className: 'w-fit font-mono p-2', buttonVariants({
}), color: 'secondary',
)} size: 'sm',
onClick={() => setIsJson(false)} className: 'p-2',
type="button" }),
> )}
Close JSON Editor onClick={() => setIsJson(true)}
</button> >
<JsonInput fieldName="body" /> Open JSON Editor
</> </button>
) : ( }
<FieldSet />
field={field}
fieldName="body"
collapsible={false}
name={
<button
className={cn(
buttonVariants({
color: 'secondary',
size: 'sm',
className: 'p-2',
}),
)}
onClick={() => setIsJson(true)}
type="button"
>
Open JSON Editor
</button>
}
/>
)}
</>
); );
} }
+1 -7
View File
@@ -62,13 +62,7 @@ export async function APIPlayground({
securities: parseSecurities(method, ctx), securities: parseSecurities(method, ctx),
method: method.method, method: method.method,
route: path, route: path,
parameters: method.parameters?.map( parameters: method.parameters as ParameterField[],
(v) =>
({
...v,
schema: writeReferences((v.schema ?? true) as ParsedSchema, context),
}) as ParameterField,
),
body: body:
bodyContent && mediaType bodyContent && mediaType
? ({ ? ({
+5 -4
View File
@@ -373,8 +373,9 @@ export function FieldSet({
if (_field === false) return null; if (_field === false) return null;
if (field.oneOf) { if (info.unionField) {
const showSelect = field.oneOf.length > 1; const union = field[info.unionField]!;
const showSelect = union.length > 1;
return ( return (
<FieldSet <FieldSet
@@ -382,7 +383,7 @@ export function FieldSet({
name={name} name={name}
fieldName={fieldName} fieldName={fieldName}
isRequired={isRequired} isRequired={isRequired}
field={field.oneOf[info.oneOf]} field={union[info.oneOf]}
depth={depth + 1} depth={depth + 1}
slotType={showSelect ? false : slotType} slotType={showSelect ? false : slotType}
toolbar={ toolbar={
@@ -397,7 +398,7 @@ export function FieldSet({
}); });
}} }}
> >
{field.oneOf.map((item, i) => ( {union.map((item, i) => (
<option key={i} value={i}> <option key={i} value={i}>
{schemaToString(item)} {schemaToString(item)}
</option> </option>
+81 -44
View File
@@ -4,6 +4,7 @@ import { createContext, ReactNode, useContext, useMemo, useState } from 'react';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event'; import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event';
import { getDefaultValue } from '@/playground/get-default-values'; import { getDefaultValue } from '@/playground/get-default-values';
import type { ParsedSchema } from '@/utils/schema';
interface SchemaContextType { interface SchemaContextType {
references: Record<string, RequestSchema>; references: Record<string, RequestSchema>;
@@ -11,9 +12,16 @@ interface SchemaContextType {
ajv: Ajv2020; ajv: Ajv2020;
} }
type UnionField = 'anyOf' | 'allOf' | 'oneOf';
export interface FieldInfo { export interface FieldInfo {
selectedType?: string; selectedType?: string;
oneOf: number; oneOf: number;
/**
* The actual field that represents union members.
*/
unionField?: UnionField;
} }
const SchemaContext = createContext<SchemaContextType | undefined>(undefined); const SchemaContext = createContext<SchemaContextType | undefined>(undefined);
@@ -67,67 +75,96 @@ export function useFieldInfo(
updateInfo: (value: Partial<FieldInfo>) => void; updateInfo: (value: Partial<FieldInfo>) => void;
} { } {
const { fieldInfoMap, ajv } = useContext(SchemaContext)!; const { fieldInfoMap, ajv } = useContext(SchemaContext)!;
const [_, trigger] = useState(0);
const form = useFormContext(); const form = useFormContext();
const keyName = `${fieldName}:${depth}`; const keyName = `${fieldName}:${depth}`;
const value = form.getValues(fieldName as 'body'); const value = form.getValues(fieldName as 'body');
const info = fieldInfoMap.get(keyName) ?? { const [info, setInfo] = useState<FieldInfo>(() => {
oneOf: -1, return fieldInfoMap.get(keyName) ?? init();
}; });
// update info if needed
if (!info.selectedType && Array.isArray(schema.type)) {
info.selectedType =
schema.type.find((type) =>
ajv.validate(
{
...schema,
type,
},
value,
),
) ?? schema.type[0];
}
if (info.oneOf === -1 && schema.oneOf) {
info.oneOf = schema.oneOf.findIndex((item) => {
return ajv.validate(item, value);
});
if (info.oneOf === -1) info.oneOf = 0;
}
fieldInfoMap.set(keyName, info); fieldInfoMap.set(keyName, info);
/**
* We automatically merge `allOf` | `anyOf` if all members are objects, but it's also possible for them to behave same as a union (`oneOf`).
*/
function isUnion(anyOrAllOf: readonly ParsedSchema[]): boolean {
return anyOrAllOf.every((item) => {
if (typeof item === 'boolean') return true;
const u = item.anyOf || item.allOf;
return item.type !== 'object' && (!u || isUnion(u));
});
}
function getUnion(): [readonly ParsedSchema[], UnionField] | undefined {
if (schema.anyOf && isUnion(schema.anyOf)) {
return [schema.anyOf, 'anyOf'];
}
if (schema.allOf && isUnion(schema.allOf)) {
return [schema.allOf, 'allOf'];
}
if (schema.oneOf) return [schema.oneOf, 'oneOf'];
}
function init(): FieldInfo {
const union = getUnion();
if (union) {
const [members, field] = union;
let oneOf = members.findIndex((item) => ajv.validate(item, value));
if (oneOf === -1) oneOf = 0;
return {
oneOf,
unionField: field,
};
}
if (Array.isArray(schema.type)) {
const types = schema.type;
return {
selectedType:
types.find((type) => {
schema.type = type;
const match = ajv.validate(schema, value);
schema.type = types;
return match;
}) ?? types[0],
oneOf: -1,
};
}
return { oneOf: -1 };
}
return { return {
info, info,
updateInfo: useEffectEvent((value) => { updateInfo: useEffectEvent((value) => {
const prev = fieldInfoMap.get(keyName);
if (!prev) return;
const updated = { const updated = {
...prev, ...info,
...value, ...value,
}; };
if ( if (
updated.oneOf === prev.oneOf && updated.oneOf === info.oneOf &&
updated.selectedType === prev.selectedType updated.selectedType === info.selectedType
) )
return; return;
fieldInfoMap.set(keyName, updated); setInfo(updated);
form.setValue(
fieldName, let valueSchema: ParsedSchema = schema;
getDefaultValue( if (updated.unionField) {
schema.oneOf && updated.oneOf !== -1 valueSchema = schema[updated.unionField]![updated.oneOf];
? schema.oneOf[updated.oneOf] } else if (updated.selectedType) {
: { valueSchema = { ...schema, type: updated.selectedType };
...schema, }
type: value.selectedType ?? schema.type,
}, form.setValue(fieldName, getDefaultValue(valueSchema));
),
);
trigger((prev) => prev + 1);
}), }),
}; };
} }
+34 -39
View File
@@ -1,57 +1,62 @@
import { type ParsedSchema, type ResolvedSchema } from '@/utils/schema'; import { type ResolvedSchema } from '@/utils/schema';
import type { ProcessedDocument } from '@/utils/process-document'; import type { ProcessedDocument } from '@/utils/process-document';
export function schemaToString( export function schemaToString(
value: ResolvedSchema, value: ResolvedSchema,
ctx?: ProcessedDocument, ctx?: ProcessedDocument,
): string { ): string {
function run(schema: ResolvedSchema, isRoot: boolean): string { function union(union: readonly ResolvedSchema[], sep: string) {
const members = new Set();
let nullable = false;
for (const item of union) {
const result = run(item);
if (result === 'null') {
nullable = true;
} else if (result !== 'unknown') {
members.add(result);
}
}
const result = Array.from(members).join(sep);
return nullable ? `${result} | null` : result;
}
function run(schema: ResolvedSchema): string {
if (schema === true) return 'any'; if (schema === true) return 'any';
else if (schema === false) return 'never'; else if (schema === false) return 'never';
if (isNullable(schema) && isRoot) {
const type = run(schema, false);
// null if schema only contains `nullable`
return type === 'unknown' ? 'null' : `${type} | null`;
}
if (schema.title) return schema.title; if (schema.title) return schema.title;
const referenceName = ctx?.dereferenceMap.get(schema); const referenceName = ctx?.dereferenceMap.get(schema);
if (referenceName) return referenceName.split('/').at(-1)!; if (referenceName) return referenceName.split('/').at(-1)!;
if (Array.isArray(schema.type)) { if (Array.isArray(schema.type)) {
return schema.type const members = new Set();
.map((type, _, originalType) => { const types = schema.type;
schema.type = type; for (const type of types) {
const str = run(schema, false); schema.type = type;
schema.type = originalType; const str = run(schema);
schema.type = types;
return str; if (str !== 'unknown') members.add(str);
}) }
.filter((v) => v !== 'unknown' && v !== 'null')
.join(' | '); return Array.from(members).join(' | ');
} }
if (schema.type === 'array') if (schema.type === 'array')
return `array<${schema.items ? run(schema.items, true) : 'unknown'}>`; return `array<${schema.items ? run(schema.items) : 'unknown'}>`;
if (schema.oneOf) { if (schema.oneOf) {
return schema.oneOf return union(schema.oneOf, ' | ');
.map((one) => run(one, false))
.filter((v) => v !== 'unknown' && v !== 'null')
.join(' | ');
} }
const combinedOf = schema.anyOf ?? schema.allOf; const combinedOf = schema.anyOf ?? schema.allOf;
if (combinedOf) { if (combinedOf) {
return combinedOf return union(combinedOf, ' & ');
.map((one) => run(one, false))
.filter((v) => v !== 'unknown' && v !== 'null')
.join(' & ');
} }
if (schema.not) return `not ${run(schema.not, false)}`; if (schema.not) return `not ${run(schema.not)}`;
if (schema.type === 'string' && schema.format === 'binary') return 'file'; if (schema.type === 'string' && schema.format === 'binary') return 'file';
if (schema.type && Array.isArray(schema.type)) { if (schema.type && Array.isArray(schema.type)) {
@@ -65,15 +70,5 @@ export function schemaToString(
return 'unknown'; return 'unknown';
} }
return run(value, true); return run(value);
}
function isNullable(schema: ParsedSchema): boolean {
if (typeof schema === 'boolean') return false;
if (Array.isArray(schema.type) && schema.type.includes('null')) return true;
const combined = schema.anyOf ?? schema.oneOf ?? schema.allOf;
if (combined && combined.some(isNullable)) return true;
return schema.type === 'null';
} }