202 lines
5.5 KiB
TypeScript
202 lines
5.5 KiB
TypeScript
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;
|
|
}
|
|
|
|
export interface UrlOpenApiSource {
|
|
name: string;
|
|
mountPath: string;
|
|
url: string;
|
|
headers?: HeadersInit;
|
|
}
|
|
|
|
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 async function fetchUrlOpenApiDocument(source: UrlOpenApiSource): Promise<ServiceOpenApiDocument> {
|
|
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<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, cfw-scheduler, and cfw-attachment.",
|
|
},
|
|
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 (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<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;
|
|
}
|