Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bridgeservice/apispec/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
generated/client/** linguist-generated=true
generated/openapi.yaml linguist-generated=true
1 change: 1 addition & 0 deletions bridgeservice/apispec/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules/
222 changes: 222 additions & 0 deletions bridgeservice/apispec/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
# Spec-first pipeline — demo slice of the bridge API

A working, runnable demonstration of authoring one bridge-service endpoint
contract-first, next to the same endpoint as the service serves it today.

Scope is deliberately one route: `GET /bridge/v1/bridges`.

## The defect this starts from

`BridgeResponse.GlobalIndex` (`bridgeservice/types/types.go`) is a `*big.Int`.
`encoding/json` writes a `*big.Int` as a **bare JSON number**, so the field goes
out as:

```json
"global_index": 18446744073709551621
```

The field's `swaggertype:"string"` tag makes the committed Swagger spec claim it
is a string, so the published contract and the wire disagree.

The value matters. A global index for an L1-origin bridge packs a mainnet flag
into bit 64: deposit count 5 encodes to 2^64+5 = 18446744073709551621. That is
above `Number.MAX_SAFE_INTEGER`, so `JSON.parse` in any JavaScript runtime
returns `18446744073709551616` — a different bridge — without an error, a
warning, or a way for the caller to notice. Every sibling field that carries a
big integer, `amount` included, already uses the `types.BigIntString` wrapper
and is correctly quoted. This one field slipped.

Nothing catches it. The Go tests decode responses with `encoding/json`, which
round-trips both forms losslessly, so the divergence exists only in the
serialised bytes that no test looks at.

## What spec-first changes

The contract becomes an artifact that both sides are generated from, instead of
a description written alongside code that may or may not match it.

```text
src/schemas.ts Zod schemas + registry (the contract, authored once)
|
| pnpm run generate:spec
v
generated/openapi.yaml OpenAPI 3.0
| |
| oapi-codegen | pnpm run generate:client
v v
../oapi/oapi.gen.go generated/client/
strict gin server codec-aware TypeScript client
```

Two properties fall out of that shape.

**The server cannot drift from the contract, because it does not choose the
wire format.** `oapi-codegen` renders the contract into Go types and a *strict*
`ServerInterface` — one whose method signatures carry typed request and response
objects. A handler that returns the wrong shape does not compile. The
hand-written service, by contrast, gets a `*gin.Context` and full freedom over
what JSON it writes, which is the freedom that let this field drift.

The big-integer fields carry a vendor extension in the contract:

```yaml
global_index:
type: string
x-go-type: types.BigIntString
x-go-type-import:
path: github.com/agglayer/aggkit/bridgeservice/types
```

so the generated struct field is aggkit's own `types.BigIntString`, which
marshals as a quoted string and accepts a string or a number on the way back
in. (`x-go-type: big.Int` is the obvious-looking choice and is wrong: a raw
`*big.Int` marshals as a bare number *and* rejects a quoted string on unmarshal,
reproducing the defect and adding a new one.)

**Consumers reject a response that lies, instead of silently corrupting it.**
The TypeScript client is generated by `@hey-api/openapi-ts` with the
`@polygonlabs/zod-to-openapi-heyapi` plugin, which imports the *actual* Zod
schemas the spec was generated from rather than reconstructing them from the
spec. `global_index` and `amount` are `BigIntegerCodec` — wire format a decimal
string, runtime value a `bigint` — so the client validates every response
against the same code that defined the contract and hands the caller exact
values with no double in the path.

## What the generated client gives you

The client is not a fetch wrapper — it is the contract, executable on the
consumer's side. Concretely:

**Errors arrive classified, not as prose to fingerprint.** Every operation's
result narrows into three categories via generated type-predicate guards, with
no casts anywhere:

- `TransportError` — the request never produced an HTTP response (DNS, abort,
connection reset). `cause` carries the native fetch error.
- `ResponseValidationError` — the server responded, but the body does not
match the contract: a 2xx body failing the response schema, or an error
body matching no registered error schema. `cause` is the `ZodError` with
the exact issue paths; `body` is the offending payload. This is how
contract drift becomes *undeliverable*: the money test below points this
client at today's live endpoint, and the bare-number `global_index` is
refused on the first row — carrying the silently-rounded double on `.body`
as evidence — instead of flowing into the application as a wrong value.
- Typed `${Op}Error` — the body matched a registered error schema for that
status, decoded through its codecs, fully typed.

Compare that with what consuming this API by hand requires today: matching
substrings of freeform error messages that have already changed between two
release candidates.

**Codecs run in both directions, so the wire format and the runtime type are
different things — honestly.** `global_index` and `amount` are declared once
as `BigIntegerCodec` (wire: decimal string; runtime: `bigint`). Every response
runs `parseAsync` through the *actual schema objects* the spec was generated
from — not a reconstruction — so the caller receives exact `bigint`s, `Date`s
from ISO strings, and so on, and the TypeScript types agree with the runtime
values by construction. Request-side inputs are encoded back to wire format
the same way: pass a `bigint`, the wire carries the string.

**React integration is one flag away.** The same plugin emits codec-aware
TanStack Query factories (`queryOptions`, query keys, hooks-ready) per
operation when `tanstackReactQuery: true` is set — this demo keeps it off to
stay minimal, but a frontend consuming this API gets typed, codec-decoding
React hooks from the same one-line config, with no additional authoring.

**One canonical import surface.** The generated barrel exports the client
singleton, every operation wrapper, the error classes and guards, and the
schema-derived types — a consumer imports from one place and cannot
accidentally reach a wire-shaped variant of a type.

**It redraws the SDK boundary correctly.** Today's `@agglayer/sdk` spends
over a thousand lines hand-maintaining a typed client, raw-text parsing, and
fixture-derived types — a shadow copy of facts this repo already owns, which
must be re-verified against every aggkit release. With the client generated
*here*, that entire layer disappears from the SDK, which keeps only what is
genuinely SDK-shaped: multi-network aggregation, claim orchestration,
on-chain reads. Two more things fall out for free: any consumer who just
wants to call one aggkit instance can depend on the thin generated client
alone, without pulling the full SDK — and because the client is generated
and published from this repo, **its release cadence is the server's**: a
contract change ships as a client version bump in the same release, so
breaking changes arrive as semver signals instead of surprises discovered
downstream.

## Running it

Prerequisites: Go (per `go.mod`), Node 24, pnpm.

```bash
cd bridgeservice/apispec
pnpm install
pnpm run generate # openapi.yaml, then the TypeScript client
```

Regenerate the Go server after any contract change:

```bash
cd ../oapi && go generate ./...
```

Serve both endpoints over one set of canned rows and compare them by hand:

```bash
go run ./bridgeservice/oapi/demo/cmd # from the repository root

curl -s 'http://127.0.0.1:8099/bridge/v1/bridges?network_id=0'
# ..."global_index":18446744073709551621,... bare number

curl -s 'http://127.0.0.1:8099/specfirst/bridge/v1/bridges?network_id=0'
# ..."global_index":"18446744073709551621",... quoted string
```

The left-hand endpoint is not a reimplementation. It is the shipped
`BridgeService`, instantiated the way `bridgeservice/bridge_test.go` instantiates
it, with mocked syncers returning the canned rows — same routing, same response
types, same serialisation.

Both halves are covered by tests:

```bash
go test ./bridgeservice/oapi/... # wire format, asserted on raw response bytes
cd bridgeservice/apispec && pnpm run demo # the generated client against both endpoints
```

`pnpm run demo` builds and starts the Go demo server itself, so it needs no
setup beyond `pnpm install && pnpm run generate`. It asserts that the current
endpoint is *rejected* by the generated client — with a Zod issue reading
`expected string, received number` at `global_index` — and that the generated
endpoint round-trips both big integers as exact `bigint`s.

## Layout

| Path | What it is |
| --- | --- |
| `src/schemas.ts` | The contract: Zod schemas mirroring `bridgeservice/types` |
| `src/routes/bridges.ts` | The one registered operation |
| `src/registry.ts` | Registry composition |
| `scripts/generate-spec.ts` | Emits `generated/openapi.yaml` |
| `openapi-ts.config.ts` | Client codegen config |
| `generated/` | Committed generated output — do not edit |
| `test/` | The generated client against both endpoints |
| `../oapi/` | `oapi-codegen` config and generated Go server |
| `../oapi/demo/` | Both servers mounted together, plus Go wire-format tests |

## What this is not

- **One route out of eighteen.** Only `GET /bridge/v1/bridges` is modelled. A
real migration covers every operation the bridge service registers.
- **Not wired into the running service.** The generated server is mounted under
a separate prefix by a demo command. Adopting it means the real service
implements the generated interface and the swaggo annotation flow
(`@Summary`/`@Param` comments plus `bridgeservice/docs`) is retired in favour
of the generated document.
- **Fixed data.** The syncer dependencies are mocks and the rows are canned;
nothing here reads a database or a chain.
- **No client is published.** The generated TypeScript client exists so the
contract can be tested from the consumer side. Shipping it to consumers is a
separate decision.

Adopting this would be a breaking wire change for `global_index` on every
endpoint that carries one, and needs to be sequenced with the consumers that
read it.
16 changes: 16 additions & 0 deletions bridgeservice/apispec/generated/client/client.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading