OpenAPI: Fix schema scope in API playgrounds

This commit is contained in:
Fuma Nama
2025-11-26 12:46:04 +08:00
parent 49e3cb03a1
commit c44d999098
9 changed files with 88 additions and 61 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Fix schema scope in API playgrounds
+20 -17
View File
@@ -2,13 +2,13 @@
import {
type FC,
Fragment,
type HTMLAttributes,
lazy,
type ReactNode,
useEffect,
useMemo,
useState,
useEffectEvent,
type ComponentProps,
} from 'react';
import type {
FieldPath,
@@ -31,11 +31,7 @@ import {
JsonInput,
ObjectInput,
} from './components/inputs';
import type {
ParameterField,
RequestSchema,
SecurityEntry,
} from '@/playground/index';
import type { ParameterField, SecurityEntry } from '@/playground/index';
import { getStatusInfo } from './status-info';
import {
joinURL,
@@ -58,6 +54,7 @@ import { cn } from 'fumadocs-ui/utils/cn';
import {
type FieldInfo,
SchemaProvider,
SchemaScope,
useResolvedSchema,
} from '@/playground/schema';
import {
@@ -87,19 +84,21 @@ export interface FormValues {
_encoded?: RequestData;
}
export interface PlaygroundClientProps extends HTMLAttributes<HTMLFormElement> {
export interface PlaygroundClientProps
extends ComponentProps<'form'>,
SchemaScope {
route: string;
method: string;
parameters?: ParameterField[];
securities: SecurityEntry[][];
body?: {
schema: RequestSchema;
schema: ParsedSchema;
mediaType: string;
};
/**
* Resolver for $ref schemas you've passed
*/
references: Record<string, RequestSchema>;
references: Record<string, ParsedSchema>;
proxyUrl?: string;
}
@@ -140,7 +139,7 @@ export interface PlaygroundClientOptions {
renderBodyField?: (
fieldName: 'body',
info: {
schema: RequestSchema;
schema: ParsedSchema;
mediaType: string;
},
) => ReactNode;
@@ -165,6 +164,8 @@ export default function PlaygroundClient({
body,
references,
proxyUrl,
writeOnly,
readOnly,
...rest
}: PlaygroundClientProps) {
const { example: exampleId, examples, setExampleData } = useExampleRequests();
@@ -306,7 +307,12 @@ export default function PlaygroundClient({
return (
<FormProvider {...form}>
<SchemaProvider fieldInfoMap={fieldInfoMap} references={references}>
<SchemaProvider
fieldInfoMap={fieldInfoMap}
references={references}
writeOnly={writeOnly}
readOnly={readOnly}
>
<form
{...rest}
className={cn(
@@ -494,7 +500,7 @@ function FormBody({
);
}
function BodyInput({ field: _field }: { field: RequestSchema }) {
function BodyInput({ field: _field }: { field: ParsedSchema }) {
const field = useResolvedSchema(_field);
const [isJson, setIsJson] = useState(false);
@@ -736,10 +742,7 @@ function useAuthInputs(
return { inputs, mapInputs, initAuthValues };
}
function Route({
route,
...props
}: HTMLAttributes<HTMLDivElement> & { route: string }) {
function Route({ route, ...props }: ComponentProps<'div'> & { route: string }) {
return (
<div
{...props}
@@ -815,7 +818,7 @@ function CollapsiblePanel({
title,
children,
...props
}: Omit<HTMLAttributes<HTMLDivElement>, 'title'> & {
}: Omit<ComponentProps<'div'>, 'title'> & {
title: ReactNode;
}) {
return (
@@ -14,7 +14,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/ui/components/select';
import type { RequestSchema } from '@/playground/index';
import { Input, labelVariants } from '@/ui/components/input';
import { getDefaultValue } from '../get-default-values';
import { cn } from 'fumadocs-ui/utils/cn';
@@ -24,7 +23,9 @@ import {
anyFields,
useFieldInfo,
useResolvedSchema,
useSchemaScope,
} from '@/playground/schema';
import type { ParsedSchema } from '@/utils/schema';
function FieldLabel(props: ComponentProps<'label'>) {
return (
@@ -68,7 +69,7 @@ export function ObjectInput({
fieldName,
...props
}: {
field: Exclude<RequestSchema, boolean>;
field: Exclude<ParsedSchema, boolean>;
fieldName: string;
} & ComponentProps<'div'>) {
const field = useResolvedSchema(_field);
@@ -149,7 +150,7 @@ function DynamicProperties({
}: {
fieldName: string;
filterKey?: (key: string) => boolean;
getType: (key: string) => RequestSchema;
getType: (key: string) => ParsedSchema;
}) {
const { control, setValue, getValues } = useFormContext();
const [nextName, setNextName] = useState('');
@@ -239,7 +240,7 @@ export function FieldInput({
isRequired,
...props
}: HTMLAttributes<HTMLElement> & {
field: Exclude<RequestSchema, boolean>;
field: Exclude<ParsedSchema, boolean>;
isRequired?: boolean;
fieldName: string;
}) {
@@ -364,7 +365,7 @@ export function FieldSet({
}: HTMLAttributes<HTMLElement> & {
isRequired?: boolean;
name?: ReactNode;
field: RequestSchema;
field: ParsedSchema;
fieldName: string;
depth?: number;
@@ -372,11 +373,14 @@ export function FieldSet({
toolbar?: ReactNode;
collapsible?: boolean;
}) {
const { readOnly, writeOnly } = useSchemaScope();
const field = useResolvedSchema(_field);
const [show, setShow] = useState(!collapsible);
const { info, updateInfo } = useFieldInfo(fieldName, field, depth);
if (_field === false) return null;
if (_field === false) return;
if (field.readOnly && !readOnly) return;
if (field.writeOnly && !writeOnly) return;
if (info.unionField) {
const union = field[info.unionField]!;
@@ -559,7 +563,7 @@ function ArrayInput({
...props
}: {
fieldName: string;
items: RequestSchema;
items: ParsedSchema;
} & ComponentProps<'div'>) {
const name = fieldName.split('.').at(-1) ?? '';
const { fields, append, remove } = useFieldArray({
@@ -1,6 +1,6 @@
import type { RequestSchema } from '@/playground/index';
import type { ParsedSchema } from '@/utils/schema';
export function getDefaultValue(schema: RequestSchema): unknown {
export function getDefaultValue(schema: ParsedSchema): unknown {
if (typeof schema === 'boolean') return null;
const type = schema.type;
+4 -4
View File
@@ -17,10 +17,8 @@ export type ParameterField = NoReference<ParameterObject> & {
in: 'cookie' | 'header' | 'query' | 'path';
};
export type RequestSchema = ParsedSchema;
interface Context {
references: Record<string, RequestSchema>;
references: Record<string, ParsedSchema>;
registered: WeakMap<Exclude<ParsedSchema, boolean>, string>;
nextId: () => string;
}
@@ -70,6 +68,8 @@ export async function APIPlayground({ path, method, ctx }: APIPlaygroundProps) {
: undefined,
references: context.references,
proxyUrl: ctx.proxyUrl,
writeOnly: true,
readOnly: false,
};
return <ClientLazy {...props} />;
@@ -79,7 +79,7 @@ function writeReferences(
schema: ParsedSchema,
ctx: Context,
stack: WeakMap<object, object> = new WeakMap(),
): RequestSchema {
): ParsedSchema {
if (typeof schema !== 'object' || !schema) return schema;
if (stack.has(schema)) {
const out = stack.get(schema)!;
+33 -16
View File
@@ -1,17 +1,28 @@
import { Ajv2020 } from 'ajv/dist/2020';
import type { RequestSchema } from '@/playground/index';
import { createContext, ReactNode, useContext, useMemo, useState } from 'react';
import { createContext, ReactNode, use, useMemo, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { getDefaultValue } from '@/playground/get-default-values';
import type { ParsedSchema } from '@/utils/schema';
import { mergeAllOf } from '@/utils/merge-schema';
interface SchemaContextType {
references: Record<string, RequestSchema>;
interface SchemaContextType extends SchemaScope {
references: Record<string, ParsedSchema>;
fieldInfoMap: Map<string, FieldInfo>;
ajv: Ajv2020;
}
export interface SchemaScope {
/**
* show write only fields
*/
writeOnly: boolean;
/**
* show read only fields
*/
readOnly: boolean;
}
type UnionField = 'anyOf' | 'oneOf';
export interface FieldInfo {
@@ -24,7 +35,7 @@ export interface FieldInfo {
unionField?: UnionField;
intersection?: {
merged: Exclude<RequestSchema, boolean>;
merged: Exclude<ParsedSchema, boolean>;
};
}
@@ -33,11 +44,13 @@ export const anyFields = {
type: ['string', 'number', 'boolean', 'array', 'object'],
items: true,
additionalProperties: true,
} satisfies RequestSchema;
} satisfies ParsedSchema;
export function SchemaProvider({
references,
fieldInfoMap,
readOnly,
writeOnly,
children,
}: Omit<SchemaContextType, 'ajv'> & { children: ReactNode }) {
const ajv = useMemo(
@@ -54,8 +67,8 @@ export function SchemaProvider({
return (
<SchemaContext.Provider
value={useMemo(
() => ({ references, fieldInfoMap, ajv }),
[fieldInfoMap, references, ajv],
() => ({ references, fieldInfoMap, ajv, readOnly, writeOnly }),
[references, fieldInfoMap, ajv, readOnly, writeOnly],
)}
>
{children}
@@ -63,6 +76,10 @@ export function SchemaProvider({
);
}
export function useSchemaScope(): SchemaScope {
return use(SchemaContext)!;
}
/**
* A hook to store dynamic info of a field, such as selected schema of `oneOf`.
*
@@ -72,13 +89,13 @@ export function SchemaProvider({
*/
export function useFieldInfo(
fieldName: string,
schema: Exclude<RequestSchema, boolean>,
schema: Exclude<ParsedSchema, boolean>,
depth: number,
): {
info: FieldInfo;
updateInfo: (value: Partial<FieldInfo>) => void;
} {
const { fieldInfoMap, ajv } = useContext(SchemaContext)!;
const { fieldInfoMap, ajv } = use(SchemaContext)!;
const form = useFormContext();
const keyName = `${fieldName}:${depth}`;
const [info, setInfo] = useState<FieldInfo>(() => {
@@ -157,9 +174,9 @@ export function useFieldInfo(
* Resolve `$ref` in the schema, **not recursive**.
*/
export function useResolvedSchema(
schema: RequestSchema,
): Exclude<RequestSchema, boolean> {
const { references } = useContext(SchemaContext)!;
schema: ParsedSchema,
): Exclude<ParsedSchema, boolean> {
const { references } = use(SchemaContext)!;
return useMemo(() => {
if (typeof schema === 'boolean') return anyFields;
@@ -169,13 +186,13 @@ export function useResolvedSchema(
}
export function fallbackAny(
schema: RequestSchema,
): Exclude<RequestSchema, boolean> {
schema: ParsedSchema,
): Exclude<ParsedSchema, boolean> {
return typeof schema === 'boolean' ? anyFields : schema;
}
function getUnion(
schema: Exclude<RequestSchema, boolean>,
schema: Exclude<ParsedSchema, boolean>,
): [readonly ParsedSchema[], UnionField] | undefined {
if (schema.anyOf) {
return [schema.anyOf, 'anyOf'];
+6 -2
View File
@@ -6,7 +6,7 @@ import type { MediaAdapter } from '@/requests/media/adapter';
import type { OpenAPIOptions } from '@/server';
import type { CreateAPIPageOptions } from './ui/api-page';
import type { CodeUsageGenerator } from './ui/operation/usage-tabs';
import type { ReactNode } from 'react';
import type { HTMLAttributes, ReactNode } from 'react';
export type Document = V3_1.Document;
export type OperationObject = V3_1.OperationObject;
@@ -40,7 +40,11 @@ export interface RenderContext
mediaAdapters: Record<string, MediaAdapter>;
renderHeading: (depth: number, text: string) => ReactNode;
renderHeading: (
depth: number,
text: string,
props?: HTMLAttributes<HTMLHeadingElement>,
) => ReactNode;
renderMarkdown: (text: string) => ReactNode;
renderCodeBlock: (lang: string, code: string) => ReactNode;
}
+2 -2
View File
@@ -276,11 +276,11 @@ export function createAPIPage(
...options.mediaAdapters,
},
slugger,
renderHeading(depth, text) {
renderHeading(depth, text, props) {
const id = slugger.slug(text);
return (
<Heading id={id} key={id} as={`h${depth}` as `h1`}>
<Heading id={id} key={id} as={`h${depth}` as `h1`} {...props}>
{text}
</Heading>
);
+5 -11
View File
@@ -91,19 +91,13 @@ export async function Operation({
bodyNode = (
<SelectTabs defaultValue={defaultValue}>
<div className="flex gap-2 items-end justify-between">
{ctx.renderHeading(headingLevel, 'Request Body')}
{ctx.renderHeading(headingLevel, 'Request Body', {
className: 'my-0!',
})}
{contentTypes.length > 1 ? (
<SelectTabTrigger
items={contentTypes.map(([key]) => key)}
className="mb-4"
/>
<SelectTabTrigger items={contentTypes.map(([key]) => key)} />
) : (
<p
className={cn(
'text-sm text-fd-muted-foreground font-medium not-prose',
headingLevel === 2 ? 'mb-7' : 'mb-4',
)}
>
<p className="text-sm text-fd-muted-foreground font-medium not-prose">
{defaultValue}
</p>
)}