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.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
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}"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue