Shaping Structures
The previous chapter assumed the Rust shape and the wire shape line up. Often they don’t: the payload is buried under a wrapper key, a nested struct is spliced flat into its parent, or a field needs a conversion no derive can guess. These attributes close that gap — and the last one, tri-state, is the answer to PATCH.
Envelopes: unwrap and wrap
Two different jobs, easy to confuse.
unwrap is a container attribute: the struct’s own fields live under a wrapper key.
#[wire(unwrap = "data")]
pub struct Page { pub items: Vec<Item>, pub next: Option<String> }
// wire: { "data": { "items": [...] } } // a None `next` is omitted
wrap is a field attribute: this one value sits under a key while Rust holds it
bare.
#[wire(wrap = "content")] pub body: Msg, // { "content": <body> }
#[wire(wrap_each = "url")] pub urls: Vec<Url>, // [{ "url": <element> }, ...]
Both wrap and wrap_each are repeatable, and several wraps nest outermost-first.
wrap_each applies to each element of a Vec or Option<Vec>. The two are not
combinable with each other, and neither combines with skip, flatten, or joined.
flatten
flatten does three distinct things depending on the field type.
Splicing a struct lifts a child’s fields into the parent object:
pub struct Request {
pub id: String,
#[wire(flatten)] pub paging: Paging, // { "id": ..., "limit": ..., "offset": ... }
}
Splicing an externally tagged enum lifts the variant’s key beside the parent’s
own keys — the shape a protobuf oneof takes when it is transcoded to JSON:
#[wire(rename_all = "camelCase")]
pub enum Target { PlaceId(String), Address(String) }
#[wire(rename_all = "camelCase")]
pub struct Waypoint {
#[wire(flatten)] pub target: Option<Target>, // { "placeId": "…", "via": true }
pub via: Option<bool>,
}
Exactly one of the variant keys may be present on decode; two is an error, and none
decodes the Option as None. An untagged enum has no key to splice and is refused.
Collecting unmatched keys turns one string-keyed map into the catch-all for everything the parent didn’t match:
pub struct Response {
pub id: String,
#[wire(flatten)] pub extra: BTreeMap<String, Value>, // every unrecognized key lands here
}
The rules are strict, because the forms claim the same territory. flatten is
rejected on sequence, scalar, Option<Map>, and skip fields; you get at most one
flatten map, and not alongside a flattened struct. An Option<Struct> or
Option<Enum> field flattens as splice-or-omit — Some splices, None contributes
nothing, and decode yields None when no claimed key is present. A flattened child’s
field names must be disjoint from the parent’s and from any other flattened child’s,
and a flattened struct must not declare its own unwrap envelope.
via: proxy conversions
via is the general escape hatch for a field whose wire shape no attribute expresses:
route it through a proxy type that can.
#[wire(via = "MyProxy")] pub odd: Weird,
The contract is TryFrom in both directions — Proxy: TryFrom<FieldType> on encode,
FieldType: TryFrom<Proxy> on decode. An infallible From/Into pair satisfies it
through std’s blanket impls, so a simple conversion needs no error type. via composes
with an outer Option, and is rejected on sequence fields and alongside skip,
flatten, wrap/wrap_each, a scalar rule, optional, or tristate.
If you know serde_with, this is the same idea with a smaller surface.
Omitting on encode
The default comes first: a plain Option<T> field omits its key on encode when it is
None, with no attribute at all. The tools below are for everything else, narrowest
first:
#[wire(skip_if_none)] // container: accepted, and redundant
pub struct Patch {
#[wire(skip_encoding_if = "Vec::is_empty")] // field: omit when the predicate says so
pub tags: Vec<String>,
}
skip_if_noneis accepted and changes nothing:Nonealready omits. A field-level predicate takes precedence over it.skip_encoding_ifnames afn(&FieldType) -> bool. Decode is unaffected, so a non-Optionfield omitted this way needs#[wire(default)]to decode back. Struct fields only; not combinable withskiporflatten.
Tri-state and optional: the PATCH model
A PATCH body has to distinguish three states per field: leave unchanged, set to a
value, or explicitly clear to null. A plain Option only has two. That’s Tristate<T>
(Keep / Set(T) / Clear) with #[wire(tristate)]:
#[capi]
#[derive(WireModel, WireEncode)]
pub struct PatchCustomVoiceRequest {
#[wire(tristate)] pub name: Tristate<String>,
#[wire(tristate)] pub description: Tristate<String>,
}
| State | On the wire | Meaning |
|---|---|---|
Keep (the Default) | omitted entirely | leave the server’s value alone |
Set(v) | the value | set it |
Clear | explicit null | clear it |
On decode the two absent-ish cases stay distinct: an absent field yields Default
(Keep), while a present null goes through the field type’s WireDecode.
#[wire(optional)] is the two-state sibling for a non-PATCH sparse body: the
field type’s WireOptional impl decides value-vs-omit on encode, and on decode both
absent and — leniently — a present null yield the default. Two types carry the
impl: Option<T>, where None omits (which a plain Option field already does
without the attribute), and String, where an empty string omits — so the
attribute’s distinctive work is the empty-string case and any wrapper type of your
own that implements WireOptional. It composes with default and wrap.
The two are mutually exclusive, neither combines with skip, flatten, or
skip_encoding_if, and both are struct-only (enum variant fields reject all three).
tristate additionally rejects default — its absent state is always its own
Default — and wrap/wrap_each.
Entries with no field
Some wire objects carry a member that describes the message rather than the model —
JSON-RPC’s "jsonrpc": "2.0", OData’s @odata.type annotation beside a typed value —
and a Rust field for it would be a field every constructor has to fill with the same
literal. #[wire(constant(...))] emits the entry from the literal instead:
#[derive(WireModel, WireEncode, WireDecode)]
#[wire(constant("jsonrpc" = "2.0"))] // container: leads the object
pub struct Call {
pub method: String,
#[wire(constant(suffix = "@odata.type", value = "Edm.Int64"))] // field: rides its host
pub count: Option<u64>, // { "count@odata.type": "Edm.Int64", "count": 3 }
}
A container constant names its entry outright, on a struct or an enum variant, and
is emitted before the container’s own members. A field constant derives its name
from the host field — the host’s wire name plus the suffix — is emitted immediately
after the host, and only on the paths where the host emits a member: an omitted
None host omits its constant, a tristate Clear keeps it. rename_all never
touches a constant’s name. The value grammar is the four scalar lanes — string,
integer, float, boolean — which every format renders without nesting, an XML
attribute and a urlencoded pair included; a field-numbered format (protobuf) refuses
a constant exactly as it refuses an unnumbered field.
A constant is encode-only. It is never a member of fields, so on decode the key is
an unknown one, handled as the type already handles those, and never verified
against the declared value: the type’s own fields are the contract, and a service
that changed "jsonrpc" has changed its protocol. The literal is source, so a
constant is never sensitive and never volatile.
What a field can be
The model implements the wire traits for a closed set of leaf and container types, and a field is one of them, a derived model, or a type with its own impls (escape hatches):
| Kind | Types |
|---|---|
| booleans and numbers | bool; i8–i64, i128, u8–u64, u128; f32, f64 — no usize/isize, whose width is the platform’s |
| text and bytes | str, String; Bytes — a byte string, rendered by a base64/base64url/hex scalar rule in text formats and as an array of byte values when no rule is set (Vec<u8> is a sequence of integers) |
| presence | Option<T>, Tristate<T> |
| containers | Vec<T>; BTreeMap<String, V> — the only map, so encoded objects are canonical |
| carriers | the datetime and decimal types, Duration under a duration rule |
There are no impls for tuples, arrays, or HashMap: a positional tuple has no wire
name to encode under, and a hash map’s iteration order would make the same value
encode differently between runs. A Vec<(K, V)> or a BTreeMap covers the map
case; a newtype or a small struct covers the rest.
Next: Scalars, Numbers, and Money — the leaf-level rules.