Skip to main content

zenkey_fleet/
decode.rs

1//! The schema-aware decode seam (issues #11/#15): wire key → type name →
2//! served schema → named-field JSON, with the honest fallbacks a generic
3//! tool owes its user.
4//!
5//! [`SchemaStore`] caches each producer's served `describe` reply (RFC 08
6//! §7) and fetches on first miss through the disciplined fan-in
7//! ([`crate::query::fleet_get`]). [`decode_sample`] is the whole pipeline in
8//! one call; encoding resolution is **sample > registry > sniff** and the
9//! sniff never goes away.
10
11use std::collections::HashMap;
12use std::sync::Mutex;
13use std::time::Duration;
14
15use anyhow::Result;
16use zenkey::schema::decode::{DecodeError, DecodedPayload, DecoderRegistry};
17use zenkey::schema::{SchemaSet, TypeSchema, WireEncoding};
18use zenoh::Session;
19
20use crate::registry::SliceSet;
21
22/// Per-producer schema sets, fetched lazily and cached for the process.
23pub struct SchemaStore {
24    base: String,
25    timeout: Duration,
26    /// producer → served set (None = asked and not served; don't re-ask).
27    sets: Mutex<HashMap<String, Option<SchemaSet>>>,
28    decoders: DecoderRegistry,
29}
30
31impl SchemaStore {
32    pub fn new(base: impl Into<String>, timeout: Duration) -> Self {
33        SchemaStore {
34            base: base.into(),
35            timeout,
36            sets: Mutex::new(HashMap::new()),
37            decoders: DecoderRegistry::new(),
38        }
39    }
40
41    /// The decoder table (register custom kinds through this).
42    pub fn decoders_mut(&mut self) -> &mut DecoderRegistry {
43        &mut self.decoders
44    }
45
46    /// The schema for `type_name` as served by `producer`, fetching
47    /// `@rpc/<producer>/describe` on first miss. `None` = the producer does
48    /// not serve describe or does not describe this type — render
49    /// structurally (never an error; RFC 08 §7 is a SHOULD for
50    /// self-describing encodings).
51    pub async fn schema_for(
52        &self,
53        session: &Session,
54        producer: &str,
55        type_name: &str,
56    ) -> Option<TypeSchema> {
57        {
58            let sets = self.sets.lock().expect("store lock");
59            if let Some(cached) = sets.get(producer) {
60                return cached.as_ref().and_then(|s| s.get(type_name).cloned());
61            }
62        }
63        let fetched = self.fetch(session, producer).await;
64        let mut sets = self.sets.lock().expect("store lock");
65        let entry = sets.entry(producer.to_string()).or_insert(fetched);
66        entry.as_ref().and_then(|s| s.get(type_name).cloned())
67    }
68
69    async fn fetch(&self, session: &Session, producer: &str) -> Option<SchemaSet> {
70        let key = zenkey::grammar::with_base(
71            &self.base,
72            zenkey::selector::fleet_rpc(producer, &["describe"]),
73        );
74        let answers = crate::query::fleet_get(session, &self.base, &key, None, self.timeout)
75            .await
76            .ok()?;
77        // Any well-formed reply will do; hashes make same-name drift a
78        // doctor finding, not a decode concern.
79        for a in answers {
80            if let crate::query::Answer::Value(bytes) = a.answer {
81                let cow = bytes.to_bytes();
82                if let Ok(text) = std::str::from_utf8(&cow)
83                    && let Ok(set) = SchemaSet::parse(text)
84                {
85                    return Some(set);
86                }
87            }
88        }
89        None
90    }
91
92    /// Decode `bytes` under a schema, if one resolves.
93    pub fn decode(
94        &self,
95        schema: &TypeSchema,
96        encoding: &WireEncoding,
97        bytes: &[u8],
98    ) -> Result<DecodedPayload, DecodeError> {
99        self.decoders.decode(schema, encoding, bytes)
100    }
101}
102
103/// How a rendered payload was produced — a tool surfaces this honestly
104/// instead of letting decoded and sniffed output look alike.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum Rendering {
107    /// Schema-decoded into named fields.
108    Typed(DecodedPayload),
109    /// No schema (or an undecodable kind): structural sniff — JSON if it
110    /// parses, CBOR diagnostic, UTF-8 text, else a byte count.
111    Structural(String),
112}
113
114/// Resolve the wire encoding: sample `Encoding` > registry `encoding` > sniff
115/// (RFC 08 §7).
116pub fn resolve_encoding(
117    sample_encoding: Option<&str>,
118    registry_encoding: Option<&str>,
119    bytes: &[u8],
120) -> WireEncoding {
121    // Zenoh's default when a publisher sets nothing is the opaque
122    // `zenoh/bytes` — that is "unsaid", not "bytes on purpose".
123    if let Some(e) = sample_encoding
124        && e != "zenoh/bytes"
125    {
126        return WireEncoding::from_encoding_str(e);
127    }
128    if let Some(e) = registry_encoding {
129        return WireEncoding::from_encoding_str(e);
130    }
131    // The sniff: JSON text starts with a JSON-ish byte; otherwise call it
132    // CBOR (the reference profile default) and let the decoder's error path
133    // fall through to structural rendering.
134    match bytes.first() {
135        Some(b'{' | b'[' | b'"') => WireEncoding::Json,
136        _ => WireEncoding::Cbor,
137    }
138}
139
140/// Structural fallback rendering — what the wire honestly says when no
141/// schema resolves.
142pub fn structural(bytes: &[u8]) -> String {
143    let looks_json = bytes.first().is_some_and(|b| {
144        matches!(
145            b,
146            b'{' | b'[' | b'"' | b'-' | b'0'..=b'9' | b't' | b'f' | b'n'
147        )
148    });
149    if looks_json && let Ok(v) = serde_json::from_slice::<serde_json::Value>(bytes) {
150        return serde_json::to_string(&v).unwrap_or_default();
151    }
152    if let Ok(v) = ciborium::from_reader::<ciborium::Value, _>(bytes)
153        && let Ok(text) = serde_json::to_string(&v)
154    {
155        return text;
156    }
157    match std::str::from_utf8(bytes) {
158        Ok(text) if !text.is_empty() => text.to_string(),
159        _ => format!("<{} bytes>", bytes.len()),
160    }
161}
162
163/// The whole decode pipeline for one sample: refine the key against the
164/// slices, resolve the schema through the store, decode — or fall back
165/// structurally, tagged with whatever we did learn.
166pub async fn decode_sample(
167    store: &SchemaStore,
168    session: &Session,
169    slices: &SliceSet,
170    base: &str,
171    wire_key: &str,
172    sample_encoding: Option<&str>,
173    bytes: &[u8],
174) -> (Option<String>, Rendering) {
175    use zenkey::grammar::ClassOrPlane;
176    let refined = zenkey::grammar::parse_full(base, wire_key).and_then(|parsed| {
177        let producer = match (&parsed.producer, &parsed.origin) {
178            (Some(p), _) => p.name().to_string(),
179            (None, zenkey::grammar::Origin::Service(s)) => {
180                slices.by_service_origin(s)?.name.clone()
181            }
182            _ => return None,
183        };
184        let ClassOrPlane::Class(class) = parsed.class else {
185            return None;
186        };
187        let (subject, _) = slices.refine(&producer, class.chunk(), &parsed.subject)?;
188        Some((
189            producer,
190            subject.type_name.clone(),
191            subject.encoding.clone(),
192        ))
193    });
194    let Some((producer, type_name, registry_encoding)) = refined else {
195        return (None, Rendering::Structural(structural(bytes)));
196    };
197    let encoding = resolve_encoding(sample_encoding, registry_encoding.as_deref(), bytes);
198    match store.schema_for(session, &producer, &type_name).await {
199        Some(schema) => match store.decode(&schema, &encoding, bytes) {
200            Ok(decoded) => (Some(type_name), Rendering::Typed(decoded)),
201            // Wrong schema/encoding is a finding for the *user*, not a crash:
202            // fall back to structure, keep the type tag.
203            Err(_) => (Some(type_name), Rendering::Structural(structural(bytes))),
204        },
205        None => (Some(type_name), Rendering::Structural(structural(bytes))),
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn encoding_resolution_order() {
215        // Sample wins…
216        assert_eq!(
217            resolve_encoding(Some("application/json"), Some("application/cbor"), b"x"),
218            WireEncoding::Json
219        );
220        // …but the opaque default is "unsaid", so the registry speaks…
221        assert_eq!(
222            resolve_encoding(Some("zenoh/bytes"), Some("application/cbor"), b"{"),
223            WireEncoding::Cbor
224        );
225        // …and with neither, the sniff.
226        assert_eq!(
227            resolve_encoding(None, None, b"{\"a\":1}"),
228            WireEncoding::Json
229        );
230        assert_eq!(resolve_encoding(None, None, &[0xa1]), WireEncoding::Cbor);
231    }
232
233    #[test]
234    fn structural_rendering_is_honest() {
235        assert_eq!(structural(b"{\"a\":1}"), "{\"a\":1}");
236        // CBOR map {1: 2} renders as structure.
237        let mut cbor = Vec::new();
238        ciborium::into_writer(&serde_json::json!({"x": 1}), &mut cbor).unwrap();
239        assert!(structural(&cbor).contains("\"x\""));
240        assert_eq!(structural(&[0xff, 0xfe, 0x00]), "<3 bytes>");
241    }
242}