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
+27 -21
View File
@@ -403,28 +403,31 @@ 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 ( return (
<> <CollapsiblePanel
{params.map((param, i) => { key={type}
if (param.length === 0) return; title={
const name = paramNames[i]; {
const type = paramTypes[i]; header: 'Header',
cookie: 'Cookies',
return ( query: 'Query',
<CollapsiblePanel key={name} title={name}> path: 'Path',
{param.map((field) => { }[type]
}
>
{items.map((field) => {
const fieldName = `${type}.${field.name}` as const; const fieldName = `${type}.${field.name}` as const;
const schema = ( const schema = (
field.content field.content
@@ -452,8 +455,12 @@ function FormBody({
})} })}
</CollapsiblePanel> </CollapsiblePanel>
); );
})} });
}, [fields.parameter, parameters]);
return (
<>
{panels}
{body && ( {body && (
<CollapsiblePanel title="Body"> <CollapsiblePanel title="Body">
{fields.body ? ( {fields.body ? (
@@ -474,9 +481,8 @@ 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 ( return (
<>
{isJson ? (
<> <>
<button <button
className={cn( className={cn(
@@ -493,13 +499,16 @@ function BodyInput({ field: _field }: { field: RequestSchema }) {
</button> </button>
<JsonInput fieldName="body" /> <JsonInput fieldName="body" />
</> </>
) : ( );
return (
<FieldSet <FieldSet
field={field} field={field}
fieldName="body" fieldName="body"
collapsible={false} collapsible={false}
name={ name={
<button <button
type="button"
className={cn( className={cn(
buttonVariants({ buttonVariants({
color: 'secondary', color: 'secondary',
@@ -508,14 +517,11 @@ function BodyInput({ field: _field }: { field: RequestSchema }) {
}), }),
)} )}
onClick={() => setIsJson(true)} onClick={() => setIsJson(true)}
type="button"
> >
Open JSON Editor Open JSON Editor
</button> </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>
+80 -43
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);
}), }),
}; };
} }
+33 -38
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;
for (const type of types) {
schema.type = type; schema.type = type;
const str = run(schema, false); const str = run(schema);
schema.type = originalType; 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';
} }