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

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_none is accepted and changes nothing: None already omits. A field-level predicate takes precedence over it.
  • skip_encoding_if names a fn(&FieldType) -> bool. Decode is unaffected, so a non-Option field omitted this way needs #[wire(default)] to decode back. Struct fields only; not combinable with skip or flatten.

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>,
}
StateOn the wireMeaning
Keep (the Default)omitted entirelyleave the server’s value alone
Set(v)the valueset it
Clearexplicit nullclear 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):

KindTypes
booleans and numbersbool; i8i64, i128, u8u64, u128; f32, f64 — no usize/isize, whose width is the platform’s
text and bytesstr, 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)
presenceOption<T>, Tristate<T>
containersVec<T>; BTreeMap<String, V> — the only map, so encoded objects are canonical
carriersthe 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.