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/<id> and /files/preview/<id>. 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.
This commit is contained in:
@@ -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 {
|
export interface ApiClientOptions {
|
||||||
apiOrigin?: string;
|
apiOrigin?: string;
|
||||||
@@ -15,43 +18,164 @@ export class ApiError extends Error {
|
|||||||
readonly requestID?: string;
|
readonly requestID?: string;
|
||||||
readonly body?: unknown;
|
readonly body?: unknown;
|
||||||
constructor(
|
constructor(
|
||||||
_message: string,
|
message: string,
|
||||||
_status: number,
|
status: number,
|
||||||
_opts?: { code?: string; requestID?: string; body?: unknown },
|
opts?: { code?: string; requestID?: string; body?: unknown },
|
||||||
) {
|
) {
|
||||||
super(_message);
|
super(message);
|
||||||
this.status = _status;
|
this.name = "ApiError";
|
||||||
this.code = _opts?.code;
|
this.status = status;
|
||||||
this.requestID = _opts?.requestID;
|
this.code = opts?.code;
|
||||||
this.body = _opts?.body;
|
this.requestID = opts?.requestID;
|
||||||
|
this.body = opts?.body;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiClient {
|
export class ApiClient {
|
||||||
constructor(_opts?: ApiClientOptions) {
|
private readonly apiOrigin: string;
|
||||||
throw new Error("ApiClient not implemented");
|
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 {
|
clearAuthToken(): void {
|
||||||
throw new Error("not implemented");
|
this.token = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private headers(extra?: Record<string, string>): Record<string, string> {
|
||||||
|
const h: Record<string, string> = {
|
||||||
|
"X-Client-Package": CLIENT_PACKAGE,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
if (this.token) {
|
||||||
|
h["X-Auth-Token"] = this.token;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async throwIfError(resp: Response): Promise<void> {
|
||||||
|
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<string, unknown>).code === "string"
|
||||||
|
) {
|
||||||
|
code = (body as Record<string, string>).code;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
body &&
|
||||||
|
typeof body === "object" &&
|
||||||
|
"message" in body &&
|
||||||
|
typeof (body as Record<string, unknown>).message ===
|
||||||
|
"string"
|
||||||
|
) {
|
||||||
|
message = (body as Record<string, string>).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<T>(
|
async getJSON<T>(
|
||||||
_path: string,
|
path: string,
|
||||||
_query?: Record<string, string | number | undefined>,
|
query?: Record<string, string | number | undefined>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
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<T>(_path: string, _body: unknown): Promise<T> {
|
|
||||||
throw new Error("not implemented");
|
async postJSON<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
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<ReadableStream<Uint8Array>> {
|
|
||||||
throw new Error("not implemented");
|
async getFileStream(fileID: number): Promise<ReadableStream<Uint8Array>> {
|
||||||
|
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(
|
async getThumbnailStream(
|
||||||
_fileID: number,
|
fileID: number,
|
||||||
): Promise<ReadableStream<Uint8Array>> {
|
): Promise<ReadableStream<Uint8Array>> {
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user