Public: anyone can read this; the crew can change it.
/contract/openapi.json answers 304 to If-None-Match #6051
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6044-contract-openapi-json-answers-304-to-if → main 4 commits fork 4 threads · 4 unresolved
4 files
changed +69
−7
src/contract/served.ts added +39
| @@ -0,0 +1,39 @@ | ||
| 1 | /** | |
| 2 | * The OpenAPI document as `/contract/openapi.json` serves it: built once per | |
| 3 | * origin and version, tagged by its own bytes, answered 304 to that tag. | |
| 4 | */ | |
| 5 | ||
| 6 | import { holds, weakEtag } from "../http/etag.ts"; | |
| 7 | import type { Handler } from "../http/context.ts"; | |
| 8 | import type { Router } from "../http/router.ts"; | |
| 9 | import { running } from "../kernel/version.ts"; | |
| 10 | import { openapi } from "./openapi.ts"; | |
| 11 | ||
| 12 | const MAX = 8; | |
| 13 | const built = new Map<string, { text: string; etag: string }>(); | |
| 14 | ||
| 15 | /** | |
| 16 | * The document and its tag. The origin comes from the request, so the map | |
| 17 | * is bounded; the oldest entry goes first. The tag hashes the text, so a | |
| 18 | * build without a version stamp still changes it when the contract does. | |
| 19 | */ | |
| 20 | export function openapiText(origin: string, version: string): { text: string; etag: string } { | |
| 21 | const key = `${version}\n${origin}`; | |
| 22 | let hit = built.get(key); | |
| 23 | if (!hit) { | |
| 24 | const text = JSON.stringify(openapi(origin, version)); | |
| 25 | hit = { text, etag: weakEtag(text) }; | |
| 26 | if (built.size >= MAX) built.delete(built.keys().next().value as string); | |
| 27 | built.set(key, hit); | |
| 28 | } | |
| 29 | return hit; | |
| 30 | } | |
| 31 | ||
| 32 | export function openapiRoute(router: Router<Handler>): void { | |
| 33 | router.add("GET", "/contract/openapi.json", (ctx) => { | |
| 34 | const { text, etag } = openapiText(ctx.url.origin, running().version); | |
| 35 | const headers = { "cache-control": "public, max-age=300", etag }; | |
| 36 | if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers }); | |
| 37 | return new Response(text, { headers: { "content-type": "application/json", ...headers } }); | |
| 38 | }); | |
| 39 | } |
src/http/mcp.ts −7
| @@ -21,7 +21,6 @@ import { CARD_KINDS, CARD_MIME, CARD_OF_TOOL, CARD_SHELL, CARD_URI, cardFor } fr | ||
| 21 | 21 | import { getRepo } from "../core/repos.ts"; |
| 22 | 22 | import { scopeAllows } from "../core/tokens.ts"; |
| 23 | 23 | import type { User } from "../core/users.ts"; |
| 24 | import { openapi } from "../contract/openapi.ts"; | |
| 25 | 24 | import type { Ctx, Handler } from "./context.ts"; |
| 26 | 25 | import { running } from "../kernel/version.ts"; |
| 27 | 26 | import { resourceMetadataUrl } from "./oauth.ts"; |
| @@ -331,10 +330,4 @@ export function mcpRoutes(router: Router<Handler>): void { | ||
| 331 | 330 | // for a proxy to cut. |
| 332 | 331 | router.add("GET", "/mcp", () => NOT_HERE("POST")); |
| 333 | 332 | router.add("DELETE", "/mcp", () => NOT_HERE("POST")); |
| 334 | // The public API as OpenAPI, for any client that reads it. | |
| 335 | router.add("GET", "/contract/openapi.json", (ctx) => | |
| 336 | json(openapi(ctx.url.origin, running().version), 200, { | |
| 337 | "cache-control": "public, max-age=300", | |
| 338 | }), | |
| 339 | ); | |
| 340 | 333 | } |
src/http/server.ts +3
| @@ -468,6 +468,7 @@ import { runGate } from "./run_gate.ts"; | ||
| 468 | 468 | import { feedRoutes } from "./feed.ts"; |
| 469 | 469 | import type { Database } from "bun:sqlite"; |
| 470 | 470 | import { mcpRoutes } from "./mcp.ts"; |
| 471 | import { openapiRoute } from "../contract/served.ts"; | |
| 471 | 472 | import { createUsageCounter, type UsageCounter } from "../core/usage_count.ts"; |
| 472 | 473 | import { |
| 473 | 474 | createRouteTiming, |
| @@ -1091,6 +1092,8 @@ export function buildRouter(): Router<Handler> { | ||
| 1091 | 1092 | // reclaim a path under `/api/`. |
| 1092 | 1093 | // The forge as an MCP server: the studio's verbs for an assistant. |
| 1093 | 1094 | mcpRoutes(router); |
| 1095 | // The public API as OpenAPI, for any client that reads it. | |
| 1096 | openapiRoute(router); | |
| 1094 | 1097 | oauthRoutes(router); |
| 1095 | 1098 | |
| 1096 | 1099 | // Issues (web). The literal /new is registered before /:number so it can |
test/openapi.test.ts +27
| @@ -388,6 +388,33 @@ test("the origin applications are described, and a client secret is written, nev | ||
| 388 | 388 | expect(JSON.stringify(add.responses["201"])).not.toContain("clientSecret"); |
| 389 | 389 | }); |
| 390 | 390 | |
| 391 | test("the served document is tagged by version and origin and answers 304 to its tag", async () => { | |
| 392 | const { openapiText } = await import("../src/contract/served.ts"); | |
| 393 | const { weakEtag } = await import("../src/http/etag.ts"); | |
| 394 | const a = openapiText("https://a.example", "1"); | |
| 395 | expect(openapiText("https://a.example", "1")).toBe(a); | |
| 396 | expect(a.etag).toBe(weakEtag(a.text)); | |
| 397 | expect(openapiText("https://a.example", "2").etag).not.toBe(a.etag); | |
| 398 | expect(openapiText("https://b.example", "1").etag).not.toBe(a.etag); | |
| 399 | ||
| 400 | const dir = await mkdtemp(join(tmpdir(), "forge-openapi-")); | |
| 401 | const app = createApp({ dataDir: dir, port: 0 }); | |
| 402 | const servers = await serve(app); | |
| 403 | try { | |
| 404 | const first = await fetch(`${servers.url}/contract/openapi.json`); | |
| 405 | const etag = first.headers.get("etag")!; | |
| 406 | expect(etag).toBeTruthy(); | |
| 407 | await first.text(); | |
| 408 | const again = await fetch(`${servers.url}/contract/openapi.json`, { headers: { "if-none-match": etag } }); | |
| 409 | expect(again.status).toBe(304); | |
| 410 | expect(await again.text()).toBe(""); | |
| 411 | } finally { | |
| 412 | servers.stop(); | |
| 413 | app.close(); | |
| 414 | await rm(dir, { recursive: true, force: true }); | |
| 415 | } | |
| 416 | }); | |
| 417 | ||
| 391 | 418 | test("people, organizations and agents are described", () => { |
| 392 | 419 | const doc = openapi("https://f.test", "1") as any; |
| 393 | 420 | for (const path of [ |
new above
offline
This edit only deletes from the frozen file. Whoever cuts #5924 should leave
/contract/openapi.jsonout of the per-tool split, because it now lives insrc/contract/served.ts.