diff --git a/apps/mobile/src/api-response.ts b/apps/mobile/src/api-response.ts new file mode 100644 index 00000000..d6e8b017 --- /dev/null +++ b/apps/mobile/src/api-response.ts @@ -0,0 +1,24 @@ +/** + * Read an API response without assuming every route answers with JSON. + * Plain-text 404s, proxy HTML pages and empty bodies must not surface as + * "Unexpected token" parse errors in the UI. + */ +export async function readApiPayload(response: Response, fallback?: string): Promise { + const text = await response.text(); + let payload: unknown; + try { + payload = JSON.parse(text); + } catch { + payload = undefined; + } + if (!response.ok) { + const error = + payload && typeof payload === "object" ? (payload as { error?: unknown }).error : undefined; + throw new Error( + typeof error === "string" ? error : (fallback ?? `Request failed (${response.status})`), + ); + } + if (payload === undefined) + throw new Error(`The server returned an unreadable response (${response.status}).`); + return payload as T; +} diff --git a/apps/mobile/src/api.ts b/apps/mobile/src/api.ts index 3bb72e4e..ab831d2a 100644 --- a/apps/mobile/src/api.ts +++ b/apps/mobile/src/api.ts @@ -1,4 +1,5 @@ import { Platform } from "react-native"; +import { readApiPayload } from "./api-response"; export const API_URL = ( process.env.EXPO_PUBLIC_API_URL || @@ -18,11 +19,7 @@ export class MuseApi { }, body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body), }); - const payload = await response.json(); - if (!response.ok) - throw new Error( - typeof payload.error === "string" ? payload.error : `Request failed (${response.status})`, - ); + const payload = await readApiPayload(response); return payload; } url(path: string) { @@ -38,7 +35,9 @@ export async function createSession( headers: { "Content-Type": "application/json" }, body: JSON.stringify({ accessKey }), }); - const payload = await response.json(); - if (!response.ok) throw new Error(payload.error || "Could not open your workspace."); + const payload = await readApiPayload<{ token: string; mode: "sample" | "live" }>( + response, + "Could not open your workspace.", + ); return payload; } diff --git a/apps/mobile/test/api-response.test.ts b/apps/mobile/test/api-response.test.ts new file mode 100644 index 00000000..cd5ebb24 --- /dev/null +++ b/apps/mobile/test/api-response.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readApiPayload } from "../src/api-response.ts"; + +test("JSON responses pass through and server errors keep their message", async () => { + assert.deepEqual( + await readApiPayload(new Response(JSON.stringify({ mode: "sample" }), { status: 200 })), + { mode: "sample" }, + ); + await assert.rejects( + readApiPayload( + new Response(JSON.stringify({ error: "Access key is incorrect" }), { status: 401 }), + ), + /Access key is incorrect/, + ); +}); + +test("plain-text and HTML failures stay readable instead of a JSON parse error", async () => { + for (const [status, body] of [ + [404, "404 Not Found"], + [502, "Bad Gateway"], + ] as const) { + const error = await readApiPayload(new Response(body, { status })).catch((e: unknown) => e); + assert.ok(error instanceof Error); + assert.equal(error.message, `Request failed (${status})`); + assert.equal(error instanceof SyntaxError, false); + assert.doesNotMatch(error.message, /Unexpected|JSON/i); + } +}); + +test("a caller fallback covers error bodies without a message", async () => { + await assert.rejects( + readApiPayload( + new Response(JSON.stringify({}), { status: 500 }), + "Could not open your workspace.", + ), + /Could not open your workspace\./, + ); + await assert.rejects( + readApiPayload( + new Response("down", { status: 503 }), + "Could not open your workspace.", + ), + /Could not open your workspace\./, + ); +}); + +test("successful responses without a readable body fail clearly", async () => { + await assert.rejects( + readApiPayload(new Response(null, { status: 204 })), + /unreadable response \(204\)/, + ); + await assert.rejects(readApiPayload(new Response("OK", { status: 200 })), /unreadable response/); +});