feat: aggregate service openapi docs
This commit is contained in:
172
src/openapi.ts
Normal file
172
src/openapi.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { FetcherLike } from "./env";
|
||||
|
||||
export interface OpenApiDocument {
|
||||
openapi: string;
|
||||
info: {
|
||||
title: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
};
|
||||
servers?: unknown[];
|
||||
tags?: unknown[];
|
||||
paths: Record<string, unknown>;
|
||||
components?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface ServiceOpenApiDocument {
|
||||
name: string;
|
||||
mountPath: string;
|
||||
document: OpenApiDocument;
|
||||
}
|
||||
|
||||
export interface ServiceOpenApiSource {
|
||||
name: string;
|
||||
mountPath: string;
|
||||
fetcher: FetcherLike;
|
||||
schemaPath: string;
|
||||
}
|
||||
|
||||
const componentRefPrefix = "#/components/";
|
||||
|
||||
export async function fetchServiceOpenApiDocument(
|
||||
source: ServiceOpenApiSource,
|
||||
origin: string,
|
||||
): Promise<ServiceOpenApiDocument> {
|
||||
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 function composeOpenApiDocument(sources: ServiceOpenApiDocument[]): OpenApiDocument {
|
||||
const paths: Record<string, unknown> = {};
|
||||
const components: Record<string, Record<string, unknown>> = {};
|
||||
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, and cfw-scheduler.",
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: "https://cfw-gateway.bowong.cc",
|
||||
description: "Production gateway",
|
||||
},
|
||||
],
|
||||
tags,
|
||||
paths,
|
||||
components,
|
||||
};
|
||||
}
|
||||
|
||||
function createComponentNameMap(source: ServiceOpenApiDocument): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
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<string, unknown>,
|
||||
source: ServiceOpenApiDocument,
|
||||
componentNameMap: Map<string, string>,
|
||||
): 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<string, Record<string, unknown>>,
|
||||
source: ServiceOpenApiDocument,
|
||||
componentNameMap: Map<string, string>,
|
||||
): 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 (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<string, string>): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => rewriteRefs(item, componentNameMap));
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
|
||||
const output: Record<string, unknown> = {};
|
||||
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, string>): 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;
|
||||
}
|
||||
Reference in New Issue
Block a user