mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-26 06:31:49 +00:00
OpenAPI: Fix intersection resolution
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-openapi': patch
|
||||
---
|
||||
|
||||
Fix intersection resolution
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user