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; } }