OpenAPI: handle recursive references

This commit is contained in:
Fuma Nama
2025-05-01 11:56:44 +08:00
parent 6576ce8282
commit 98f4f1cc8a
8 changed files with 278 additions and 244 deletions
+1 -20
View File
@@ -239,15 +239,7 @@
"type": "object"
},
"V1DecryptRequestBody": {
"additionalProperties": false,
"properties": {
"$schema": {
"description": "A URL to the JSON Schema for this object.",
"example": "https://api.unkey.dev/schemas/V1DecryptRequestBody.json",
"format": "uri",
"readOnly": true,
"type": "string"
},
"encrypted": {
"description": "The encrypted base64 string.",
"minLength": 1,
@@ -260,17 +252,7 @@
"test": {
"oneOf": [
{
"type": "object",
"required": ["keyring", "data"],
"properties": {
"$schema": {
"description": "A URL to the JSON Schema for this object.",
"example": "https://api.unkey.dev/schemas/V1EncryptBulkRequestBody.json",
"format": "uri",
"readOnly": true,
"type": "string"
}
}
"$ref": "#/components/schemas/V1DecryptRequestBody"
},
{
"oneOf": [
@@ -304,7 +286,6 @@
]
}
},
"required": ["keyring", "encrypted"],
"type": "object"
},
"V1DecryptResponseBody": {
+12 -121
View File
@@ -1,13 +1,11 @@
'use client';
import {
createContext,
type FC,
Fragment,
type HTMLAttributes,
lazy,
type ReactElement,
type ReactNode,
useContext,
useEffect,
useMemo,
useState,
@@ -47,9 +45,11 @@ import type { RequestData } from '@/requests/_shared';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { cn } from 'fumadocs-ui/utils/cn';
import { cva } from 'class-variance-authority';
import { resolve } from '@/playground/resolve';
import { Ajv2020 } from 'ajv/dist/2020';
import { getDefaultValue } from '@/playground/get-default-values';
import {
type FieldInfo,
SchemaProvider,
useResolvedSchema,
} from '@/playground/schema';
interface FormValues {
authorization:
@@ -108,22 +108,6 @@ export type ClientProps = HTMLAttributes<HTMLFormElement> & {
}>;
};
interface SchemaContextType {
fieldInfoMap: Map<string, FieldInfo>;
}
interface FieldInfo {
selectedType?: string;
oneOf: number;
}
const ajv = new Ajv2020({
validateSchema: false,
strict: false,
});
const SchemaContext = createContext<SchemaContextType | undefined>(undefined);
function toRequestData(
method: string,
mediaType: string | undefined,
@@ -144,8 +128,8 @@ export default function Client({
route,
method = 'GET',
authorization,
parameters: _parameters,
body: _body,
parameters,
body,
fields,
references,
proxyUrl,
@@ -156,12 +140,6 @@ export default function Client({
const requestData = useRequestData();
const fieldInfoMap = useMemo(() => new Map<string, FieldInfo>(), []);
const authInfo = usePersistentAuthInfo(authorization);
const parameters = useMemo(
() => resolve(_parameters, references),
[_parameters, references],
);
const body = useMemo(() => resolve(_body, references), [_body, references]);
const defaultValues: FormValues = useMemo(
() => ({
authorization: authInfo.info,
@@ -230,14 +208,7 @@ export default function Client({
return (
<FormProvider {...form}>
<SchemaContext.Provider
value={useMemo(
() => ({
fieldInfoMap,
}),
[fieldInfoMap],
)}
>
<SchemaProvider fieldInfoMap={fieldInfoMap} references={references}>
<form
{...rest}
className={cn(
@@ -274,7 +245,7 @@ export default function Client({
{testQuery.data ? <ResultDisplay data={testQuery.data} /> : null}
{authorization && <AuthFooter authorization={authorization} />}
</form>
</SchemaContext.Provider>
</SchemaProvider>
</FormProvider>
);
}
@@ -285,12 +256,11 @@ const paramTypes = ['header', 'cookie', 'query', 'path'] as const;
function FormBody({
authorization,
parameters = [],
body,
fields = {},
body,
}: Pick<ClientProps, 'parameters' | 'authorization' | 'body' | 'fields'>) {
const { servers } = useApiContext();
const { server, setServer, setServerVariables } = useServerSelectContext();
const params = useMemo(() => {
return paramTypes.map((param) => parameters.filter((v) => v.in === param));
}, [parameters]);
@@ -400,7 +370,8 @@ const bodyTypeVariants = cva(
},
);
function BodyInput({ field }: { field: RequestSchema }) {
function BodyInput({ field: _field }: { field: RequestSchema }) {
const field = useResolvedSchema(_field);
const [isJson, setIsJson] = useState(false);
return (
@@ -635,83 +606,3 @@ function writeAuthHeader(
header.Authorization = input;
}
}
/**
* 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 depth - The depth to avoid duplicated field name with same schema (e.g. nested `oneOf`).
*/
export function useFieldInfo(
fieldName: string,
schema: Exclude<RequestSchema, boolean>,
depth: number,
): {
info: FieldInfo;
updateInfo: (value: Partial<FieldInfo>) => void;
} {
const keyName = `${fieldName}:${depth}`;
const { fieldInfoMap } = useContext(SchemaContext)!;
const [_, trigger] = useState(0);
const form = useFormContext();
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) => ajv.validate(item, value));
if (info.oneOf === -1) info.oneOf = 0;
}
fieldInfoMap.set(keyName, info);
return {
info,
updateInfo: useEffectEvent((value) => {
const prev = fieldInfoMap.get(keyName);
if (!prev) return;
const updated = {
...prev,
...value,
};
if (
updated.oneOf === prev.oneOf &&
updated.selectedType === prev.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);
}),
};
}
+29 -16
View File
@@ -58,16 +58,16 @@ export async function APIPlayground({
parameters: method.parameters?.map((v) => ({
name: v.name,
in: v.in as ParameterField['in'],
schema: writeReferences(v.schema ?? true, context) as ParsedSchema,
schema: writeReferences((v.schema ?? true) as ParsedSchema, context),
description: v.description,
})),
body:
bodyContent && mediaType
? ({
schema: writeReferences(
bodyContent[mediaType].schema,
bodyContent[mediaType].schema as ParsedSchema,
context,
) as ParsedSchema,
),
mediaType,
} as ClientProps['body'])
: undefined,
@@ -80,33 +80,46 @@ export async function APIPlayground({
}
function writeReferences(
schema: unknown,
schema: ParsedSchema,
ctx: Context,
stack: WeakMap<object, string> = new WeakMap(),
): unknown {
stack: WeakMap<object, object> = new WeakMap(),
): RequestSchema {
if (typeof schema !== 'object' || !schema) return schema;
if (stack.has(schema)) {
const id = stack.get(schema)!;
ctx.references[id] = schema;
const out = stack.get(schema)!;
const id = ctx.nextId();
ctx.references[id] = out;
return {
$ref: id,
};
}
stack.set(schema, ctx.nextId());
const output = { ...schema };
stack.set(schema, output);
if (Array.isArray(schema)) {
return schema.map((item) => writeReferences(item, ctx, stack));
for (const name of ['items', 'additionalProperties'] as const) {
if (!output[name]) continue;
output[name] = writeReferences(output[name], ctx, stack);
}
const v: Record<string, unknown> = {};
for (const key in schema) {
v[key] = writeReferences(schema[key as keyof object], ctx, stack);
for (const name of ['oneOf', 'allOf', 'anyOf'] as const) {
if (!output[name]) continue;
output[name] = output[name].map((item) =>
writeReferences(item, ctx, stack),
);
}
return v;
for (const name of ['properties', 'patternProperties'] as const) {
if (!output[name]) continue;
output[name] = { ...output[name] };
for (const key in output[name]) {
output[name][key] = writeReferences(output[name][key], ctx, stack);
}
}
return output;
}
function getAuthorizationField(
+54 -38
View File
@@ -3,7 +3,6 @@ import {
type HTMLAttributes,
type LabelHTMLAttributes,
type ReactNode,
useMemo,
useState,
} from 'react';
import { Plus, Trash2 } from 'lucide-react';
@@ -23,17 +22,15 @@ import {
import type { RequestSchema } from '@/playground/index';
import { Input, labelVariants } from '@/ui/components/input';
import { getDefaultValue } from './get-default-values';
import { useFieldInfo } from './client';
import { cn } from 'fumadocs-ui/utils/cn';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { combineSchema } from '@/utils/combine-schema';
import { schemaToString } from '@/utils/schema-to-string';
const anyFields = {
type: ['string', 'number', 'boolean', 'array', 'object'],
items: true,
additionalProperties: true,
} satisfies RequestSchema;
import {
anyFields,
useFieldInfo,
useResolvedSchema,
} from '@/playground/schema';
interface InputHeaderProps {
name?: ReactNode;
@@ -56,7 +53,7 @@ function FieldHeader({
{required ? <span className="text-red-500">*</span> : null}
<div className="flex-1" />
{type ? (
<code data-type className="text-xs text-fd-muted-foreground">
<code data-type={true} className="text-xs text-fd-muted-foreground">
{type}
</code>
) : null}
@@ -73,11 +70,9 @@ export function ObjectInput({
field: Exclude<RequestSchema, boolean>;
fieldName: string;
} & HTMLAttributes<HTMLDivElement>) {
const field = useMemo(() => {
return fallbackAny(
combineSchema([_field, ...(_field.allOf ?? []), ...(_field.anyOf ?? [])]),
);
}, [_field]);
const field = useResolvedSchema(
combineSchema([_field, ...(_field.allOf ?? []), ...(_field.anyOf ?? [])]),
);
return (
<div {...props} className={cn('flex flex-col gap-6', props.className)}>
@@ -99,12 +94,11 @@ export function ObjectInput({
getType={(key) => {
for (const pattern in field.patternProperties) {
if (key.match(RegExp(pattern))) {
return fallbackAny(field.patternProperties[pattern]);
return field.patternProperties[pattern];
}
}
if (field.additionalProperties)
return fallbackAny(field.additionalProperties);
if (field.additionalProperties) return field.additionalProperties;
return anyFields;
}}
@@ -114,10 +108,6 @@ export function ObjectInput({
);
}
function fallbackAny(schema: RequestSchema): Exclude<RequestSchema, boolean> {
return typeof schema === 'boolean' ? anyFields : schema;
}
export function JsonInput({ fieldName }: { fieldName: string }) {
const controller = useController({
name: fieldName,
@@ -150,7 +140,7 @@ function DynamicProperties({
}: {
fieldName: string;
filterKey?: (key: string) => boolean;
getType: (key: string) => Exclude<RequestSchema, boolean>;
getType: (key: string) => RequestSchema;
}) {
const { control, setValue, getValues } = useFormContext();
const [nextName, setNextName] = useState('');
@@ -352,10 +342,30 @@ export function FieldSet({
depth?: number;
toolbar?: ReactNode;
}) {
const field = fallbackAny(_field);
const field = useResolvedSchema(_field);
const [show, setShow] = useState(
() =>
typeof _field !== 'object' ||
!_field.$ref ||
(field.type !== 'object' && !field.allOf && !field.oneOf && !field.anyOf),
);
const { info, updateInfo } = useFieldInfo(fieldName, field, depth);
if (_field === false) return null;
if (!show) {
return (
<FieldHeader {...props} name={name} required={isRequired}>
{toolbar}
<button
type="button"
className={cn(buttonVariants({ size: 'sm' }))}
onClick={() => setShow(true)}
>
Show
</button>
</FieldHeader>
);
}
if (field.oneOf) {
return (
@@ -365,7 +375,7 @@ export function FieldSet({
name={name}
fieldName={fieldName}
isRequired={isRequired}
field={fallbackAny(field.oneOf[info.oneOf])}
field={field.oneOf[info.oneOf]}
depth={depth + 1}
toolbar={
<>
@@ -416,16 +426,14 @@ export function FieldSet({
{toolbar}
</FieldHeader>
<p className="text-xs text-fd-muted-foreground">{field.description}</p>
{info.selectedType && (
<FieldInput
isRequired={isRequired}
field={{
...field,
type: info.selectedType,
}}
fieldName={fieldName}
/>
)}
<FieldInput
isRequired={isRequired}
field={{
...field,
type: info.selectedType,
}}
fieldName={fieldName}
/>
</fieldset>
);
}
@@ -443,7 +451,17 @@ export function FieldSet({
>
{toolbar}
</FieldHeader>
<FieldInput field={field} fieldName={fieldName} isRequired={isRequired} />
{show ? (
<FieldInput
field={field}
fieldName={fieldName}
isRequired={isRequired}
/>
) : (
<div>
<button onClick={() => setShow((prev) => !prev)}>Show</button>
</div>
)}
</fieldset>
);
}
@@ -500,9 +518,7 @@ function ArrayInput({
}),
)}
onClick={() => {
const itemType = fallbackAny(items);
append(getDefaultValue(itemType));
append(getDefaultValue(items));
}}
>
<Plus className="size-4" />
@@ -1,24 +0,0 @@
import { type RequestSchema } from '@/playground/index';
/**
* Resolve reference
*/
export function resolve<T>(
schema: T,
references: Record<string, RequestSchema>,
): T {
if (!schema || typeof schema !== 'object') return schema;
if (Array.isArray(schema))
return schema.map((item) => resolve(item, references)) as T;
if ('$ref' in schema && schema.$ref) {
return resolve(references[schema.$ref as string], references) as T;
}
for (const key in schema) {
schema[key] = resolve(schema[key], references);
}
return schema;
}
+154
View File
@@ -0,0 +1,154 @@
import { Ajv2020 } from 'ajv/dist/2020';
import type { RequestSchema } from '@/playground/index';
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';
interface SchemaContextType {
references: Record<string, RequestSchema>;
fieldInfoMap: Map<string, FieldInfo>;
ajv: Ajv2020;
}
export interface FieldInfo {
selectedType?: string;
oneOf: number;
}
const SchemaContext = createContext<SchemaContextType | undefined>(undefined);
export const anyFields = {
type: ['string', 'number', 'boolean', 'array', 'object'],
items: true,
additionalProperties: true,
} satisfies RequestSchema;
export function SchemaProvider({
references,
fieldInfoMap,
children,
}: Omit<SchemaContextType, 'ajv'> & { children: ReactNode }) {
const ajv = useMemo(
() =>
new Ajv2020({
strict: false,
validateSchema: false,
validateFormats: false,
schemas: references,
}),
[references],
);
return (
<SchemaContext.Provider
value={useMemo(
() => ({ references, fieldInfoMap, ajv }),
[fieldInfoMap, references, ajv],
)}
>
{children}
</SchemaContext.Provider>
);
}
/**
* 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 depth - The depth to avoid duplicated field name with same schema (e.g. nested `oneOf`).
*/
export function useFieldInfo(
fieldName: string,
schema: Exclude<RequestSchema, boolean>,
depth: number,
): {
info: FieldInfo;
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;
}
fieldInfoMap.set(keyName, info);
return {
info,
updateInfo: useEffectEvent((value) => {
const prev = fieldInfoMap.get(keyName);
if (!prev) return;
const updated = {
...prev,
...value,
};
if (
updated.oneOf === prev.oneOf &&
updated.selectedType === prev.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);
}),
};
}
/**
* Resolve `$ref` in the schema, **not recursive**.
*/
export function useResolvedSchema(
schema: RequestSchema,
): Exclude<RequestSchema, boolean> {
const { references } = useContext(SchemaContext)!;
return useMemo(() => {
if (typeof schema === 'boolean') return anyFields;
if (schema.$ref) return fallbackAny(references[schema.$ref]);
return schema;
}, [references, schema]);
}
export function fallbackAny(
schema: RequestSchema,
): Exclude<RequestSchema, boolean> {
return typeof schema === 'boolean' ? anyFields : schema;
}
@@ -96,9 +96,11 @@ export function Operation({
bodyNode = (
<>
{heading(headingLevel, 'Request Body', ctx)}
<div className="mb-8 flex flex-row items-center justify-between gap-2">
<div className="mb-4 p-3 bg-fd-card rounded-xl border flex flex-row items-center justify-between gap-2">
<code>{type}</code>
<span>{body.required ? 'Required' : 'Optional'}</span>
<span className="text-xs">
{body.required ? 'Required' : 'Optional'}
</span>
</div>
{body.description ? <Markdown text={body.description} /> : null}
<Schema
+24 -23
View File
@@ -173,29 +173,30 @@ export function Schema({
...(schema.type === 'array' && schema.items ? [schema.items] : []),
].filter((s) => isComplexType(s) && !stack.includes(s));
footer = mentionedObjectTypes.map((s, idx) => {
return (
<renderer.ObjectCollapsible
key={idx}
name={
typeof s === 'object' && s.title
? s.title
: mentionedObjectTypes.length === 1
? 'Show Attributes'
: `Object ${idx + 1}`
}
>
<Schema
name="element"
schema={s}
ctx={{
...ctx,
stack: [schema, ...stack],
}}
/>
</renderer.ObjectCollapsible>
);
});
footer = (
<div className="flex flex-col gap-2">
{mentionedObjectTypes.map((s, idx) => {
let title = typeof s === 'object' ? s.title : null;
title ??=
mentionedObjectTypes.length === 1
? 'Show Attributes'
: `Object ${idx + 1}`;
return (
<renderer.ObjectCollapsible key={idx} name={title}>
<Schema
name="element"
schema={s}
ctx={{
...ctx,
stack: [schema, ...stack],
}}
/>
</renderer.ObjectCollapsible>
);
})}
</div>
);
}
return (