import type { FetcherLike } from "./env"; export interface OpenApiDocument { openapi: string; info: { title: string; version: string; description?: string; }; servers?: unknown[]; tags?: unknown[]; paths: Record; components?: Record>; } export interface ServiceOpenApiDocument { name: string; mountPath: string; document: OpenApiDocument; } export interface ServiceOpenApiSource { name: string; mountPath: string; fetcher: FetcherLike; schemaPath: string; } export interface UrlOpenApiSource { name: string; mountPath: string; url: string; headers?: HeadersInit; } const componentRefPrefix = "#/components/"; export async function fetchServiceOpenApiDocument( source: ServiceOpenApiSource, origin: string, ): Promise { const url = new URL(source.schemaPath, origin); const response = await source.fetcher.fetch(new Request(url)); if (!response.ok) { throw new Error(`Failed to fetch ${source.name} OpenAPI schema: ${response.status}`); } return { name: source.name, mountPath: source.mountPath, document: (await response.json()) as OpenApiDocument, }; } export async function fetchUrlOpenApiDocument(source: UrlOpenApiSource): Promise { const response = await fetch( new Request(source.url, { headers: source.headers, }), ); if (!response.ok) { throw new Error(`Failed to fetch ${source.name} OpenAPI schema: ${response.status}`); } return { name: source.name, mountPath: source.mountPath, document: (await response.json()) as OpenApiDocument, }; } export function composeOpenApiDocument(sources: ServiceOpenApiDocument[]): OpenApiDocument { const paths: Record = {}; const components: Record> = {}; const tags = sources.map((source) => ({ name: source.name, description: `${source.name} service endpoints`, })); for (const source of sources) { const componentNameMap = createComponentNameMap(source); mergePaths(paths, source, componentNameMap); mergeComponents(components, source, componentNameMap); } return { openapi: "3.1.0", info: { title: "cfw-gateway API", version: "0.1.0", description: "Aggregated API reference for cfw-auth, cfw-ops, cfw-scheduler, and cfw-attachment.", }, servers: [ { url: "https://cfw-gateway.bowong.cc", description: "Production gateway", }, ], tags, paths, components, }; } function createComponentNameMap(source: ServiceOpenApiDocument): Map { const map = new Map(); for (const [section, values] of Object.entries(source.document.components ?? {})) { for (const name of Object.keys(values)) { map.set(`${section}/${name}`, `${source.name}_${name}`); } } return map; } function mergePaths( paths: Record, source: ServiceOpenApiDocument, componentNameMap: Map, ): void { for (const [path, pathItem] of Object.entries(source.document.paths ?? {})) { const gatewayPath = prefixPath(source.mountPath, path); paths[gatewayPath] = rewriteRefs(pathItem, componentNameMap); } } function mergeComponents( components: Record>, source: ServiceOpenApiDocument, componentNameMap: Map, ): void { for (const [section, values] of Object.entries(source.document.components ?? {})) { components[section] ??= {}; for (const [name, value] of Object.entries(values)) { const newName = componentNameMap.get(`${section}/${name}`); if (newName) { components[section][newName] = rewriteRefs(value, componentNameMap); } } } } function prefixPath(mountPath: string, path: string): string { const normalizedMount = normalizePath(mountPath); const normalizedPath = normalizePath(path); if (normalizedMount === "/") { return normalizedPath; } if (normalizedPath === normalizedMount || normalizedPath.startsWith(`${normalizedMount}/`)) { return normalizedPath; } return `${normalizedMount}${normalizedPath === "/" ? "" : normalizedPath}`; } function normalizePath(path: string): string { const withLeadingSlash = path.startsWith("/") ? path : `/${path}`; return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/, "") : withLeadingSlash; } function rewriteRefs(value: unknown, componentNameMap: Map): unknown { if (Array.isArray(value)) { return value.map((item) => rewriteRefs(item, componentNameMap)); } if (!value || typeof value !== "object") { return value; } const output: Record = {}; for (const [key, childValue] of Object.entries(value)) { if (key === "$ref" && typeof childValue === "string") { output[key] = rewriteRef(childValue, componentNameMap); continue; } output[key] = rewriteRefs(childValue, componentNameMap); } return output; } function rewriteRef(ref: string, componentNameMap: Map): string { if (!ref.startsWith(componentRefPrefix)) { return ref; } const componentKey = ref.slice(componentRefPrefix.length); const slashIndex = componentKey.indexOf("/"); if (slashIndex === -1) { return ref; } const section = componentKey.slice(0, slashIndex); const name = componentKey.slice(slashIndex + 1); const newName = componentNameMap.get(`${section}/${name}`); return newName ? `${componentRefPrefix}${section}/${newName}` : ref; }