Skip to main content

zenkey_fleet/
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 anyhow::{Result, anyhow};
23use zenkey::schema::{SchemaKind, TypeSchema, WireEncoding};
24use zenoh::Session;
25
26use crate::decode::SchemaStore;
27use crate::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::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<&str>,
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.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/// Encode `body` against `producer`'s served schema for `type_name`, or
111/// explain why it could not.
112///
113/// `Ok` with [`BodySource::AsTyped`] when no schema resolves — that is not a
114/// failure, and it is not silence either (RFC 08 §7 is a SHOULD; a producer
115/// that serves no `describe` has said nothing about this type, which is
116/// different from having said "any bytes will do").
117#[allow(clippy::too_many_arguments)]
118pub async fn prepare_request(
119    session: &Session,
120    store: &SchemaStore,
121    producer: &str,
122    type_name: &str,
123    declared_encoding: Option<&str>,
124    registry_encoding: Option<&str>,
125    body: &[u8],
126    mode: PrepareMode,
127) -> Result<PreparedBody> {
128    if mode == PrepareMode::Raw {
129        return Ok(PreparedBody::raw(
130            body.to_vec(),
131            encode_encoding(declared_encoding, registry_encoding, None),
132            Some("raw: bytes sent verbatim, not encoded against the served schema".into()),
133        ));
134    }
135    let schema = store.schema_for(session, producer, type_name).await;
136    let encoding = encode_encoding(declared_encoding, registry_encoding, schema.as_ref());
137    let Some(schema) = schema else {
138        return Ok(PreparedBody {
139            bytes: body.to_vec(),
140            encoding,
141            source: BodySource::AsTyped,
142            note: Some(format!(
143                "{producer} serves no schema for {type_name} — body sent as typed, unchecked \
144                 (RFC 08 §7 describe is a SHOULD; \"not served\" is not \"anything goes\")"
145            )),
146        });
147    };
148
149    let lenient = mode == PrepareMode::Lenient;
150    let value: serde_json::Value = match serde_json::from_slice(body) {
151        Ok(v) => v,
152        Err(e) if lenient => {
153            return Ok(PreparedBody {
154                bytes: body.to_vec(),
155                encoding,
156                source: BodySource::AsTyped,
157                note: Some(format!(
158                    "body is not JSON, so it could not be encoded as {type_name} ({e}) — \
159                     sent as typed"
160                )),
161            });
162        }
163        Err(e) => {
164            return Err(anyhow!(
165                "body is not JSON but {producer} declares schema-validated type {type_name} — {e}"
166            ));
167        }
168    };
169
170    let target = encoding
171        .as_deref()
172        .map(WireEncoding::from_encoding_str)
173        // With nothing declared anywhere the target is the schema's own kind,
174        // and for `json-schema` that is JSON — the framing an operator typed.
175        .unwrap_or(WireEncoding::Json);
176    match store.encode(&schema, &value, &target) {
177        Ok(bytes) => Ok(PreparedBody {
178            bytes,
179            encoding,
180            source: BodySource::Encoded {
181                type_name: type_name.to_string(),
182            },
183            note: None,
184        }),
185        Err(e) if lenient => Ok(PreparedBody {
186            bytes: body.to_vec(),
187            encoding,
188            source: BodySource::AsTyped,
189            note: Some(format!(
190                "body rejected by {type_name}'s served schema ({e}) — sent as typed anyway"
191            )),
192        }),
193        Err(e) => Err(anyhow!("body rejected by {type_name}'s served schema: {e}")),
194    }
195}
196
197/// The publish-side entry point: refine a **full wire key** against the loaded
198/// slices, then [`prepare_request`] on whatever type it names.
199///
200/// An unregistered key is not an error — it is the ordinary case on a bus this
201/// convention does not govern, and the note says which case happened.
202#[allow(clippy::too_many_arguments)]
203pub async fn prepare_publish(
204    session: &Session,
205    store: &SchemaStore,
206    slices: Option<&SliceSet>,
207    base: &str,
208    wire_key: &str,
209    declared_encoding: Option<&str>,
210    body: &[u8],
211    mode: PrepareMode,
212) -> Result<PreparedBody> {
213    if mode == PrepareMode::Raw {
214        return Ok(PreparedBody::raw(
215            body.to_vec(),
216            declared_encoding.map(str::to_string),
217            Some("raw: bytes sent verbatim, not encoded against the served schema".into()),
218        ));
219    }
220
221    let description = crate::facts::describe_key(base, wire_key, slices);
222    let crate::facts::Registration::Registered(subject) = &description.facts.registration else {
223        return Ok(PreparedBody {
224            bytes: body.to_vec(),
225            encoding: declared_encoding.map(str::to_string),
226            source: BodySource::AsTyped,
227            note: Some(match slices {
228                // O4: with no slices loaded the tool has not asked, and
229                // "not asked" is not "unregistered".
230                None => format!(
231                    "no registry loaded, so {wire_key} was never classified — body sent as typed"
232                ),
233                Some(_) => format!(
234                    "{wire_key} is not a registered subject ({:?}) — body sent as typed",
235                    description.facts.registration
236                ),
237            }),
238        });
239    };
240    let Some(producer) = subject_producer(&description) else {
241        return Ok(PreparedBody {
242            bytes: body.to_vec(),
243            encoding: encode_encoding(declared_encoding, subject.encoding.as_deref(), None),
244            source: BodySource::AsTyped,
245            note: Some(format!(
246                "{wire_key} refines to a registered subject with no producer chunk to ask for a \
247                 schema — body sent as typed"
248            )),
249        });
250    };
251    if subject.type_name.is_empty() {
252        return Ok(PreparedBody {
253            bytes: body.to_vec(),
254            encoding: encode_encoding(declared_encoding, subject.encoding.as_deref(), None),
255            source: BodySource::AsTyped,
256            note: Some(format!(
257                "{wire_key} is registered but declares no payload type — body sent as typed"
258            )),
259        });
260    }
261
262    prepare_request(
263        session,
264        store,
265        &producer,
266        &subject.type_name,
267        declared_encoding,
268        subject.encoding.as_deref(),
269        body,
270        mode,
271    )
272    .await
273}
274
275/// The producer a registered description refined through. `SubjectFacts` does
276/// not carry it (a service slice's name is not a key chunk), so it is derived
277/// from the key shape.
278pub fn subject_producer(description: &crate::facts::KeyDescription) -> Option<String> {
279    match &description.facts.shape {
280        crate::facts::KeyShape::V1(v) => v.producer.clone(),
281        _ => None,
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use serde_json::json;
289
290    #[test]
291    fn the_encode_ladder_never_sniffs_the_operators_text() {
292        let protobuf = TypeSchema::protobuf("t.Blob", b"\x0a\x00");
293        // Nothing declared: the kind decides — not the JSON the operator typed.
294        assert_eq!(
295            encode_encoding(None, None, Some(&protobuf)).as_deref(),
296            Some("application/protobuf")
297        );
298        // The registry outranks the kind…
299        assert_eq!(
300            encode_encoding(None, Some("application/cbor"), Some(&protobuf)).as_deref(),
301            Some("application/cbor")
302        );
303        // …and the flag outranks the registry.
304        assert_eq!(
305            encode_encoding(Some("application/json"), Some("application/cbor"), None).as_deref(),
306            Some("application/json")
307        );
308        // An unknown kind's framing is unknown, and saying nothing is the
309        // honest answer (O4).
310        let json = TypeSchema::json_schema(json!({"type": "object"}));
311        assert_eq!(
312            encode_encoding(None, None, Some(&json)).as_deref(),
313            Some("application/json")
314        );
315        assert_eq!(encode_encoding(None, None, None), None);
316    }
317}