feat: aggregate service openapi docs

This commit is contained in:
2026-06-11 19:41:14 -07:00
parent c18350a0f0
commit 21f43660c6
10 changed files with 622 additions and 31 deletions

View File

@@ -4,5 +4,6 @@ export interface FetcherLike {
export interface Env {
AUTH: FetcherLike;
DEFAULT_WORKER_NAME: string;
OPS: FetcherLike;
SCHEDULER: FetcherLike;
}

View File

@@ -1,8 +1,22 @@
import { Scalar } from "@scalar/hono-api-reference";
import { Hono } from "hono";
import type { Env } from "./env";
import { composeOpenApiDocument, fetchServiceOpenApiDocument } from "./openapi";
import { resolveRoute } from "./route-policy";
const app = new Hono<{ Bindings: Env }>();
const gatewayReference = Scalar<{ Bindings: Env }>(() => ({
url: "/openapi.json",
pageTitle: "cfw-gateway API Reference",
theme: "default",
metaData: {
title: "cfw-gateway API",
description: "Aggregated API reference for cfw-auth, cfw-ops, and cfw-scheduler.",
},
}));
app.get("/docs", gatewayReference);
app.get("/reference", gatewayReference);
app.all("*", async (c) => {
const target = resolveRoute(c.req.raw, c.env);
@@ -14,14 +28,81 @@ app.all("*", async (c) => {
});
}
if (target.kind === "gateway-openapi") {
const origin = new URL(c.req.url).origin;
try {
const documents = await Promise.all([
fetchServiceOpenApiDocument(
{
name: "auth",
mountPath: "/api/auth",
fetcher: c.env.AUTH,
schemaPath: "/api/auth/open-api/generate-schema",
},
origin,
),
fetchServiceOpenApiDocument(
{
name: "ops",
mountPath: "/api/ops",
fetcher: c.env.OPS,
schemaPath: "/openapi.json",
},
origin,
),
fetchServiceOpenApiDocument(
{
name: "scheduler",
mountPath: "/api/scheduler",
fetcher: c.env.SCHEDULER,
schemaPath: "/openapi.json",
},
origin,
),
]);
return c.json(composeOpenApiDocument(documents));
} catch (error) {
return c.json(
{
ok: false,
error: "openapi_aggregation_failed",
message: error instanceof Error ? error.message : "Unknown OpenAPI aggregation error",
},
502,
);
}
}
if (target.kind === "gateway-docs") {
return gatewayReference(c, async () => undefined);
}
if (target.kind === "auth") {
return c.env.AUTH.fetch(c.req.raw);
}
return c.json({
ok: true,
routedTo: target.workerName,
});
if (target.kind === "ops") {
return c.env.OPS.fetch(rewritePath(c.req.raw, "/api/ops"));
}
if (target.kind === "scheduler") {
return c.env.SCHEDULER.fetch(rewritePath(c.req.raw, "/api/scheduler"));
}
return c.json(
{
ok: false,
error: "not_found",
},
404,
);
});
function rewritePath(request: Request, prefix: string): Request {
const url = new URL(request.url);
url.pathname = url.pathname.slice(prefix.length) || "/";
return new Request(url, request);
}
export default app;

172
src/openapi.ts Normal file
View 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;
}

View File

@@ -2,8 +2,12 @@ import type { Env } from "./env";
export type RouteTarget =
| { kind: "auth" }
| { kind: "dynamic-worker"; workerName: string }
| { kind: "gateway-health" };
| { kind: "ops" }
| { kind: "scheduler" }
| { kind: "gateway-docs" }
| { kind: "gateway-health" }
| { kind: "gateway-openapi" }
| { kind: "not-found" };
export function resolveRoute(request: Request, env: Env): RouteTarget {
const url = new URL(request.url);
@@ -12,12 +16,25 @@ export function resolveRoute(request: Request, env: Env): RouteTarget {
return { kind: "gateway-health" };
}
if (url.pathname === "/docs" || url.pathname === "/reference") {
return { kind: "gateway-docs" };
}
if (url.pathname === "/openapi.json") {
return { kind: "gateway-openapi" };
}
if (url.pathname.startsWith("/api/auth/")) {
return { kind: "auth" };
}
return {
kind: "dynamic-worker",
workerName: env.DEFAULT_WORKER_NAME,
};
if (url.pathname.startsWith("/api/ops/")) {
return { kind: "ops" };
}
if (url.pathname.startsWith("/api/scheduler/")) {
return { kind: "scheduler" };
}
return { kind: "not-found" };
}