Public: anyone can read this; the crew can change it.

/contract/openapi.json answers 304 to If-None-Match #6051

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/6044-contract-openapi-json-answers-304-to-if → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/6044-contract-openapi-json-answers-304-to-if was merged into main as 161193f.

Review

Sign in to review this change.
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
2121 import { getRepo } from "../core/repos.ts";
2222 import { scopeAllows } from "../core/tokens.ts";
2323 import type { User } from "../core/users.ts";
24 import { openapi } from "../contract/openapi.ts";
2524 import type { Ctx, Handler } from "./context.ts";
2625 import { running } from "../kernel/version.ts";
2726 import { resourceMetadataUrl } from "./oauth.ts";
@@ -331,10 +330,4 @@ export function mcpRoutes(router: Router<Handler>): void {
331330 // for a proxy to cut.

This edit only deletes from the frozen file. Whoever cuts #5924 should leave /contract/openapi.json out of the per-tool split, because it now lives in src/contract/served.ts.

332331 router.add("GET", "/mcp", () => NOT_HERE("POST"));
333332 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 );
340333 }
src/http/server.ts +3
@@ -468,6 +468,7 @@ import { runGate } from "./run_gate.ts";
468468 import { feedRoutes } from "./feed.ts";
469469 import type { Database } from "bun:sqlite";
470470 import { mcpRoutes } from "./mcp.ts";
471 import { openapiRoute } from "../contract/served.ts";
471472 import { createUsageCounter, type UsageCounter } from "../core/usage_count.ts";
472473 import {
473474 createRouteTiming,
@@ -1091,6 +1092,8 @@ export function buildRouter(): Router<Handler> {
10911092 // reclaim a path under `/api/`.
10921093 // The forge as an MCP server: the studio's verbs for an assistant.
10931094 mcpRoutes(router);
1095 // The public API as OpenAPI, for any client that reads it.
1096 openapiRoute(router);
10941097 oauthRoutes(router);
10951098
10961099 // 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
388388 expect(JSON.stringify(add.responses["201"])).not.toContain("clientSecret");
389389 });
390390
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
391418 test("people, organizations and agents are described", () => {
392419 const doc = openapi("https://f.test", "1") as any;
393420 for (const path of [

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs