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;
}
const paramNames = ['Headers', 'Cookies', 'Query', 'Path'] as const;
const paramTypes = ['header', 'cookie', 'query', 'path'] as const;
const ParamTypes = ['path', 'header', 'cookie', 'query'] as const;
function FormBody({
parameters = [],
fields = {},
body,
}: Pick<ClientProps, 'parameters' | 'body' | 'fields'>) {
const params = useMemo(() => {
return paramTypes.map((param) => parameters.filter((v) => v.in === param));
}, [parameters]);
const panels = useMemo(() => {
return ParamTypes.map((type) => {
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 (
<>
{params.map((param, i) => {
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>
);
})}
{panels}
{body && (
<CollapsiblePanel title="Body">
{fields.body ? (
@@ -474,48 +481,47 @@ function BodyInput({ field: _field }: { field: RequestSchema }) {
if (field.format === 'binary')
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 (
<>
{isJson ? (
<>
<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" />
</>
) : (
<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>
}
/>
)}
</>
<FieldSet
field={field}
fieldName="body"
collapsible={false}
name={
<button
type="button"
className={cn(
buttonVariants({
color: 'secondary',
size: 'sm',
className: 'p-2',
}),
)}
onClick={() => setIsJson(true)}
>
Open JSON Editor
</button>
}
/>
);
}
+1 -7
View File
@@ -62,13 +62,7 @@ export async function APIPlayground({
securities: parseSecurities(method, ctx),
method: method.method,
route: path,
parameters: method.parameters?.map(
(v) =>
({
...v,
schema: writeReferences((v.schema ?? true) as ParsedSchema, context),
}) as ParameterField,
),
parameters: method.parameters as ParameterField[],
body:
bodyContent && mediaType
? ({
+5 -4
View File
@@ -373,8 +373,9 @@ export function FieldSet({
if (_field === false) return null;
if (field.oneOf) {
const showSelect = field.oneOf.length > 1;
if (info.unionField) {
const union = field[info.unionField]!;
const showSelect = union.length > 1;
return (
<FieldSet
@@ -382,7 +383,7 @@ export function FieldSet({
name={name}
fieldName={fieldName}
isRequired={isRequired}
field={field.oneOf[info.oneOf]}
field={union[info.oneOf]}
depth={depth + 1}
slotType={showSelect ? false : slotType}
toolbar={
@@ -397,7 +398,7 @@ export function FieldSet({
});
}}
>
{field.oneOf.map((item, i) => (
{union.map((item, i) => (
<option key={i} value={i}>
{schemaToString(item)}
</option>
+81 -44
View File
@@ -4,6 +4,7 @@ import { createContext, ReactNode, useContext, useMemo, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event';
import { getDefaultValue } from '@/playground/get-default-values';
import type { ParsedSchema } from '@/utils/schema';
interface SchemaContextType {
references: Record<string, RequestSchema>;
@@ -11,9 +12,16 @@ interface SchemaContextType {
ajv: Ajv2020;
}
type UnionField = 'anyOf' | 'allOf' | 'oneOf';
export interface FieldInfo {
selectedType?: string;
oneOf: number;
/**
* The actual field that represents union members.
*/
unionField?: UnionField;
}
const SchemaContext = createContext<SchemaContextType | undefined>(undefined);
@@ -67,67 +75,96 @@ export function useFieldInfo(
updateInfo: (value: Partial<FieldInfo>) => void;
} {
const { fieldInfoMap, ajv } = useContext(SchemaContext)!;
const [_, trigger] = useState(0);
const form = useFormContext();
const keyName = `${fieldName}:${depth}`;
const value = form.getValues(fieldName as 'body');
const info = fieldInfoMap.get(keyName) ?? {
oneOf: -1,
};
// 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;
}
const [info, setInfo] = useState<FieldInfo>(() => {
return fieldInfoMap.get(keyName) ?? init();
});
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 {
info,
updateInfo: useEffectEvent((value) => {
const prev = fieldInfoMap.get(keyName);
if (!prev) return;
const updated = {
...prev,
...info,
...value,
};
if (
updated.oneOf === prev.oneOf &&
updated.selectedType === prev.selectedType
updated.oneOf === info.oneOf &&
updated.selectedType === info.selectedType
)
return;
fieldInfoMap.set(keyName, updated);
form.setValue(
fieldName,
getDefaultValue(
schema.oneOf && updated.oneOf !== -1
? schema.oneOf[updated.oneOf]
: {
...schema,
type: value.selectedType ?? schema.type,
},
),
);
trigger((prev) => prev + 1);
setInfo(updated);
let valueSchema: ParsedSchema = schema;
if (updated.unionField) {
valueSchema = schema[updated.unionField]![updated.oneOf];
} else if (updated.selectedType) {
valueSchema = { ...schema, type: updated.selectedType };
}
form.setValue(fieldName, getDefaultValue(valueSchema));
}),
};
}
+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';
export function schemaToString(
value: ResolvedSchema,
ctx?: ProcessedDocument,
): 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';
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;
const referenceName = ctx?.dereferenceMap.get(schema);
if (referenceName) return referenceName.split('/').at(-1)!;
if (Array.isArray(schema.type)) {
return schema.type
.map((type, _, originalType) => {
schema.type = type;
const str = run(schema, false);
schema.type = originalType;
const members = new Set();
const types = schema.type;
for (const type of types) {
schema.type = type;
const str = run(schema);
schema.type = types;
return str;
})
.filter((v) => v !== 'unknown' && v !== 'null')
.join(' | ');
if (str !== 'unknown') members.add(str);
}
return Array.from(members).join(' | ');
}
if (schema.type === 'array')
return `array<${schema.items ? run(schema.items, true) : 'unknown'}>`;
return `array<${schema.items ? run(schema.items) : 'unknown'}>`;
if (schema.oneOf) {
return schema.oneOf
.map((one) => run(one, false))
.filter((v) => v !== 'unknown' && v !== 'null')
.join(' | ');
return union(schema.oneOf, ' | ');
}
const combinedOf = schema.anyOf ?? schema.allOf;
if (combinedOf) {
return combinedOf
.map((one) => run(one, false))
.filter((v) => v !== 'unknown' && v !== 'null')
.join(' & ');
return union(combinedOf, ' & ');
}
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 && Array.isArray(schema.type)) {
@@ -65,15 +70,5 @@ export function schemaToString(
return 'unknown';
}
return run(value, true);
}
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';
return run(value);
}