OpenAPI: Fix intersection resolution

This commit is contained in:
Fuma Nama
2026-03-04 22:02:17 +08:00
parent d306f41717
commit 440a7efaec
6 changed files with 167 additions and 39 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Fix intersection resolution
+2 -2
View File
@@ -27,7 +27,7 @@ import { ChevronDown, LoaderCircle } from 'lucide-react';
import { encodeRequestData } from '@/requests/media/encode';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { cn } from '@/utils/cn';
import { SchemaProvider, SchemaScope, useSchemaUtils } from '@/playground/schema';
import { SchemaProvider, SchemaScope, useResolvedSchema } from '@/playground/schema';
import {
Select,
SelectContent,
@@ -424,7 +424,7 @@ function FormBody({ parameters = [], body }: Pick<PlaygroundClientProps, 'parame
}
function BodyInput({ field: _field }: { field: ParsedSchema }) {
const field = useSchemaUtils().resolve(_field);
const field = useResolvedSchema(_field);
const [isJson, setIsJson] = useState(false);
if (field.format === 'binary') return <FieldSet field={field} fieldName={['body']} isRequired />;
@@ -13,7 +13,13 @@ import { Input, labelVariants } from '@/ui/components/input';
import { cn } from '@/utils/cn';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { FormatFlags } from '@/utils/schema-to-string';
import { anyFields, useFieldInfo, useSchemaUtils, useSchemaScope } from '@/playground/schema';
import {
anyFields,
useFieldInfo,
useSchemaUtils,
useSchemaScope,
useResolvedSchema,
} from '@/playground/schema';
import type { ParsedSchema } from '@/utils/schema';
import { stringifyFieldKey } from '@fumari/stf/lib/utils';
import { cva } from 'class-variance-authority';
@@ -36,8 +42,8 @@ export function ObjectInput({
field: Exclude<ParsedSchema, boolean>;
fieldName: FieldKey;
} & ComponentProps<'div'>) {
const { resolve, generateDefault } = useSchemaUtils();
const field = resolve(_field);
const { generateDefault } = useSchemaUtils();
const field = useResolvedSchema(_field);
const schemaPropKeys = field.properties ? Object.keys(field.properties) : [];
const {
patternProperties = {},
@@ -299,8 +305,8 @@ export function FieldSet({
collapsible?: boolean;
}) {
const { readOnly, writeOnly } = useSchemaScope();
const { resolve, generateDefault, schemaToString } = useSchemaUtils();
const field = resolve(_field);
const { generateDefault, schemaToString } = useSchemaUtils();
const field = useResolvedSchema(_field);
const [show, setShow] = useState(!collapsible);
const { info, updateInfo } = useFieldInfo(fieldName, field, depth);
const id = stringifyFieldKey(fieldName);
@@ -443,8 +449,7 @@ export function FieldSet({
);
}
if (field.type === 'object' || info.intersection) {
const schema = info.intersection?.merged ?? field;
if (field.type === 'object') {
return (
<fieldset
{...props}
@@ -452,14 +457,14 @@ export function FieldSet({
className={cn('flex flex-col gap-1.5 col-span-full @container', props.className)}
>
<div className={fieldLabelVariants()}>
{renderLabelTrigger(schema)}
{renderLabelTrigger(field)}
{slotType ?? <FieldLabelType>{schemaToString(field)}</FieldLabelType>}
{toolbar}
{!isRequired && isDefined && renderUnsetButton()}
</div>
{show && (
<ObjectInput
field={schema}
field={field}
fieldName={fieldName}
className="rounded-lg border border-fd-primary/20 bg-fd-background/50 p-2 shadow-sm"
/>
+27 -23
View File
@@ -34,10 +34,6 @@ export interface FieldInfo {
* The actual field that represents union members.
*/
unionField?: UnionField;
intersection?: {
merged: Exclude<ParsedSchema, boolean>;
};
}
const SchemaContext = createContext<SchemaContextType | undefined>(undefined);
@@ -83,7 +79,7 @@ export function useSchemaScope(): SchemaScope {
* A hook to store dynamic info of a field, such as selected schema of `oneOf`.
*
* @param fieldName - field name of form.
* @param schema - The JSON Schema to generate initial values.
* @param schema - The **resolved** JSON Schema to generate initial values.
* @param depth - The depth to avoid duplicated field name with same schema (e.g. nested `oneOf`).
*/
export function useFieldInfo(
@@ -92,6 +88,7 @@ export function useFieldInfo(
depth = 0,
): {
info: FieldInfo;
schema: Exclude<ParsedSchema, boolean>;
updateInfo: (value: Partial<FieldInfo>) => void;
} {
const { ajv, references } = use(SchemaContext)!;
@@ -124,18 +121,6 @@ export function useFieldInfo(
}) ?? types[0];
}
if (schema.allOf) {
const merged = mergeAllOf(schema, {
dereference(schema) {
return dereference(schema, references);
},
});
if (typeof merged !== 'boolean')
out.intersection = {
merged,
};
}
return out;
},
});
@@ -145,6 +130,7 @@ export function useFieldInfo(
return {
info,
schema,
updateInfo(value) {
const updated = {
...info,
@@ -179,12 +165,6 @@ export function useSchemaUtils() {
references,
);
},
/**
* Resolve `$ref` in the schema, **not recursive**.
*/
resolve(schema: ParsedSchema): Exclude<ParsedSchema, boolean> {
return fallbackAny(dereference(schema, references));
},
schemaToString(value: ResolvedSchema, flags?: FormatFlags) {
return schemaToString(
value,
@@ -195,6 +175,30 @@ export function useSchemaUtils() {
};
}
/**
* resolve $ref & merge `allOf`.
*/
export function useResolvedSchema(raw: ParsedSchema): Exclude<ParsedSchema, boolean> {
const { references } = use(SchemaContext)!;
return useMemo(() => {
const schema = dereference(raw, references);
if (typeof schema === 'boolean') return anyFields;
if (schema.allOf) {
return fallbackAny(
mergeAllOf(schema, {
dereference(schema) {
return dereference(schema, references);
},
}),
);
}
return schema;
}, [raw, references]);
}
function dereference(schema: ParsedSchema, references: Record<string, ParsedSchema>): ParsedSchema {
if (typeof schema === 'boolean') return schema;
+25 -5
View File
@@ -33,6 +33,21 @@ function intersection(a: ParsedSchema, b: ParsedSchema, options: Options): Parse
if (typeof a === 'boolean') return a;
if (typeof b === 'boolean') return b;
for (const unionField of ['anyOf', 'oneOf'] as const) {
if (a[unionField] === undefined && b[unionField] !== undefined) {
return {
...b,
[unionField]: b[unionField].map((item) => intersection(item, a, options)),
};
}
if (a[unionField] !== undefined && b[unionField] === undefined) {
return {
...a,
[unionField]: a[unionField].map((item) => intersection(item, b, options)),
};
}
}
const result: ParsedSchema = { ...a };
for (const _k in b) {
const key = _k as keyof typeof b;
@@ -41,7 +56,6 @@ function intersection(a: ParsedSchema, b: ParsedSchema, options: Options): Parse
case '$id':
case '$comment':
case 'description':
case 'additionalItems':
case 'examples':
case 'allOf':
case 'writeOnly':
@@ -80,16 +94,21 @@ function intersection(a: ParsedSchema, b: ParsedSchema, options: Options): Parse
result[key] = result[key] === undefined ? value : Math.min(result[key], value);
break;
}
// intersection
case 'enum':
case 'anyOf':
case 'oneOf': {
case 'enum': {
const value = b[key];
if (value === undefined) break;
result[key] = result[key] === undefined ? value : intersectArray(result[key], value);
break;
}
case 'anyOf':
case 'oneOf': {
const value = b[key];
if (value !== undefined && result[key] !== undefined) {
result[key] = intersectArray(result[key], value);
}
break;
}
// require same
case 'format':
case 'const':
@@ -139,6 +158,7 @@ function intersection(a: ParsedSchema, b: ParsedSchema, options: Options): Parse
break;
}
case 'additionalProperties':
case 'additionalItems':
case 'contains':
case 'items': {
const value = b[key];
+94
View File
@@ -218,6 +218,100 @@ describe('Merge object schemas', () => {
}
`);
});
test('Production: `allOf`', () => {
const result = mergeAllOf({
type: 'object',
allOf: [
{
properties: {
name: { type: 'string' },
},
},
{
oneOf: [
{
type: 'object',
title: 'human',
required: ['human'],
properties: {
human: {
type: 'object',
properties: {
givenName: { type: 'string' },
familyName: { type: 'string' },
},
},
},
},
{
type: 'object',
title: 'machine',
required: ['machine'],
properties: {
machine: {
type: 'object',
properties: {
serialNumber: { type: 'string' },
},
},
},
},
],
},
],
});
expect(result).toMatchInlineSnapshot(`
{
"oneOf": [
{
"properties": {
"human": {
"properties": {
"familyName": {
"type": "string",
},
"givenName": {
"type": "string",
},
},
"type": "object",
},
"name": {
"type": "string",
},
},
"required": [
"human",
],
"title": "human",
"type": "object",
},
{
"properties": {
"machine": {
"properties": {
"serialNumber": {
"type": "string",
},
},
"type": "object",
},
"name": {
"type": "string",
},
},
"required": [
"machine",
],
"title": "machine",
"type": "object",
},
],
}
`);
});
});
describe('URL utilities', () => {