Public: anyone can read this; the crew can change it.
GET /api/v1/repos/:owner/:name/builds/:label/shots carries an ETag and answers a repeat with 304 until a shot is added, removed or reordered #9288
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9276-get-api-v1-repos-owner-name-builds-label → main 2 commits fork
2 files
changed +34
−1
src/http/api/build_shots.ts +8 −1
| @@ -16,6 +16,7 @@ import { readShots, type Shots } from "../../git/shots.ts"; | ||
| 16 | 16 | import { mayFetchLook } from "../../web/pages/looks.ts"; |
| 17 | 17 | import { loadRepo } from "../api.ts"; |
| 18 | 18 | import type { Handler } from "../context.ts"; |
| 19 | import { holds, weakEtag } from "../etag.ts"; | |
| 19 | 20 | import { readBounded } from "../limits.ts"; |
| 20 | 21 | import { apiError, body, json, notFound, requireScope, respondAccess } from "../respond.ts"; |
| 21 | 22 | import type { Router } from "../router.ts"; |
| @@ -48,7 +49,13 @@ export function buildShotRoutes(router: Router<Handler>): void { | ||
| 48 | 49 | const label = ctx.params.label ?? ""; |
| 49 | 50 | const dir = dirOf(repo, ctx.app.reposRoot); |
| 50 | 51 | if ((await readBuildPin(dir, label)) === undefined) return notFound(); |
| 51 | return json(items(repo, label, await readShots(dir, label))); | |
| 52 | // The tag is of the exact bytes sent, so an upload, delete, pick, caption or reorder moves it; | |
| 53 | // private because a private repository's pictures are for its readers only. | |
| 54 | const text = JSON.stringify(items(repo, label, await readShots(dir, label))); | |
| 55 | const etag = weakEtag(text); | |
| 56 | const headers = { etag, "cache-control": "private, no-cache", vary: "authorization, cookie" }; | |
| 57 | if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers }); | |
| 58 | return new Response(text, { headers: { "content-type": "application/json", ...headers } }); | |
| 52 | 59 | }); |
| 53 | 60 | |
| 54 | 61 | // Upload one: multipart, the picture as `shot`, an optional `caption` and the `tree` it was read from as `at`. |
test/build-shots.test.ts +26
| @@ -243,6 +243,32 @@ describe("pin screenshots", () => { | ||
| 243 | 243 | expect((await shots()).tree).toBe(before); |
| 244 | 244 | }); |
| 245 | 245 | |
| 246 | test("the gallery carries an ETag: a repeat is 304 until a shot is added, moved or removed", async () => { | |
| 247 | const first = await api("GET", "/alice/karts/builds/v1/shots", aliceToken); | |
| 248 | const tag = first.headers.get("etag")!; | |
| 249 | expect(tag).toStartWith('W/"'); | |
| 250 | expect(first.headers.get("cache-control")).toBe("private, no-cache"); | |
| 251 | const repeat = await fetch(`${base}/api/v1/repos/alice/karts/builds/v1/shots`, { headers: { authorization: `Bearer ${aliceToken}`, "if-none-match": tag } }); | |
| 252 | expect(repeat.status).toBe(304); | |
| 253 | expect(repeat.headers.get("etag")).toBe(tag); | |
| 254 | expect(repeat.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 255 | expect(await repeat.text()).toBe(""); | |
| 256 | const tagged = async () => (await api("GET", "/alice/karts/builds/v1/shots", aliceToken)).headers.get("etag")!; | |
| 257 | const room = (await shots()).items.length < 12; | |
| 258 | expect(room).toBe(true); | |
| 259 | expect((await api("POST", "/alice/karts/builds/v1/shots", aliceToken, upload(PNG(77), "tag"))).status).toBe(201); | |
| 260 | const added = await tagged(); | |
| 261 | expect(added).not.toBe(tag); | |
| 262 | const stale = await fetch(`${base}/api/v1/repos/alice/karts/builds/v1/shots`, { headers: { authorization: `Bearer ${aliceToken}`, "if-none-match": tag } }); | |
| 263 | expect(stale.status).toBe(200); | |
| 264 | const last = (await shots()).items.at(-1)!.file; | |
| 265 | expect((await api("POST", `/alice/karts/builds/v1/shots/${last}`, aliceToken, JSON.stringify({ move: "up" }), "application/json")).status).toBe(200); | |
| 266 | const moved = await tagged(); | |
| 267 | expect(moved).not.toBe(added); | |
| 268 | expect((await api("DELETE", `/alice/karts/builds/v1/shots/${last}`, aliceToken)).status).toBe(204); | |
| 269 | expect(await tagged()).not.toBe(moved); | |
| 270 | }); | |
| 271 | ||
| 246 | 272 | test("a stranger to a private repository gets 404 for its pictures and its writes", async () => { |
| 247 | 273 | expect((await get("/alice/secret/builds/v1/shots/01.png", "alice")).status).toBe(200); |
| 248 | 274 | expect((await get("/alice/secret/builds/v1/shots/01.png", "bob")).status).toBe(404); |
new above
offline