mirror of
https://github.com/ComposioHQ/composio.git
synced 2026-09-22 11:46:35 +08:00
16fa3d963a
## Summary - upgrades `fumadocs-openapi` 10 → 11, `fumadocs-mdx` 14 → 15, and `fumadocs-core` / `fumadocs-ui` 16.4 → 16.13 - migrates the Fumadocs OpenAPI API while preserving the custom schema renderer - restores local `$ref` resolution in both the visible API schema UI and generated LLM markdown - reduces API-reference client payloads by slicing the bundled OpenAPI document to each page's reachable operations and components - restores required badges for GET parameters - normalizes the OpenAPI `no_auth` sentinel so explicitly public endpoints render without authentication - moves to `getOpenAPIPageProps()` / `OpenAPIPageProps` and removes obsolete CSS overrides ## Correctness fixes Fumadocs 11 changed the page contract from a server-resolved document id to a client-side bundled document. That exposed several silent regressions: - **Reference resolution:** bundled documents retain local `$ref`s. The LLM renderer now dereferences them, including alias chains and cycles, while the custom schema renderer uses Fumadocs' resolver and retains raw reference identity for stable deduplication. - **Dereference reuse:** repeated LLM-page requests reuse the dereferenced copy for each cached bundled document instead of walking the complete spec per page. - **Client payload size:** each API page now receives only its selected operations and transitively reachable components. The slicer falls back to the complete document for non-component pointers, deep component pointers, missing operations, or dangling references. - **Required badges:** `readOnly` cannot distinguish GET inputs from responses. The renderer now uses the page hook's client name to identify responses. - **Recursive rendering:** schema markdown rendering now caps both structural recursion and nested array type rendering. - **No-auth normalization:** the undeclared `no_auth` sentinel is removed without discarding any real security alternatives that may accompany it. - **Contract drift:** code consuming `getSchema()` now treats `bundled` as required, matching the upstream type. Review follow-up also replaces the new OpenAPI `any` types with typed Fumadocs page props and a narrow recursive schema model. Historical Fumadocs 10/11 migration explanations live here in the PR, not as version-specific source comments; source comments retain only durable invariants. ## Payload impact | | before | after | | --- | --- | --- | | bundled document | 451 KB | 7.7 KB avg / 30 KB worst | | served page HTML | 693 KB | 198 KB | | 10-page sample | 6.55 MB | 2.02 MB (69% smaller) | ## Verification - `bun install --frozen-lockfile` - `bun run test` — 89 pass - `bun run lint:links` — 0 errors - `bun run lint` — 0 errors (77 existing warnings) - `bun run types:check` - `bun run build` - production server + `bun run test:integration` — 74 pass, including v3.1/v3 API pages, redirects, search, and LLM endpoints The production build has one existing Turbopack NFT tracing warning from `next.config.mjs`; it does not fail the build. ## Production vs preview checks A live sample comparison between [production](https://docs.composio.dev) and the [PR preview](https://docs-git-chore-docs-fumadocs-11.preview.composio.dev) found no docs regression: - all 14 representative routes returned 200 with matching titles, headings, canonical production URLs, and key content - redirects for `/`, `/api-reference`, `/tools`, and `/docs/welcome` matched exactly - the sampled pages exposed the same 1,137 internal-link targets; a balanced sample of 29 links resolved successfully on both deployments - sampled v3 and v3.1 OpenAPI pages retained endpoint paths, required fields, response schemas, and legacy indicators - the generated OpenAPI LLM page was byte-for-byte identical - selecting TypeScript in a hydrated browser rendered both inactive-tab examples and synchronized the language tab groups - `llms.txt` retained the same 139 unique lines in a different order - `/docs/quickstart.md` only added an explicit `[#next]` heading anchor The sampled OpenAPI HTML was roughly 35–42% smaller in the preview, consistent with document slicing rather than missing rendered content.
192 lines
6.0 KiB
TypeScript
192 lines
6.0 KiB
TypeScript
// Fumadocs passes its bundled OpenAPI document to a client component. This
|
|
// module trims that payload to the operations or webhooks on one reference page.
|
|
|
|
import type { OpenAPIPageProps } from 'fumadocs-openapi/ui';
|
|
|
|
export interface PageOperation {
|
|
path: string;
|
|
method: string;
|
|
}
|
|
|
|
export interface PageWebhook {
|
|
name: string;
|
|
method: string;
|
|
}
|
|
|
|
const HTTP_METHODS = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']);
|
|
|
|
// Path-item keys that must travel with a kept operation.
|
|
const SHARED_PATH_ITEM_KEYS = ['parameters', 'servers', 'summary', 'description'];
|
|
|
|
interface SliceableDocument {
|
|
paths?: Record<string, object | undefined>;
|
|
webhooks?: Record<string, object | undefined>;
|
|
components?: {
|
|
securitySchemes?: Record<string, unknown>;
|
|
};
|
|
}
|
|
|
|
function decodePointerSegment(segment: string): string {
|
|
return segment.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
}
|
|
|
|
function resolvePointer(document: unknown, ref: string): unknown {
|
|
let node = document;
|
|
|
|
for (const segment of ref.slice(2).split('/')) {
|
|
if (node === null || typeof node !== 'object' || Array.isArray(node)) return undefined;
|
|
node = (node as Record<string, unknown>)[decodePointerSegment(segment)];
|
|
}
|
|
|
|
return node;
|
|
}
|
|
|
|
/**
|
|
* Collects every `#/components/...` pointer reachable from `node`, following
|
|
* references transitively. Returns null if a non-component internal reference
|
|
* is found, signalling the caller to keep the whole document.
|
|
*/
|
|
function collectComponentRefs(
|
|
document: unknown,
|
|
node: unknown,
|
|
found: Set<string>
|
|
): Set<string> | null {
|
|
if (Array.isArray(node)) {
|
|
for (const item of node) {
|
|
if (!collectComponentRefs(document, item, found)) return null;
|
|
}
|
|
return found;
|
|
}
|
|
|
|
if (node === null || typeof node !== 'object') return found;
|
|
|
|
for (const [key, value] of Object.entries(node)) {
|
|
if (key === '$ref' && typeof value === 'string') {
|
|
// External references are resolved before bundling; leave them alone.
|
|
if (!value.startsWith('#/')) continue;
|
|
if (!value.startsWith('#/components/')) return null;
|
|
if (found.has(value)) continue;
|
|
|
|
found.add(value);
|
|
if (!collectComponentRefs(document, resolvePointer(document, value), found)) {
|
|
return null;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!collectComponentRefs(document, value, found)) return null;
|
|
}
|
|
|
|
return found;
|
|
}
|
|
|
|
function pickOperations(
|
|
pathItem: object,
|
|
methods: Set<string>
|
|
): Record<string, unknown> | undefined {
|
|
const kept: Record<string, unknown> = {};
|
|
let matched = false;
|
|
|
|
for (const [key, value] of Object.entries(pathItem)) {
|
|
if (HTTP_METHODS.has(key.toLowerCase())) {
|
|
if (!methods.has(key.toLowerCase())) continue;
|
|
kept[key] = value;
|
|
matched = true;
|
|
continue;
|
|
}
|
|
if (SHARED_PATH_ITEM_KEYS.includes(key)) kept[key] = value;
|
|
}
|
|
|
|
return matched ? kept : undefined;
|
|
}
|
|
|
|
function groupByKey<T extends { method: string }>(
|
|
entries: T[],
|
|
keyOf: (entry: T) => string
|
|
): Map<string, Set<string>> {
|
|
const grouped = new Map<string, Set<string>>();
|
|
for (const entry of entries) {
|
|
const key = keyOf(entry);
|
|
const methods = grouped.get(key) ?? new Set<string>();
|
|
methods.add(entry.method.toLowerCase());
|
|
grouped.set(key, methods);
|
|
}
|
|
return grouped;
|
|
}
|
|
|
|
/**
|
|
* Returns a self-contained document containing the selected operations or
|
|
* webhooks and every component they reference transitively.
|
|
*
|
|
* When the requested operation is missing or a reference cannot be represented
|
|
* safely, the original document is returned instead of a partial slice.
|
|
*/
|
|
export function sliceDocumentForPage<T extends object>(
|
|
bundled: T,
|
|
operations: PageOperation[] = [],
|
|
webhooks: PageWebhook[] = []
|
|
): T {
|
|
if (operations.length === 0 && webhooks.length === 0) return bundled;
|
|
|
|
const document = bundled as T & SliceableDocument;
|
|
const paths: Record<string, unknown> = {};
|
|
for (const [path, methods] of groupByKey(operations, op => op.path)) {
|
|
const pathItem = document.paths?.[path];
|
|
if (!pathItem) return bundled; // Unexpected shape -- do not risk a partial document.
|
|
const kept = pickOperations(pathItem, methods);
|
|
if (!kept) return bundled;
|
|
paths[path] = kept;
|
|
}
|
|
|
|
const keptWebhooks: Record<string, unknown> = {};
|
|
for (const [name, methods] of groupByKey(webhooks, hook => hook.name)) {
|
|
const webhookItem = document.webhooks?.[name];
|
|
if (!webhookItem) return bundled;
|
|
const kept = pickOperations(webhookItem, methods);
|
|
if (!kept) return bundled;
|
|
keptWebhooks[name] = kept;
|
|
}
|
|
|
|
const refs = collectComponentRefs(bundled, { paths, webhooks: keptWebhooks }, new Set());
|
|
if (!refs) return bundled; // Non-component internal reference -- keep everything.
|
|
|
|
const components: Record<string, Record<string, unknown>> = {};
|
|
for (const ref of refs) {
|
|
const [, , group, ...rest] = ref.split('/');
|
|
if (!group || rest.length !== 1) return bundled;
|
|
const name = decodePointerSegment(rest[0]);
|
|
const value = resolvePointer(bundled, ref);
|
|
if (value === undefined) return bundled; // Dangling pointer -- keep everything.
|
|
(components[group] ??= {})[name] = value;
|
|
}
|
|
|
|
// Security requirements name schemes directly rather than via `$ref`, so the
|
|
// reachability walk above never sees them.
|
|
if (document.components?.securitySchemes) {
|
|
components.securitySchemes = document.components.securitySchemes;
|
|
}
|
|
|
|
const sliced: Record<string, unknown> = { ...bundled, paths };
|
|
if (Object.keys(components).length > 0) sliced.components = components;
|
|
else delete sliced.components;
|
|
if (document.webhooks) sliced.webhooks = keptWebhooks;
|
|
|
|
return sliced as T;
|
|
}
|
|
|
|
/**
|
|
* Applies {@link sliceDocumentForPage} to fumadocs' page props, leaving any
|
|
* other props shape (for example the preloaded variant) untouched.
|
|
*/
|
|
export function sliceApiPageProps<T extends OpenAPIPageProps>(props: T): T {
|
|
if (!('payload' in props)) return props;
|
|
|
|
return {
|
|
...props,
|
|
payload: {
|
|
...props.payload,
|
|
bundled: sliceDocumentForPage(props.payload.bundled, props.operations, props.webhooks),
|
|
},
|
|
} as T;
|
|
}
|