Encode and Decode
Two styles — pick whichever fits:
fixed() struct (fill every field at once — compile error if a field is missing):
#![allow(unused)] fn main() { pub fn demo_fixed_heartbeat() -> Result<Vec<u8>, Box<dyn std::error::Error>> { // Const length → stack array (no heap). let mut buf = [0u8; HeartbeatEncoder::compute_length_with_header()]; let nanos: i64 = 1_720_000_000_000_000_000; // Buffer pre-sized via const compute_length_with_header; try_* still validates extent. let written = HeartbeatEncoder::try_wrap_and_apply_header(&mut buf, 0) .unwrap() .fixed(&HeartbeatFixedFields { sequence: 7, timestamp: nanos as u64, }) .encoded_length_with_header(); let dec = HeartbeatDecoder::try_decode(&buf[..written], 0)?; assert_eq!(dec.sequence(), 7); let decoded_ts: DateTime<Utc> = dec.try_timestamp()?; assert_eq!(decoded_ts.timestamp_nanos_opt(), Some(nanos)); Ok(buf[..written].to_vec()) } }
(This code comes from the sbe-feature-tour sample crate.)
{Msg}FixedFields has no Default. Every required scalar must appear in
the struct literal (optional fields use Option only when schema
presence="optional"). That is intentional: zero-filling required IDs hides
bugs. For large messages, build the literal next to the encode call in wire
order.
On a fixed-only message (no groups or var-data), as_bytes_with_header,
as_body_bytes, encoded_length*, and into_remaining_mut exist only after
fixed(&FixedFields). Calling them on the value returned by wrap* is a type
error — that is what stops a reused buffer from publishing leftover body bytes
or packing the next message over a stale body.
Individual setters stay on the unfixed encoder after wrap* and also on
raw_fixed() (body-relative offsets). Prefer fixed(&FixedFields) when you
have every field; use raw_fixed() when you want a dedicated writer:
#![allow(unused)] fn main() { // Dedicated raw writer (setters also exist on the unfixed encoder). // `as_bytes_with_header` is only on FieldsFixed after `fixed(&FixedFields)`. let mut buf = [0u8; HeartbeatEncoder::compute_length_with_header()]; let mut w = HeartbeatEncoder::try_wrap_and_apply_header(&mut buf, 0)? .raw_fixed(); w.sequence(7); w.timestamp_wire(0); let dec = HeartbeatDecoder::try_from(&buf[..HeartbeatEncoder::ENCODED_LENGTH])?; assert_eq!(dec.sequence(), 7); }
(From book/examples/heartbeat-encode.rs — compiled against the feature-tour codec.)
raw_fixed() writes into the buffer you already sized. It does not mark
the message complete: omitted required setters leave stale bytes, and
as_bytes_with_header / encoded_length* / into_remaining_mut stay locked.
Set every required field, then call fixed(&FixedFields) for the
complete-message views. Slicing &buf[..ENCODED_LENGTH] yourself is possible
but is not the completeness-checked path.
Optional fields and apply_nulls
try_wrap_and_apply_header / wrap_and_apply_header write the message header
only — they do not fill optional fixed fields with schema null sentinels
(sbe-tool parity). Unwritten optional bytes retain whatever was already in the
buffer (often zero, sometimes stale).
fixed() closes that gap for you. Optional fields are Option<T> in the
generated FixedFields struct, and fixed() writes the schema null wire image
for every None — including fixed arrays and nested optional composite
members. Since fixed() is the only route to a message's tails and to
fixed-only complete byte views, the ordinary path never leaves a stale
optional behind:
// `price` is optional; None writes the schema null image, not stale bytes.
// (Illustrative — use your schema's message name and optional field.)
let len = OrderEncoder::wrap_and_apply_header(&mut buf, 0)
.fixed(&OrderFixedFields { symbol: *b"IBM ", price: None })
.encoded_length_with_header();
apply_nulls() remains on the unfixed encoder after wrap*, for the case
where you set individual optional fields yourself and no FixedFields value
describes which optionals are unset.
See Why NullVal Instead of Option.
Character arrays: fixed-width char fields become [u8; N]. Pass a shorter
&str via the _str setter — auto-padded with NULs. On decode, copy_*
copies the raw bytes into your buffer, or read the slice with vehicle_code():
#![allow(unused)] fn main() { let complete_len = CarEncoder::compute_length() .fuel_figures_ragged(0, |_| Ok(()))? .performance_figures_ragged(0, |_| Ok(()))? .manufacturer(5)? .model(5)? .activation_code(3)? .encoded_length_with_header(); const PAD: usize = 256; assert!(complete_len <= PAD, "car length {complete_len} exceeds pad {PAD}"); let mut storage = [0u8; PAD]; let buf = &mut storage[..complete_len]; let fields = CarFixedFields { serial_number: 1234, model_year: 2013, available: true.into(), code: Model::A, some_numbers: [10, 20, 30, 40], vehicle_code: *b"ABCDEF", extras: OptionalExtras::default(), engine: Engine::new(2000, 4, *b"123", 0i8, false.into(), Booster::new(BoostType::TURBO, 210)), }; let n = CarEncoder::try_wrap_and_apply_header(buf, 0)? .fixed(&fields) .fuel_figures(0, |_| Ok(()))? .performance_figures(0, |_| Ok(()))? .manufacturer(b"Honda")? .model(b"Civic")? .activation_code(b"abc")? .encoded_length_with_header(); assert_eq!(n, complete_len); let car = CarDecoder::try_from(&buf[..n])?; let mut dst = [0u8; 6]; assert_eq!(car.copy_vehicle_code(&mut dst), 6); assert_eq!(&dst, b"ABCDEF"); assert_eq!(car.vehicle_code(), *b"ABCDEF"); }
(From book/examples/fixed-char-arrays.rs — compiled against the feature-tour codec.)
The Car encoder's fixed fields include vehicle_code: [u8; 6] (schema char array)
and some_numbers: [u32; 4]. See the feature tour for the
complete Car example with groups and var-data.
Start here for a full runnable map of features:
sbe-feature-tour
(cargo run --manifest-path samples/sbe-feature-tour/Cargo.toml).
More recipes: Recipes.