POC: contract-first pipeline for the bridge service (one-route demo slice) - #1816
Draft
MaximusHaximus wants to merge 9 commits into
Draft
POC: contract-first pipeline for the bridge service (one-route demo slice)#1816MaximusHaximus wants to merge 9 commits into
MaximusHaximus wants to merge 9 commits into
Conversation
Authors the GET /bridge/v1/bridges contract as Zod schemas and emits an OpenAPI 3.0 document from them. The two fields that exceed 2^53 -- global_index and amount -- use BigIntegerCodec, whose wire format is a quoted decimal string, and carry x-go-type: types.BigIntString so the Go generator substitutes aggkit's existing string-wire wrapper rather than a raw big.Int (which would marshal as a bare number and reproduce the very defect this demonstrates).
oapi-codegen v2.8.0 renders bridgeservice/apispec/generated/openapi.yaml into a strict gin ServerInterface plus models. The x-go-type extension in the contract makes GlobalIndex and Amount land as types.BigIntString -- aggkit's existing quoted-string wrapper -- rather than as *big.Int, and the generated query binding reads the same snake_case parameter names the current handler does. Adds github.com/oapi-codegen/runtime for the generated query-parameter binding; go mod tidy pulled in go-jsonmerge transitively and moved go-colorable and x/time up to the versions it requires.
One gin engine mounts the real BridgeService at its normal routes -- instantiated the way bridgeservice's own test suite does it, with mocked syncers over canned rows -- and the generated strict server at /specfirst. Both render the same three bridges, including an L1-origin row whose global index is 2^64+5 and whose amount is 10^18. Tests assert on the raw response bytes rather than on decoded values, because Go decodes both forms losslessly; the divergence exists only in the serialised output, which is exactly why it survived the current test suite.
The hey-api client is generated from the same openapi.yaml as the Go server, with @polygonlabs/zod-to-openapi-heyapi sourcing the actual Zod schemas rather than reconstructing them from the spec -- so the response transformer runs BigIntegerCodec, the schema that defined the field. Pointed at the endpoint the service serves today the client refuses the response and names global_index; pointed at the generated endpoint it returns exact bigints. vitest owns the Go server's lifecycle, so the whole thing runs from a clean checkout with pnpm install, pnpm run generate, pnpm run demo. schemasFrom uses the package's own name plus a self-link rather than the '#schemas' subpath alias the plugin's README suggests for this layout: the plugin dynamic-imports the specifier from inside its own node_modules directory, where a consumer's subpath alias is not visible.
Covers the global_index wire-format defect the demo starts from, why authoring the contract first removes that class of bug on both the server and the consumer side, how to run both halves, and what the demo deliberately does not do.
@polygonlabs/openapi-registry ^3.0.0, zod-codecs ^1.2.0, and zod-to-openapi-heyapi ^2.0.4 -- the published releases of the fixes this demo helped surface. The money test now asserts the honest error class: a 2xx body failing response validation is a ResponseValidationError (2.0.3 misclassified it as TransportError, the workaround this replaces), and the typed guard exposes cause.issues and the offending post-parse body without casts -- the test additionally asserts the rejected body carries the silently-rounded double, which is the corruption being refused. Client regenerated under 2.0.4; Go round-trip tests and the end-to-end demo are green. Claude-Session: https://claude.ai/code/session_0143a61WSX93nWv3eLd4Ys7E
@polygonlabs/zod-to-openapi-heyapi 2.1.0 resolves schemasFrom from the consumer's perspective (anchored at the codegen output directory), so the same-package pattern is a plain package.json imports alias again — the 'link:.' self-dependency and the exports entry it required are deleted. The generated client now imports '#schemas' directly. Regenerated and re-verified from a clean state: install, generate, typecheck, Go round-trip tests, and the end-to-end demo all green. Claude-Session: https://claude.ai/code/session_0143a61WSX93nWv3eLd4Ys7E
The README prosecuted the wire defect and the strict-server half but undersold the consumer half of the pipeline: the classified error taxonomy (TransportError / ResponseValidationError with cause+body / typed per-operation errors, all via type-predicate guards), two-way codec marshalling (exact bigints in, wire strings out, types agreeing with runtime by construction), contract drift being undeliverable rather than silently corrupting, codec-aware TanStack Query factories behind one flag, and the single canonical import surface. Claude-Session: https://claude.ai/code/session_0143a61WSX93nWv3eLd4Ys7E
The generated client replaces the SDK's hand-maintained shadow of the server contract, leaving the SDK only what is genuinely SDK-shaped (aggregation, orchestration, on-chain reads); the thin client is usable standalone without the full SDK; and publishing the client from this repo ties its release cadence to the server's, so contract changes ship as semver signals. Claude-Session: https://claude.ai/code/session_0143a61WSX93nWv3eLd4Ys7E
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Proof of concept — not for merge as-is.
/bridge/v1/bridgescurrently emitsglobal_indexas a bare JSON number (≥ 2⁶⁴ for every L1-origin bridge) while the committed swagger declares it a string —JSON.parsein any JS consumer silently rounds it into a different bridge's index. This POC demonstrates the pipeline that makes that class of defect impossible: the contract is authored once (Zod schemas), and both a strict Go server (wrong wire shapes don't compile) and a codec-validating TypeScript client (contract violations are rejected at the first response, exactbigints otherwise) are generated from it.The demo mounts today's real bridge service and the generated slice side by side on the same canned data, and its tests show the same client rejecting the live endpoint and round-tripping the generated one.
Beyond fixing the defect class, this shape also redraws the client/SDK boundary: the generated client replaces the thousand-plus lines the SDK currently spends hand-maintaining a shadow of this repo's contract, is usable standalone by any consumer who doesn't need the full SDK — and, published from this repo, its release cadence is the server's, so contract changes ship as client semver bumps instead of surprises discovered downstream.
Full walkthrough, rationale, and how to run it:
bridgeservice/apispec/README.md. Scope notes (what is deliberately not done) are in the README's final section.