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).
|
- **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).
|
- **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`.
|
- **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
|
### 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`, ...).
|
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
|
## 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.
|
- [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 "./entities";
|
||||||
export * from "./errors";
|
export * from "./errors";
|
||||||
export * from "./requests";
|
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