OpenAPI: Use schema ID for anti-circular reference

This commit is contained in:
Fuma Nama
2025-08-27 19:56:53 +08:00
parent e776ee5201
commit 3d3790b3c5
6 changed files with 80 additions and 48 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Use schema ID for anti-circular reference
+2 -2
View File
@@ -11,7 +11,7 @@ import {
generateTags,
} from './generate';
import {
processDocument,
processDocumentCached,
type ProcessedDocument,
} from '@/utils/process-document';
import type { OpenAPIServer } from '@/server';
@@ -194,7 +194,7 @@ export async function generateFilesOnly(
await Promise.all(
targets.map(async (item) => {
schemas[item] = await processDocument(path.join(cwd, item));
schemas[item] = await processDocumentCached(path.join(cwd, item));
}),
);
} else {
+2 -2
View File
@@ -5,7 +5,7 @@ import { createMethod } from '@/server/create-method';
import { createRenders } from '@/render/renderer';
import type { OpenAPIV3_1 } from 'openapi-types';
import {
processDocument,
processDocumentCached,
type ProcessedDocument,
} from '@/utils/process-document';
import { defaultAdapters } from '@/media/adapter';
@@ -37,7 +37,7 @@ export async function APIPage(props: ApiPageProps) {
const { operations, hasHead = true, webhooks } = props;
const processed =
typeof props.document === 'string'
? await processDocument(props.document)
? await processDocumentCached(props.document)
: await props.document;
const ctx = await getContext(processed, props);
const { document } = processed;
+50 -20
View File
@@ -43,6 +43,12 @@ export function Schema({
) => ReactNode,
ctx: Context,
) {
if (ctx.stack.has(schema)) return;
const next = {
...ctx,
stack: ctx.stack.next(schema),
};
if (Array.isArray(schema.type)) {
const items = schema.type.flatMap((type) => {
const composed = {
@@ -52,9 +58,10 @@ export function Schema({
if (!isComplexType(composed)) return [];
return composed;
});
if (items.length === 0) return;
if (items.length === 1)
return propertyBody(items[0], renderPrimitive, ctx);
return propertyBody(items[0], renderPrimitive, next);
return (
<Tabs defaultValue={items[0].type}>
@@ -80,9 +87,10 @@ export function Schema({
const oneOf = schema.oneOf.filter((item) =>
isComplexType(item),
) as Exclude<ResolvedSchema, boolean>[];
if (oneOf.length === 0) return;
if (oneOf.length === 0 || oneOf.some((item) => ctx.stack.has(item)))
return;
if (oneOf.length === 1) {
return propertyBody(oneOf[0], renderPrimitive, ctx);
return propertyBody(oneOf[0], renderPrimitive, next);
}
return (
@@ -101,7 +109,7 @@ export function Schema({
{propertyBody(
item,
(child, ctx) => primitiveBody(child, ctx, false, true),
ctx,
next,
)}
</TabsContent>
))}
@@ -111,9 +119,10 @@ export function Schema({
const of = schema.allOf ?? schema.anyOf;
if (of) {
const arr = of.filter((item) => !ctx.stack.has(item));
if (arr.length === 0) return;
const combined = combineSchema(arr);
if (of.length === 0) return;
if (of.some((item) => typeof item === 'object' && ctx.stack.has(item)))
return;
const combined = combineSchema(of as ResolvedSchema[]);
if (typeof combined === 'boolean') return;
return renderPrimitive(combined, ctx);
@@ -224,8 +233,9 @@ export function Schema({
collapsible: boolean,
nested: boolean,
) {
if (ctx.stack.has(schema)) return <p>Recursive</p>;
if (schema.type === 'object') {
if (ctx.stack.has(schema)) return <p>Recursive</p>;
const props = Object.entries(schema.properties ?? {});
const patternProps = Object.entries(schema.patternProperties ?? {});
const next = {
@@ -305,6 +315,7 @@ export function Schema({
return <renderer.Property name={key} type="never" {...props} />;
}
if (ctx.stack.has(schema)) return;
if ((schema.readOnly && !readOnly) || (schema.writeOnly && !writeOnly))
return;
@@ -327,7 +338,7 @@ export function Schema({
}
const context: Context = {
stack: schemaStack(),
stack: schemaStack(renderContext),
};
if (
typeof schema === 'boolean' ||
@@ -343,18 +354,28 @@ export function Schema({
}
interface SchemaStack {
history: ResolvedSchema[];
next(...schema: ResolvedSchema[]): SchemaStack;
add(schema: ResolvedSchema): void;
has(schema: ResolvedSchema): boolean;
has(schema: Exclude<ResolvedSchema, boolean>): boolean;
}
function schemaStack(parent?: SchemaStack): SchemaStack {
const titles = new Set<string>();
const history = new WeakSet();
function schemaStack(
renderContext: RenderContext,
parent?: SchemaStack,
): SchemaStack {
const ids = new Set<string>();
function getId(schema: ResolvedSchema) {
if (typeof schema !== 'object') return;
return schema.title ?? renderContext.schema.dereferenceMap.get(schema);
}
return {
history: parent ? [...parent.history] : [],
next(...schemas) {
const child = schemaStack(this);
const child = schemaStack(renderContext, this);
for (const item of schemas) {
child.add(item);
}
@@ -363,15 +384,24 @@ function schemaStack(parent?: SchemaStack): SchemaStack {
add(schema) {
if (typeof schema !== 'object') return;
if (schema.title) titles.add(schema.title);
history.add(schema);
const id = getId(schema);
if (id) ids.add(id);
this.history.push(schema);
},
has(schema) {
if (typeof schema !== 'object') return false;
if (parent && parent.has(schema)) return true;
if (schema.title && titles.has(schema.title)) return true;
if (this.history.length > 30) {
console.warn(
`[Fumadocs OpenAPI] schema depth exceeded 30, this might be unexpected.`,
);
// stopping at here
return true;
}
return history.has(schema);
if (parent && parent.has(schema)) return true;
const id = getId(schema);
if (id) return ids.has(id);
return this.history.includes(schema);
},
};
}
+7 -11
View File
@@ -13,6 +13,7 @@ import type { MethodInformation } from '@/types';
import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
import {
processDocument,
processDocumentCached,
type ProcessedDocument,
} from '@/utils/process-document';
@@ -80,9 +81,6 @@ export interface OpenAPIOptions extends SharedOpenAPIOptions {
*/
input?: string[] | (() => Promise<SchemaMap>);
/**
* By default, it is disabled on dev mode
*/
disableCache?: boolean;
}
@@ -93,11 +91,7 @@ export interface OpenAPIServer {
}
export function createOpenAPI(options: OpenAPIOptions = {}): OpenAPIServer {
const {
input = [],
disableCache = process.env.NODE_ENV === 'development',
...shared
} = options;
const { input = [], disableCache = false, ...shared } = options;
let schemas: Promise<ProcessedSchemaMap> | undefined;
async function getSchemas() {
@@ -106,13 +100,13 @@ export function createOpenAPI(options: OpenAPIOptions = {}): OpenAPIServer {
if (Array.isArray(input)) {
await Promise.all(
input.map(async (item) => {
out[item] = await processDocument(item, disableCache);
out[item] = await processDocument(item);
}),
);
} else {
await Promise.all(
Object.entries(await input()).map(async ([k, v]) => {
out[k] = await processDocument(v, disableCache);
out[k] = await processDocument(v);
}),
);
}
@@ -123,6 +117,8 @@ export function createOpenAPI(options: OpenAPIOptions = {}): OpenAPIServer {
return {
createProxy,
async getSchemas() {
if (disableCache) return getSchemas();
return (schemas ??= getSchemas());
},
getAPIPageProps({ document, ...props }) {
@@ -132,7 +128,7 @@ export function createOpenAPI(options: OpenAPIOptions = {}): OpenAPIServer {
document:
typeof document === 'string'
? this.getSchemas().then((map) => {
return map[document] ?? processDocument(document, disableCache);
return map[document] ?? processDocumentCached(document);
})
: document,
};
+14 -13
View File
@@ -12,18 +12,25 @@ export type ProcessedDocument = {
const cache = new Map<string, ProcessedDocument>();
export async function processDocumentCached(
input: string | OpenAPIV3_1.Document | OpenAPIV3.Document,
): Promise<ProcessedDocument> {
if (typeof input !== 'string') return processDocument(input);
const cached = cache.get(input);
if (cached) return cached;
const processed = await processDocument(input);
cache.set(input, processed);
return processed;
}
/**
* process & reference input document to a Fumadocs OpenAPI compatible format
*/
export async function processDocument(
input: string | OpenAPIV3_1.Document | OpenAPIV3.Document,
disableCache = false,
): Promise<ProcessedDocument> {
const cached =
!disableCache && typeof input === 'string' ? cache.get(input) : null;
if (cached) return cached;
const dereferenceMap: DereferenceMap = new Map();
let document = await bundle(input as string, {
plugins: [fetchUrls(), readFiles()],
@@ -38,15 +45,9 @@ export async function processDocument(
},
});
const processed: ProcessedDocument = {
return {
document: dereferenced as NoReference<Document>,
dereferenceMap,
downloaded: document as Document,
};
if (!disableCache && typeof input === 'string') {
cache.set(input, processed);
}
return processed;
}