From 439c35ae3515124bf35a4d25602c956fa96eb32f Mon Sep 17 00:00:00 2001 From: fireshaper Date: Fri, 2 Oct 2026 20:51:45 -0400 Subject: [PATCH] Step 4.2: add API route catalog Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 7 ++ memory-bank/architecture.md | 20 +++++ memory-bank/progress.md | 2 +- packages/schema/src/index.ts | 1 + packages/schema/src/routes.ts | 133 ++++++++++++++++++++++++++++ packages/schema/test/routes.test.ts | 58 ++++++++++++ 6 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 packages/schema/src/routes.ts create mode 100644 packages/schema/test/routes.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index b8ef002..86a0233 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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`, so the server and client get exact request and response types per route name (`RouteRequest<"buyShip">`). diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 19aca4b..7763150 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -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 | diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 76a382d..0884c44 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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`. diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 43b72ce..ade9769 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -1,3 +1,4 @@ export * from "./entities"; export * from "./errors"; export * from "./requests"; +export * from "./routes"; diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts new file mode 100644 index 0000000..cc14371 --- /dev/null +++ b/packages/schema/src/routes.ts @@ -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; + +export type Routes = typeof routes; +export type RouteName = keyof Routes; +export type RouteRequest = z.input; +export type RouteResponse = z.output; diff --git a/packages/schema/test/routes.test.ts b/packages/schema/test/routes.test.ts new file mode 100644 index 0000000..95735f8 --- /dev/null +++ b/packages/schema/test/routes.test.ts @@ -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"]); + }); +});