Step 4.2: add API route catalog
CI / check (push) Successful in 40s
Details
CI / check (push) Successful in 40s
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f95acd06ba
commit
439c35ae35
|
|
@ -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">`).
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./entities";
|
||||
export * from "./errors";
|
||||
export * from "./requests";
|
||||
export * from "./routes";
|
||||
|
|
|
|||
|
|
@ -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"]>;
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue