mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
OpenAPI: Use schema ID for anti-circular reference
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-openapi': patch
|
||||
---
|
||||
|
||||
Use schema ID for anti-circular reference
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user