feat: aggregate service openapi docs
This commit is contained in:
@@ -4,5 +4,6 @@ export interface FetcherLike {
|
||||
|
||||
export interface Env {
|
||||
AUTH: FetcherLike;
|
||||
DEFAULT_WORKER_NAME: string;
|
||||
OPS: FetcherLike;
|
||||
SCHEDULER: FetcherLike;
|
||||
}
|
||||
|
||||
89
src/index.ts
89
src/index.ts
@@ -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
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;
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user