Spec normalisation
scripts/normalize-spec.py takes the IBKR upstream spec and applies a
series of surgical transforms so every downstream generator can consume it
cleanly.
Each transform is a distinct, upstreamable fix — the end goal is that IBKR fixes these in their spec and we can delete the corresponding normalisation step.
The 13 steps (current)
- Strip null security scopes. IBKR emits
security[].scheme: [null]where OpenAPI 3.0 requires[]or[scope-string]. - Synthesise missing
operationIds. Progenitor and oas3-gen both require every operation to have one; IBKR omits them on ~50 operations. - Disambiguate duplicate
operationIds. IBKR ships at least one duplicate (getTradingSchedule× 2 on different paths). We append a path-derived suffix to later occurrences. - Desugar ambiguous enum variants. Enums whose values collapse into
non-unique Rust identifiers after sanitisation (
>=,<=,>,<,==) are downgraded to plaintype: stringwith the variants captured in thedescription. - Rewrite exotic content types. IBKR uses
application/jwtin a few places; we rewrite totext/plainwith a string-typed schema. - Reconcile enum values with declared
type. Example: a field declaredtype: numberwith enum["0", "1", "2"]gets the enum values coerced to numbers. - Demote misplaced path parameters. Several operations declare
in: pathparameters whose placeholder isn’t in the URL template. We demote them toin: query. - Drop unknown string formats.
format: "jwt"isn’t a standard string format; we strip it so generators don’t emit broken wrappers. - Demote cookie parameters to headers. Progenitor doesn’t support
in: cookie; we rewrite toin: header. - Collapse multi-content-type success responses. When IBKR offers a
200 response in both
application/jsonandapplication/pdf, we pick JSON and drop the rest so progenitor’s assertion holds. - Drop WebSocket upgrade operations. Operations with only
1xxresponses (e.g.101 Switching Protocols) can’t be modelled as REST. - Stringify numeric-array query parameters. oas3-gen’s
StringWithCommaSeparatoronly handles strings; array-of-integer query params get their items coerced to strings. - Widen
integerfields with float example values. IBKR declaresSMA,balance,accruedInterestetc. asintegerbut ships their example payloads as368538.0. The snapshot tests catch this and the normaliser widens the field tonumberautomatically.
The spec-example-widening story
Step 13 was discovered by the snapshot tests in bezant-core/tests/examples.rs.
Those tests round-trip real IBKR example payloads through the generated Rust
types. The first run failed on SMA: 368538.0 because the type was i32.
Rather than papering over it with a manual cast, we made the normaliser
smarter: walk every example, find every integer-typed field with a float
value, widen the schema to number. 37 fields get widened per codegen run
now.
This is the canonical pattern: a failing test should prompt a normalisation step, not a hand-patch. It catches future IBKR drift without human attention.