Bridge Training
Contributing

API contract

One source for the HTTP wire, from route tables to response schemas, and what the api, the clients and the OpenAPI document derive from it.

The HTTP wire of the product has one owner, @workspace/api-contract. It holds, per module, the XxxApi type the apps program against, the DTO types, the zod schemas of bodies, queries and responses, the mappers from the domain to the DTOs, the route tables and the error keys. Everything else is derived from it: apps/api serves it, @workspace/api-client calls it, and the OpenAPI document describes it.

What lives where

ConcernOwnerDerived by
Methods and pathsthe route tables in api-contract/src/routescontractRoute(table, key) in the api, client.send(route, { params, query, body }) in the clients
Request bodies and queriesapi-contract/src/schemas/<module>.tsthe api validates with them; the clients send what they infer
Response shapesapi-contract/src/schemas/<module>.ts, checked against the DTO types, named by each routethe api documents them; client.send parses with the route's
DTO typesapi-contract/src/dtos.ts and the module filesthe XxxApi return types
Errorsapi-contract/src/errors.ts and the per-module xxxApiErrorsthe api maps domain errors; the clients rehydrate them

Authentication is not part of the contract. The guards, the session and the API key live next to the api.

Routes

A route table names each operation once, with its method, its full pattern, {param} placeholders included, and the schema of its answer:

export const groupsRoutes = {
  get: { method: "GET", pattern: "/groups/{id}", response: GroupDtoSchema },
  delete: { method: "DELETE", pattern: "/groups/{id}", response: NoContentSchema },
} as const satisfies RouteTable<GroupsApi>;

The api mounts every module at / and takes the pattern as it is through contractRoute; the clients call it through client.send(route, …), which takes the method and the path from the entry, so a client cannot speak another verb than the table, and resolves to what the route's response parses, so a client cannot type an answer the table does not declare. response is required: a route without one does not type-check. params is required exactly when the pattern has {param} segments, and body exactly when the route declares one. renderPattern is the only parser of {param}: the contract never carries framework syntax. A test in the api walks every table and checks that each entry is served by the mounted router, and that every served route comes from a table, so a route cannot exist on one side only.

Request schemas

Bodies and queries are zod schemas in the contract, strict on input: unknown keys are stripped, bounds are the wire's own (input-limits.ts). Business rules stay in the services of @workspace/training, which throw typed errors; the wire schema never duplicates them.

Response schemas

Every response has a zod schema in the contract, and that schema is checked against the type the XxxApi method returns:

export const GroupDtoSchema = z.object({
  id: z.string().uuid(),
  name: z.string(),
  // …
}) satisfies z.ZodType<GroupDto>;

satisfies z.ZodType<Dto> is the lockstep. A field added to the DTO type without its schema, or a schema narrower than its type, fails the type-check of the package. Where the api returns a domain type as is (BoardResult, BoardTheme, PublicLink…), the schema is checked against that type. Schemas that stand for an opaque or union payload (DealViewSchema) keep their inferred type as the wire type.

The views the apps consume may differ from the wire: a null limit becomes Number.POSITIVE_INFINITY in EntitlementsView or PlanView. Then the route answers the wire type (EntitlementsDto, PlanDto, SubscriptionDto) and the contract holds a toXxxView function the client applies to the parsed DTO.

Reading a response

client.send parses every successful answer with the route's response: unknown keys are stripped, nothing else is coerced. A 204 route declares NoContentSchema and resolves to undefined. An answer the schema refuses, or a body that is not JSON, throws an ApiResponseInvalidError: it names the route, the status and the issue paths, never the payload, and it is not an ApiError, so the apps report it as unexpected rather than explain it as a refusal.

A response schema must accept everything the api serializes, so it is never stricter than its DTO type. The boards are parsed in full like any other payload: the api degrades a corrupt game payload of the deal store into the board's diagnostics, which the schema accepts, rather than serve it as is.

The api is a naming layer

apps/api/src/modules/<module>/<module>.schema.ts declares no shape. It imports the contract schema and attaches the OpenAPI component name:

export const GroupDtoSchema = BaseGroupDtoSchema.openapi("GroupDto");

Two exceptions keep the document readable, and both stay typed:

  • a nested component that must keep its own name in the document is re-attached by recomposing the parent from the contract's .shape, and the result is checked with the same satisfies z.ZodType<Dto>;
  • a server-side default that the contract does not know (the grid of plans a pre-grid app bundle reads) extends the contract body with .extend().

Query schemas of listings (page, includeArchived) and path parameters are the api's own, as they describe the server's reading of the URL.

The OpenAPI document is therefore a projection of the contract. Comparing the document before and after a change of the contract is the review tool: a difference is either an intended change of the wire or a bug.

Checklist for a new operation

  1. Add the method to the XxxApi type and the DTO type it returns.
  2. Add the response schema, satisfies z.ZodType<Dto>, or NoContentSchema for a 204.
  3. Add the route to the module's table with its response, and the body or query schema if any.
  4. In the api: contractRoute(table, key), the contract schemas with their .openapi() names, the handler.
  5. In the client: client.send(route, …); the answer comes back parsed and typed by the route.
  6. Run the api tests: the route coverage test and the integration suite are the oracle.