From 5558296b77f84bc99aa758be60e7610827fd138b Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 01:51:29 -0400 Subject: [PATCH] Step 8.2: add typed API client Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 7 +++ apps/web/src/api/client.ts | 98 ++++++++++++++++++++++++++++++++ apps/web/test/api-client.test.ts | 72 +++++++++++++++++++++++ eslint.config.js | 7 +++ memory-bank/progress.md | 2 +- 5 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/api/client.ts create mode 100644 apps/web/test/api-client.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 3878268..1e8a6e6 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -368,3 +368,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Screens are listed once in `src/routes.ts`**, shared by the router and the bottom navigation. The Locations tab is labelled "Places" to fit six tabs at 375px. - **Header credits and clock are placeholders** until Steps 8.3 and 8.5 supply live values. - **ESLint gives `apps/web` browser globals.** + +## Step 8.2 + +- **One generic `call(name, input)`** rather than a generated method per route. Types come from the catalog (`RouteRequest` / `RouteResponse`), so a new route needs no client change. +- **Requests:** `:params` are filled from the input; remaining fields go in the query string for GET and the JSON body for POST. `credentials: "include"` sends the session cookie. The base URL defaults to same origin (decision 0.5.20). +- **Errors:** a response failing its schema throws `ApiValidationError`; a non-2xx response throws `ApiError` with the server's code and message (or `internal-error` if the body is not the documented format). +- **ESLint now ignores unused names starting with `_`**, for deliberately unused parameters. diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts new file mode 100644 index 0000000..c43daae --- /dev/null +++ b/apps/web/src/api/client.ts @@ -0,0 +1,98 @@ +import { + errorResponseSchema, + routes, + type ErrorCode, + type RouteName, + type RouteRequest, + type RouteResponse, +} from "@deadhead/schema"; + +/** The server answered with its documented error format. */ +export class ApiError extends Error { + constructor( + readonly status: number, + readonly code: ErrorCode, + message: string, + ) { + super(message); + this.name = "ApiError"; + } +} + +/** The server's response did not match the route catalog. */ +export class ApiValidationError extends Error { + constructor( + readonly route: RouteName, + message: string, + ) { + super(`Unexpected response from ${route}: ${message}`); + this.name = "ApiValidationError"; + } +} + +export interface ApiClientOptions { + /** Defaults to the global `fetch`. */ + fetch?: typeof fetch; + /** Defaults to "" (same origin, decision 0.5.20). */ + baseUrl?: string; +} + +export interface ApiClient { + call(name: N, input: RouteRequest): Promise>; +} + +/** Builds a request URL and body from a route and its input. */ +function buildRequest(path: string, method: "GET" | "POST", input: Record) { + const rest = { ...input }; + const url = path.replace(/:(\w+)/g, (_match, param: string) => { + const value = rest[param]; + delete rest[param]; + return encodeURIComponent(String(value)); + }); + if (method === "GET") { + const query = new URLSearchParams( + Object.entries(rest).map(([key, value]) => [key, String(value)]), + ).toString(); + return { url: query ? `${url}?${query}` : url, body: undefined }; + } + return { url, body: Object.keys(rest).length > 0 ? JSON.stringify(rest) : undefined }; +} + +/** A client for every catalog route: typed inputs and validated, typed outputs. */ +export function createApiClient(options: ApiClientOptions = {}): ApiClient { + const doFetch = options.fetch ?? ((...args: Parameters) => fetch(...args)); + const baseUrl = options.baseUrl ?? ""; + + return { + async call(name, input) { + const route = routes[name]; + const { url, body } = buildRequest( + route.path, + route.method, + input as Record, + ); + const response = await doFetch(baseUrl + url, { + method: route.method, + credentials: "include", + headers: body ? { "content-type": "application/json" } : {}, + body, + }); + + const json: unknown = await response.json().catch(() => undefined); + if (!response.ok) { + const parsed = errorResponseSchema.safeParse(json); + if (parsed.success) { + throw new ApiError(response.status, parsed.data.error.code, parsed.data.error.message); + } + throw new ApiError( + response.status, + "internal-error", + `Request failed (${response.status}).`, + ); + } + const parsed = route.response.safeParse(json); + if (!parsed.success) throw new ApiValidationError(name, parsed.error.message); + return parsed.data as RouteResponse; + }, + }; +} diff --git a/apps/web/test/api-client.test.ts b/apps/web/test/api-client.test.ts new file mode 100644 index 0000000..511180d --- /dev/null +++ b/apps/web/test/api-client.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, expectTypeOf, it, vi } from "vitest"; +import { ApiError, ApiValidationError, createApiClient } from "../src/api/client"; + +function mockFetch(status: number, body: unknown) { + return vi.fn( + async (_url: RequestInfo | URL, _init?: RequestInit) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); +} + +describe("typed API client", () => { + it("returns a valid response, typed", async () => { + const fetch = mockFetch(200, { realTime: 1000, gameTime: 60_000 }); + const api = createApiClient({ fetch }); + const time = await api.call("getServerTime", {}); + expect(time).toEqual({ realTime: 1000, gameTime: 60_000 }); + expectTypeOf(time.realTime).toEqualTypeOf(); + expect(fetch).toHaveBeenCalledWith("/api/time", expect.objectContaining({ method: "GET" })); + }); + + it("raises a validation error when the response breaks its schema", async () => { + const api = createApiClient({ fetch: mockFetch(200, { realTime: "soon" }) }); + await expect(api.call("getServerTime", {})).rejects.toBeInstanceOf(ApiValidationError); + }); + + it("fills path parameters and sends the rest as a JSON body", async () => { + const pilot = { + id: "p1", + name: "Rae Okafor", + isAvatar: false, + xp: 0, + level: 0, + skills: {}, + skillPointsAvailable: 0, + assignedShipId: "s1", + status: "idle", + locationId: "earth", + }; + const fetch = mockFetch(200, { pilot }); + const api = createApiClient({ fetch }); + await api.call("assignPilot", { pilotId: "p1", shipId: "s1" }); + + const [url, init] = fetch.mock.calls[0]!; + expect(url).toBe("/api/pilots/p1/assign"); + expect(init).toMatchObject({ method: "POST", credentials: "include" }); + expect(JSON.parse(init!.body as string)).toEqual({ shipId: "s1" }); + }); + + it("puts path parameters in GET URLs", async () => { + const fetch = mockFetch(200, { contracts: [], periodEndsAt: 7_200_000 }); + await createApiClient({ fetch }).call("getContractBoard", { locationId: "moon" }); + expect(fetch.mock.calls[0]![0]).toBe("/api/bays/moon/board"); + }); + + it("throws the server's error code and message", async () => { + const fetch = mockFetch(409, { + error: { code: "insufficient-funds", message: "Not enough credits." }, + }); + const error = await createApiClient({ fetch }) + .call("buyShip", { modelId: "courier" }) + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ + status: 409, + code: "insufficient-funds", + message: "Not enough credits.", + }); + }); +}); diff --git a/eslint.config.js b/eslint.config.js index 1edbd7c..3d03e68 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,6 +15,13 @@ export default tseslint.config( languageOptions: { globals: { ...globals.node }, }, + rules: { + // Allow deliberately unused names such as placeholder parameters (_url). + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + }, }, { files: ["apps/web/**/*.{ts,tsx}"], diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 25619ef..612745c 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -78,7 +78,7 @@ ## Phase 8 — Client Foundation - [x] **Step 8.1 — Vite + React app.** `apps/web`: Vite 8, React 19, Tailwind 4, React Router 8. Layout (header with credits and game clock, main area, fixed six-tab bottom navigation) and placeholder screens for HQ, Contracts, Fleet, Pilots, Market, Locations. Component tests in jsdom. Dev server proxies `/api` to `127.0.0.1:3000`. Manual 375px check left to the user. -- [ ] Step 8.2 — Typed API client +- [x] **Step 8.2 — Typed API client.** `apps/web/src/api/client.ts`: `createApiClient().call(name, input)` built from the route catalog: path params filled, GET query / POST JSON body, `credentials: "include"`, responses validated (`ApiValidationError`), server errors raised as `ApiError` with code and message. - [ ] Step 8.3 — Server-synced clock - [ ] Step 8.4 — Guest onboarding - [ ] Step 8.5 — Player state store