Files
cfw-gateway/src/openapi.ts

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;
}