OpenAPI: workaround ajv limitations

This commit is contained in:
Fuma Nama
2026-04-03 18:39:00 +08:00
parent 98cfc1dd2a
commit 94a45da98a
12 changed files with 135 additions and 125 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
workaround ajv limitations
+33 -42
View File
@@ -33,12 +33,7 @@ import {
SelectValue,
} from '@/ui/components/select';
import { labelVariants } from '@/ui/components/input';
import {
getPreferredType,
type SecurityEntry,
type NoReference,
type ParsedSchema,
} from '@/utils/schema';
import { getPreferredType, type SecurityEntry, type ParsedSchema } from '@/utils/schema';
import ServerSelect from './components/server-select';
import { useStorageKey } from '@/ui/client/storage-key';
import {
@@ -52,13 +47,12 @@ import {
} from '@fumari/stf';
import { objectGet, objectSet, stringifyFieldKey } from '@fumari/stf/lib/utils';
import { FieldInput, FieldSet, JsonInput, ObjectInput } from './components/inputs';
import type { Document, HttpMethods, OperationObject, ParameterObject } from '@/types';
import type { Document, HttpMethods, ParameterObject } from '@/types';
import { ClientCodeBlock } from '@/ui/components/codeblock';
import { useTranslations } from '@/ui/client/i18n';
import { useOperationContext } from '@/ui/operation/client';
import { OauthDialog, OauthDialogTrigger } from './components/oauth-dialog';
import { dereferenceDocument } from '@/utils/document/dereference';
import type { ProcessedDocument } from '@/utils/document/process';
import { dereferenceSwallow } from '@/utils/schema/dereference';
export interface FormValues extends Record<string, unknown> {
path: Record<string, unknown>;
@@ -72,14 +66,8 @@ export interface PlaygroundClientProps extends ComponentProps<'form'>, SchemaSco
route: string;
method: HttpMethods;
securities: SecurityEntry[][];
/** the OpenAPI document */
doc:
| {
bundled: Document;
}
| {
processed: ProcessedDocument;
};
/** the OpenAPI document (not dereferenced) */
doc: Document;
proxyUrl?: string;
}
@@ -135,19 +123,6 @@ interface RequestBodyInfo {
mediaType: string;
}
function getBodyInfo(operation: NoReference<OperationObject>): RequestBodyInfo | undefined {
const content = operation.requestBody?.content;
if (!content) return;
const mediaType = getPreferredType(content);
if (mediaType) {
return {
mediaType,
schema: content[mediaType].schema ?? true,
};
}
}
export default function PlaygroundClient({
route,
method,
@@ -159,13 +134,29 @@ export default function PlaygroundClient({
...rest
}: PlaygroundClientProps) {
const t = useTranslations();
const schema =
'processed' in doc
? doc.processed
: // oxlint-disable-next-line eslint-plugin-react-hooks/rules-of-hooks -- assume unchanged
useMemo(() => dereferenceDocument(doc.bundled), [doc.bundled]);
const operation: NoReference<OperationObject> = schema.dereferenced.paths![route]![method]!;
const body = getBodyInfo(operation);
const { parameters, body } = useMemo(() => {
const operation = doc.paths![route]![method]!;
const parameters: ParameterObject[] =
operation.parameters?.map((param) => dereferenceSwallow(param, doc)) ?? [];
let body: RequestBodyInfo | undefined;
if (operation.requestBody) {
const content = dereferenceSwallow(operation.requestBody, doc).content;
const mediaType = content ? getPreferredType(content) : undefined;
if (content && mediaType) {
body = {
mediaType,
schema: dereferenceSwallow(content[mediaType], doc).schema ?? true,
};
}
}
return {
body,
parameters,
};
}, [doc, route, method]);
const { example: exampleId, examples, setExampleData } = useOperationContext();
const { server } = useServerContext();
@@ -220,7 +211,7 @@ export default function PlaygroundClient({
const encoded = encodeRequestData(
{ ...mapInputs(input), method, bodyMediaType: body?.mediaType },
mediaAdapters,
operation.parameters,
parameters,
);
return fetcher.fetch(
joinURL(
@@ -255,7 +246,7 @@ export default function PlaygroundClient({
method,
bodyMediaType: body?.mediaType,
};
setExampleData(data, encodeRequestData(data, mediaAdapters, operation.parameters));
setExampleData(data, encodeRequestData(data, mediaAdapters, parameters));
},
timerRef.current ? 400 : 0,
);
@@ -277,7 +268,7 @@ export default function PlaygroundClient({
return (
<StfProvider value={stf}>
<SchemaProvider schema={schema} writeOnly={writeOnly} readOnly={readOnly}>
<SchemaProvider doc={doc} writeOnly={writeOnly} readOnly={readOnly}>
<form
{...rest}
className={cn(
@@ -314,7 +305,7 @@ export default function PlaygroundClient({
))}
</SecurityTabs>
)}
<ParametersForm parameters={operation.parameters} />
<ParametersForm parameters={parameters} />
{body && (
<CollapsiblePanel data-type="body" title={t.body}>
{renderBodyField ? renderBodyField('body', body) : <BodyInput field={body.schema} />}
@@ -441,7 +432,7 @@ function ParameterItem({ type, parameters }: { type: ParamType; parameters: Para
});
}
function ParametersForm({ parameters = [] }: { parameters?: ParameterObject[] }) {
function ParametersForm({ parameters }: { parameters: ParameterObject[] }) {
const { components: { CollapsiblePanel = DefaultCollapsiblePanel } = {} } =
useApiContext().client.playground ?? {};
const t = useTranslations();
+41 -24
View File
@@ -1,15 +1,17 @@
import { Ajv2020 } from 'ajv/dist/2020';
import { createContext, ReactNode, use, useMemo } from 'react';
import type { ParsedSchema, ResolvedSchema } from '@/utils/schema';
import { mergeAllOf } from '@/utils/merge-schema';
import type { ParsedSchema } from '@/utils/schema';
import { mergeAllOf } from '@/utils/schema/merge';
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';
import { DereferencedDocument } from '@/utils/document/dereference';
import { dereferenceSwallow } from '@/utils/schema/dereference';
import type { Document } from '@/types';
interface SchemaContextType extends SchemaScope {
schema: DereferencedDocument;
/** (not dereferenced) */
doc: Document;
ajv: Ajv2020;
}
@@ -45,7 +47,7 @@ export const anyFields = {
} satisfies ParsedSchema;
export function SchemaProvider({
schema,
doc,
readOnly,
writeOnly,
children,
@@ -62,10 +64,7 @@ export function SchemaProvider({
return (
<SchemaContext.Provider
value={useMemo(
() => ({ schema, ajv, readOnly, writeOnly }),
[schema, ajv, readOnly, writeOnly],
)}
value={useMemo(() => ({ doc, ajv, readOnly, writeOnly }), [doc, ajv, readOnly, writeOnly])}
>
{children}
</SchemaContext.Provider>
@@ -92,7 +91,7 @@ export function useFieldInfo(
schema: Exclude<ParsedSchema, boolean>;
updateInfo: (value: Partial<FieldInfo>) => void;
} {
const { ajv } = use(SchemaContext)!;
const { ajv, doc } = use(SchemaContext)!;
const engine = useDataEngine();
const { generateDefault } = useSchemaUtils();
const fieldData = useNamespace({
@@ -106,7 +105,9 @@ export function useFieldInfo(
if (union) {
const [members, field] = union;
out.oneOf = members.findIndex((item) => ajv.validate(item, value));
out.oneOf = members.findIndex(
(item) => typeof item === 'object' && ajv.validate({ ...doc, ...item }, value),
);
if (out.oneOf === -1) out.oneOf = 0;
out.unionField = field;
}
@@ -116,7 +117,7 @@ export function useFieldInfo(
out.selectedType =
types.find((type) => {
return ajv.validate({ ...schema, type }, value);
return ajv.validate({ ...doc, ...schema, type }, value);
}) ?? types[0];
}
@@ -154,35 +155,51 @@ export function useFieldInfo(
}
export function useSchemaUtils() {
const { schema, readOnly } = use(SchemaContext)!;
const { doc, readOnly } = use(SchemaContext)!;
return {
generateDefault(schema: ParsedSchema): unknown {
return sample(schema as never, {
skipNonRequired: true,
skipReadOnly: !readOnly,
quiet: true,
});
return sample(
schema as never,
{
skipNonRequired: true,
skipReadOnly: !readOnly,
quiet: true,
},
doc,
);
},
schemaToString(value: ResolvedSchema, flags?: FormatFlags) {
return schemaToString(value, schema, flags);
schemaToString(value: ParsedSchema, flags?: FormatFlags) {
return schemaToString(
value,
(raw) => ({
raw,
dereferenced: dereferenceSwallow(raw, doc),
}),
flags,
);
},
};
}
/**
* merge `allOf`.
* dereference & merge `allOf`.
*/
export function useResolvedSchema(raw: ParsedSchema): Exclude<ParsedSchema, boolean> {
const { doc } = use(SchemaContext)!;
return useMemo(() => {
let out = raw;
let out = dereferenceSwallow(raw, doc);
if (typeof out === 'object' && out.allOf) {
out = mergeAllOf(out);
out = mergeAllOf(out, {
dereference(schema) {
return dereferenceSwallow(schema, doc);
},
});
}
return typeof out === 'boolean' ? anyFields : out;
}, [raw]);
}, [doc, raw]);
}
function getUnion(
+1 -6
View File
@@ -253,12 +253,7 @@ export function createAPIPage(
route={path}
securities={parseSecurities(method, ctx.schema.dereferenced)}
method={method.method}
doc={{
bundled: pickSchema(
ctx.schema.bundled,
encodeInternalRef(['paths', path, method.method]),
),
}}
doc={pickSchema(ctx.schema.bundled, encodeInternalRef(['paths', path, method.method]))}
proxyUrl={ctx.proxyUrl}
writeOnly
readOnly={false}
+1 -1
View File
@@ -90,7 +90,7 @@ export function createClientAPIPage({
route={path}
securities={parseSecurities(method, ctx.schema.dereferenced)}
method={method.method}
doc={{ processed: ctx.schema }}
doc={ctx.schema.bundled}
proxyUrl={ctx.proxyUrl}
writeOnly
readOnly={false}
+10 -10
View File
@@ -1,8 +1,8 @@
import { useMemo, type ReactNode } from 'react';
import type { ResolvedSchema } from '@/utils/schema';
import type { ParsedSchema } from '@/utils/schema';
import type { RenderContext } from '@/types';
import { FormatFlags, schemaToString } from '@/utils/schema/to-string';
import { mergeAllOf } from '@/utils/merge-schema';
import { mergeAllOf } from '@/utils/schema/merge';
import type { SchemaUIProps } from '@/ui/schema/client';
import { I18nLabel } from '../client/i18n';
@@ -59,7 +59,7 @@ export type SchemaData = FieldBase &
);
export interface SchemaUIOptions {
root: ResolvedSchema;
root: ParsedSchema;
client: Omit<SchemaUIProps, 'generated'>;
/**
@@ -99,7 +99,7 @@ export function Schema({
}
export function generateSchemaUI(
root: ResolvedSchema,
root: ParsedSchema,
readOnly = false,
writeOnly = false,
ctx: RenderContext,
@@ -107,7 +107,7 @@ export function generateSchemaUI(
const refs: Record<string, SchemaData> = {};
const { showExample = false } = ctx.schemaUI ?? {};
function generateInfoTags(schema: Exclude<ResolvedSchema, boolean>) {
function generateInfoTags(schema: Exclude<ParsedSchema, boolean>) {
const fields: InfoTag[] = [];
if (schema.default !== undefined) {
@@ -176,8 +176,8 @@ export function generateSchemaUI(
}
let _counter = 0;
const autoIds = new WeakMap<Exclude<ResolvedSchema, boolean>, string>();
function getSchemaId(schema: ResolvedSchema): string {
const autoIds = new WeakMap<Exclude<ParsedSchema, boolean>, string>();
function getSchemaId(schema: ParsedSchema): string {
if (typeof schema === 'boolean') return String(schema);
const raw = ctx.schema.getRawRef(schema);
if (raw) return raw;
@@ -190,14 +190,14 @@ export function generateSchemaUI(
return generated;
}
function isVisible(schema: ResolvedSchema): boolean {
function isVisible(schema: ParsedSchema): boolean {
if (typeof schema === 'boolean') return true;
if (schema.writeOnly) return writeOnly;
if (schema.readOnly) return readOnly;
return true;
}
function base(schema: ResolvedSchema): FieldBase {
function base(schema: ParsedSchema): FieldBase {
if (typeof schema === 'boolean') {
const name = schema ? 'any' : 'never';
return {
@@ -215,7 +215,7 @@ export function generateSchemaUI(
};
}
function scanRefs(id: string, schema: ResolvedSchema) {
function scanRefs(id: string, schema: ParsedSchema) {
if (id in refs) return;
if (typeof schema === 'boolean') {
refs[id] = {
@@ -1,5 +1,7 @@
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
import { resolveRefSync } from './resolve-ref';
import type { NoReferenceSwallow } from '.';
import { isPlainObject } from '../is-plain-object';
/**
* Resolves all $ref pointers in a schema and returns a new schema without any $ref pointers.
@@ -13,26 +15,18 @@ export function dereferenceSync(
const cloned = structuredClone(schema);
function resolve(current: unknown): JSONSchema {
if (typeof current === 'object' && current !== null) {
// make sure we don't visit the same node twice
if (visitedNodes.has(current)) {
return current;
// make sure we don't visit the same node twice
if (visitedNodes.has(current)) {
return current as never;
}
visitedNodes.add(current);
if (Array.isArray(current)) {
for (let index = 0; index < current.length; index++) {
current[index] = resolve(current[index]);
}
visitedNodes.add(current);
if (Array.isArray(current)) {
// array
for (let index = 0; index < current.length; index++) {
current[index] = resolve(current[index]);
}
return current as JSONSchema;
}
const obj = current as Record<string, unknown>;
// object
if ('$ref' in current && typeof current['$ref'] === 'string') {
} else if (isPlainObject(current)) {
if (typeof current.$ref === 'string') {
const ref = current['$ref'];
delete current['$ref'];
const resolved = resolve(resolveRefSync(ref, cloned) as JSONSchema);
@@ -42,13 +36,13 @@ export function dereferenceSync(
if (typeof resolved === 'boolean') throw new Error('invalid schema');
for (const k in resolved) {
if (!(k in current)) {
obj[k] = resolved[k as never];
current[k] = resolved[k as never];
}
}
}
for (const key in current) {
obj[key] = resolve(obj[key]);
current[key] = resolve(current[key]);
}
}
@@ -57,3 +51,17 @@ export function dereferenceSync(
return resolve(cloned);
}
export function dereferenceSwallow<T>(schema: T, full: unknown): NoReferenceSwallow<T> {
if (isPlainObject(schema)) {
if (typeof schema.$ref !== 'string') return schema as never;
const { $ref, ...rest } = schema;
const resolved = dereferenceSwallow(resolveRefSync($ref, full), full);
if (typeof resolved !== 'object') throw new Error(`invalid schema referenced via "${$ref}"`);
return { ...resolved, ...rest } as never;
}
return schema as never;
}
+1 -6
View File
@@ -25,18 +25,13 @@ export type NoReference<T> = T extends (infer I)[]
}
: T;
type NoReferenceJSONSchema<T> = T extends (infer I)[]
? NoReference<I>[]
: T extends { $ref?: string }
? Omit<T, '$ref'>
: T;
export type NoReferenceSwallow<T> = T extends ReferenceObject ? Exclude<T, ReferenceObject> : T;
export type ParsedSchema =
| (JSONSchema & {
'x-playground-lazy'?: boolean;
})
| boolean;
export type ResolvedSchema = NoReferenceJSONSchema<ParsedSchema>;
export function getPreferredType(body: Record<string, unknown>): string | undefined {
if ('application/json' in body) return 'application/json';
@@ -1,5 +1,5 @@
import { type ParsedSchema } from '@/utils/schema';
import { deepEqual } from './deep-equal';
import { deepEqual } from '../deep-equal';
interface Options {
dereference?: (schema: ParsedSchema) => ParsedSchema | undefined;
+10 -10
View File
@@ -1,4 +1,4 @@
import type { ParsedSchema, ResolvedSchema } from '@/utils/schema';
import type { ParsedSchema } from '@/utils/schema';
import type { DereferencedDocument } from '@/utils/document/dereference';
export enum FormatFlags {
@@ -6,13 +6,14 @@ export enum FormatFlags {
UseAlias = 1 << 0,
}
type Resolver = (schema: ResolvedSchema) => {
dereferenced: ResolvedSchema;
type Resolver = (schema: ParsedSchema) => {
/** swallowly dereferenced schema */
dereferenced: ParsedSchema;
raw?: ParsedSchema;
};
export function schemaToString(
value: ResolvedSchema,
value: ParsedSchema,
_resolver?: DereferencedDocument | Resolver,
flags: FormatFlags = FormatFlags.None,
): string {
@@ -28,7 +29,7 @@ export function schemaToString(
raw: ref ? { $ref: ref } : undefined,
};
};
function union(union: readonly ResolvedSchema[], sep: string, flags: FormatFlags) {
function union(union: readonly ParsedSchema[], sep: string, flags: FormatFlags) {
const members = new Set();
let nullable = false;
@@ -46,9 +47,8 @@ export function schemaToString(
return nullable ? `${result} | null` : result;
}
function run(schema: ResolvedSchema, flags: FormatFlags): string {
const resolved = resolver(schema);
schema = resolved.dereferenced;
function run(input: ParsedSchema, flags: FormatFlags): string {
const { dereferenced: schema, raw } = resolver(input);
if (schema === true) return 'any';
else if (schema === false) return 'never';
@@ -56,8 +56,8 @@ export function schemaToString(
if ((flags & FormatFlags.UseAlias) === FormatFlags.UseAlias) {
if (schema.title) return schema.title;
if (typeof resolved.raw === 'object' && resolved.raw.$ref) {
const ref = resolved.raw.$ref.split('/');
if (typeof raw === 'object' && raw.$ref) {
const ref = raw.$ref.split('/');
if (ref.length > 0) return ref[ref.length - 1];
}
}
+2 -3
View File
@@ -3,14 +3,13 @@ import { expect, test } from 'vitest';
import { renderContextFrom } from './utils';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import type { ResolvedSchema } from '@/utils/schema';
const cwd = fileURLToPath(new URL('./', import.meta.url));
test('double-oneOf in allOf does not crash', async () => {
const ctx = await renderContextFrom(path.join(cwd, './fixtures/double-oneof.yaml'));
const createItem = ctx.schema.dereferenced.components!.schemas!.CreateItem as ResolvedSchema;
const createItem = ctx.schema.dereferenced.components!.schemas!.CreateItem;
const out = generateSchemaUI(createItem, undefined, undefined, ctx);
// Should produce a cross-product of 4 oneOf variants, not crash
@@ -22,7 +21,7 @@ test('test', async () => {
const ctx = await renderContextFrom(path.join(cwd, './fixtures/unkey.json'));
const out = generateSchemaUI(
ctx.schema.dereferenced.components!.schemas!.V1KeysVerifyKeyResponse as ResolvedSchema,
ctx.schema.dereferenced.components!.schemas!.V1KeysVerifyKeyResponse,
undefined,
undefined,
ctx,
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest';
import { mergeAllOf } from '@/utils/merge-schema';
import { mergeAllOf } from '@/utils/schema/merge';
import { pickSchema } from '@/utils/schema/pick';
import { joinURL, resolveRequestData, resolveServerUrl, withBase } from '@/utils/url';
import type { RequestData } from '@/requests/types';