OpenAPI: fix $ref handling in stringifier

This commit is contained in:
Fuma Nama
2026-02-26 19:23:14 +08:00
parent b01665e14b
commit 41ac2c67ed
9 changed files with 85 additions and 41 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
fix `$ref` handling in stringifier
+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, useResolvedSchema } from '@/playground/schema';
import { SchemaProvider, SchemaScope, useSchemaUtils } 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 = useResolvedSchema(_field);
const field = useSchemaUtils().resolve(_field);
const [isJson, setIsJson] = useState(false);
if (field.format === 'binary') return <FieldSet field={field} fieldName={['body']} isRequired />;
@@ -10,11 +10,10 @@ import {
SelectValue,
} from '@/ui/components/select';
import { Input, labelVariants } from '@/ui/components/input';
import { getDefaultValue } from '../get-default-values';
import { cn } from '@/utils/cn';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { FormatFlags, schemaToString } from '@/utils/schema-to-string';
import { anyFields, useFieldInfo, useResolvedSchema, useSchemaScope } from '@/playground/schema';
import { FormatFlags } from '@/utils/schema-to-string';
import { anyFields, useFieldInfo, useSchemaUtils, useSchemaScope } from '@/playground/schema';
import type { ParsedSchema } from '@/utils/schema';
import { stringifyFieldKey } from '@fumari/stf/lib/utils';
import { cva } from 'class-variance-authority';
@@ -37,16 +36,20 @@ export function ObjectInput({
field: Exclude<ParsedSchema, boolean>;
fieldName: FieldKey;
} & ComponentProps<'div'>) {
const field = useResolvedSchema(_field);
const { patternProperties = {}, additionalProperties } = field;
const { resolve, generateDefault } = useSchemaUtils();
const field = resolve(_field);
const schemaPropKeys = field.properties ? Object.keys(field.properties) : [];
const isLazy = field['x-playground-lazy'] ?? schemaPropKeys.length > 100;
const isDynamic = Object.keys(patternProperties).length > 0 || additionalProperties !== undefined;
const {
patternProperties = {},
additionalProperties,
'x-playground-lazy': isLazy = schemaPropKeys.length > 100,
} = field;
const isDynamic = Object.keys(patternProperties).length > 0 || additionalProperties;
const [nextName, setNextName] = useState('');
const { properties, onAppend, onDelete, _objectKeys } = useObject(fieldName, {
lazy: isLazy,
defaultValue: () => getDefaultValue(field) as object,
defaultValue: () => generateDefault(field) as object,
properties: field.properties ?? {},
fallback: additionalProperties,
patternProperties: patternProperties,
@@ -296,7 +299,8 @@ export function FieldSet({
collapsible?: boolean;
}) {
const { readOnly, writeOnly } = useSchemaScope();
const field = useResolvedSchema(_field);
const { resolve, generateDefault, schemaToString } = useSchemaUtils();
const field = resolve(_field);
const [show, setShow] = useState(!collapsible);
const { info, updateInfo } = useFieldInfo(fieldName, field, depth);
const id = stringifyFieldKey(fieldName);
@@ -321,7 +325,7 @@ export function FieldSet({
type="button"
className={cn(labelVariants(), 'inline-flex items-center gap-1 font-mono me-auto')}
onClick={() => {
dataEngine.init(fieldName, getDefaultValue(schema));
dataEngine.init(fieldName, generateDefault(schema));
setShow((prev) => !prev);
}}
>
@@ -381,7 +385,7 @@ export function FieldSet({
>
{union.map((item, i) => (
<option key={i} value={i} className="bg-fd-popover text-fd-popover-foreground">
{schemaToString(item, undefined, FormatFlags.UseAlias)}
{schemaToString(item, FormatFlags.UseAlias)}
</option>
))}
</select>
@@ -510,9 +514,8 @@ function ArrayInput({
items: ParsedSchema;
} & ComponentProps<'div'>) {
const name = fieldName.at(-1) ?? '';
const { items, insertItem, removeItem } = useArray(fieldName, {
defaultValue: [],
});
const { generateDefault } = useSchemaUtils();
const { items, insertItem, removeItem } = useArray(fieldName);
return (
<div {...props} className={cn('flex flex-col gap-2', props.className)}>
@@ -554,7 +557,7 @@ function ArrayInput({
}),
)}
onClick={() => {
insertItem(getDefaultValue(itemSchema));
insertItem(generateDefault(itemSchema));
}}
>
<Plus className="size-4" />
@@ -1,6 +0,0 @@
import type { ParsedSchema } from '@/utils/schema';
import { sample } from 'openapi-sampler';
export function getDefaultValue(schema: ParsedSchema): unknown {
return sample(schema as never, { skipNonRequired: true, skipReadOnly: true, quiet: true });
}
+1 -1
View File
@@ -80,7 +80,7 @@ function writeReferences(
ctx.references[id] = out;
return {
$ref: id,
$ref: `#/${id}`,
};
}
+32 -10
View File
@@ -1,10 +1,11 @@
import { Ajv2020 } from 'ajv/dist/2020';
import { createContext, ReactNode, use, useMemo } from 'react';
import { getDefaultValue } from '@/playground/get-default-values';
import type { ParsedSchema } from '@/utils/schema';
import type { ParsedSchema, ResolvedSchema } from '@/utils/schema';
import { mergeAllOf } from '@/utils/merge-schema';
import { FieldKey, useDataEngine, useFieldValue, useNamespace } from '@fumari/stf';
import { stringifyFieldKey } from '@fumari/stf/lib/utils';
import { sample } from 'openapi-sampler';
import { FormatFlags, schemaToString } from '@/utils/schema-to-string';
interface SchemaContextType extends SchemaScope {
references: Record<string, ParsedSchema>;
@@ -96,6 +97,7 @@ export function useFieldInfo(
} {
const { ajv } = use(SchemaContext)!;
const engine = useDataEngine();
const { generateDefault } = useSchemaUtils();
const fieldData = useNamespace({
namespace: `field-info:${depth}:${stringifyFieldKey(fieldName)}`,
initial(): FieldInfo {
@@ -155,22 +157,42 @@ export function useFieldInfo(
valueSchema = { ...schema, type: updated.selectedType };
}
engine.update(fieldName, getDefaultValue(valueSchema));
engine.update(fieldName, generateDefault(valueSchema));
},
};
}
/**
* Resolve `$ref` in the schema, **not recursive**.
*/
export function useResolvedSchema(schema: ParsedSchema): Exclude<ParsedSchema, boolean> {
export function useSchemaUtils() {
const { references } = use(SchemaContext)!;
return useMemo(() => {
function resolve(schema: ParsedSchema): Exclude<ParsedSchema, boolean> {
if (typeof schema === 'boolean') return anyFields;
if (schema.$ref) return fallbackAny(references[schema.$ref]);
let ref = schema.$ref;
if (ref) {
// use swallow resolution as it is already preprocessed in `playground/index.tsx`
const prefix = '#/';
if (ref.startsWith(prefix)) ref = ref.slice(prefix.length);
if (ref in references) return fallbackAny(references[ref]);
}
return schema;
}, [references, schema]);
}
return {
generateDefault(schema: ParsedSchema): unknown {
return sample(
schema as never,
{ skipNonRequired: true, skipReadOnly: true, quiet: true },
references,
);
},
/**
* Resolve `$ref` in the schema, **not recursive**.
*/
resolve,
schemaToString(value: ResolvedSchema, flags?: FormatFlags) {
return schemaToString(value, (s) => ({ dereferenced: resolve(s), raw: s }), flags);
},
};
}
export function fallbackAny(schema: ParsedSchema): Exclude<ParsedSchema, boolean> {
+1 -1
View File
@@ -308,7 +308,7 @@ export function generateSchemaUI(
});
}
if (additionalProperties !== undefined && isVisible(additionalProperties)) {
if (additionalProperties && isVisible(additionalProperties)) {
const $type = getSchemaId(additionalProperties);
scanRefs($type, additionalProperties);
+22 -3
View File
@@ -1,4 +1,4 @@
import { type ResolvedSchema } from '@/utils/schema';
import type { ParsedSchema, ResolvedSchema } from '@/utils/schema';
import type { ProcessedDocument } from '@/utils/process-document';
export enum FormatFlags {
@@ -6,11 +6,27 @@ export enum FormatFlags {
UseAlias = 1 << 0,
}
type Resolver = (schema: ResolvedSchema) => {
dereferenced: ResolvedSchema;
raw?: Exclude<ParsedSchema, boolean>;
};
export function schemaToString(
value: ResolvedSchema,
ctx?: ProcessedDocument,
_resolver?: ProcessedDocument | Resolver,
flags: FormatFlags = FormatFlags.None,
): string {
const resolver: Resolver =
typeof _resolver === 'function'
? _resolver
: (schema) => {
const ref = _resolver?.getRawRef(schema);
return {
dereferenced: schema,
raw: ref ? { $ref: ref } : undefined,
};
};
function union(union: readonly ResolvedSchema[], sep: string, flags: FormatFlags) {
const members = new Set();
let nullable = false;
@@ -30,13 +46,16 @@ export function schemaToString(
}
function run(schema: ResolvedSchema, flags: FormatFlags): string {
const resolved = resolver(schema);
schema = resolved.dereferenced;
if (schema === true) return 'any';
else if (schema === false) return 'never';
if ((flags & FormatFlags.UseAlias) === FormatFlags.UseAlias) {
if (schema.title) return schema.title;
const ref = ctx?.getRawRef(schema)?.split('/');
const ref = resolved.raw?.$ref?.split('/');
if (ref && ref.length > 0) return ref[ref.length - 1];
}
+4 -3
View File
@@ -51,8 +51,9 @@ export function useArray(
options: { defaultValue?: DefaultValue<unknown[]> } = {},
) {
const engine = useDataEngine();
const { defaultValue } = options;
const [items] = useFieldValue(field, {
defaultValue: options.defaultValue,
defaultValue,
compute(value) {
const items: ArrayItemInfo[] = [];
if (!Array.isArray(value)) return items;
@@ -74,8 +75,8 @@ export function useArray(
items,
insertItem(itemValue?: unknown) {
const value = engine.get(field);
engine.update(field, Array.isArray(value) ? [...value, itemValue] : [itemValue]);
const idx = Array.isArray(value) ? value.length : 0;
engine.init([...field, idx], itemValue);
},
removeItem(index: number) {
engine.delete([...field, index]);