Code
The shared contract
C4 level 4 for the seam between the browser and the origin: one Rust crate of wire types, an OpenAPI document generated from the handlers, TypeScript types generated from that, and three tests that keep all of it honest.
Suggest an editThe shared contract
You'll be able to: trace a field from a Rust struct to the TypeScript that decodes it; name the three mechanical checks that stop the two sides drifting; and say what was given up when the client stopped being Rust.
The origin is Rust and the web tier is TypeScript. Every request crosses that line, so the shape of each request and response has to be agreed somewhere — and agreed in a way a build can check.
The kernel crate
shared/ is the smallest crate in the workspace, and its only dependency is serde. It holds the
wire types both sides need — one module per context (catalog, execution, submission, notes,
canvas, tutor, authoring, …) — plus a few pure models, such as the judge's verdict rules.
It compiles both natively, for the server and for fast tests, and to wasm32. The visualisation
engine used to live here too and moved out to its own viz-wasm crate: the server never references
it, so it had no business in the contract kernel.
From Rust struct to TypeScript type
The chain has four links:
- A DTO is a Rust struct in
shared/— for examplesynapse_shared::notes::NoteDto. - Each handler is annotated for utoipa, and
lib.rscollects every path and schema intoApiDoc. The OpenAPI document is code-first: the handlers are the specification. dev-tools/gen-api-types.shbuilds thedump_openapibinary, runs it to print the document, and feeds that toopenapi-typescript, which writesweb/src/lib/api/schema.gen.ts.- The web tier imports its request and response types from that generated file.
A renamed field therefore breaks the TypeScript build, not a reader's page.
Three checks keep it honest
| Check | Where | Catches |
|---|---|---|
| Contract lock | server/tests/contract_it.rs |
the rendered OpenAPI document must cover every path and method in api/openapi.oracle.yaml, and agree with it on property names and required fields |
| Freshness | the CI job that runs gen-api-types.sh and then git diff --exit-code on schema.gen.ts |
a server change that was not regenerated into the web tier |
| Twin test | shared/test-vectors/judge-vectors.json, run by both shared/src/execution/tests/judge_vectors.rs and web/src/lib/execution/judge.test.ts |
the TypeScript port of the judge (verdicts, stdin_for) disagreeing with the Rust original |
The oracle file is the API's record of what it promised, separate from what it currently compiles to. It grows in lock-step as endpoints are added, so removing or renaming a promised field is a red test rather than a production surprise.
The twin test exists because one piece of logic genuinely runs on both sides: the workbench judges sample cases in the browser for instant feedback, and the server judges the hidden ones. The two implementations are checked against the same JSON vectors, so drift is caught mechanically on either side of the port.
One quirk is worth knowing. BookDto and ChapterDto are self-referential trees, marked
schema(no_recursion), which stops utoipa from walking into them — so lib.rs lists BookEntryDto
and ChapterDto explicitly, or the document would ship a dangling $ref. openapi-typescript is a
stricter reader than the server's own tests, and it is what found that.
What was given up when the client stopped being Rust?
A one-step guarantee became a two-step one. With a Rust client, both sides were checked against the
same type definitions by the same compiler in the same build; there was no artifact in between that
could be stale. Now there is — schema.gen.ts — and its freshness is enforced by a CI job. A CI job
is a good mechanism, but it is a mechanism; the old property needed none.
What came back is a published contract. Anything can read the OpenAPI document, not just Rust, and the oracle gives the API a record of its promises that the shared crate never had. The web tier tells the rest of that story.