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}