API Versioning
API Versioning
Section titled “API Versioning”A versioned IDL describes one contract: the latest state of the API. The version history is declared next to it, and older versions are derived from annotations instead of copied files.
#pragma xidlc package petstore
@versionenum ApiVersions { @rename("1.0") v1, @rename("2.0") v2,};
struct Pet { string id;
// introduced in v2 @added(ApiVersions::v2) @optional string note;
// deprecated at v2 and gone from then on @removed(ApiVersions::v2) string color;};
interface Pets { @get(path = "/pets/{id}") Pet get(@path string id);
@removed(ApiVersions::v2) @get(path = "/legacy") string legacy();};Declaring the history
Section titled “Declaring the history”@version marks exactly one enum as the API version timeline. Its members are
the versions, in declaration order; the last member is the version the
source describes. Annotate a member with @rename("...") to give it a display
value such as a date or a semantic version:
@versionenum ApiVersions { @rename("2025-01") v2025_01, @rename("2025-06") v2025_06, @rename("2026-01") v2026_01,};The member name (v2025_06) is the stable machine name used in output paths
and selection flags; the value (2025-06) is what versioned documents carry.
Per-version documents ignore #pragma xidlc version and use the projected
version value as info.version.
Carrying the version
Section titled “Carrying the version”Use @version(in = "header" | "query" | "cookie", name = "...") to declare
where clients send their module’s version. Both arguments are required; in
ignores case. A bare @version does not add a transport parameter.
@version(in = "query", name = "api_version")enum ApiVersions { @rename("2025-01") v2025_01, @rename("2025-06") v2025_06,};Header and cookie names must be non-empty HTTP tokens. Query names must be
non-empty and contain no control characters. A version parameter cannot share
its name and location with an operation parameter or an API key. Header names
ignore case; query and cookie names are case-sensitive. A version header also
cannot overwrite a business response header. Header version values must be
ASCII without leading or trailing HTTP whitespace. Transport fields such as
Content-Type, Cookie, Set-Cookie, and WebSocket handshake headers cannot
be used as version headers; an active authentication scheme also reserves
Authorization. Query and cookie values
are encoded for their transport.
Each OpenAPI operation declares an optional parameter with a singleton enum containing its own version. Generated Rust, TypeScript, and Go REST clients pin the parameter after request parameters and credentials are applied. Other query parameters and cookies in the request are preserved. For Rust, pass cookies through operation parameters or authentication; cookies hidden in a custom reqwest client’s default headers cannot be merged with an explicit generated Cookie header.
With in = "header", every generated operation response declares the header
and the server writes its own module’s version, including errors. A v2
server writes v2 even when the request carries v1, an unknown value, or no
version. Query and cookie parameters are not echoed.
Servers never resolve the version. Routing to a version remains the application or gateway’s responsibility. A generated server does not read the version parameter to choose a module or reject a request.
| Location | HTTP clients | WebSocket handshake |
|---|---|---|
| header | Rust, TypeScript, and Go REST send the pinned header | Rust and Node send it; browsers use the server default |
| query | Every client sends the encoded query parameter | Every client, including browsers, pins it in the URL |
| cookie | Node and Rust send Cookie; browser HTTP and WebSocket clients use app-set cookies | Node and Rust send Cookie; browsers use app-set cookies |
Browser WebSocket constructors cannot set custom headers. In Node, pass a
headers-capable factory to the generated open_*Session helper:
import WebSocket from "ws";
const session = await open_watchSession(baseUrl, { connect: ({ url, protocols, headers }) => new WebSocket(url, protocols, { headers }),});The generated helper combines caller headers with the pinned version before
calling connect. Browser callers can use the default WebSocket constructor;
query versions work without a custom factory. An application’s WebSocket
upgrade entry point owns the HTTP handshake response; generated TypeScript
server operation metadata exposes response.fixedHeaders for that entry point.
Lifecycle annotations
Section titled “Lifecycle annotations”| Annotation | Meaning |
|---|---|
@added(Versions::vN) |
the declaration exists from that version onward |
@removed(Versions::vN) |
the declaration is removed at that version |
Both can appear on modules, type declarations (structs, unions, enums, exceptions, typedefs, bitsets, bitmasks), struct/union members, enumerators, bitmask values, interfaces, operations, operation parameters and interface attributes.
Rules:
- Annotation order never matters.
@added(v2) @removed(v3)and@removed(v3) @added(v2)describe the same interval. - Each declaration carries at most one
@addedand one@removed, and@addedmust come strictly before@removed. Cycles such as@added(v2) @removed(v3) @added(v4)are rejected; declare a second member with a disjoint interval instead. - A child’s interval must stay inside its parent’s. A member added in v1 of a struct added in v2 is an error.
- A surviving declaration must not reference a type that the projected version drops. Such a reference is reported with the declaration and the version.
raises(...)entries are the one filtered position. An exception set has no annotation slot, so an operation raises a versioned exception only in the versions that declare it: older projections drop the entry.- Root-qualified references (
::Models::Item) keep pointing at the same declaration: projection rewrites them relative to the version module, so they resolve insidemodule <version>rather than at the file root.
Unsupported versioning annotations (@versioned, @renamedFrom, @replaces,
@madeOptional, @madeRequired, @typeChangedFrom,
@returnTypeChangedFrom, @defaultVersion) are rejected with an explicit
error instead of being silently ignored. These names do not imply planned
support. Use @rename("wire_name") to select a serialized name; it does not
describe a rename at a particular version. Changes between versions use the
disjoint declarations described below.
Renames and shape changes
Section titled “Renames and shape changes”A rename or a shape change is
written as a new member with a disjoint interval; @rename keeps the wire
name stable when needed:
struct Pet { @added(Versions::v2) @removed(Versions::v3) int32 age;
@added(Versions::v4) @rename("age") int32 age2_;};The compiler does not infer links between removed and added fields, even when
only their names differ. Application hooks explicitly assign the new value and
acknowledge the old input with IGNORED. The downcast hook obtains old facts
from StoredValues, or returns OMIT for an absent optional fact. NEEDS
continues to report the old wire name while a supported version reads it; a
reader may obtain that fact directly from Head when the application knows the
two fields have identical meaning.
struct Parcel { @removed(Versions::v3) @optional string courier_note; @added(Versions::v3) @optional string courier_remark;};// Parts of the application's generated VersionAdapters / StoredValues contract.parcelUpcastFromV2: source => ok({ courier_remark: source.courier_note, courier_note: a.IGNORED,}),parcelDowncastToV2: (source, stored) => ok({ courier_note: stored.parcelCourierNote(source) ?? a.OMIT,}),parcelCourierNote: source => source.courier_remark,Both a supplied value and its absence survive this application’s round trip. The assignments are business decisions, not a generated matching rule.
Generated output
Section titled “Generated output”Projection wraps every version in module <member-name> { ... }, evaluates
declaration availability, and removes lifecycle annotations before rendering
the per-version contract.
Code targets keep one module per version inside a single artifact:
pub mod v1 { /* structs, traits and servers for v1 */ }pub mod v2 { /* structs, traits and servers for v2 */ }OpenAPI and OpenRPC emit one document per version, named after the version member:
out/openapi_petstore.v1.json # info.version = "1.0"out/openapi_petstore.v2.json # info.version = "2.0"To freeze released documents in CI, regenerate the version directory and let the repository review the byte-level result:
xidlc gen --out-dir api/versions openapi api.idlgit diff --exit-code api/versionsFor a human-readable contract review, compare two projections as formatted IDL:
xidlc version diff v1 v2 api.idlThe two version arguments accept enum member names or display values. The diff
shows contract annotations, routes, and parameter locations, while ignoring
source whitespace and documentation comments. Identical projections produce no
output and exit with 0; differences exit with 1; invalid inputs or output
failures exit with 2. Unknown versions report the available choices.
Every declared version is emitted by default. Narrow the output with
--api-version (one version) or --api-versions (a comma separated list, or
* for every version). Selection accepts the member name or the display value,
and rejects unknown versions with the list of declared ones:
xidlc gen openapi -o dist petstore.idlxidlc gen openapi --api-version v1 -o dist petstore.idlxidlc gen rust --api-versions v1,v3 -o dist petstore.idlxidl-build exposes the same selection through Builder::with_api_version and
Builder::with_api_versions.
Version change facts
Section titled “Version change facts”Generate a language-neutral table for the complete timeline:
xidlc gen --out-dir out version-map petstore.idlout/petstore.version-map.json contains direct changes between adjacent versions.
The same JSON value is available to generators and plugins as props.version_map.
Version selection does not narrow this table. Keys are sorted, indentation uses
two spaces, and the file ends with a newline.
Format 2 includes declaration_id in member, enumerator, union-case, operation,
and parameter descriptors. Request parameters are in parameters; output and
inout parameters also appear in response_parameters, keyed by location and
wire name. Each response binding contains an array of descriptors in source
order, including single-item bindings; this preserves repeated response headers.
An inout declaration has the same token on both sides. The synthetic
return value has no parameter identity. It distinguishes two declarations that happen to
have the same name, wire name, and type. The same source declaration keeps its
token across projections; two distinct declarations receive different tokens.
Tokens are opaque and local to the current source, so consumers must compare
them within one table and must not use them as persistent identifiers across
source revisions. Whitespace and comment changes preserve the tokens.
This changes the meaning of an omitted change: equal endpoint shapes alone no longer hide a declaration replacement. Format 2 readers can distinguish that replacement without recovering lifecycle annotations from projected HIR. The table records facts; applications and optional adapter targets decide how to convert values, resolve API versions, and route requests.
Target support
Section titled “Target support”Versioned contracts project into one module per version, so they need targets whose module support renders a single module per API version:
| Target | Versioned contracts |
|---|---|
rust, rust-axum, typescript, typescript-rest, go-rest |
supported |
openapi, openrpc |
supported, one document per version |
go, rust-jsonrpc |
rejected with a clear error |
The remaining targets already misinterpret modules today (they repeat the module wrapper or reference unqualified type names), so generation for them fails loudly instead of emitting code that does not compile.
Serving several versions from one implementation (runtime conversion) is not part of this feature; each version module is an ordinary generated server that can be started on its own.
Optional TypeScript version adapters
Section titled “Optional TypeScript version adapters”Generate the ordinary SDK and the adapter module independently:
xidlc gen --out-dir gen typescript-rest api.idlxidlc gen --out-dir gen typescript-rest-adapters api.idlThe second target writes only api.adapters.ts. Ordinary SDK and server
artifacts are unchanged. It consumes projected HIR and format-2
props.version_map; the compiler core records facts and does not select an
application architecture.
The application resolves a version identifier and extracts HTTP parameters.
Pass a source-parameter envelope to dispatch<Interface><Operation>(version, raw, handler, adapters): the envelope uses IDL parameter names, while nested
JSON payloads retain wire keys. For example, { parcel: await request.json() }
preserves unknown fields for served-version validation. Do not deserialize the
payload first: an ordinary schema may discard fields that an older version
must reject. Header, query and cookie parameters with the same wire name remain
separate source parameters.
Generated decoders recursively validate required fields, named types, sequences,
fixed arrays, records and unions before calling any conversion hook. Renamed
keys and flattened fields become model properties only after wire validation.
Unknown object keys and known fields belonging to another version are rejected;
flattened records retain their own arbitrary keys. Reserved __proto__ object
keys are rejected before Zod can discard them.
Implement the generated VersionAdapters interface with pure decisions. Missing
hooks fail TypeScript compilation. Removed input members require IGNORED;
optional additions remain absent unless a simultaneous removal/addition requires
an explicit replacement decision. An addition that declares @default is the
other exception: the Head handler would otherwise apply the new default to
every older request, so the upcast decision requires the value each older
version implied, as a plain (non-optional) field. When that value equals the
new default the hook returns the default - the equality is a product fact and
stays visible in code. The version map records every declared default on its
member descriptor, so a default that changes between versions shows up as a
fact rather than as silent behaviour drift. Conversion follows every adjacent step to
Head, short-circuiting on refusal. Handlers receive both the original version id
and its declared display value. Typed exceptions downgrade through every step;
framework errors preserve their identity.
A member an older version declares and Head removed is always an explicit
decision, derived from @added / @removed alone. On the way up the hook
acknowledges the value with IGNORED. On the way down the decision requires the
member: read it from storage through the generated StoredValues interface, or
write OMIT to leave it absent — a key can no longer disappear silently. A
required retired member has no OMIT escape: the decision needs a value, and a
hook that cannot supply one refuses with UnsupportedProjection.
Each reader is a pure accessor for one retired member, named after its type or
response (parcelHandlingNote, depotTraceResponseTraceId). The reader
receives the newer-side instance that owns the member — for an operation
response, that is the response value. The application resolves identity itself:
a reader can only recover a per-instance value when the instance carries
something addressable (an id, a natural key); otherwise the reader returns
undefined and the decision omits or refuses. The chain stays pure and never
touches storage. NEEDS lists, per served version and per type or operation
key, the wire names to preload; Head reads nothing. Because readers are
synchronous, batch reads follow a two-phase shape: preload the rows for
NEEDS[version] in the handler, then return readers that close over them —
one batch per request, whatever the response size. Dispatch handlers return
{ stored } beside their outcome so the whole downcast chain receives the same
readers. Serving Head through the aggregate dispatch entry still expects a
stored value; the per-version entries keep the exact signatures.
downcast<Interface><Operation>OutcomeTo<Version> preserves the original Head
outcome when conversion fails. A write may already have committed: the application
owns idempotency, recording that outcome and rendering its declared refusal
error. Dispatch never retries the handler. downcast<Type>(version, head, adapters) exposes the same complete chain for reachable result types.
Phase one rejects retired or replaced operation declarations, stream/upgrade frame conversion, inherited structs, custom template types and inline union case declarations whose nested identity is not exposed by the public map. These are explicit generation errors, not silent field loss. This target does not read version headers, choose routes, provide defaults or infer rename intent.
Optional Rust version adapters
Section titled “Optional Rust version adapters”The Rust target writes a separate artifact beside the ordinary Axum models:
xidlc gen --out-dir gen rust-axum --client --server api.idlxidlc gen --out-dir gen rust-axum-adapters api.idlInclude api.rs and api.adapters.rs in the same Rust module. Implement the
mandatory VersionAdapters trait with pure hooks, then pass an explicitly
resolved Version to dispatch_<interface>_<operation>. As in TypeScript, raw
request envelopes use IDL parameter names and nested payloads use wire keys.
The application owns parameter extraction, version resolution and routing.
The decoder constructs the exact served model while checking its wire layout. Nested structs, flattened records, arrays, recursion and unions follow the same rules before any hook or handler runs. Numeric record keys use ordinary Serde map-key decoding; distinct wire keys that decode to the same key are rejected. An HTTP union constructs its strictly matching variant directly; ambiguous payloads are rejected instead of choosing a different variant and losing data.
Conversions borrow their sources. downcast_<interface>_<operation>_outcome
returns the original owned Head result on refusal, including composite responses
and typed errors that do not implement Clone. The Head identity branch moves
that result directly. downcast_<type> consumes its Head model so its identity
branch also avoids a whole-value clone. Asynchronous dispatch accepts thread-safe
hooks and can be awaited inside an Axum handler; it never retries the handler.
Retired members follow the same explicit-decision rule as TypeScript: an upcast
acknowledges them with IGNORED, and a downcast decision requires the field —
an optional retired member reads the generated StoredValues trait or writes
None explicitly, a required one needs a value or a refusal. Every retired
member needs one reader method, so a member retired too early shows up as a
missing method, not as lost data. NEEDS lists the wire names each served
version still reads; Head reads nothing. Dispatch handlers return
HeadOutcome { outcome, stored } so the chain receives the readers.
In addition to the shared phase-one limits, Rust rejects skipped physical model
fields or cases and ordinary unions with multiple or default discriminator
labels. Their public model constructors do not expose a lossless construction
contract. Ordinary rust-axum output remains byte-identical when generating
adapters separately.
Update adapters
Section titled “Update adapters”A @patch or @put operation whose Head declaration returns one named struct
takes part in the update flow when one adjacent step removes the same wire
member from both its request payload and its return type. The retirement is
then a replacement of a writable value, not a loss: the step’s upcast
conversions return the persisted member to the application instead of
acknowledging it.
Such conversions return an Upcast value that pairs the projected request
with a stored bag holding every paired member collected along the chain.
The bag is named after the operation (for example DepotUpdateStored) and
merges across steps, so a multi-step chain hands the handler one complete
bag. The decision hook of a paired conversion receives current, the Head
return-type instance the application is about to patch, and its decision
records both the acknowledgement (IGNORED) and the values to persist:
parcelPatchUpcastFromV2: (source, current) => ok({ handling_note: IGNORED, stored: { handling_note: source.handling_note }, }),decodeAndUpcast<Operation> and dispatch<Operation> accept current as an
explicit parameter; the update handler receives it together with the context
and the bag, persists the bag members with its own storage, and returns the
Head outcome together with the stored values its response downcast still
reads. Because the request member and the return member share one wire name,
a value written by an old caller reads back identically: write-then-read and
read-then-unchanged-write are golden properties of the generated chain.
The selection stays strict. Request chains must be operation-disjoint; two
update operations may not share one request conversion. current never
routes through alias or union conversions, and never reaches a paired
conversion hidden inside a sequence, map, or optional member. Every one of
these shapes is a generation error naming the offending declaration.