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

GET /api/v1/repos/:owner/:name/builds carries an ETag and answers a repeat with 304 when no pin has changed #9132

Merged opened this

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

Merged

mason/forge:mason/9127-get-api-v1-repos-owner-name-builds-carri → main 2 commits fork

  • Merged. mason/9127-get-api-v1-repos-owner-name-builds-carri was merged into main as 4f813d0.

Review

Sign in to review this change.
3 files changed +98 −2
src/contract/openapi/routes/getApiV1ReposOwnerNameBuilds.ts +2 −1
@@ -3,7 +3,8 @@ import { REPO, type Route } from "../shared.ts";
33 export const route: Route = {
44 method: "get",
55 path: `${REPO}/builds`,
6 summary: "List a repository's build pins (refs/builds/<label>), newest first; each item is { label, oid, note, parent, source, pinnedBy, pinnedAt }; answers { items, next_cursor }",
6 summary: "List a repository's build pins (refs/builds/<label>), newest first; each item is { label, oid, note, parent, source, pinnedBy, pinnedAt }; answers { items, next_cursor }; the 200 carries a weak etag of the body and cache-control private, no-cache (it varies by reader), and a repeat sending If-None-Match gets 304 with no body until a pin is added, noted or deleted",
7 alsoStatus: [304],
78 auth: false,
89 reply: { items: "array", next_cursor: "null" },
910 refusals: { not_found: "404: no such repository, or it is not readable" },
src/http/api/builds.ts +9 −1
@@ -8,6 +8,7 @@ import { deletePin, notePin, pinBuild } from "../../actions/builds.ts";
88 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
99 import { listBuildPins } from "../../git/builds.ts";
1010 import { repoDir } from "../../git/repos.ts";
11 import { holds, weakEtag } from "../etag.ts";
1112 import { loadRepo } from "../api.ts";
1213 import type { Handler } from "../context.ts";
1314 import { body, json, notFound, requireScope, respondAccess, str } from "../respond.ts";
@@ -20,7 +21,14 @@ export function buildRoutes(router: Router<Handler>): void {
2021 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
2122 const repo = loadRepo(ctx);
2223 if (!repo) return notFound();
23 return json({ items: await listBuildPins(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name)), next_cursor: null });
24 const items = await listBuildPins(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name));
25 // The tag is of the exact bytes sent, so a new, noted or deleted pin changes it;
26 // private because a private repository's pins are for its readers only.
27 const text = JSON.stringify({ items, next_cursor: null });
28 const etag = weakEtag(text);
29 const headers = { etag, "cache-control": "private, no-cache", vary: "authorization, cookie" };
30 if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers });
31 return new Response(text, { headers: { "content-type": "application/json", ...headers } });
2432 });
2533
2634 router.add("POST", "/api/v1/repos/:owner/:name/builds", async (ctx) => {
test/builds-etag.test.ts added +87
@@ -0,0 +1,87 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { serve, type Servers } from "../src/http/server.ts";
7 import { seedAdmin } from "./helpers/admin.ts";
8
9 describe("builds list ETag", () => {
10 let dataDir: string;
11 let app: App;
12 let servers: Servers;
13 let base: string;
14 let token = "";
15
16 const send = (path: string, method = "GET", bodyObj?: unknown, headers: Record<string, string> = {}, auth = true) =>
17 fetch(`${base}${path}`, {
18 method,
19 headers: { ...(auth ? { authorization: `Bearer ${token}` } : {}), "content-type": "application/json", ...headers },
20 ...(bodyObj === undefined ? {} : { body: JSON.stringify(bodyObj) }),
21 });
22 const LIST = "/api/v1/repos/dev/lib/builds";
23 const commit = async (repo: string, message: string) => {
24 const r = await send(`/api/v1/repos/dev/${repo}/contents`, "POST", { branch: "main", message, files: [{ path: `${message}.txt`, content: "x\n" }] });
25 return ((await r.json()) as { commitOid?: string; oid?: string; sha?: string }) ;
26 };
27
28 beforeAll(async () => {
29 dataDir = await mkdtemp(join(tmpdir(), "forge-builds-etag-"));
30 app = createApp({ dataDir, port: 0 });
31 servers = await serve(app);
32 base = servers.url;
33 await seedAdmin(app, { username: "dev", email: "[email protected]", password: "etag-pass-1" });
34 token = await fetch(`${base}/api/v1/tokens`, {
35 method: "POST",
36 headers: { "content-type": "application/json" },
37 body: JSON.stringify({ adminConfirm: true, username: "dev", password: "etag-pass-1", name: "c", scope: "rw" }),
38 })
39 .then((r) => r.json())
40 .then((j) => (j as { token: string }).token);
41 await send("/api/v1/repos", "POST", { name: "lib", visibility: "public" });
42 await send("/api/v1/repos", "POST", { name: "secret", visibility: "private" });
43 });
44
45 afterAll(async () => {
46 await servers.stop();
47 await rm(dataDir, { recursive: true, force: true });
48 });
49
50 test("200, 304 on a repeat, 200 with a new tag after a pin", async () => {
51 const first = await send(LIST);
52 expect(first.status).toBe(200);
53 const tag = first.headers.get("etag")!;
54 expect(tag).toMatch(/^W\//);
55 expect(first.headers.get("cache-control")).toBe("private, no-cache");
56 expect(first.headers.get("vary")).toContain("authorization, cookie");
57 await first.text();
58
59 const again = await send(LIST, "GET", undefined, { "if-none-match": tag });
60 expect(again.status).toBe(304);
61 expect(again.headers.get("etag")).toBe(tag);
62 expect(await again.text()).toBe("");
63
64 const c = await commit("lib", "one");
65 const sha = c.commitOid ?? c.oid ?? c.sha;
66 expect(typeof sha).toBe("string");
67 const pinned = await send(LIST, "POST", { label: "v1", sha });
68 expect(pinned.status).toBe(201);
69
70 const changed = await send(LIST, "GET", undefined, { "if-none-match": tag });
71 expect(changed.status).toBe(200);
72 const newTag = changed.headers.get("etag")!;
73 expect(newTag).not.toBe(tag);
74 expect(((await changed.json()) as { items: unknown[] }).items.length).toBe(1);
75
76 await send(`${LIST}/v1`, "PATCH", { note: "hello" });
77 const noted = await send(LIST, "GET", undefined, { "if-none-match": newTag });
78 expect(noted.status).toBe(200);
79 expect(noted.headers.get("etag")).not.toBe(newTag);
80 });
81
82 test("a private repository is a 404 to a stranger holding any tag", async () => {
83 const r = await send("/api/v1/repos/dev/secret/builds", "GET", undefined, { "if-none-match": "*" }, false);
84 expect(r.status).toBe(404);
85 expect(r.headers.get("etag")).toBeNull();
86 });
87 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs