Merge: Phase 4 ApiClient
HTTP client with injectable fetch, production/self-hosted origin routing, X-Auth-Token/X-Client-Package headers, JSON helpers, streaming file/thumbnail downloads, and ApiError mapping. 19 tests, all green.
This commit is contained in:
@@ -619,11 +619,13 @@ Phase 3: SRP + auth
|
|||||||
|
|
||||||
Phase 4: HTTP client + endpoints
|
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
|
- [ ] Typed wrappers for the endpoints listed above
|
||||||
- [ ] Retry policy: no retry on 4xx, exponential backoff on 5xx and network
|
- [ ] Retry policy: no retry on 4xx, exponential backoff on 5xx and network
|
||||||
errors
|
errors
|
||||||
- [ ] `ApiError` that surfaces the server's error code and request id
|
|
||||||
|
|
||||||
Phase 5: collections and files
|
Phase 5: collections and files
|
||||||
|
|
||||||
|
|||||||
181
src/api/client.ts
Normal file
181
src/api/client.ts
Normal file
@@ -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<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>(
|
||||||
|
path: string,
|
||||||
|
query?: Record<string, string | number | undefined>,
|
||||||
|
): Promise<T> {
|
||||||
|
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> {
|
||||||
|
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>> {
|
||||||
|
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<ReadableStream<Uint8Array>> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
385
test/api/client.test.ts
Normal file
385
test/api/client.test.ts
Normal file
@@ -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/<id>` and `/files/preview/<id>` 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<Uint8Array>` 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<string, string> = {},
|
||||||
|
): Response =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json", ...headers },
|
||||||
|
});
|
||||||
|
|
||||||
|
const textResponse = (
|
||||||
|
body: string,
|
||||||
|
status = 200,
|
||||||
|
headers: Record<string, string> = {},
|
||||||
|
): Response =>
|
||||||
|
new Response(body, {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "text/plain", ...headers },
|
||||||
|
});
|
||||||
|
|
||||||
|
const streamResponse = (
|
||||||
|
body: Uint8Array,
|
||||||
|
status = 200,
|
||||||
|
headers: Record<string, string> = {},
|
||||||
|
): 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<Response> => {
|
||||||
|
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/<id>.
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user