Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Why the Wire Model Exists

Every API client has to turn a Rust value into bytes on the way out and bytes back into a Rust value on the way in. Rust already has a superb answer to that problem in Serde, so a new one needs to justify itself. This chapter is that justification — what the wire model is, the two things it does that Serde’s design cannot, and why it is nonetheless optional.

What it is

The wire model is a model-first (de)serialization family. A type describes what it is on the wire through a derive-emitted &'static Binding — a static table of its name, fields, tagging, and format knobs — and every codec, value tree, and diagnostic is a thin consumer of that one description.

It lives in its own workspace (capi_wire) and has no dependency on Capi. It is a standalone library you could use for anything; a client names it as a dependency of its own, because the capi_rs facade re-exports the Capi framework workspace and nothing else. What ties the two together is priority rather than coupling: the wire model is designed around the needs of API clients first and other use cases second, so its conveniences are the ones API work keeps demanding.

If you know Serde, most of the surface will feel familiar on purpose — rename, rename_all, default, flatten, skip, the four enum tagging modes, untagged, even #[wire(crate = "...")] all mean what their #[serde(...)] counterparts mean. Some ideas are borrowed from the ecosystem around Serde rather than Serde itself: the proxy-type conversions and field-level transforms will read as familiar to anyone who has used serde_with.

The two structural reasons

Familiar surface, different machine. Two properties of Serde’s design are load-bearing for it and disqualifying here.

1. A type cannot describe itself without being instantiated. Serde’s model is value-driven: Serialize::serialize pushes a value’s fields into a Serializer as it walks them. There is no way to ask a type what its wire shape is. For a self-describing format that’s fine — JSON writes names as it goes. For a non-self-describing format it isn’t: protobuf addresses fields by number, not name, and a decoder must know the number→field mapping, the wire types, and the proto2/proto3 presence rules before it has any value in hand. The wire model’s Binding is exactly that missing description, available as T::BINDING without an instance. It’s why protobuf is an ordinary format here rather than a separate derive ecosystem — and the same static description is what lets the test framework compare fixtures semantically, by reading a type’s shape rather than guessing from bytes.

2. The core trait is not object-safe. fn serialize<S: Serializer>(&self, s: S) is generic over S, so dyn Serialize is illegal and “a codec chosen at runtime” needs a crate like erased_serde to bridge the gap. Runtime format selection is the entire point of a Codec handle, so the wire model defines an object-safe split instead of papering over a generic one. The result is no erased_serde on the path and no unsafe anywhere in the family’s own code; the wire workspace itself is no_std + alloc throughout (of the codecs, only capiw_quick_xml links the standard library). Codecs and Format Contracts shows the split.

There’s a third, softer reason. Serde has no room for a format to contribute its own vocabulary — #[serde(...)] is a fixed set. XML needs to say “this field is an attribute, not an element”; protobuf needs field numbers. The wire model reserves an extension bag in the binding that any format can write into and read back through typed helpers, which is how XML, urlencoded, and protobuf each add their own annotations without the core knowing anything about them. That mechanism gets its own chapter.

Why not a reflection crate

A reflection library such as Facet attacks the same first problem — making a type’s shape inspectable — and it is a genuinely interesting approach. The trade-off is implementation machinery: building a runtime picture of arbitrary Rust types means raw pointer work, and that means unsafe.

The wire model reaches a comparable place from the other direction. Because a derive already sees the type at compile time, it can emit the description as a plain &'static table, and reading that table is ordinary safe Rust. For a library that handles credentials and request bodies, “no unsafe anywhere” is worth more than the generality full reflection would buy.

What it adds for API work

Beyond the structural reasons, the wire model ships the conveniences that API integration keeps needing and that are otherwise re-invented per client:

ConvenienceThe problem it answers
Tri-state fieldsPATCH bodies must distinguish unchanged, set, and explicitly null
Open string enumsa documented value set that the service will grow later
Number laningAPIs that quote numbers as strings, and money that must never be f64
Datetime contractsepoch vs RFC 3339 vs a bespoke format — where a wrong unit is the costliest bug
Sentinel mappingservices that encode absence as "", -1, or 9999-12-31
Value classes and redactionmarking a field secret or non-deterministic, and a typed transform so a request is born without its secrets
Constant entriesa "jsonrpc": "2.0" or an @odata.type the wire needs and no Rust field should carry

Each has a Serde-ecosystem analogue you could assemble by hand. Having them in the model — machine-readable, in the binding — is what lets tooling act on them.

It is not required

The wire model is a convenience layer, and the boundary is worth stating plainly.

At bottom a Capi endpoint is a method, a URL, an optional body implementing ToBody, and a function over response bytes. None of that mentions the wire model. A client author who wants to use Serde in their own library can: derive Serialize/Deserialize, call serde_json directly in the body and decoder, and never write a #[wire(...)] attribute. Nothing in the framework prevents it. A client that speaks a binary protocol can skip serialization libraries altogether and hand-roll bytes — the icecast_connect reference client does exactly that, storing no Codec at all.

What you give up is everything above: the swappable codec, the format markers that catch a JSON/XML mix-up at compile time, and the conveniences table. The relationship is the same one Serde has with the rest of Rust — technically optional, practically synonymous. The wire model is the default path because it earns the position, not because the framework depends on it. That argument is made in full in the design rationale.

How this part is organized

The next chapter is the one everyone needs; the rest are reference you can read when a type forces the question.