From 21f43660c697ced9ef832a79569891b5196c9315 Mon Sep 17 00:00:00 2001 From: imeepos Date: Thu, 11 Jun 2026 19:41:14 -0700 Subject: [PATCH] feat: aggregate service openapi docs --- package.json | 1 + pnpm-lock.yaml | 82 ++++++++++++++ src/env.ts | 3 +- src/index.ts | 89 +++++++++++++++- src/openapi.ts | 172 ++++++++++++++++++++++++++++++ src/route-policy.ts | 29 +++-- tests/gateway-worker.test.ts | 119 ++++++++++++++++++--- tests/openapi-aggregation.test.ts | 109 +++++++++++++++++++ tests/route-policy.test.ts | 38 +++++++ wrangler.jsonc | 11 +- 10 files changed, 622 insertions(+), 31 deletions(-) create mode 100644 src/openapi.ts create mode 100644 tests/openapi-aggregation.test.ts create mode 100644 tests/route-policy.test.ts diff --git a/package.json b/package.json index 0cf2af2..69f2ecc 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "ready": "pnpm typecheck && pnpm test" }, "dependencies": { + "@scalar/hono-api-reference": "^0.11.3", "hono": "^4.8.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a5602e..2857d75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@scalar/hono-api-reference': + specifier: ^0.11.3 + version: 0.11.3(hono@4.12.25) hono: specifier: ^4.8.0 version: 4.12.25 @@ -702,6 +705,32 @@ packages: cpu: [x64] os: [win32] + '@scalar/client-side-rendering@0.2.3': + resolution: {integrity: sha512-AazrKedEZ/JR/ze9UGrWCqpRp9T88QlgBZWVOYC8QAMISz5CGUDZKqpayz2iwGzzZAtKF0VW/Y0sbL7ERTvn6w==} + engines: {node: '>=22'} + + '@scalar/helpers@0.8.2': + resolution: {integrity: sha512-qNbqUjSB3S4Gr4A0oANcm5G1Ip+EqBxICYKhe9YzmnaBpbmW6shxqpiivApTvvuDf+uIhR3uMwWyVQbYcGLsxA==} + engines: {node: '>=22'} + + '@scalar/hono-api-reference@0.11.3': + resolution: {integrity: sha512-dgBIOKeQnlXzL2G27sPbvlA7RHfp+BuEiNEZBRihdIaohhWDYaE4uw2OkPzeaMWHov+QYxsdgWc3IMIEVdnusw==} + engines: {node: '>=22'} + peerDependencies: + hono: ^4.12.5 + + '@scalar/schemas@0.4.3': + resolution: {integrity: sha512-YZ/FcxM5ctZ65m07q5DOOUhuQA5SjZvnoyP6jCCfoiaQ/aVXTDF1vZ1+RMYhKqDwfV/QCW36k0H/38aG/8lNyA==} + engines: {node: '>=22'} + + '@scalar/types@0.13.3': + resolution: {integrity: sha512-+rtVPVC7UPDGaqvBZY8FjXI5cW2xJtJDVsOGuoevRUBkJb3feSiBkGIZxYy0JkJCl9wVUi/PqGEcut6Nb3RnJw==} + engines: {node: '>=22'} + + '@scalar/validation@0.6.0': + resolution: {integrity: sha512-tpmmG+/xRE2Kn9RpflU3AIyZv08v10+E1ZrJCx7z6+/91zHVxy0M73kC1LT4/8PbYNt85ywyC8+n+D99JdMcGA==} + engines: {node: '>=20'} + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -854,6 +883,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.11: + resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==} + engines: {node: ^18 || >=20} + hasBin: true + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -909,6 +943,10 @@ packages: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -934,6 +972,10 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-fest@5.7.0: + resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} + engines: {node: '>=20'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1057,6 +1099,9 @@ packages: youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@cloudflare/kv-asset-handler@0.5.0': {} @@ -1441,6 +1486,33 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.61.1': optional: true + '@scalar/client-side-rendering@0.2.3': + dependencies: + '@scalar/schemas': 0.4.3 + '@scalar/types': 0.13.3 + '@scalar/validation': 0.6.0 + + '@scalar/helpers@0.8.2': {} + + '@scalar/hono-api-reference@0.11.3(hono@4.12.25)': + dependencies: + '@scalar/client-side-rendering': 0.2.3 + hono: 4.12.25 + + '@scalar/schemas@0.4.3': + dependencies: + '@scalar/helpers': 0.8.2 + '@scalar/validation': 0.6.0 + + '@scalar/types@0.13.3': + dependencies: + '@scalar/helpers': 0.8.2 + nanoid: 5.1.11 + type-fest: 5.7.0 + zod: 4.4.3 + + '@scalar/validation@0.6.0': {} + '@sindresorhus/is@7.2.0': {} '@speed-highlight/core@1.2.16': {} @@ -1625,6 +1697,8 @@ snapshots: nanoid@3.3.12: {} + nanoid@5.1.11: {} + path-to-regexp@6.3.0: {} pathe@2.0.3: {} @@ -1719,6 +1793,8 @@ snapshots: supports-color@10.2.2: {} + tagged-tag@1.0.0: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -1737,6 +1813,10 @@ snapshots: tslib@2.8.1: optional: true + type-fest@5.7.0: + dependencies: + tagged-tag: 1.0.0 + typescript@5.9.3: {} undici@7.24.8: {} @@ -1860,3 +1940,5 @@ snapshots: '@speed-highlight/core': 1.2.16 cookie: 1.1.1 youch-core: 0.3.3 + + zod@4.4.3: {} diff --git a/src/env.ts b/src/env.ts index 3ad8121..535ff9d 100644 --- a/src/env.ts +++ b/src/env.ts @@ -4,5 +4,6 @@ export interface FetcherLike { export interface Env { AUTH: FetcherLike; - DEFAULT_WORKER_NAME: string; + OPS: FetcherLike; + SCHEDULER: FetcherLike; } diff --git a/src/index.ts b/src/index.ts index 02bafc7..9410bfe 100644 --- a/src/index.ts +++ b/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; diff --git a/src/openapi.ts b/src/openapi.ts new file mode 100644 index 0000000..a8581b8 --- /dev/null +++ b/src/openapi.ts @@ -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; + components?: Record>; +} + +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 { + 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 = {}; + const components: Record> = {}; + 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 { + const map = new Map(); + 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, + source: ServiceOpenApiDocument, + componentNameMap: Map, +): 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>, + source: ServiceOpenApiDocument, + componentNameMap: Map, +): 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): unknown { + if (Array.isArray(value)) { + return value.map((item) => rewriteRefs(item, componentNameMap)); + } + + if (!value || typeof value !== "object") { + return value; + } + + const output: Record = {}; + 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 { + 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; +} diff --git a/src/route-policy.ts b/src/route-policy.ts index 5326e39..4cb6711 100644 --- a/src/route-policy.ts +++ b/src/route-policy.ts @@ -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" }; } diff --git a/tests/gateway-worker.test.ts b/tests/gateway-worker.test.ts index f51c96c..9bfabba 100644 --- a/tests/gateway-worker.test.ts +++ b/tests/gateway-worker.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import worker from "../src/index"; import type { Env, FetcherLike } from "../src/env"; -function createAuthService(status: number, body: unknown): FetcherLike { +function createJsonService(status: number, body: unknown): FetcherLike { return { fetch: vi.fn(async () => new Response(JSON.stringify(body), { @@ -15,12 +15,18 @@ function createAuthService(status: number, body: unknown): FetcherLike { }; } +function createEnv(overrides: Partial = {}): Env { + return { + AUTH: createJsonService(401, { authenticated: false }), + OPS: createJsonService(404, { service: "ops" }), + SCHEDULER: createJsonService(404, { service: "scheduler" }), + ...overrides, + }; +} + describe("cfw-gateway worker", () => { it("returns health status", async () => { - const env: Env = { - AUTH: createAuthService(401, { authenticated: false }), - DEFAULT_WORKER_NAME: "customer-worker-1", - }; + const env = createEnv(); const response = await worker.fetch(new Request("http://gateway.local/healthz"), env); expect(response.status).toBe(200); @@ -31,11 +37,10 @@ describe("cfw-gateway worker", () => { }); it("proxies Better Auth routes to the auth service", async () => { - const auth = createAuthService(200, { proxied: true }); - const env: Env = { + const auth = createJsonService(200, { proxied: true }); + const env = createEnv({ AUTH: auth, - DEFAULT_WORKER_NAME: "customer-worker-1", - }; + }); const response = await worker.fetch(new Request("http://gateway.local/api/auth/session"), env); expect(response.status).toBe(200); @@ -43,18 +48,98 @@ describe("cfw-gateway worker", () => { expect(auth.fetch).toHaveBeenCalledOnce(); }); - it("routes non-auth requests without consulting auth service", async () => { - const auth = createAuthService(401, { authenticated: false }); - const env: Env = { + it("proxies ops and scheduler routes with gateway prefixes removed", async () => { + const ops = createJsonService(200, { proxied: "ops" }); + const scheduler = createJsonService(200, { proxied: "scheduler" }); + const env = createEnv({ + OPS: ops, + SCHEDULER: scheduler, + }); + + const opsResponse = await worker.fetch( + new Request("http://gateway.local/api/ops/observations?limit=10"), + env, + ); + const schedulerResponse = await worker.fetch( + new Request("http://gateway.local/api/scheduler/jobs"), + env, + ); + + expect(opsResponse.status).toBe(200); + expect(schedulerResponse.status).toBe(200); + const opsRequest = vi.mocked(ops.fetch).mock.calls[0]?.[0]; + const schedulerRequest = vi.mocked(scheduler.fetch).mock.calls[0]?.[0]; + expect(new URL(opsRequest.url).pathname).toBe("/observations"); + expect(new URL(opsRequest.url).search).toBe("?limit=10"); + expect(new URL(schedulerRequest.url).pathname).toBe("/jobs"); + }); + + it("serves Scalar API docs for the aggregated schema", async () => { + const response = await worker.fetch(new Request("http://gateway.local/docs"), createEnv()); + + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain("Scalar API Reference"); + expect(html).toContain('"url": "/openapi.json"'); + }); + + it("serves the aggregated OpenAPI schema", async () => { + const env = createEnv({ + AUTH: createJsonService(200, { + openapi: "3.1.0", + info: { title: "auth", version: "1.0.0" }, + paths: { "/api/auth/sign-in/email": { post: { responses: { "200": { description: "ok" } } } } }, + }), + OPS: createJsonService(200, { + openapi: "3.1.0", + info: { title: "ops", version: "1.0.0" }, + paths: { "/observations": { post: { responses: { "202": { description: "accepted" } } } } }, + }), + SCHEDULER: createJsonService(200, { + openapi: "3.1.0", + info: { title: "scheduler", version: "1.0.0" }, + paths: { "/jobs": { get: { responses: { "200": { description: "ok" } } } } }, + }), + }); + + const response = await worker.fetch(new Request("http://gateway.local/openapi.json"), env); + + expect(response.status).toBe(200); + const schema = await response.json() as { info: { title: string }; paths: Record }; + expect(schema.info.title).toBe("cfw-gateway API"); + expect(schema.paths["/api/auth/sign-in/email"]).toBeTruthy(); + expect(schema.paths["/api/ops/observations"]).toBeTruthy(); + expect(schema.paths["/api/scheduler/jobs"]).toBeTruthy(); + }); + + it("returns a diagnostic error when aggregated OpenAPI fetch fails", async () => { + const env = createEnv({ + AUTH: { + fetch: vi.fn(async () => new Response("auth unavailable", { status: 503 })), + }, + }); + + const response = await worker.fetch(new Request("http://gateway.local/openapi.json"), env); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toEqual({ + ok: false, + error: "openapi_aggregation_failed", + message: "Failed to fetch auth OpenAPI schema: 503", + }); + }); + + it("returns not found for unknown routes without consulting auth service", async () => { + const auth = createJsonService(401, { authenticated: false }); + const env = createEnv({ AUTH: auth, - DEFAULT_WORKER_NAME: "customer-worker-1", - }; + }); const response = await worker.fetch(new Request("http://gateway.local/app"), env); - expect(response.status).toBe(200); + expect(response.status).toBe(404); await expect(response.json()).resolves.toEqual({ - ok: true, - routedTo: "customer-worker-1", + ok: false, + error: "not_found", }); expect(auth.fetch).not.toHaveBeenCalled(); }); diff --git a/tests/openapi-aggregation.test.ts b/tests/openapi-aggregation.test.ts new file mode 100644 index 0000000..aebcfdc --- /dev/null +++ b/tests/openapi-aggregation.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { composeOpenApiDocument } from "../src/openapi"; + +describe("openapi aggregation", () => { + it("prefixes service paths and rewrites component refs", () => { + const document = composeOpenApiDocument([ + { + name: "auth", + mountPath: "/api/auth", + document: { + openapi: "3.1.0", + info: { title: "auth", version: "1.0.0" }, + paths: { + "/api/auth/open-api/generate-schema": { + get: { + responses: { + "200": { + description: "ok", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/AuthSchema" }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + AuthSchema: { + type: "object", + properties: { + message: { type: "string" }, + }, + }, + }, + }, + }, + }, + { + name: "ops", + mountPath: "/api/ops", + document: { + openapi: "3.1.0", + info: { title: "ops", version: "1.0.0" }, + paths: { + "/observations": { + get: { + responses: { + "200": { + description: "ok", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ObservationList" }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + ObservationList: { + type: "object", + properties: { + items: { + type: "array", + items: { $ref: "#/components/schemas/Observation" }, + }, + }, + }, + Observation: { + type: "object", + properties: { + id: { type: "string" }, + }, + }, + }, + }, + }, + }, + ]); + + expect(document.openapi).toBe("3.1.0"); + expect(document.info.title).toBe("cfw-gateway API"); + expect(document.paths["/api/auth/open-api/generate-schema"]).toBeTruthy(); + expect(document.paths["/api/ops/observations"]).toBeTruthy(); + + expect(document.components?.schemas?.["auth_AuthSchema"]).toBeTruthy(); + expect(document.components?.schemas?.["ops_ObservationList"]).toBeTruthy(); + expect(document.components?.schemas?.["ops_Observation"]).toBeTruthy(); + + expect( + (document.paths["/api/auth/open-api/generate-schema"] as any).get.responses["200"].content[ + "application/json" + ].schema.$ref, + ).toBe("#/components/schemas/auth_AuthSchema"); + expect( + (document.paths["/api/ops/observations"] as any).get.responses["200"].content[ + "application/json" + ].schema.$ref, + ).toBe("#/components/schemas/ops_ObservationList"); + expect( + (document.components?.schemas?.["ops_ObservationList"] as any).properties.items.items.$ref, + ).toBe("#/components/schemas/ops_Observation"); + }); +}); diff --git a/tests/route-policy.test.ts b/tests/route-policy.test.ts new file mode 100644 index 0000000..69398b8 --- /dev/null +++ b/tests/route-policy.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { resolveRoute } from "../src/route-policy"; +import type { Env } from "../src/env"; + +const env: Env = { + AUTH: { fetch: async () => new Response("auth") }, + OPS: { fetch: async () => new Response("ops") }, + SCHEDULER: { fetch: async () => new Response("scheduler") }, +}; + +describe("route policy", () => { + it("routes docs and schema to the gateway", () => { + expect(resolveRoute(new Request("http://gateway.local/docs"), env)).toEqual({ + kind: "gateway-docs", + }); + expect(resolveRoute(new Request("http://gateway.local/openapi.json"), env)).toEqual({ + kind: "gateway-openapi", + }); + }); + + it("routes each service prefix to its service binding", () => { + expect(resolveRoute(new Request("http://gateway.local/api/auth/reference"), env)).toEqual({ + kind: "auth", + }); + expect(resolveRoute(new Request("http://gateway.local/api/ops/observations"), env)).toEqual({ + kind: "ops", + }); + expect(resolveRoute(new Request("http://gateway.local/api/scheduler/jobs"), env)).toEqual({ + kind: "scheduler", + }); + }); + + it("routes unknown paths to not found", () => { + expect(resolveRoute(new Request("http://gateway.local/app"), env)).toEqual({ + kind: "not-found", + }); + }); +}); diff --git a/wrangler.jsonc b/wrangler.jsonc index 426f2ba..2fab2c4 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -13,13 +13,18 @@ "custom_domain": true } ], - "vars": { - "DEFAULT_WORKER_NAME": "customer-worker-1" - }, "services": [ { "binding": "AUTH", "service": "cfw-auth" + }, + { + "binding": "OPS", + "service": "cfw-ops" + }, + { + "binding": "SCHEDULER", + "service": "cfw-scheduler" } ] }