Skip to main content

zenkey_fleet/bus/
body.rs

1//! Preparing a body for the wire (issue #97) — the encode half of the codec
2//! seam, on the path a write actually takes.
3//!
4//! Until this module, both frontends *validated* an outgoing body by encoding
5//! it against the producer's served schema and then **threw the encoded bytes
6//! away**, putting the operator's JSON text on the wire. Everything downstream
7//! was therefore a lie for any subject whose declared encoding was not JSON: a
8//! `application/protobuf` subject could be described, refined, decoded — and
9//! not published to. The fix is one seam, here, so that "the body was checked"
10//! and "the body was encoded" stop being two different things.
11//!
12//! Three obligations this owes its callers, all of them RFC 09 §5.1 O4 in
13//! different clothes:
14//!
15//! - a body that was **not** encoded says so ([`BodySource`]) — publishing
16//!   as-typed is a legitimate outcome, silently publishing as-typed is not;
17//! - the wire `Encoding` is resolved from what was *declared*, never sniffed
18//!   off the operator's text ([`encode_encoding`]);
19//! - a refusal happens **before** the bus, and the caller can opt out of the
20//!   refusal ([`PrepareMode`]) without opting out of the labelling.
21
22use crate::{Error, Result};
23use zenkey::schema::{SchemaKind, TypeSchema, WireEncoding};
24use zenoh::Session;
25
26use crate::model::decode::SchemaStore;
27use crate::model::registry::SliceSet;
28
29/// How the bytes on the wire came to be — carried out of [`prepare_publish`]
30/// so a frontend can say it, not guess it.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum BodySource {
33    /// Encoded through the producer's served schema for this type name.
34    Encoded { type_name: String },
35    /// No schema resolved — an unregistered key, an untyped subject, or a
36    /// producer that serves no `describe`. The body ships as the caller typed
37    /// it, which is honest only because it is labelled.
38    AsTyped,
39    /// The caller asked for verbatim bytes ([`PrepareMode::Raw`]).
40    Raw,
41}
42
43/// What to do when a schema resolves and the body does not fit it.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum PrepareMode {
46    /// Refuse before the bus (`zenctl` default).
47    Encode,
48    /// Try to encode; on failure ship the body as typed, with a note
49    /// (`--no-validate`). "Do not refuse" — never "do not tell me".
50    Lenient,
51    /// Never encode; ship verbatim (`--raw`).
52    Raw,
53}
54
55/// A body ready for the wire, with the provenance a caller must surface.
56#[derive(Debug, Clone)]
57pub struct PreparedBody {
58    pub bytes: Vec<u8>,
59    /// The wire `Encoding` to set, when one is known. `None` means nothing was
60    /// declared anywhere — the publisher says nothing rather than guessing.
61    pub encoding: Option<String>,
62    pub source: BodySource,
63    /// A line for the caller to print/render verbatim. `None` when the
64    /// ordinary thing happened (encoded against a served schema).
65    pub note: Option<String>,
66}
67
68impl PreparedBody {
69    fn raw(bytes: Vec<u8>, encoding: Option<String>, note: Option<String>) -> PreparedBody {
70        PreparedBody {
71            bytes,
72            encoding,
73            source: BodySource::Raw,
74            note,
75        }
76    }
77}
78
79/// The encoding a body should be **encoded into**, which is a different
80/// question from the one [`crate::model::decode::resolve_encoding`] answers.
81///
82/// Decoding resolves *sample > registry > sniff* because a received payload
83/// has bytes to sniff. An outgoing body has none that mean anything — the
84/// operator typed JSON whatever the subject carries, so sniffing it would
85/// label every protobuf subject `application/json`. The ladder is therefore
86/// **declared flag > registry `encoding` > the schema kind's native
87/// encoding**, and the kind is the authority of last resort precisely because
88/// it is the one thing that cannot be wrong.
89pub fn encode_encoding(
90    declared: Option<&str>,
91    registry: Option<&WireEncoding>,
92    schema: Option<&TypeSchema>,
93) -> Option<String> {
94    if let Some(e) = declared {
95        return Some(e.to_string());
96    }
97    if let Some(e) = registry {
98        return Some(e.as_encoding_str().to_string());
99    }
100    schema.and_then(|s| match s.kind().as_str() {
101        SchemaKind::JSON_SCHEMA => Some("application/json".to_string()),
102        SchemaKind::PROTOBUF => Some("application/protobuf".to_string()),
103        SchemaKind::CDR => Some("application/cdr".to_string()),
104        // An unknown kind's native framing is exactly what this tool does not
105        // know. Saying nothing beats naming the wrong one.
106        _ => None,
107    })
108}
109
110/// What to encode — the operator's half of a prepare, shared by both entry
111/// points below.
112///
113/// The other half is what to encode it *against*, which is exactly what
114/// differs between them: [`prepare_request`] is told the producer and type
115/// outright, [`prepare_publish`] refines them out of a wire key. That split
116/// is why this is one spec and not two: everything in it means the same
117/// thing on both paths.
118#[derive(Debug, Clone, Copy)]
119pub struct PrepareSpec<'a> {
120    /// The wire encoding the caller stated (`--encoding`), when they did.
121    pub declared_encoding: Option<&'a str>,
122    /// The bytes as the operator supplied them, before any encode.
123    pub body: &'a [u8],
124    /// Encode against the served schema, or ship verbatim.
125    pub mode: PrepareMode,
126}
127
128/// Encode `spec.body` against `producer`'s served schema for `type_name`, or
129/// explain why it could not.
130///
131/// `Ok` with [`BodySource::AsTyped`] when no schema resolves — that is not a
132/// failure, and it is not silence either (RFC 08 §7 is a SHOULD; a producer
133/// that serves no `describe` has said nothing about this type, which is
134/// different from having said "any bytes will do").
135///
136/// Base-less, so a bare `&Session` rather than a [`crate::Fleet`]: nothing
137/// here composes a key — the producer is named, and the schema comes through
138/// the store, which carries the base already. [`prepare_publish`] takes a
139/// `Fleet` because it *refines a wire key*, which needs one.
140pub async fn prepare_request(
141    session: &Session,
142    store: &SchemaStore,
143    producer: &str,
144    type_name: &str,
145    registry_encoding: Option<&WireEncoding>,
146    spec: PrepareSpec<'_>,
147) -> Result<PreparedBody> {
148    let PrepareSpec {
149        declared_encoding,
150        body,
151        mode,
152    } = spec;
153    if mode == PrepareMode::Raw {
154        return Ok(PreparedBody::raw(
155            body.to_vec(),
156            encode_encoding(declared_encoding, registry_encoding, None),
157            Some("raw: bytes sent verbatim, not encoded against the served schema".into()),
158        ));
159    }
160    let schema = store.schema_for(session, producer, type_name).await;
161    let encoding = encode_encoding(declared_encoding, registry_encoding, schema.as_ref());
162    let Some(schema) = schema else {
163        return Ok(PreparedBody {
164            bytes: body.to_vec(),
165            encoding,
166            source: BodySource::AsTyped,
167            note: Some(format!(
168                "{producer} serves no schema for {type_name} — body sent as typed, unchecked \
169                 (RFC 08 §7 describe is a SHOULD; \"not served\" is not \"anything goes\")"
170            )),
171        });
172    };
173
174    let lenient = mode == PrepareMode::Lenient;
175    let value: serde_json::Value = match serde_json::from_slice(body) {
176        Ok(v) => v,
177        Err(e) if lenient => {
178            return Ok(PreparedBody {
179                bytes: body.to_vec(),
180                encoding,
181                source: BodySource::AsTyped,
182                note: Some(format!(
183                    "body is not JSON, so it could not be encoded as {type_name} ({e}) — \
184                     sent as typed"
185                )),
186            });
187        }
188        Err(e) => {
189            // The caller handed us this body.
190            return Err(Error::unaskable(
191                "body",
192                format!(
193                    "is not JSON but {producer} declares schema-validated type \
194                     {type_name} — {e}"
195                ),
196            ));
197        }
198    };
199
200    let target = encoding
201        .as_deref()
202        .map(WireEncoding::from_encoding_str)
203        // With nothing declared anywhere the target is the schema's own kind,
204        // and for `json-schema` that is JSON — the framing an operator typed.
205        .unwrap_or(WireEncoding::Json);
206    match store.encode(&schema, &value, &target) {
207        Ok(bytes) => Ok(PreparedBody {
208            bytes,
209            encoding,
210            source: BodySource::Encoded {
211                type_name: type_name.to_string(),
212            },
213            note: None,
214        }),
215        Err(e) if lenient => Ok(PreparedBody {
216            bytes: body.to_vec(),
217            encoding,
218            source: BodySource::AsTyped,
219            note: Some(format!(
220                "body rejected by {type_name}'s served schema ({e}) — sent as typed anyway"
221            )),
222        }),
223        Err(e) => Err(Error::unaskable(
224            "body",
225            format!("rejected by {type_name}'s served schema: {e}"),
226        )),
227    }
228}
229
230/// The publish-side entry point: refine a **full wire key** against the loaded
231/// slices, then [`prepare_request`] on whatever type it names.
232///
233/// An unregistered key is not an error — it is the ordinary case on a bus this
234/// convention does not govern, and the note says which case happened.
235pub async fn prepare_publish(
236    fleet: &crate::Fleet<'_>,
237    store: &SchemaStore,
238    slices: Option<&SliceSet>,
239    wire_key: &str,
240    spec: PrepareSpec<'_>,
241) -> Result<PreparedBody> {
242    let (session, base) = (fleet.session(), fleet.base());
243    let PrepareSpec {
244        declared_encoding,
245        body,
246        mode,
247    } = spec;
248    if mode == PrepareMode::Raw {
249        return Ok(PreparedBody::raw(
250            body.to_vec(),
251            declared_encoding.map(str::to_string),
252            Some("raw: bytes sent verbatim, not encoded against the served schema".into()),
253        ));
254    }
255
256    let description = crate::model::facts::describe_key(base, wire_key, slices);
257    let crate::model::facts::Registration::Registered(subject) = &description.facts.registration
258    else {
259        return Ok(PreparedBody {
260            bytes: body.to_vec(),
261            encoding: declared_encoding.map(str::to_string),
262            source: BodySource::AsTyped,
263            note: Some(match slices {
264                // O4: with no slices loaded the tool has not asked, and
265                // "not asked" is not "unregistered".
266                None => format!(
267                    "no registry loaded, so {wire_key} was never classified — body sent as typed"
268                ),
269                Some(_) => format!(
270                    "{wire_key} is not a registered subject ({:?}) — body sent as typed",
271                    description.facts.registration
272                ),
273            }),
274        });
275    };
276    let Some(producer) = subject_producer(&description) else {
277        return Ok(PreparedBody {
278            bytes: body.to_vec(),
279            encoding: encode_encoding(declared_encoding, subject.encoding.as_ref(), None),
280            source: BodySource::AsTyped,
281            note: Some(format!(
282                "{wire_key} refines to a registered subject with no producer chunk to ask for a \
283                 schema — body sent as typed"
284            )),
285        });
286    };
287    if subject.type_name.is_empty() {
288        return Ok(PreparedBody {
289            bytes: body.to_vec(),
290            encoding: encode_encoding(declared_encoding, subject.encoding.as_ref(), None),
291            source: BodySource::AsTyped,
292            note: Some(format!(
293                "{wire_key} is registered but declares no payload type — body sent as typed"
294            )),
295        });
296    }
297
298    prepare_request(
299        session,
300        store,
301        &producer,
302        &subject.type_name,
303        subject.encoding.as_ref(),
304        spec,
305    )
306    .await
307}
308
309/// The producer a registered description refined through. `SubjectFacts` does
310/// not carry it (a service slice's name is not a key chunk), so it is derived
311/// from the key shape.
312pub fn subject_producer(description: &crate::model::facts::KeyDescription) -> Option<String> {
313    match &description.facts.shape {
314        crate::model::facts::KeyShape::V1(v) => v.producer.clone(),
315        _ => None,
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use serde_json::json;
323
324    #[test]
325    fn the_encode_ladder_never_sniffs_the_operators_text() {
326        let protobuf = TypeSchema::protobuf("t.Blob", b"\x0a\x00");
327        // Nothing declared: the kind decides — not the JSON the operator typed.
328        assert_eq!(
329            encode_encoding(None, None, Some(&protobuf)).as_deref(),
330            Some("application/protobuf")
331        );
332        // The registry outranks the kind…
333        assert_eq!(
334            encode_encoding(None, Some(&WireEncoding::Cbor), Some(&protobuf)).as_deref(),
335            Some("application/cbor")
336        );
337        // …and the flag outranks the registry.
338        assert_eq!(
339            encode_encoding(Some("application/json"), Some(&WireEncoding::Cbor), None).as_deref(),
340            Some("application/json")
341        );
342        // An unknown kind's framing is unknown, and saying nothing is the
343        // honest answer (O4).
344        let json = TypeSchema::json_schema(json!({"type": "object"}));
345        assert_eq!(
346            encode_encoding(None, None, Some(&json)).as_deref(),
347            Some("application/json")
348        );
349        assert_eq!(encode_encoding(None, None, None), None);
350    }
351}