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 diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..fe4ea52 --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,181 @@ +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; + 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.name = "ApiError"; + this.status = status; + this.code = opts?.code; + this.requestID = opts?.requestID; + this.body = opts?.body; + } +} + +export class ApiClient { + 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 { + this.token = token; + } + + clearAuthToken(): void { + 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, + ): Promise { + 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 { + 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> { + 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, + ): Promise> { + 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; + } +} 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"); + }); +});