Step 8.2: add typed API client

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 01:51:29 -04:00
parent acf550c901
commit 5558296b77
5 changed files with 185 additions and 1 deletions

View File

@ -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. - **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. - **Header credits and clock are placeholders** until Steps 8.3 and 8.5 supply live values.
- **ESLint gives `apps/web` browser globals.** - **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.

View File

@ -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<N extends RouteName>(name: N, input: RouteRequest<N>): Promise<RouteResponse<N>>;
}
/** Builds a request URL and body from a route and its input. */
function buildRequest(path: string, method: "GET" | "POST", input: Record<string, unknown>) {
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<typeof fetch>) => 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<string, unknown>,
);
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<typeof name>;
},
};
}

View File

@ -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<number>();
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.",
});
});
});

View File

@ -15,6 +15,13 @@ export default tseslint.config(
languageOptions: { languageOptions: {
globals: { ...globals.node }, 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}"], files: ["apps/web/**/*.{ts,tsx}"],

View File

@ -78,7 +78,7 @@
## Phase 8 — Client Foundation ## 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. - [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.3 — Server-synced clock
- [ ] Step 8.4 — Guest onboarding - [ ] Step 8.4 — Guest onboarding
- [ ] Step 8.5 — Player state store - [ ] Step 8.5 — Player state store