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

Describing a Type

A type joins the wire model with three derives. This chapter covers what each one emits, the Binding they produce, and the two attribute families every type needs — naming and presence.

use capi_wire_model::{WireDecode, WireEncode, WireModel};

#[derive(WireModel, WireEncode, WireDecode)]
struct Shipment {
    id: String,
    weight_grams: u32,
}

The derives come from capi_wire_model, which a client crate depends on directly and enables with its derive feature.

The three derives

They divide cleanly — three derives, one description, plus the shape and the redaction the description implies:

  • WireModel emits a const BINDING: &'static Binding — a static description of the type’s wire shape, the single source of truth a codec reads; tooling can read T::BINDING to learn a type’s shape without instantiating it. It also emits two impls the description implies: WireShape, the recursive Shape every field binding stores for its type, and Redact, the typed redaction transform that replaces the model’s sensitive fields with placeholders. The Redact emission has a compile-time consequence: every non-skip field’s type must implement Redact — derived models, the primitives, Option, Vec, BTreeMap<String, _>, Tristate, Bytes, and the datetime and decimal types all do, and a hand-written leaf adds two identity methods (Value Classes and Redaction).
  • WireEncode implements fn encode<E: Encoder>(&self, encoder: E) — the value pushes itself into whatever encoder a codec provides.
  • WireDecode<'de> is the inverse, building a T from a decoder.

WireEncode/WireDecode reference <Self as WireModel>::BINDING, so a type that derives either must also derive WireModel — the one exception being #[wire(scalar)], which carries no binding. You normally derive all three together, and derive only WireEncode for a request type that never comes back or only WireDecode for a response type you never send.

The derives accept named-field structs, single-field tuple structs (transparent newtypes), and enums in four tagging modes.

What the Binding holds

The binding is the whole point of the design, so it’s worth seeing:

pub struct Binding {
    pub name: &'static str,                        // the type's wire name
    pub envelope: Wrap,                            // struct-level wrap/unwrap policy
    pub fields: &'static [FieldBinding],           // in declaration order; empty for an enum
    pub variants: &'static [VariantBinding],       // non-empty marks this an enum
    pub tagging: Tagging,                          // how variants are tagged
    pub unknown_variant: UnknownPolicy,            // what to do with an unrecognized tag
    pub tag_field: Option<&'static FieldBinding>,  // discriminator, for internal/adjacent
    pub content_field: Option<&'static FieldBinding>,
    pub ext: &'static [(&'static str, ExtVal)],    // format-extension knobs
    pub constants: &'static [ConstantBinding],     // wire entries emitted from literals, no field behind them
}

Each entry in fields is a FieldBinding — the table a codec and the walker actually read, one row per field:

pub struct FieldBinding {
    pub rust_name: &'static str,   // for error paths, and the default wire name source
    pub wire: WireName,            // the encode name plus any decode-only aliases
    pub wrap: Wrap,                // per-field wrap/unwrap policy
    pub scalar: Option<ScalarRule>,// the leaf rendering rule, when the field is a scalar
    pub presence: Presence,        // whether the field must be present
    pub shape: Shape,              // the recursive wire shape of the field's type
    pub class: ValueClass,         // Plain, Volatile, or Sensitive(Mask)
    pub flatten: bool,             // spliced into the parent rather than nested
    pub ext: &'static [(&'static str, ExtVal)],
}

A VariantBinding carries the same for each enum variant, constants included.

Everything is &'static — the binding is a table baked into the binary, not something built at runtime. That is what makes it readable without a value, which is what makes non-self-describing formats work. The ext bag at the end is the format-extension mechanism.

For a non-generic type the derive emits a shared static; for a generic one it emits a per-instantiation associated const, because a plain static cannot name type parameters.

Two macros, one type

Request and response types often sit between two independent macros, and keeping them straight avoids most early confusion:

  • #[capi] is ergonomics only — it generates a constructor and setters so callers build a value fluently. It adds nothing to the wire mapping.
  • #[derive(WireModel, WireEncode, WireDecode)] is the wire mapping. Every #[wire(...)] attribute in this part tunes that.

A type often carries both. They don’t interact — one shapes the Rust API, the other shapes the wire.

#[capi]
#[derive(Debug, Clone, WireModel, WireEncode)]
pub struct CreateShipment {
    pub address: String,           // required → a `new` parameter
    pub weight_grams: Option<u32>, // optional → a `.weight_grams(...)` setter
    pub tags: Vec<String>,         // optional → a `.tags(...)` setter
}

// caller:
let s = CreateShipment::new("12 Main St")
    .weight_grams(500)
    .tags(["express", "insured"]);

#[capi] also emits a rustdoc # Parameters section documenting each field, picking up the wire contract so readers of the model never have to guess. A field opts out of that documentation with #[wire(doc = false)], which has no wire effect.

Argument types follow one rule. Primitives — the integer and float types, bool and char — appear as themselves, so a bare literal infers: weight_grams(500). Every other type is taken as impl Into<T>, which is what lets new("12 Main St") hand a &str to a String field. A Vec<T> field accepts any iterator whose items convert Into<T> (the IntoVec<T> blanket), which is why .tags(["express", "insured"]) reaches a Vec<String>; a Vec of a primitive takes an iterator of that primitive.

That’s the entire builder story; it’s deliberately dumb, so it never fights the wire mapping.

Naming

#[derive(WireModel, WireEncode, WireDecode)]
#[wire(rename_all = "camelCase")]        // container: recase every field
pub struct Query {
    #[wire(rename = "type")]             // field: an exact wire name
    pub kind: String,
    pub r#type: String,                  // a raw identifier contributes its bare name, "type"

    #[wire(alias = "max", alias = "cap")] // field: extra names accepted on decode only
    pub limit: u32,
}
  • rename sets an exact wire name, on the container (the type’s own wire name) or a field. A reserved word needs no rename: a raw identifier (r#type) contributes its bare name, type, the way it resolves in Rust.
  • rename_all recases field names that lack an explicit rename. Accepts lowercase, UPPERCASE, camelCase, PascalCase, snake_case, SCREAMING_SNAKE_CASE, kebab-case, SCREAMING-KEBAB-CASE. On an enum it recases variant tags, not their fields.
  • alias (repeatable) adds names accepted on decode; encode always writes the canonical name. This is the tool for an API that renamed a field but still sends the old one. Canonical names and aliases must be distinct across the struct.

Presence: required, optional, skipped

Decoding fails with a missing-field error naming the wire key unless a field is optional. A field is optional when it is an Option, carries default, or is marked optional/tristate:

pub struct Query {
    pub kind: String,                       // required

    pub limit: Option<u32>,                 // optional: absent → None

    #[wire(default)]                        // optional: absent → Default::default()
    pub page: u32,

    #[wire(default = "default_page_size")]  // optional: absent → the named function
    pub size: u32,

    #[wire(skip)]                           // never on the wire, absent from the binding
    pub internal: Cache,
}
  • default makes a field optional on the wire and fills an absent one from Default::default() or a named function. On an Option field, default = "path" fills the absent case from the path rather than collapsing to None.
  • skip removes the field from the wire and from the binding entirely; it is produced from its default on decode. Use it for cached or derived state that has no business on the wire.

Omitting a field on encode is a separate question. The default needs no attribute: a plain Option<T> field omits its key when it is None. The other structure-shaping attributes are in Shaping Structures: skip_encoding_if for a predicate, and optional/tristate for the richer presence models.

Transparent newtypes

A single-field tuple struct is transparent: its wire form is the inner value’s, and its encode/decode delegate straight through.

#[derive(WireModel, WireEncode, WireDecode)]
pub struct Rate(u64);      // on the wire this is just a number

Because it has no fields of its own, it accepts only rename, ext, bound, and crate; its binding is fieldless and carries a ("transparent", true) ext marker so tooling can recognize it. This is the cheapest way to give a primitive a domain type without changing the wire at all.

Entries with no field

A wire object sometimes needs a member no Rust field should carry — JSON-RPC’s "jsonrpc": "2.0", OData’s @odata.type beside a typed value. #[wire(constant(...))] emits such an entry from a literal: on a container, #[wire(constant("jsonrpc" = "2.0"))] names the entry outright and it leads the object; on a field, #[wire(constant(suffix = "@odata.type", value = "Edm.Int64"))] derives the name from the host field and rides its presence. Constants are encode-only and never members of fields — on decode the key is an unknown one, handled as the type already handles those — and they populate the binding’s constants slice for introspection. Shaping Structures has the rules.

Container attributes the derive also takes

Three more container attributes rarely appear on a type and matter when they do: bound = "…" (or bound(encode = "…", decode = "…")) adds where-clauses the derive cannot infer for a generic type; crate = "path" renames the wire-model crate the generated code refers to; and datetime_crate = "path" does the same for the datetime carriers. Hand-Written Impls and the Value Tree covers the generic and crate-path cases.

Next: Shaping Structures — what to do when the wire shape and the Rust shape don’t line up.