Step 8.2: add typed API client
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
acf550c901
commit
5558296b77
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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}"],
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue