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

GET /api/v1/releases?shipped=true carries an ETag and answers a repeat with 304 until something new goes live #9135

Merged opened this

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

Merged

mason/forge:mason/9129-get-api-v1-releases-shipped-true-carries → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/9129-get-api-v1-releases-shipped-true-carries was merged into main as c44b255.

Review

Sign in to review this change.
3 files changed +70 −4
src/contract/openapi/routes/getApiV1Releases.ts +2 −1
@@ -3,7 +3,8 @@ import type { Route } from "../shared.ts";
33 export const route: Route = {
44 method: "get",
55 path: "/api/v1/releases",
6 summary: "The kept releases newest first, for the forge's administrators and its repository's readers; ?shipped=true is what went live and what is on its way, for anyone",
6 summary: "The kept releases newest first, for the forge's administrators and its repository's readers; ?shipped=true is what went live and what is on its way, for anyone; the 200 carries a weak etag of the JSON sent (shipped: cache-control public, max-age=60; kept releases: private, no-cache with vary authorization, cookie), a repeat sending If-None-Match gets 304 with no body, and a 403 or 422 carries no etag",
7 alsoStatus: [304],
78 auth: false,
89 query: { shipped: "boolean" },
910 refusals: {
src/http/api/releases.ts +18 −3
@@ -7,17 +7,32 @@
77 import { releasesAnswer, shippedAnswer } from "../../release/answer.ts";
88 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
99 import type { Handler } from "../context.ts";
10 import { apiError, json } from "../respond.ts";
10 import { holds, weakEtag } from "../etag.ts";
11 import { apiError } from "../respond.ts";
1112 import type { Router } from "../router.ts";
1213
14 /** The JSON sent, tagged by its own bytes; a repeat holding the tag gets 304 and no body. */
15 function tagged(request: Request, data: unknown, headers: Record<string, string>): Response {
16 const text = JSON.stringify(data);
17 const etag = weakEtag(text);
18 const all = { etag, ...headers };
19 if (holds(request, etag)) return new Response(null, { status: 304, headers: all });
20 return new Response(text, { headers: { "content-type": "application/json", ...all } });
21 }
22
1323 export function releasesRoutes(router: Router<Handler>): void {
1424 router.add("GET", "/api/v1/releases", async (ctx) => {
1525 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
1626 const shipped = ctx.url.searchParams.get("shipped");
1727 if (shipped !== null && shipped !== "true")
1828 return apiError("invalid_shipped", 422, "shipped is true (what went live) or absent (the kept releases)");
19 if (shipped === "true") return json(await shippedAnswer(ctx.app, ctx.url.searchParams.get("repo")));
29 if (shipped === "true") {
30 const data = await shippedAnswer(ctx.app, ctx.url.searchParams.get("repo"));
31 return tagged(ctx.request, data, { "cache-control": "public, max-age=60" });
32 }
2033 const answer = releasesAnswer(ctx.app, ctx.auth.user);
21 return answer.ok ? json(answer.value) : apiError(answer.error, 403, answer.message);
34 return answer.ok
35 ? tagged(ctx.request, answer.value, { "cache-control": "private, no-cache", vary: "authorization, cookie" })
36 : apiError(answer.error, 403, answer.message);
2237 });
2338 }
test/api-releases.test.ts +50
@@ -18,6 +18,7 @@ describe("GET /api/v1/releases", () => {
1818 let servers: Servers;
1919 let admin: string;
2020 let bob: string;
21 let repoId: Parameters<typeof recordRelease>[1]["repoId"];
2122 const saved = process.env["FORGE_COMMIT"];
2223 const get = (query: string, token?: string) =>
2324 fetch(`${servers.url}/api/v1/releases${query}`, token ? { headers: { authorization: `Bearer ${token}` } } : {});
@@ -41,6 +42,7 @@ describe("GET /api/v1/releases", () => {
4142 bob = createToken(app.db, b.id, "b", "admin").token;
4243 const made = await createRepo(app, alice, { name: "forge", visibility: "public" });
4344 if (!made.ok) throw new Error(String(made.error));
45 repoId = made.value.id;
4446 recordRelease(app.db, { sha: LIVE, repoId: made.value.id, pullNumber: 2, ok: true, line: "ok" });
4547 }, 30_000);
4648
@@ -88,6 +90,54 @@ describe("GET /api/v1/releases", () => {
8890 }
8991 });
9092
93 test("each 200 carries an etag; a repeat gets 304; a new shipped release changes the tag", async () => {
94 const again = async (query: string, token?: string) => {
95 const first = await get(query, token);
96 expect(first.status).toBe(200);
97 const etag = first.headers.get("etag")!;
98 expect(etag).toMatch(/^W\//);
99 await first.text();
100 const headers: Record<string, string> = { "if-none-match": etag };
101 if (token) headers["authorization"] = `Bearer ${token}`;
102 const second = await fetch(`${servers.url}/api/v1/releases${query}`, { headers });
103 expect(second.status).toBe(304);
104 expect(second.headers.get("etag")).toBe(etag);
105 expect(await second.text()).toBe("");
106 return { etag, first };
107 };
108 const shipped = await again("?shipped=true");
109 expect(shipped.first.headers.get("cache-control")).toBe("public, max-age=60");
110 const kept = await again("", admin);
111 expect(kept.first.headers.get("cache-control")).toBe("private, no-cache");
112 expect(kept.first.headers.get("vary")).toStartWith("authorization, cookie");
113
114 // A reader now refused never gets a 304, even holding the tag.
115 const refused = await fetch(`${servers.url}/api/v1/releases`, {
116 headers: { authorization: `Bearer ${bob}`, "if-none-match": kept.etag },
117 });
118 expect(refused.status).toBe(403);
119 expect(refused.headers.get("etag")).toBeNull();
120
121 recordRelease(app.db, { sha: "b".repeat(40), repoId, pullNumber: 3, ok: true, line: "ok" });
122 // What ?shipped=true reads is the merged pulls: a new one landing changes its tag.
outdated

Only the kept-releases tag is checked after the new release. Also request ?shipped=true with the earlier shipped etag in If-None-Match and expect 200 with a new tag.

123 const author = app.db.query<{ id: string }, []>("SELECT id FROM user LIMIT 1").get()!.id;
124 const branch = app.db.query<{ default_branch: string }, [string]>("SELECT default_branch FROM repo WHERE id = ?1").get(repoId as string)!.default_branch;
125 app.db.query(`INSERT INTO issue (id, repo_id, number, kind, title, author_id, state) VALUES ('ship-new', ?1, 3, 'pull', 'A new landing', ?2, 'closed')`).run(repoId as string, author);
126 app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at, merged_oid) VALUES ('ship-new', ?1, 'b', 'base', '', '2026-10-09T10:00:00.000Z', 'oid-new')`).run(branch);
127 const newShipped = await fetch(`${servers.url}/api/v1/releases?shipped=true`, { headers: { "if-none-match": shipped.etag } });
128 expect(newShipped.status).toBe(200);
129 expect(newShipped.headers.get("etag")).not.toBe(shipped.etag);
130 const after = await get("", admin);
131 expect(after.status).toBe(200);
132 expect(after.headers.get("etag")).not.toBe(kept.etag);
133 });
134
135 test("an invalid shipped carries no etag", async () => {
136 const res = await get("?shipped=maybe");
137 expect(res.status).toBe(422);
138 expect(res.headers.get("etag")).toBeNull();
139 });
140
91141 test("the OpenAPI document lists the route", async () => {
92142 const doc = (await (await fetch(`${servers.url}/api/v1/openapi.json`)).json()) as any;
93143 expect(doc.paths["/api/v1/releases"].get).toBeDefined();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs