Format Extensions
Advanced. You need this chapter to write a new format, and to understand why XML and
protobuf annotations look different from ordinary #[wire(...)] ones. Using a shipped
format needs only the short worked examples at the end.
Every attribute so far has been format-neutral: rename and flatten and default
mean something in any format. But real formats need vocabulary the core cannot
anticipate. XML has to distinguish an element from an attribute from text. Protobuf
addresses fields by number and cares about proto2-vs-proto3 presence rules. A URL form
has no single convention for repeated keys.
Serde’s answer to this is that there isn’t one: #[serde(...)] is a fixed set, so
format-specific needs get solved by parallel derive ecosystems. The wire model’s answer
is an extension bag in the binding that any format can write into and read back.
The mechanism: the ext bag
Every Binding, FieldBinding, and VariantBinding carries an ext slice of
(&'static str, ExtVal) pairs:
pub enum ExtVal {
Str(&'static str),
Int(i64),
Bool(bool),
}
Three value kinds, all 'static, all Copy — enough to express a field number, an
element name, or a boolean mode, and nothing more. The keys are plain strings, and
namespaced keys like "xml.root" keep formats from colliding.
You can write into the bag directly:
#[wire(ext("xml.root" = "Order", "proto.id" = 3))]
but you normally shouldn’t. The raw keys are an internal contract between an extension’s macro (which writes them) and its codec (which reads them) — which is why every shipped extension provides a decoration macro to write them and typed readers to read them, so neither library authors nor codec writers ever touch a stringly key.
Anatomy of a format contract
A format contract crate is three things, and the shipped ones are all built the same way:
- A marker. One zero-sized type —
Xml,Protobuf,UrlForm— so a codec speaking the contract returnsCodec<Xml>and an API boundary requiring XML names it. This is the whole of the JSON contract, since JSON is self-describing and needs no annotations at all. - A decoration macro, behind a
modelfeature.#[xml_extension],#[proto_extension],#[urlform_extension]— each accepts format-shaped attributes and lowers them into the coreextbag. Crucially, the macro expands to the core#[derive(WireModel, WireEncode, WireDecode)], so a decorated type is an ordinary wire-model type that happens to carry extra knobs. - Typed readers. Functions like
place(),root_name(),xmlns(),field_number(),list_convention()that read the bag back and return a real type instead of anOption<&str>a codec would have to interpret.
The model feature is off by default, and the reason is worth noting: a codec needs
only the marker and the readers, never the macro. Gating the macro means a codec crate
never compiles a proc-macro it will not use.
The division is the same one the contract/engine split
draws everywhere else: the contract crate is tiny and codec-agnostic, and one or more
byte engines implement it. capiw_ext_xml defines what XML annotations mean;
capiw_quick_xml is one engine that honors them.
The shipped extensions
Four contract crates ship: capiw_ext_json (the Json marker and nothing else —
JSON is self-describing), capiw_ext_xml, capiw_ext_urlencoded, and
capiw_ext_protobuf. The three with vocabulary each carry a decoration macro behind
an off-default model feature; a codec consumes the contract without it.
XML
XML needs to know where a field goes in the document. The decoration macro expands to the three wire derives itself, so a decorated type derives only its ordinary Rust traits:
#[xml_extension(root = "Grantee", xmlns = "http://s3.amazonaws.com/doc/2006-03-01/")]
#[derive(Debug, Clone, PartialEq)]
pub struct Grantee {
#[xml(attribute)]
#[wire(rename = "xsi:type")]
pub kind: String, // rendered as an attribute, not a child element
pub id: String, // a child element by default
}
The field annotations are #[xml(attribute)], #[xml(text)], and #[xml(element)],
read back through place() (yielding an XmlPlace). The type-level root and
xmlns arguments carry the root element name and namespace, read through
root_name() and xmlns(). #[xml_extension] takes a named-field struct; an enum
uses the plain derives.
URL forms
A form has no agreed encoding for a list field — APIs variously want repeated keys, bracketed keys, or one delimited value — and the choice is per field, so it rides on the field’s bag:
#[urlform_extension]
pub struct Filter {
pub tag: Vec<String>, // bare, or #[urlform(repeated)]: tag=a&tag=b
#[urlform(brackets)] pub id: Vec<u32>, // id[]=1&id[]=2
#[urlform(comma)] pub kind: Vec<Kind>, // kind=a,b
}
#[urlform(delim = "…")] covers other separators, and the codec reads the choice back
through list_convention(). The UrlForm marker also implements QueryFormat —
the format emits key=value text — which is what makes Codec<UrlForm>::encode_qs
exist for an endpoint’s query string. Note that the delimited forms lower to the codec-agnostic
#[wire(joined = ...)] scalar rule rather than to an
XML-style ext key — an extension should reach for a core rule when one already says what
it means.
Protobuf
Protobuf is the case that justifies the whole design, because it is non-self-describing: fields are addressed by number, and the decoder needs the mapping before it sees a value.
#[proto_extension]
pub struct Order {
#[proto(id = 1)] pub id: u64,
#[proto(id = 2, sint)] pub delta: i32,
#[proto(id = 3, packed)] pub tags: Vec<u32>,
pub note: String, // no id: numbered sequentially, here 4
}
Fields without an id are numbered sequentially from the last explicit number,
starting at 1 — most messages carry no #[proto(id)] at all and let declaration
order number them. The other field knobs are sint / fixed (the integer wire
encoding; the fixed width resolves from the declared type), packed / unpacked
(overriding the syntax-level default for a repeated scalar), and, under proto2,
default = <literal>. On an enum, #[proto_extension] numbers the variants: a unit
enum’s values from 0 (proto3 requires a variant numbered 0), a data-carrying enum’s
oneof members from 1; #[proto_extension(enum_type = "open" | "closed")]
overrides the openness. The macro validates every number at compile time —
positive, unique per message, within 1..=536_870_911, outside the reserved
19000..=19999 — and rejects an id on a skip or flatten field, which protobuf
cannot address. A constant entry is refused the same way: a field-numbered format
has no slot for an entry with no number.
The readers are field_number(), variant_number(), syntax(), int_encoding(),
packed(), and enum_openness(); the type-level #[proto_extension(syntax = "proto2")]
bundle switches the presence semantics wholesale — explicit scalar presence,
unpacked repeated fields, closed enums, custom defaults — and the per-field keys
override it. A Duration field on a gRPC-transcoded API takes the
duration(format = "protobuf") rule from the datetime chapter. The capiw_prost codec wraps prost’s fuzzed
varint/tag/length-delimited primitives — deliberately not the derive-based
prost::Message trait, because a Capi codec consumes the erased event stream, which a
derive-generated Message impl cannot speak.
This is the concrete payoff of a static binding:
the codec learns every field number from T::BINDING without ever holding a value.
Writing your own
To add a format, you write a contract crate with the three parts above:
- Define the marker and
impl Format for YourMarker {}. It’s an unsealed, empty trait, so this is one line in your own crate — no central registry, no orphan-rule problem. Check first that a contract for your format doesn’t already exist: a second marker for an existing format splits it into two mutually incompatible formats as far as the compiler is concerned, which is the one thing worth coordinating. - Decide your keys, namespaced (
"myfmt.thing"), and write typed readers over them. - If your format needs author-facing annotations, add a decoration macro behind a
modelfeature that lowers them into#[wire(ext(...))]and expands to the core derives. If it doesn’t — if your format is self-describing — skip this entirely and ship just the marker, as JSON does.
Then a byte engine implements ByteEncode and ByteDecode — WireFormat is the
blanket over the two — and returns Codec<YourMarker> from a codec() function.
ByteEncode::encode_into (append to a caller-owned buffer) has a provided default
over encode_u8, and a scalar rule’s render_bytes / parse_bytes are what an
engine calls for a leaf with a rule. Nothing in the core changes, and nothing in the
core needed to know your format existed.
Next: Hand-Written Impls and the Value Tree — the last resort when a type’s shape defeats every attribute.