mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
OpenAPI: Fix handling of primitive types in anyOf/allOf
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'fumadocs-openapi': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix handling of primitive types in `anyOf`/`allOf`
|
||||||
+1106
-946
File diff suppressed because it is too large
Load Diff
@@ -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>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
? ({
|
? ({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user