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

GET /api/v1/users/:username/repos carries an ETag and answers a repeat with 304 when none of that person's repositories it lists has changed #9154

Merged opened this

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

Merged

mason/forge:mason/9147-get-api-v1-users-username-repos-carries → main 2 commits fork

  • Merged. mason/9147-get-api-v1-users-username-repos-carries was merged into main as 470bc0b.

Review

Sign in to review this change.
5 files changed +150 −27
src/contract/openapi/routes/getApiV1UsersUsernameRepos.ts +1 −1
@@ -1,4 +1,4 @@
11 import { type Route, limitTo } from "../shared.ts";
22 import { LIST_LIMIT_MAX } from "../../../core/issues.ts";
33
4 export const route: Route = { method: "get", path: "/api/v1/users/{username}/repos", summary: "List the repositories a person owns that the caller may see (private ones only to those with access); answers { items, next_cursor }, paged by `limit` and `cursor`", auth: false, query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" }, refusals: { invalid_limit: "limit is not a whole number from 1 to 200 (422)", invalid_cursor: "cursor is not the next_cursor of a previous page (422)" } };
4 export const route: Route = { method: "get", path: "/api/v1/users/{username}/repos", summary: "List the repositories a person owns that the caller may see (private ones only to those with access); answers { items, next_cursor }, paged by `limit` and `cursor`. Carries a weak `ETag` (owner, viewer, page and a marker over the rows the caller may see); a repeat with a matching `If-None-Match` answers 304 with no body until one of them is added, pushed to, renamed, changes visibility or its open counts move", auth: false, query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" }, refusals: { invalid_limit: "limit is not a whole number from 1 to 200 (422)", invalid_cursor: "cursor is not the next_cursor of a previous page (422)" } };
src/core/repos.ts +7 −23
@@ -809,6 +809,8 @@ export interface ListReposOptions {
809809 withoutArchived?: boolean;
810810 /** Only archived repositories. @default false */
811811 archivedOnly?: boolean;
812 /** Only this owner's repositories (a username, any case). */
813 owner?: string;
812814 }
813815
814816 // Visibility in SQL: public, owned, or owned by an org the viewer is on a
@@ -829,6 +831,10 @@ function visibleClause(
829831 ): { where: string; params: (string | number)[] } {
830832 const params: (string | number)[] = [viewer?.id ?? ""];
831833 let where = viewer ? VISIBLE : PUBLIC_VISIBLE;
834 if (options.owner !== undefined) {
835 params.push(options.owner);
836 where += ` AND user.username = ?${params.length} COLLATE NOCASE`;
837 }
832838 if (options.query !== undefined && options.query.trim() !== "") {
833839 params.push(`%${likeEscape(options.query.trim())}%`);
834840 const p = params.length;
@@ -949,29 +955,7 @@ export function listReposByOwner(
949955 viewer: User | undefined,
950956 options: ListReposOptions = {},
951957 ): Repo[] {
952 const params: (string | number)[] = [viewer?.id ?? "", ownerName];
953 let where = `user.username = ?2 COLLATE NOCASE AND ${
954 viewer ? VISIBLE : PUBLIC_VISIBLE
955 }`;
956 if (options.query !== undefined && options.query.trim() !== "") {
957 params.push(`%${likeEscape(options.query.trim())}%`);
958 const p = params.length;
959 where += ` AND (repo.name LIKE ?${p} ESCAPE '\\' COLLATE NOCASE
960 OR user.username LIKE ?${p} ESCAPE '\\' COLLATE NOCASE)`;
961 }
962 if (options.cursor !== undefined) {
963 params.push(options.cursor);
964 where += ` AND repo.id < ?${params.length}`;
965 }
966 if (options.withoutArchived === true) where += " AND repo.archived_at IS NULL";
967 params.push(pageLimit(options.limit));
968 const rows = db
969 .query<RepoRow, (string | number)[]>(
970 `${SELECT} WHERE ${where} ORDER BY ${newestFirst(options)} LIMIT ?${params.length}`,
971 )
972 .all(...params)
973 .map(toRepo);
974 return withCounts(db, rows);
958 return listReposVisibleTo(db, viewer, { ...options, owner: ownerName });
975959 }
976960
977961 /**
src/http/api.ts +10 −1
@@ -1185,11 +1185,20 @@ export function apiRoutes(router: Router<Handler>): void {
11851185 if (!user || hiddenFrom(user, ctx.auth.user)) return notFound();
11861186 const query = repoQuery(ctx);
11871187 if (query instanceof Response) return query;
1188 const owned = { ...query, owner: user.username };
1189 const marker = timePhase(ctx.request, "tag", () => visibleReposMarker(ctx.app.db, ctx.auth.user, owned));
1190 const viewer = ctx.auth.user === undefined ? "anon" : `u${Bun.hash(ctx.auth.user.id).toString(36)}`;
1191 const headers = {
1192 etag: `W/"user-repos-${Bun.hash(`${user.id}|${viewer}|${query.limit}|${query.cursor ?? ""}|${marker}`).toString(36)}"`,
1193 "cache-control": "private, no-cache",
1194 vary: "authorization, cookie",
1195 };
1196 if (holds(ctx.request, headers.etag)) return new Response(null, { status: 304, headers });
11881197 const items = timePhase(ctx.request, "repos", () =>
11891198 listReposByOwner(ctx.app.db, user.username, ctx.auth.user, query),
11901199 );
11911200 return timePhase(ctx.request, "render", () =>
1192 json(paged(items, query.limit, (r) => r.id)),
1201 json(paged(items, query.limit, (r) => r.id), 200, headers),
11931202 );
11941203 });
11951204
test/api-personrepos-timing.test.ts +2 −2
@@ -53,10 +53,10 @@ async function phases(path: string): Promise<{ status: number; names: string[];
5353 return { status: res.status, names: names.slice(names.indexOf("app") + 1), body };
5454 }
5555
56 test("the person's repositories list names session, repos, render in order", async () => {
56 test("the person's repositories list names session, tag, repos, render in order", async () => {
5757 const got = await phases("/api/v1/users/alice/repos");
5858 expect(got.status).toBe(200);
59 expect(got.names).toEqual(["session", "repos", "render"]);
59 expect(got.names).toEqual(["session", "tag", "repos", "render"]);
6060 expect(Array.isArray(JSON.parse(got.body).items ?? JSON.parse(got.body))).toBe(true);
6161 });
6262
test/user-repos-etag.test.ts added +130
@@ -0,0 +1,130 @@
1 /** GET /api/v1/users/:username/repos is tagged and answers a repeat 304 until one of that owner's listed repositories changes (#9147). */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser, type User } from "../src/core/users.ts";
10 import { updateRepoSettings, type Repo } from "../src/core/repos.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { promote } from "./helpers/members.ts";
13
14 let dataDir: string;
15 let app: App;
16 let servers: Servers;
17 let alice: User;
18 let bob: User;
19 let tokens: Record<string, string>;
20 let open: Repo;
21
22 const get = (who: string | undefined, etag?: string, path = "/api/v1/users/alice/repos") =>
23 fetch(`${servers.url}${path}`, {
24 headers: {
25 ...(who === undefined ? {} : { authorization: `Bearer ${tokens[who]}` }),
26 ...(etag === undefined ? {} : { "if-none-match": etag }),
27 },
28 });
29 const tagOf = async (who: string | undefined, path?: string) => {
30 const res = await get(who, undefined, path);
31 await res.arrayBuffer();
32 expect(res.status).toBe(200);
33 return res.headers.get("etag")!;
34 };
35
36 beforeAll(async () => {
37 dataDir = await mkdtemp(join(tmpdir(), "forge-user-repos-etag-"));
38 app = createApp({ dataDir, port: 0 });
39 servers = await serve(app);
40 const mk = async (username: string, first = false) => {
41 const r = await createUser(app.db, {
42 origin: "seed", trusted: true, username, allowReserved: true,
43 email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first,
44 });
45 if (!r.ok) throw new Error(r.error);
46 promote(app.db, r.value);
47 return r.value;
48 };
49 await mk("root", true);
50 alice = await mk("alice");
51 bob = await mk("bob");
52 tokens = {
53 alice: createToken(app.db, alice.id, "t", "rw").token,
54 bob: createToken(app.db, bob.id, "t", "rw").token,
55 };
56 const o = await createRepo(app, alice, { name: "open", visibility: "public" });
57 const s = await createRepo(app, alice, { name: "secret", visibility: "private" });
58 const b = await createRepo(app, bob, { name: "bobs", visibility: "private" });
59 if (!o.ok || !s.ok || !b.ok) throw new Error("repos");
60 open = o.value;
61 });
62
63 afterAll(async () => {
64 servers.stop();
65 app.close();
66 await rm(dataDir, { recursive: true, force: true });
67 });
68
69 describe("GET /api/v1/users/:username/repos etag", () => {
70 test("carries a weak tag, private no-cache and vary", async () => {
71 const res = await get(undefined);
72 await res.arrayBuffer();
73 expect(res.headers.get("etag")).toMatch(/^W\//);
74 expect(res.headers.get("cache-control")).toBe("private, no-cache");
75 expect(res.headers.get("vary")).toContain("authorization, cookie");
76 });
77
78 test("a repeat is a 304 with no body and no repos phase", async () => {
79 const tag = await tagOf("alice");
80 const res = await get("alice", tag);
81 expect(res.status).toBe(304);
82 expect(await res.text()).toBe("");
83 const timing = res.headers.get("server-timing") ?? "";
84 expect(timing).toContain("tag");
85 expect(timing).not.toContain("repos");
86 });
87
88 test("an unknown user is a 404 with no tag", async () => {
89 const res = await get(undefined, undefined, "/api/v1/users/nobody/repos");
90 expect(res.status).toBe(404);
91 expect(res.headers.get("etag")).toBeNull();
92 });
93
94 test("a new repository makes the next answer a 200 with a new tag", async () => {
95 const tag = await tagOf(undefined);
96 const made = await createRepo(app, alice, { name: "fresh", visibility: "public" });
97 if (!made.ok) throw new Error("repo");
98 const res = await get(undefined, tag);
99 expect(res.status).toBe(200);
100 expect(res.headers.get("etag")).not.toBe(tag);
101 expect(JSON.stringify(await res.json())).toContain("fresh");
102 });
103
104 test("a push changes the tag", async () => {
105 const tag = await tagOf(undefined);
106 app.bus.append({ kind: "push", repoId: open.id, actorId: alice.id, payload: {} });
107 expect(await tagOf(undefined)).not.toBe(tag);
108 });
109
110 test("a repository under another owner does not change it", async () => {
111 const tag = await tagOf(undefined);
112 const made = await createRepo(app, bob, { name: "elsewhere", visibility: "public" });
113 if (!made.ok) throw new Error("repo");
114 expect((await get(undefined, tag)).status).toBe(304);
115 });
116
117 test("the owner and a stranger never share a tag", async () => {
118 const [a, b, anon] = [await tagOf("alice"), await tagOf("bob"), await tagOf(undefined)];
119 expect(new Set([a, b, anon]).size).toBe(3);
120 expect((await get("bob", a)).status).toBe(200);
121 });
122
123 test("turning a listed repository private changes an anonymous viewer's tag", async () => {
124 const tag = await tagOf(undefined);
125 updateRepoSettings(app.db, open, { visibility: "private" });
126 const res = await get(undefined, tag);
127 expect(res.status).toBe(200);
128 expect(res.headers.get("etag")).not.toBe(tag);
129 });
130 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs