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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9129-get-api-v1-releases-shipped-true-carries → main 3 commits fork 1 thread · 1 unresolved
3 files
changed +70
−4
src/contract/openapi/routes/getApiV1Releases.ts +2 −1
| @@ -3,7 +3,8 @@ import type { Route } from "../shared.ts"; | ||
| 3 | 3 | export const route: Route = { |
| 4 | 4 | method: "get", |
| 5 | 5 | 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], | |
| 7 | 8 | auth: false, |
| 8 | 9 | query: { shipped: "boolean" }, |
| 9 | 10 | refusals: { |
src/http/api/releases.ts +18 −3
| @@ -7,17 +7,32 @@ | ||
| 7 | 7 | import { releasesAnswer, shippedAnswer } from "../../release/answer.ts"; |
| 8 | 8 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 9 | 9 | 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"; | |
| 11 | 12 | import type { Router } from "../router.ts"; |
| 12 | 13 | |
| 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 | ||
| 13 | 23 | export function releasesRoutes(router: Router<Handler>): void { |
| 14 | 24 | router.add("GET", "/api/v1/releases", async (ctx) => { |
| 15 | 25 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 16 | 26 | const shipped = ctx.url.searchParams.get("shipped"); |
| 17 | 27 | if (shipped !== null && shipped !== "true") |
| 18 | 28 | 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 | } | |
| 20 | 33 | 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); | |
| 22 | 37 | }); |
| 23 | 38 | } |
test/api-releases.test.ts +50
| @@ -18,6 +18,7 @@ describe("GET /api/v1/releases", () => { | ||
| 18 | 18 | let servers: Servers; |
| 19 | 19 | let admin: string; |
| 20 | 20 | let bob: string; |
| 21 | let repoId: Parameters<typeof recordRelease>[1]["repoId"]; | |
| 21 | 22 | const saved = process.env["FORGE_COMMIT"]; |
| 22 | 23 | const get = (query: string, token?: string) => |
| 23 | 24 | fetch(`${servers.url}/api/v1/releases${query}`, token ? { headers: { authorization: `Bearer ${token}` } } : {}); |
| @@ -41,6 +42,7 @@ describe("GET /api/v1/releases", () => { | ||
| 41 | 42 | bob = createToken(app.db, b.id, "b", "admin").token; |
| 42 | 43 | const made = await createRepo(app, alice, { name: "forge", visibility: "public" }); |
| 43 | 44 | if (!made.ok) throw new Error(String(made.error)); |
| 45 | repoId = made.value.id; | |
| 44 | 46 | recordRelease(app.db, { sha: LIVE, repoId: made.value.id, pullNumber: 2, ok: true, line: "ok" }); |
| 45 | 47 | }, 30_000); |
| 46 | 48 | |
| @@ -88,6 +90,54 @@ describe("GET /api/v1/releases", () => { | ||
| 88 | 90 | } |
| 89 | 91 | }); |
| 90 | 92 | |
| 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. | |
| 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 | ||
| 91 | 141 | test("the OpenAPI document lists the route", async () => { |
| 92 | 142 | const doc = (await (await fetch(`${servers.url}/api/v1/openapi.json`)).json()) as any; |
| 93 | 143 | expect(doc.paths["/api/v1/releases"].get).toBeDefined(); |
new above
offline
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.