1use crate::report::SampleRow;
30
31impl SampleRow {
32 pub fn of_key(key: &str, base: &str) -> SampleRow {
39 let parsed = zenkey::grammar::parse_full(base, key);
40 SampleRow {
41 key: key.to_string(),
42 origin: parsed.as_ref().map(|p| p.origin.chunk().to_string()),
43 subject: parsed.as_ref().map(|p| p.subject.join("/")),
44 ..SampleRow::default()
45 }
46 }
47
48 pub fn with_wire(mut self, view: &crate::SampleView) -> SampleRow {
54 self.delete = view.kind == zenoh::sample::SampleKind::Delete;
55 if !view.encoding.is_empty() {
56 self.encoding = Some(view.encoding.clone());
57 }
58 self.timestamp = view.timestamp.map(|t| t.to_string());
59 self.qos = zenkey::qos::QosProfile::ALL
63 .into_iter()
64 .find(|p| view.qos_matches(*p))
65 .map(|p| p.name().to_string());
66 self
67 }
68
69 pub fn with_payload_bytes(mut self, payload: &[u8]) -> SampleRow {
72 self.bytes = Some(b64(payload));
73 self
74 }
75
76 pub fn to_line(&self) -> String {
78 serde_json::to_string(self).expect("a sample row serializes")
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct IngestRow {
85 pub key: String,
88 pub payload: Vec<u8>,
92 pub encoding: Option<String>,
94 pub qos: Option<String>,
96 pub delete: bool,
98 pub attachment: Option<Vec<u8>>,
101}
102
103fn value_bytes(v: &serde_json::Value) -> Vec<u8> {
104 match v {
105 serde_json::Value::String(s) => s.clone().into_bytes(),
106 other => serde_json::to_vec(other).unwrap_or_default(),
107 }
108}
109
110pub(crate) fn b64(bytes: &[u8]) -> String {
113 use base64::Engine as _;
114 base64::engine::general_purpose::STANDARD.encode(bytes)
115}
116
117fn b64_bytes(
119 obj: &serde_json::Map<String, serde_json::Value>,
120 field: &str,
121) -> Result<Option<Vec<u8>>, String> {
122 use base64::Engine as _;
123
124 match obj.get(field) {
125 None | Some(serde_json::Value::Null) => Ok(None),
126 Some(serde_json::Value::String(s)) => base64::engine::general_purpose::STANDARD
127 .decode(s)
128 .map(Some)
129 .map_err(|e| format!("\"{field}\" is not base64: {e}")),
130 Some(_) => Err(format!("\"{field}\" is not a base64 string")),
131 }
132}
133
134pub fn parse_row(line: &str) -> Result<IngestRow, String> {
136 let v: serde_json::Value =
137 serde_json::from_str(line).map_err(|e| format!("not a JSON object: {e}"))?;
138 let obj = v.as_object().ok_or("not a JSON object")?;
139 let key = obj
140 .get("key")
141 .and_then(|k| k.as_str())
142 .ok_or("no \"key\" field")?
143 .to_string();
144 if key.is_empty() {
145 return Err("empty \"key\"".into());
146 }
147 let delete = obj.get("delete").and_then(|d| d.as_bool()).unwrap_or(false);
148 let payload = match (b64_bytes(obj, "bytes")?, obj.get("value")) {
151 (Some(raw), _) => raw,
152 (None, Some(serde_json::Value::Null) | None) if delete => Vec::new(),
153 (None, Some(serde_json::Value::Null) | None) => {
154 return Err("no \"value\" or \"bytes\" field (and not a delete row)".into());
155 }
156 (None, Some(v)) => value_bytes(v),
157 };
158 let attachment = match b64_bytes(obj, "attachment_b64")? {
159 Some(raw) => Some(raw),
160 None => obj.get("attachment").map(value_bytes),
161 };
162 Ok(IngestRow {
163 key,
164 payload,
165 encoding: obj
166 .get("encoding")
167 .and_then(|e| e.as_str())
168 .filter(|e| !e.is_empty())
169 .map(str::to_string),
170 qos: obj.get("qos").and_then(|q| q.as_str()).map(str::to_string),
171 delete,
172 attachment,
173 })
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
186pub enum StreamLine {
187 Sample(IngestRow),
189 Meta(String),
192}
193
194pub fn parse_stream_line(line: &str) -> Result<StreamLine, String> {
202 if let Ok(serde_json::Value::Object(obj)) = serde_json::from_str::<serde_json::Value>(line)
203 && let Some(tag) = obj.get("row").and_then(serde_json::Value::as_str)
204 && tag != "sample"
205 {
206 return Ok(StreamLine::Meta(tag.to_string()));
207 }
208 parse_row(line).map(StreamLine::Sample)
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
224 fn a_hand_written_row_reads_back_with_its_extras_ignored() {
225 let row = parse_row(
226 r#"{"key":"v1/h-1/state/p/health","origin":"h-1","type":"Health","typed":true,
227 "encoding":"application/json","timestamp":null,"delete":false,
228 "value":{"status":"ok"}}"#,
229 )
230 .unwrap();
231 assert_eq!(row.key, "v1/h-1/state/p/health");
232 assert_eq!(row.payload, br#"{"status":"ok"}"#);
233 assert_eq!(row.encoding.as_deref(), Some("application/json"));
234 assert!(!row.delete);
235
236 let text = parse_row(r#"{"key":"k","value":"just words"}"#).unwrap();
237 assert_eq!(text.payload, b"just words");
238 }
239
240 #[test]
243 fn tombstones_and_malformed_rows_are_told_apart() {
244 let del = parse_row(r#"{"key":"k","delete":true,"value":null}"#).unwrap();
245 assert!(del.delete);
246 assert!(del.payload.is_empty());
247
248 let err = parse_row(r#"{"key":"k"}"#).unwrap_err();
249 assert!(err.contains("value"), "{err}");
250 let err = parse_row(r#"{"value":1}"#).unwrap_err();
251 assert!(err.contains("key"), "{err}");
252 let err = parse_row("not json").unwrap_err();
253 assert!(err.contains("JSON"), "{err}");
254 }
255
256 #[test]
260 fn lossless_bytes_win_over_the_rendering() {
261 let row = parse_row(
262 r#"{"key":"k","t":125000,"bytes":"AAEC/w==","value":"lossy render",
263 "attachment_b64":"3q0=","encoding":"application/octet-stream"}"#,
264 )
265 .unwrap();
266 assert_eq!(row.payload, vec![0x00, 0x01, 0x02, 0xff]);
267 assert_eq!(row.attachment.as_deref(), Some([0xde, 0xad].as_ref()));
268
269 let row = parse_row(r#"{"key":"k","bytes":"aGk="}"#).unwrap();
271 assert_eq!(row.payload, b"hi");
272
273 let err = parse_row(r#"{"key":"k","bytes":"not base64!"}"#).unwrap_err();
274 assert!(err.contains("base64"), "{err}");
275 let err = parse_row(r#"{"key":"k","bytes":7}"#).unwrap_err();
276 assert!(err.contains("base64"), "{err}");
277 }
278
279 fn view(payload: &[u8], profile: Option<zenkey::qos::QosProfile>) -> crate::SampleView {
282 use zenkey::qos::QosProfile;
283 let p = profile.unwrap_or(QosProfile::Sampled);
286 crate::SampleView {
287 key: "v1/h-3fa9c2d41b7e/state/sysinfo/health".into(),
288 payload: zenoh::bytes::ZBytes::from(payload.to_vec()),
289 encoding: "application/json".into(),
290 kind: zenoh::sample::SampleKind::Put,
291 timestamp: None,
292 stamped_by: None,
293 attachment: None,
294 priority: if profile.is_some() {
295 p.priority()
296 } else {
297 zenoh::qos::Priority::Background
298 },
299 congestion_control: p.congestion_control(),
300 reliability: p.reliability(),
301 express: p.express(),
302 source: None,
303 received: std::time::Instant::now(),
304 }
305 }
306
307 #[test]
311 fn the_qos_field_only_ever_carries_a_name_the_reader_can_resolve() {
312 let named = SampleRow::of_key("k", "")
313 .with_wire(&view(b"{}", Some(zenkey::qos::QosProfile::Alert)));
314 assert_eq!(named.qos.as_deref(), Some("alert"));
315 assert!(
316 zenkey::qos::QosProfile::from_name(named.qos.as_deref().unwrap()).is_some(),
317 "whatever lands in `qos` must resolve, or every row of the pipe is malformed"
318 );
319
320 let unnamed = SampleRow::of_key("k", "").with_wire(&view(b"{}", None));
324 assert_eq!(
325 unnamed.qos, None,
326 "axes matching no profile are omitted, never spelled into `qos`"
327 );
328 }
329
330 #[test]
334 fn a_row_this_crate_wrote_is_a_row_this_crate_reads() {
335 let v = view(
336 br#"{"status":"ok"}"#,
337 Some(zenkey::qos::QosProfile::Refreshed),
338 );
339
340 let mut observed = SampleRow::of_key(&v.key, "").with_wire(&v);
343 observed.value = Some(serde_json::json!({"status": "ok"}));
344 observed.qos_axes = Some("data/drop/reliable".into());
345 observed.payload_bytes = Some(15);
346 let back = parse_row(&observed.to_line()).expect("the observer's row reads back");
347 assert_eq!(back.payload, br#"{"status":"ok"}"#);
348 assert_eq!(back.qos.as_deref(), Some("refreshed"));
349 assert_eq!(back.encoding.as_deref(), Some("application/json"));
350
351 let captured = SampleRow {
354 key: v.key.clone(),
355 t: Some(1_250),
356 ..SampleRow::default()
357 }
358 .with_wire(&v)
359 .with_payload_bytes(&v.payload.to_bytes());
360 let back = parse_row(&captured.to_line()).expect("the capture's row reads back");
361 assert_eq!(back.payload, br#"{"status":"ok"}"#);
362 assert_eq!(back.qos.as_deref(), Some("refreshed"));
363 }
364
365 #[test]
370 fn an_unheld_fact_is_absent_from_the_row_rather_than_null() {
371 let row = SampleRow {
372 key: "demo/foreign".into(),
373 delete: true,
374 ..SampleRow::default()
375 };
376 assert_eq!(
377 serde_json::to_value(&row).unwrap(),
378 serde_json::json!({"key": "demo/foreign", "delete": true}),
379 "a key that did not parse carries no origin/subject, an unstamped \
380 sample carries no timestamp, and an undecoded one carries no type"
381 );
382 }
383
384 #[test]
388 fn tagged_meta_rows_are_skipped_not_malformed() {
389 assert_eq!(
390 parse_stream_line(r#"{"dropped":7,"row":"dropped"}"#).unwrap(),
391 StreamLine::Meta("dropped".into())
392 );
393 assert_eq!(
394 parse_stream_line(r#"{"row":"seed","seed_complete":{"superseded":0}}"#).unwrap(),
395 StreamLine::Meta("seed".into())
396 );
397 assert!(parse_stream_line(r#"{"row":7}"#).is_err());
400 assert!(matches!(
401 parse_stream_line(r#"{"key":"k","value":1}"#).unwrap(),
402 StreamLine::Sample(r) if r.key == "k"
403 ));
404 let err = parse_stream_line(r#"{"key":"k"}"#).unwrap_err();
405 assert!(err.contains("value"), "{err}");
406 }
407
408 #[test]
410 fn attachments_ride_rows() {
411 let row =
412 parse_row(r#"{"key":"k","value":1,"attachment":{"who":"me"},"qos":"alert"}"#).unwrap();
413 assert_eq!(row.attachment.as_deref(), Some(br#"{"who":"me"}"#.as_ref()));
414 assert_eq!(row.qos.as_deref(), Some("alert"));
415 }
416}