From ef3f10fecc8eb5d011a731550d7a13d670e7e8aa Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 11 May 2026 01:01:34 -0700 Subject: [PATCH 1/3] Phase 4 red: ApiClient tests and stub 19 tests covering ApiClient's full public surface: default and custom origins, X-Client-Package and X-Auth-Token headers, getJSON with query params, postJSON with JSON body, ApiError on 4xx/5xx, streaming file and thumbnail downloads, and self-hosted origin routing. Tests inject a recording fetch via the constructor, so nothing hits the network. The test file is documented to serve as canonical usage reference per the development workflow. --- src/api/client.ts | 57 ++++++ test/api/client.test.ts | 385 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 442 insertions(+) create mode 100644 src/api/client.ts create mode 100644 test/api/client.test.ts diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..b92c166 --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,57 @@ +// Stub: see the README "Development workflow" section for TDD policy. + +export interface ApiClientOptions { + apiOrigin?: string; + filesOrigin?: string; + thumbsOrigin?: string; + authToken?: string; + fetch?: typeof globalThis.fetch; + userAgent?: string; +} + +export class ApiError extends Error { + readonly status: number; + readonly code?: string; + readonly requestID?: string; + readonly body?: unknown; + constructor( + _message: string, + _status: number, + _opts?: { code?: string; requestID?: string; body?: unknown }, + ) { + super(_message); + this.status = _status; + this.code = _opts?.code; + this.requestID = _opts?.requestID; + this.body = _opts?.body; + } +} + +export class ApiClient { + constructor(_opts?: ApiClientOptions) { + throw new Error("ApiClient not implemented"); + } + setAuthToken(_token: string): void { + throw new Error("not implemented"); + } + clearAuthToken(): void { + throw new Error("not implemented"); + } + async getJSON( + _path: string, + _query?: Record, + ): Promise { + throw new Error("not implemented"); + } + async postJSON(_path: string, _body: unknown): Promise { + throw new Error("not implemented"); + } + async getFileStream(_fileID: number): Promise> { + throw new Error("not implemented"); + } + async getThumbnailStream( + _fileID: number, + ): Promise> { + throw new Error("not implemented"); + } +} diff --git a/test/api/client.test.ts b/test/api/client.test.ts new file mode 100644 index 0000000..a56b236 --- /dev/null +++ b/test/api/client.test.ts @@ -0,0 +1,385 @@ +/** + * Tests for `ApiClient`. + * + * `ApiClient` is the HTTP layer that every other module in quack calls to + * reach the Ente server. It handles: + * + * - Base URL resolution. Production uses `https://api.ente.io` for the + * API, `https://files.ente.io` for file downloads, and + * `https://thumbnails.ente.io` for thumbnail downloads. Self-hosted + * deployments override the API origin via the constructor or via the + * `ENTE_API_ENDPOINT` environment variable. When a custom API origin + * is set, file and thumbnail downloads route through that same origin + * at `/files/download/` and `/files/preview/` instead of the + * dedicated CDN hosts. + * + * - Required headers. Every request carries `X-Client-Package` + * (`berlin.sneak.quack`). Authenticated requests also carry + * `X-Auth-Token` with the token recovered by `unwrapAuth`. + * + * - JSON serialization. `getJSON` and `postJSON` handle Accept / + * Content-Type headers and JSON parsing. + * + * - Error mapping. Non-2xx responses become `ApiError` instances that + * expose `.status`, `.code` (from the server's JSON error body, if + * present), `.requestID` (from the `x-request-id` response header), + * and `.body` (the raw parsed body). + * + * - Streaming downloads. `getFileStream` and `getThumbnailStream` + * return a `ReadableStream` from the appropriate CDN + * (or the self-hosted fallback path). + * + * All tests inject a fake `fetch` via the constructor so nothing touches + * the network. The fake records every call for assertion. + */ + +import { describe, expect, it } from "vitest"; +import { ApiClient, ApiError } from "../../src/api/client.js"; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +/** + * A minimal fake Response that satisfies the subset of the Response API + * that ApiClient uses. Keeps tests short. + */ +const jsonResponse = ( + body: unknown, + status = 200, + headers: Record = {}, +): Response => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, + }); + +const textResponse = ( + body: string, + status = 200, + headers: Record = {}, +): Response => + new Response(body, { + status, + headers: { "content-type": "text/plain", ...headers }, + }); + +const streamResponse = ( + body: Uint8Array, + status = 200, + headers: Record = {}, +): Response => new Response(body, { status, headers }); + +/** + * Build a recording fetch. Returns the fake function and a list of + * captured `{ url, init }` pairs. Each call pops the next canned + * response; if the list runs out, the call rejects. + */ +const recordingFetch = ( + ...responses: Response[] +): { + fetch: typeof globalThis.fetch; + calls: { url: string; init: RequestInit | undefined }[]; +} => { + const calls: { url: string; init: RequestInit | undefined }[] = []; + let i = 0; + const fake = async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + calls.push({ url, init }); + if (i >= responses.length) { + throw new Error(`recordingFetch: no response for call #${i}`); + } + return responses[i++]!; + }; + return { fetch: fake as typeof globalThis.fetch, calls }; +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("ApiClient defaults", () => { + it("uses production origins when no overrides are given", async () => { + const { fetch, calls } = recordingFetch(jsonResponse({ ok: true })); + const client = new ApiClient({ fetch }); + await client.getJSON("/health"); + + expect(calls[0]!.url).toBe("https://api.ente.io/health"); + }); + + it("attaches X-Client-Package to every request", async () => { + const { fetch, calls } = recordingFetch(jsonResponse({})); + const client = new ApiClient({ fetch }); + await client.getJSON("/ping"); + + const headers = new Headers(calls[0]!.init?.headers as HeadersInit); + expect(headers.get("X-Client-Package")).toBe("berlin.sneak.quack"); + }); +}); + +describe("ApiClient auth token", () => { + it("does not send X-Auth-Token when no token is set", async () => { + const { fetch, calls } = recordingFetch(jsonResponse({})); + const client = new ApiClient({ fetch }); + await client.getJSON("/public"); + + const headers = new Headers(calls[0]!.init?.headers as HeadersInit); + expect(headers.has("X-Auth-Token")).toBe(false); + }); + + it("sends X-Auth-Token after setAuthToken", async () => { + const { fetch, calls } = recordingFetch(jsonResponse({})); + const client = new ApiClient({ fetch }); + client.setAuthToken("my-token-123"); + await client.getJSON("/private"); + + const headers = new Headers(calls[0]!.init?.headers as HeadersInit); + expect(headers.get("X-Auth-Token")).toBe("my-token-123"); + }); + + it("accepts authToken in constructor options", async () => { + const { fetch, calls } = recordingFetch(jsonResponse({})); + const client = new ApiClient({ fetch, authToken: "ctor-token" }); + await client.getJSON("/private"); + + const headers = new Headers(calls[0]!.init?.headers as HeadersInit); + expect(headers.get("X-Auth-Token")).toBe("ctor-token"); + }); + + it("stops sending X-Auth-Token after clearAuthToken", async () => { + const { fetch, calls } = recordingFetch( + jsonResponse({}), + jsonResponse({}), + ); + const client = new ApiClient({ fetch, authToken: "temp" }); + await client.getJSON("/a"); + client.clearAuthToken(); + await client.getJSON("/b"); + + const h0 = new Headers(calls[0]!.init?.headers as HeadersInit); + const h1 = new Headers(calls[1]!.init?.headers as HeadersInit); + expect(h0.has("X-Auth-Token")).toBe(true); + expect(h1.has("X-Auth-Token")).toBe(false); + }); +}); + +describe("ApiClient.getJSON", () => { + it("sends a GET request and parses JSON", async () => { + const { fetch, calls } = recordingFetch( + jsonResponse({ collections: [1, 2, 3] }), + ); + const client = new ApiClient({ fetch }); + const result = await client.getJSON<{ collections: number[] }>( + "/collections/v2", + ); + + expect(calls[0]!.init?.method).toBe("GET"); + expect(result.collections).toEqual([1, 2, 3]); + }); + + it("appends query parameters to the URL", async () => { + const { fetch, calls } = recordingFetch(jsonResponse({})); + const client = new ApiClient({ fetch }); + await client.getJSON("/users/srp/attributes", { + email: "a@b.c", + sinceTime: 12345, + }); + + const url = new URL(calls[0]!.url); + expect(url.searchParams.get("email")).toBe("a@b.c"); + expect(url.searchParams.get("sinceTime")).toBe("12345"); + }); + + it("omits query parameters whose value is undefined", async () => { + const { fetch, calls } = recordingFetch(jsonResponse({})); + const client = new ApiClient({ fetch }); + await client.getJSON("/x", { keep: "yes", drop: undefined }); + + const url = new URL(calls[0]!.url); + expect(url.searchParams.has("keep")).toBe(true); + expect(url.searchParams.has("drop")).toBe(false); + }); +}); + +describe("ApiClient.postJSON", () => { + it("sends a POST with JSON body and Content-Type", async () => { + const { fetch, calls } = recordingFetch( + jsonResponse({ sessionID: "abc" }), + ); + const client = new ApiClient({ fetch }); + const result = await client.postJSON<{ sessionID: string }>( + "/users/srp/create-session", + { userID: "u1", A: "aaa" }, + ); + + expect(calls[0]!.init?.method).toBe("POST"); + const headers = new Headers(calls[0]!.init?.headers as HeadersInit); + expect(headers.get("Content-Type")).toBe("application/json"); + expect(JSON.parse(calls[0]!.init?.body as string)).toEqual({ + userID: "u1", + A: "aaa", + }); + expect(result.sessionID).toBe("abc"); + }); +}); + +describe("ApiClient custom origins", () => { + it("uses a custom apiOrigin for API calls", async () => { + const { fetch, calls } = recordingFetch(jsonResponse({})); + const client = new ApiClient({ + fetch, + apiOrigin: "https://my-ente.example.com", + }); + await client.getJSON("/health"); + + expect(calls[0]!.url).toBe("https://my-ente.example.com/health"); + }); + + it("routes file downloads through apiOrigin when custom", async () => { + // Self-hosted servers don't have dedicated CDN hosts. File + // downloads use the API origin at /files/download/. + const body = new Uint8Array([0xca, 0xfe]); + const { fetch, calls } = recordingFetch(streamResponse(body)); + const client = new ApiClient({ + fetch, + apiOrigin: "https://my-ente.example.com", + }); + await client.getFileStream(99); + + expect(calls[0]!.url).toBe( + "https://my-ente.example.com/files/download/99", + ); + }); + + it("routes thumbnail downloads through apiOrigin when custom", async () => { + const body = new Uint8Array([0xde, 0xad]); + const { fetch, calls } = recordingFetch(streamResponse(body)); + const client = new ApiClient({ + fetch, + apiOrigin: "https://my-ente.example.com", + }); + await client.getThumbnailStream(77); + + expect(calls[0]!.url).toBe( + "https://my-ente.example.com/files/preview/77", + ); + }); + + it("uses production CDN hosts when apiOrigin is default", async () => { + const body = new Uint8Array([1, 2, 3]); + const { fetch, calls } = recordingFetch( + streamResponse(body), + streamResponse(body), + ); + const client = new ApiClient({ fetch }); + await client.getFileStream(10); + await client.getThumbnailStream(20); + + expect(calls[0]!.url).toBe("https://files.ente.io/?fileID=10"); + expect(calls[1]!.url).toBe("https://thumbnails.ente.io/?fileID=20"); + }); +}); + +describe("ApiError", () => { + it("throws ApiError on 4xx with status, code, requestID", async () => { + const { fetch } = recordingFetch( + jsonResponse({ code: "INVALID_TOKEN", message: "bad token" }, 401, { + "x-request-id": "req-abc", + }), + ); + const client = new ApiClient({ fetch }); + + try { + await client.getJSON("/protected"); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ApiError); + const apiErr = err as ApiError; + expect(apiErr.status).toBe(401); + expect(apiErr.code).toBe("INVALID_TOKEN"); + expect(apiErr.requestID).toBe("req-abc"); + expect(apiErr.body).toEqual({ + code: "INVALID_TOKEN", + message: "bad token", + }); + } + }); + + it("throws ApiError on 5xx", async () => { + const { fetch } = recordingFetch( + textResponse("Internal Server Error", 500, { + "x-request-id": "req-xyz", + }), + ); + const client = new ApiClient({ fetch }); + + try { + await client.postJSON("/boom", {}); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ApiError); + const apiErr = err as ApiError; + expect(apiErr.status).toBe(500); + expect(apiErr.requestID).toBe("req-xyz"); + } + }); + + it("throws ApiError on non-2xx stream downloads", async () => { + const { fetch } = recordingFetch( + textResponse("Not Found", 404, { "x-request-id": "req-404" }), + ); + const client = new ApiClient({ fetch }); + + try { + await client.getFileStream(999); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).status).toBe(404); + } + }); +}); + +describe("ApiClient.getFileStream / getThumbnailStream", () => { + it("returns a ReadableStream on success", async () => { + const payload = new Uint8Array([10, 20, 30, 40]); + const { fetch } = recordingFetch(streamResponse(payload)); + const client = new ApiClient({ fetch }); + + const stream = await client.getFileStream(5); + expect(stream).toBeInstanceOf(ReadableStream); + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + const all = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); + let offset = 0; + for (const c of chunks) { + all.set(c, offset); + offset += c.length; + } + expect(all).toEqual(payload); + }); + + it("attaches X-Auth-Token to CDN downloads", async () => { + const { fetch, calls } = recordingFetch( + streamResponse(new Uint8Array(0)), + ); + const client = new ApiClient({ fetch, authToken: "tk" }); + await client.getFileStream(1); + + const headers = new Headers(calls[0]!.init?.headers as HeadersInit); + expect(headers.get("X-Auth-Token")).toBe("tk"); + }); +}); From 0f70409fd879dd8d25f03147442fc98e8f435025 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 11 May 2026 01:02:03 -0700 Subject: [PATCH 2/3] Phase 4 green: implement ApiClient Production origins: api.ente.io, files.ente.io, thumbnails.ente.io. Custom apiOrigin routes file/thumbnail downloads through the same host at /files/download/ and /files/preview/. Every request carries X-Client-Package (berlin.sneak.quack). Authenticated requests carry X-Auth-Token, toggled via setAuthToken / clearAuthToken or the constructor authToken option. getJSON appends query params (skipping undefined values), parses JSON. postJSON sends JSON with Content-Type header, parses JSON response. getFileStream / getThumbnailStream return the response body stream. Non-2xx responses throw ApiError with status, code (from JSON body), requestID (from x-request-id header), and raw body. Retry logic is deferred to a follow-on branch. All 57 tests pass. --- src/api/client.ts | 170 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 147 insertions(+), 23 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index b92c166..fe4ea52 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,4 +1,7 @@ -// Stub: see the README "Development workflow" section for TDD policy. +const DEFAULT_API_ORIGIN = "https://api.ente.io"; +const DEFAULT_FILES_ORIGIN = "https://files.ente.io"; +const DEFAULT_THUMBS_ORIGIN = "https://thumbnails.ente.io"; +const CLIENT_PACKAGE = "berlin.sneak.quack"; export interface ApiClientOptions { apiOrigin?: string; @@ -15,43 +18,164 @@ export class ApiError extends Error { readonly requestID?: string; readonly body?: unknown; constructor( - _message: string, - _status: number, - _opts?: { code?: string; requestID?: string; body?: unknown }, + message: string, + status: number, + opts?: { code?: string; requestID?: string; body?: unknown }, ) { - super(_message); - this.status = _status; - this.code = _opts?.code; - this.requestID = _opts?.requestID; - this.body = _opts?.body; + super(message); + this.name = "ApiError"; + this.status = status; + this.code = opts?.code; + this.requestID = opts?.requestID; + this.body = opts?.body; } } export class ApiClient { - constructor(_opts?: ApiClientOptions) { - throw new Error("ApiClient not implemented"); + private readonly apiOrigin: string; + private readonly isCustomOrigin: boolean; + private readonly filesOrigin: string; + private readonly thumbsOrigin: string; + private readonly _fetch: typeof globalThis.fetch; + private token: string | undefined; + + constructor(opts?: ApiClientOptions) { + this.apiOrigin = (opts?.apiOrigin ?? DEFAULT_API_ORIGIN).replace( + /\/+$/, + "", + ); + this.isCustomOrigin = this.apiOrigin !== DEFAULT_API_ORIGIN; + this.filesOrigin = (opts?.filesOrigin ?? DEFAULT_FILES_ORIGIN).replace( + /\/+$/, + "", + ); + this.thumbsOrigin = ( + opts?.thumbsOrigin ?? DEFAULT_THUMBS_ORIGIN + ).replace(/\/+$/, ""); + this._fetch = opts?.fetch ?? globalThis.fetch; + this.token = opts?.authToken; } - setAuthToken(_token: string): void { - throw new Error("not implemented"); + + setAuthToken(token: string): void { + this.token = token; } + clearAuthToken(): void { - throw new Error("not implemented"); + this.token = undefined; } + + private headers(extra?: Record): Record { + const h: Record = { + "X-Client-Package": CLIENT_PACKAGE, + ...extra, + }; + if (this.token) { + h["X-Auth-Token"] = this.token; + } + return h; + } + + private async throwIfError(resp: Response): Promise { + if (resp.ok) return; + const requestID = resp.headers.get("x-request-id") ?? undefined; + let body: unknown; + let code: string | undefined; + let message = `HTTP ${resp.status}`; + try { + const ct = resp.headers.get("content-type") ?? ""; + if (ct.includes("application/json")) { + body = await resp.json(); + if ( + body && + typeof body === "object" && + "code" in body && + typeof (body as Record).code === "string" + ) { + code = (body as Record).code; + } + if ( + body && + typeof body === "object" && + "message" in body && + typeof (body as Record).message === + "string" + ) { + message = (body as Record).message!; + } + } else { + body = await resp.text(); + } + } catch { + // body parsing failed; proceed with what we have + } + throw new ApiError(message, resp.status, { code, requestID, body }); + } + async getJSON( - _path: string, - _query?: Record, + path: string, + query?: Record, ): Promise { - throw new Error("not implemented"); + const url = new URL(path, this.apiOrigin + "/"); + // new URL with a base resolves relative paths; ensure we keep the + // origin from apiOrigin even when path starts with / + url.protocol = new URL(this.apiOrigin).protocol; + url.host = new URL(this.apiOrigin).host; + url.pathname = path; + if (query) { + for (const [k, v] of Object.entries(query)) { + if (v !== undefined) { + url.searchParams.set(k, String(v)); + } + } + } + const resp = await this._fetch(url.href, { + method: "GET", + headers: this.headers(), + }); + await this.throwIfError(resp); + return (await resp.json()) as T; } - async postJSON(_path: string, _body: unknown): Promise { - throw new Error("not implemented"); + + async postJSON(path: string, body: unknown): Promise { + const url = `${this.apiOrigin}${path}`; + const resp = await this._fetch(url, { + method: "POST", + headers: this.headers({ "Content-Type": "application/json" }), + body: JSON.stringify(body), + }); + await this.throwIfError(resp); + return (await resp.json()) as T; } - async getFileStream(_fileID: number): Promise> { - throw new Error("not implemented"); + + async getFileStream(fileID: number): Promise> { + const url = this.isCustomOrigin + ? `${this.apiOrigin}/files/download/${fileID}` + : `${this.filesOrigin}/?fileID=${fileID}`; + const resp = await this._fetch(url, { + method: "GET", + headers: this.headers(), + }); + await this.throwIfError(resp); + if (!resp.body) { + throw new Error("response body is null"); + } + return resp.body; } + async getThumbnailStream( - _fileID: number, + fileID: number, ): Promise> { - throw new Error("not implemented"); + const url = this.isCustomOrigin + ? `${this.apiOrigin}/files/preview/${fileID}` + : `${this.thumbsOrigin}/?fileID=${fileID}`; + const resp = await this._fetch(url, { + method: "GET", + headers: this.headers(), + }); + await this.throwIfError(resp); + if (!resp.body) { + throw new Error("response body is null"); + } + return resp.body; } } From 87ff5f3108912e63d2b200dd9215af23b1a515af Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 11 May 2026 01:02:13 -0700 Subject: [PATCH 3/3] Tick off Phase 4 ApiClient TODO --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7b3159a..86f61d3 100644 --- a/README.md +++ b/README.md @@ -619,11 +619,13 @@ Phase 3: SRP + auth Phase 4: HTTP client + endpoints -- [ ] `ApiClient` that attaches `X-Auth-Token` and `X-Client-Package` +- [x] `ApiClient` that attaches `X-Auth-Token` and `X-Client-Package` +- [x] `ApiError` that surfaces the server's error code and request id +- [x] `getJSON` / `postJSON` with query-param and JSON-body handling +- [x] `getFileStream` / `getThumbnailStream` with self-hosted routing - [ ] Typed wrappers for the endpoints listed above - [ ] Retry policy: no retry on 4xx, exponential backoff on 5xx and network errors -- [ ] `ApiError` that surfaces the server's error code and request id Phase 5: collections and files