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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9147-get-api-v1-users-username-repos-carries → main 2 commits fork
5 files
changed +150
−27
src/contract/openapi/routes/getApiV1UsersUsernameRepos.ts +1 −1
| @@ -1,4 +1,4 @@ | ||
| 1 | 1 | import { type Route, limitTo } from "../shared.ts"; |
| 2 | 2 | import { LIST_LIMIT_MAX } from "../../../core/issues.ts"; |
| 3 | 3 | |
| 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 { | ||
| 809 | 809 | withoutArchived?: boolean; |
| 810 | 810 | /** Only archived repositories. @default false */ |
| 811 | 811 | archivedOnly?: boolean; |
| 812 | /** Only this owner's repositories (a username, any case). */ | |
| 813 | owner?: string; | |
| 812 | 814 | } |
| 813 | 815 | |
| 814 | 816 | // Visibility in SQL: public, owned, or owned by an org the viewer is on a |
| @@ -829,6 +831,10 @@ function visibleClause( | ||
| 829 | 831 | ): { where: string; params: (string | number)[] } { |
| 830 | 832 | const params: (string | number)[] = [viewer?.id ?? ""]; |
| 831 | 833 | 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 | } | |
| 832 | 838 | if (options.query !== undefined && options.query.trim() !== "") { |
| 833 | 839 | params.push(`%${likeEscape(options.query.trim())}%`); |
| 834 | 840 | const p = params.length; |
| @@ -949,29 +955,7 @@ export function listReposByOwner( | ||
| 949 | 955 | viewer: User | undefined, |
| 950 | 956 | options: ListReposOptions = {}, |
| 951 | 957 | ): 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 }); | |
| 975 | 959 | } |
| 976 | 960 | |
| 977 | 961 | /** |
src/http/api.ts +10 −1
| @@ -1185,11 +1185,20 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1185 | 1185 | if (!user || hiddenFrom(user, ctx.auth.user)) return notFound(); |
| 1186 | 1186 | const query = repoQuery(ctx); |
| 1187 | 1187 | 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 }); | |
| 1188 | 1197 | const items = timePhase(ctx.request, "repos", () => |
| 1189 | 1198 | listReposByOwner(ctx.app.db, user.username, ctx.auth.user, query), |
| 1190 | 1199 | ); |
| 1191 | 1200 | 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), | |
| 1193 | 1202 | ); |
| 1194 | 1203 | }); |
| 1195 | 1204 |
test/api-personrepos-timing.test.ts +2 −2
| @@ -53,10 +53,10 @@ async function phases(path: string): Promise<{ status: number; names: string[]; | ||
| 53 | 53 | return { status: res.status, names: names.slice(names.indexOf("app") + 1), body }; |
| 54 | 54 | } |
| 55 | 55 | |
| 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 () => { | |
| 57 | 57 | const got = await phases("/api/v1/users/alice/repos"); |
| 58 | 58 | expect(got.status).toBe(200); |
| 59 | expect(got.names).toEqual(["session", "repos", "render"]); | |
| 59 | expect(got.names).toEqual(["session", "tag", "repos", "render"]); | |
| 60 | 60 | expect(Array.isArray(JSON.parse(got.body).items ?? JSON.parse(got.body))).toBe(true); |
| 61 | 61 | }); |
| 62 | 62 |
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 | }); |
new above
offline