Step 4.2: add API route catalog
CI / check (push) Successful in 40s Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-02 20:51:45 -04:00
parent f95acd06ba
commit 439c35ae35
6 changed files with 220 additions and 1 deletions

View File

@ -193,3 +193,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **One dispatch request, three kinds:** `contract` (contract + ship + pilot), `tutorial` (contract only; the avatar flies the loaner), `reposition` (ship + pilot + destination).
- **Create-guest returns the new player's id and full state.** The session itself travels as a cookie (decision 0.5.20).
- **Compile-time tests** check that the schema `Contract` and sim `Contract` are mutually assignable, and that a sim `DispatchRecord` fits the API `Dispatch`.
## Step 4.2
- **One `request` schema per route covers path parameters and body together** (e.g. `assignPilot` takes `pilotId` from `/api/pilots/:pilotId/assign` and `shipId` from the body). The server merges the two before validating; a test checks that every `:param` is a field of the request schema.
- **Each route has an `auth` flag.** Only `getServerTime` and `createGuest` work without a session.
- **REST-style paths:** collections are created with `POST` (`/api/ships`, `/api/bays`, `/api/pilots`, `/api/dispatches`), and pilot actions are sub-resources (`/api/pilots/:pilotId/assign`).
- **The catalog is an `as const` object** that satisfies `Record<string, RouteDef>`, so the server and client get exact request and response types per route name (`RouteRequest<"buyShip">`).

View File

@ -38,3 +38,23 @@ Living reference for how the code is organised. Update it after any database sch
### Errors
Every non-2xx response has the body `{ error: { code, message } }`. `code` is one of `ERROR_CODES`: every sim dispatch and reposition validation reason (`DISPATCH_FAILURES`, `REPOSITION_FAILURES`), unchanged, plus the server codes in `SERVER_ERROR_CODES` (`validation-error`, `unauthorized`, `not-found`, `insufficient-funds`, ...).
## API routes (`packages/schema/src/routes.ts`)
The `routes` catalog is the single source for both server and client. Each entry has `method`, `path`, `request` (path parameters merged with the body), `response` (2xx body) and `auth`. Errors always use the error body above.
| Name | Method & path | Request | Response | Auth |
|---|---|---|---|---|
| `getServerTime` | `GET /api/time` | — | `ServerTime` | no |
| `createGuest` | `POST /api/guest` | — | `{ playerId, state: PlayerState }` + session cookie | no |
| `getPlayerState` | `GET /api/state` | — | `PlayerState` | yes |
| `getContractBoard` | `GET /api/bays/:locationId/board` | `locationId` | `{ contracts, periodEndsAt }` | yes |
| `getTutorialBoard` | `GET /api/tutorial/board` | — | `{ contracts, periodEndsAt }` | yes |
| `createDispatch` | `POST /api/dispatches` | `{ kind: "contract", contractId, shipId, pilotId }` / `{ kind: "tutorial", contractId }` / `{ kind: "reposition", shipId, pilotId, destinationId }` | `{ dispatch }` | yes |
| `buyShip` | `POST /api/ships` | `modelId` | `{ ship }` | yes |
| `leaseBay` | `POST /api/bays` | `locationId` | `{ bay }` | yes |
| `hirePilot` | `POST /api/pilots` | `locationId` (a player bay) | `{ pilot }` | yes |
| `assignPilot` | `POST /api/pilots/:pilotId/assign` | `pilotId`, `shipId` | `{ pilot }` | yes |
| `unassignPilot` | `POST /api/pilots/:pilotId/unassign` | `pilotId` | `{ pilot }` | yes |
| `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes |
| `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds` | `{ acknowledged }` | yes |

View File

@ -41,4 +41,4 @@
## Phase 4 — Shared Schema Package
- [x] **Step 4.1 — API data shapes.** `packages/schema/src`: entity schemas (`entities.ts`), error format and codes (`errors.ts`), request/response bodies (`requests.ts`). Error codes include every sim validation reason; sim now exports `DISPATCH_FAILURES` / `REPOSITION_FAILURES`. `architecture.md` started with the API data model.
- [ ] Step 4.2 — API route catalog
- [x] **Step 4.2 — API route catalog.** `packages/schema/src/routes.ts`: 13 typed endpoints under `/api` with request/response schemas and an `auth` flag, plus `RouteRequest` / `RouteResponse` helper types. Route table added to `architecture.md`.

View File

@ -1,3 +1,4 @@
export * from "./entities";
export * from "./errors";
export * from "./requests";
export * from "./routes";

View File

@ -0,0 +1,133 @@
import type { z } from "zod";
import { playerStateSchema, serverTimeSchema } from "./entities";
import {
acknowledgeSummariesRequestSchema,
acknowledgeSummariesResponseSchema,
assignPilotRequestSchema,
bayResponseSchema,
buyShipRequestSchema,
contractBoardRequestSchema,
contractBoardResponseSchema,
createDispatchRequestSchema,
createGuestResponseSchema,
dispatchResponseSchema,
emptyRequestSchema,
hirePilotRequestSchema,
leaseBayRequestSchema,
pilotResponseSchema,
shipResponseSchema,
spendSkillPointRequestSchema,
unassignPilotRequestSchema,
} from "./requests";
export interface RouteDef {
method: "GET" | "POST";
/** Under `/api` (decision 0.5.20). `:name` segments are path parameters. */
path: string;
/** Path parameters plus body: every `:name` in the path is a field here. */
request: z.ZodType;
/** Body of the 2xx response. Errors always use `errorResponseSchema`. */
response: z.ZodType;
/** Whether a session is required. */
auth: boolean;
}
/** Every endpoint of the base game: the contract between server and client. */
export const routes = {
getServerTime: {
method: "GET",
path: "/api/time",
request: emptyRequestSchema,
response: serverTimeSchema,
auth: false,
},
createGuest: {
method: "POST",
path: "/api/guest",
request: emptyRequestSchema,
response: createGuestResponseSchema,
auth: false,
},
getPlayerState: {
method: "GET",
path: "/api/state",
request: emptyRequestSchema,
response: playerStateSchema,
auth: true,
},
getContractBoard: {
method: "GET",
path: "/api/bays/:locationId/board",
request: contractBoardRequestSchema,
response: contractBoardResponseSchema,
auth: true,
},
getTutorialBoard: {
method: "GET",
path: "/api/tutorial/board",
request: emptyRequestSchema,
response: contractBoardResponseSchema,
auth: true,
},
createDispatch: {
method: "POST",
path: "/api/dispatches",
request: createDispatchRequestSchema,
response: dispatchResponseSchema,
auth: true,
},
buyShip: {
method: "POST",
path: "/api/ships",
request: buyShipRequestSchema,
response: shipResponseSchema,
auth: true,
},
leaseBay: {
method: "POST",
path: "/api/bays",
request: leaseBayRequestSchema,
response: bayResponseSchema,
auth: true,
},
hirePilot: {
method: "POST",
path: "/api/pilots",
request: hirePilotRequestSchema,
response: pilotResponseSchema,
auth: true,
},
assignPilot: {
method: "POST",
path: "/api/pilots/:pilotId/assign",
request: assignPilotRequestSchema,
response: pilotResponseSchema,
auth: true,
},
unassignPilot: {
method: "POST",
path: "/api/pilots/:pilotId/unassign",
request: unassignPilotRequestSchema,
response: pilotResponseSchema,
auth: true,
},
spendSkillPoint: {
method: "POST",
path: "/api/pilots/:pilotId/skills",
request: spendSkillPointRequestSchema,
response: pilotResponseSchema,
auth: true,
},
acknowledgeSummaries: {
method: "POST",
path: "/api/summaries/acknowledge",
request: acknowledgeSummariesRequestSchema,
response: acknowledgeSummariesResponseSchema,
auth: true,
},
} as const satisfies Record<string, RouteDef>;
export type Routes = typeof routes;
export type RouteName = keyof Routes;
export type RouteRequest<N extends RouteName> = z.input<Routes[N]["request"]>;
export type RouteResponse<N extends RouteName> = z.output<Routes[N]["response"]>;

View File

@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { routes, type RouteDef } from "../src/index";
const entries = Object.entries(routes) as [string, RouteDef][];
describe("route catalog", () => {
it("lists every base-game endpoint", () => {
expect(Object.keys(routes).sort()).toEqual(
[
"getServerTime",
"createGuest",
"getPlayerState",
"getContractBoard",
"getTutorialBoard",
"createDispatch",
"buyShip",
"leaseBay",
"hirePilot",
"assignPilot",
"unassignPilot",
"spendSkillPoint",
"acknowledgeSummaries",
].sort(),
);
});
it.each(entries)("%s has request and response schemas", (_name, route) => {
expect(route.request).toBeInstanceOf(z.ZodType);
expect(route.response).toBeInstanceOf(z.ZodType);
});
it("never gives two endpoints the same method and path", () => {
const keys = entries.map(([, r]) => `${r.method} ${r.path}`);
expect(new Set(keys).size).toBe(keys.length);
});
it("keeps every path under /api", () => {
for (const [, route] of entries) {
expect(route.path.startsWith("/api/")).toBe(true);
}
});
it.each(entries)("%s declares each path parameter in its request schema", (_name, route) => {
const params = [...route.path.matchAll(/:(\w+)/g)].map((m) => m[1]!);
if (params.length === 0) return;
expect(route.request).toBeInstanceOf(z.ZodObject);
const shape = (route.request as z.ZodObject).shape;
for (const param of params) {
expect(shape).toHaveProperty(param);
}
});
it("requires a session everywhere except server time and guest creation", () => {
const open = entries.filter(([, r]) => !r.auth).map(([name]) => name);
expect(open.sort()).toEqual(["createGuest", "getServerTime"]);
});
});