Skip to main content

laser_sdk/
schema_codecs.rs

1use crate::error::LaserError;
2use crate::query::{SchemaDef, SchemaSource};
3use prost_reflect::{DescriptorPool, DynamicMessage, MessageDescriptor};
4use serde::Serialize;
5use std::sync::Arc;
6
7/// A registered writer schema compiled for client-side use: encode Avro bodies
8/// from serde values and validate payloads against the definition BEFORE
9/// publishing, with the same decode semantics LaserData Cloud's projector applies.
10/// Without this, the first feedback about a bad schema-first payload is a
11/// managed-side warning the producer cannot see.
12///
13/// Compile once per schema and reuse the result across publishes (parsing is
14/// the expensive part):
15///
16/// ```no_run
17/// # use laser_sdk::prelude::*;
18/// # use laser_sdk::schema_codecs::CompiledSchema;
19/// # use serde::Serialize;
20/// # #[derive(Serialize)] struct Reading { host: String, cpu: i64 }
21/// # async fn run(laser: &Laser, reading: Reading) -> Result<(), LaserError> {
22/// let info = laser.schemas().get(7).await?.expect("schema registered");
23/// let compiled = CompiledSchema::compile(&info.schema)?;
24/// let readings = laser.topic("readings");
25/// readings.publish()
26///     .index("host", &reading.host)
27///     .avro(&compiled, 7, &reading)?
28///     .send().await?;
29/// # Ok(()) }
30/// ```
31#[derive(Clone)]
32pub enum CompiledSchema {
33    /// A parsed Avro writer schema.
34    Avro(apache_avro::Schema),
35    /// A resolved Protobuf message descriptor.
36    Protobuf(MessageDescriptor),
37    /// A compiled JSON Schema validator (draft 2020-12) for the
38    /// self-describing codecs.
39    Json(Arc<jsonschema::Validator>),
40}
41
42impl std::fmt::Debug for CompiledSchema {
43    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Self::Avro(schema) => formatter.debug_tuple("Avro").field(schema).finish(),
46            Self::Protobuf(descriptor) => {
47                formatter.debug_tuple("Protobuf").field(descriptor).finish()
48            }
49            Self::Json(_) => formatter.debug_tuple("Json").finish(),
50        }
51    }
52}
53
54impl CompiledSchema {
55    /// Parse a registered [`SchemaDef`] into its compiled form. Fails with
56    /// [`LaserError::Invalid`] when the definition does not parse (malformed
57    /// Avro JSON, undecodable descriptor set, or a missing message type) -
58    /// the same definitions LaserData Cloud would skip with a warning.
59    pub fn compile(def: &SchemaDef) -> Result<Self, LaserError> {
60        match &def.source {
61            SchemaSource::Avro { schema } => {
62                let parsed = apache_avro::Schema::parse_str(schema).map_err(|error| {
63                    LaserError::Invalid(format!("schema {}: unparseable Avro: {error}", def.id))
64                })?;
65                Ok(Self::Avro(parsed))
66            }
67            SchemaSource::Protobuf {
68                descriptor_set,
69                message_type,
70            } => {
71                let pool = DescriptorPool::decode(descriptor_set.as_slice()).map_err(|error| {
72                    LaserError::Invalid(format!(
73                        "schema {}: undecodable Protobuf descriptor set: {error}",
74                        def.id
75                    ))
76                })?;
77                let descriptor = pool.get_message_by_name(message_type).ok_or_else(|| {
78                    LaserError::Invalid(format!(
79                        "schema {}: descriptor set has no message `{message_type}`",
80                        def.id
81                    ))
82                })?;
83                Ok(Self::Protobuf(descriptor))
84            }
85            SchemaSource::JsonSchema { schema } => {
86                let value: serde_json::Value = serde_json::from_str(schema).map_err(|error| {
87                    LaserError::Invalid(format!(
88                        "schema {}: JSON Schema is not valid JSON: {error}",
89                        def.id
90                    ))
91                })?;
92                let validator = jsonschema::validator_for(&value).map_err(|error| {
93                    LaserError::Invalid(format!(
94                        "schema {}: JSON Schema does not compile: {error}",
95                        def.id
96                    ))
97                })?;
98                Ok(Self::Json(Arc::new(validator)))
99            }
100            _ => Err(LaserError::Invalid(format!(
101                "schema {}: unknown schema source kind",
102                def.id
103            ))),
104        }
105    }
106
107    /// Whether `payload` decodes (Avro, Protobuf) or, for a JSON Schema,
108    /// parses as JSON text and passes validation, exactly the check
109    /// LaserData Cloud's projector runs before indexing body fields. `false`
110    /// means the record would fall back to header-only `agdx.idx.*` extraction. For
111    /// non-JSON self-describing payloads (MessagePack, CBOR, BSON), decode
112    /// them yourself and use [`CompiledSchema::validate_value`].
113    pub fn validate(&self, payload: &[u8]) -> bool {
114        self.decode(payload).is_ok()
115    }
116
117    /// Validate an already-decoded payload against a JSON Schema, the check
118    /// LaserData Cloud runs on a self-describing record stamping `agdx.sid`. Avro and
119    /// Protobuf schemas return `false`: stamping their id on a
120    /// self-describing record is itself the mismatch LaserData Cloud reports.
121    pub fn validate_value(&self, value: &serde_json::Value) -> bool {
122        match self {
123            Self::Json(validator) => validator.is_valid(value),
124            Self::Avro(_) | Self::Protobuf(_) => false,
125        }
126    }
127
128    /// Decode `payload` under this schema, lowered to a `serde_json::Value` -
129    /// the same model LaserData Cloud extracts indexed fields from. Use it to check
130    /// which JSON pointers a projection's extraction plan would resolve.
131    pub fn decode(&self, payload: &[u8]) -> Result<serde_json::Value, LaserError> {
132        match self {
133            Self::Avro(schema) => {
134                let mut cursor = payload;
135                let value = apache_avro::reader::datum::GenericDatumReader::builder(schema)
136                    .build()
137                    .and_then(|reader| reader.read_value(&mut cursor))
138                    .map_err(|error| {
139                        LaserError::Codec(format!("payload does not decode as Avro: {error}"))
140                    })?;
141                apache_avro::from_value::<serde_json::Value>(&bytes_as_arrays(value)).map_err(
142                    |error| {
143                        LaserError::Codec(format!("Avro value does not lower to JSON: {error}"))
144                    },
145                )
146            }
147            Self::Protobuf(descriptor) => {
148                let message =
149                    DynamicMessage::decode(descriptor.clone(), payload).map_err(|error| {
150                        LaserError::Codec(format!("payload does not decode as Protobuf: {error}"))
151                    })?;
152                serde_json::to_value(&message).map_err(|error| {
153                    LaserError::Codec(format!("Protobuf message does not lower to JSON: {error}"))
154                })
155            }
156            Self::Json(validator) => {
157                let value: serde_json::Value =
158                    serde_json::from_slice(payload).map_err(|error| {
159                        LaserError::Codec(format!("payload does not parse as JSON: {error}"))
160                    })?;
161                if !validator.is_valid(&value) {
162                    return Err(LaserError::Codec(
163                        "payload fails its JSON Schema".to_owned(),
164                    ));
165                }
166                Ok(value)
167            }
168        }
169    }
170
171    /// Encode a serde value as a raw Avro datum (single-object encoding, no
172    /// container header), exactly the bytes a producer stamps alongside
173    /// `agdx.sid`. Avro schemas only. A Protobuf schema returns
174    /// [`LaserError::Invalid`] (encode Protobuf bodies with `prost` and ship
175    /// them via `.raw_bytes(bytes, ContentType::Protobuf)`).
176    pub fn encode_avro<T: Serialize>(&self, body: &T) -> Result<Vec<u8>, LaserError> {
177        let Self::Avro(schema) = self else {
178            return Err(LaserError::Invalid(
179                "encode_avro requires an Avro schema".to_owned(),
180            ));
181        };
182        // Lower through JSON, not serde-direct: apache-avro's serde treats a
183        // `u64` (every non-negative integer arriving as a `serde_json::Value`)
184        // as its 8-byte Fixed marker type and refuses a `long` schema, while
185        // the JSON conversion maps integers to Int/Long.
186        let json = serde_json::to_value(body)
187            .map_err(|error| LaserError::Codec(format!("body does not lower to JSON: {error}")))?;
188        let resolved = apache_avro::types::Value::try_from(json)
189            .and_then(|value| arrays_as_bytes(value, schema).resolve(schema))
190            .map_err(|error| {
191                LaserError::Codec(format!("body does not match the Avro schema: {error}"))
192            })?;
193        apache_avro::writer::datum::GenericDatumWriter::builder(schema)
194            .build()
195            .and_then(|writer| writer.write_value_to_vec(resolved))
196            .map_err(|error| LaserError::Codec(format!("Avro datum encode failed: {error}")))
197    }
198}
199
200// The inverse of `bytes_as_arrays`, guided by the schema: a JSON array of byte
201// values in a `bytes` or `fixed` position becomes Avro bytes, which also
202// resolves into `fixed`. Arrays anywhere else stay arrays.
203fn arrays_as_bytes(
204    value: apache_avro::types::Value,
205    schema: &apache_avro::Schema,
206) -> apache_avro::types::Value {
207    use apache_avro::Schema;
208    use apache_avro::types::Value;
209    match (schema, value) {
210        (Schema::Bytes | Schema::Fixed(_), Value::Array(items)) => {
211            let bytes: Option<Vec<u8>> = items
212                .iter()
213                .map(|item| match item {
214                    Value::Int(byte) => u8::try_from(*byte).ok(),
215                    Value::Long(byte) => u8::try_from(*byte).ok(),
216                    _ => None,
217                })
218                .collect();
219            bytes.map_or(Value::Array(items), Value::Bytes)
220        }
221        (Schema::Array(array), Value::Array(items)) => Value::Array(
222            items
223                .into_iter()
224                .map(|item| arrays_as_bytes(item, &array.items))
225                .collect(),
226        ),
227        (Schema::Map(map), Value::Map(entries)) => Value::Map(
228            entries
229                .into_iter()
230                .map(|(key, item)| (key, arrays_as_bytes(item, &map.types)))
231                .collect(),
232        ),
233        (Schema::Record(record), Value::Map(entries)) => Value::Map(
234            entries
235                .into_iter()
236                .map(|(key, item)| {
237                    let item = match record.fields.iter().find(|field| field.name == key) {
238                        Some(field) => arrays_as_bytes(item, &field.schema),
239                        None => item,
240                    };
241                    (key, item)
242                })
243                .collect(),
244        ),
245        (Schema::Union(union), value) => {
246            let variant = union.variants().iter().find(|variant| {
247                matches!(
248                    (variant, &value),
249                    (Schema::Bytes | Schema::Fixed(_), Value::Array(_))
250                        | (Schema::Record(_) | Schema::Map(_), Value::Map(_))
251                )
252            });
253            let has_array = union
254                .variants()
255                .iter()
256                .any(|variant| matches!(variant, Schema::Array(_)));
257            match variant {
258                Some(variant) if !(has_array && matches!(value, Value::Array(_))) => {
259                    arrays_as_bytes(value, variant)
260                }
261                _ => value,
262            }
263        }
264        (_, value) => value,
265    }
266}
267
268// JSON has no byte type, so an Avro `bytes` or `fixed` value lowers to an
269// array of byte values, the same shape `encode_avro` accepts for that field.
270fn bytes_as_arrays(value: apache_avro::types::Value) -> apache_avro::types::Value {
271    use apache_avro::types::Value;
272    match value {
273        Value::Bytes(bytes) | Value::Fixed(_, bytes) => Value::Array(
274            bytes
275                .into_iter()
276                .map(|byte| Value::Int(i32::from(byte)))
277                .collect(),
278        ),
279        Value::Array(items) => Value::Array(items.into_iter().map(bytes_as_arrays).collect()),
280        Value::Map(entries) => Value::Map(
281            entries
282                .into_iter()
283                .map(|(key, item)| (key, bytes_as_arrays(item)))
284                .collect(),
285        ),
286        Value::Record(fields) => Value::Record(
287            fields
288                .into_iter()
289                .map(|(name, item)| (name, bytes_as_arrays(item)))
290                .collect(),
291        ),
292        Value::Union(index, item) => Value::Union(index, Box::new(bytes_as_arrays(*item))),
293        other => other,
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn given_bytes_and_fixed_fields_when_encoded_then_should_decode_back() {
303        let def = SchemaDef {
304            id: 8,
305            source: SchemaSource::Avro {
306                schema: r#"{
307                    "type":"record","name":"Frame",
308                    "fields":[
309                        {"name":"body","type":"bytes"},
310                        {"name":"digest","type":{"type":"fixed","name":"Digest","size":2}},
311                        {"name":"tag","type":["null","bytes"]}
312                    ]
313                }"#
314                .to_owned(),
315            },
316            name: None,
317            version: None,
318        };
319        let compiled = CompiledSchema::compile(&def).expect("schema compiles");
320        let body = serde_json::json!({"body": [1, 2, 255], "digest": [7, 9], "tag": [4]});
321        let datum = compiled.encode_avro(&body).expect("body encodes");
322        assert!(compiled.validate(&datum), "own encoding validates");
323        assert_eq!(compiled.decode(&datum).expect("datum decodes"), body);
324    }
325
326    const READING_AVRO_SCHEMA: &str = r#"{
327        "type":"record","name":"Reading",
328        "fields":[
329            {"name":"host","type":"string"},
330            {"name":"cpu","type":"long"}
331        ]
332    }"#;
333
334    #[derive(Serialize)]
335    struct Reading {
336        host: String,
337        cpu: i64,
338    }
339
340    fn avro_def() -> SchemaDef {
341        SchemaDef {
342            id: 7,
343            source: SchemaSource::Avro {
344                schema: READING_AVRO_SCHEMA.to_owned(),
345            },
346            name: None,
347            version: None,
348        }
349    }
350
351    #[test]
352    fn given_an_avro_def_when_encoded_then_should_decode_and_validate_back() {
353        let compiled = CompiledSchema::compile(&avro_def()).expect("schema compiles");
354        let reading = Reading {
355            host: "node-7".to_owned(),
356            cpu: 42,
357        };
358        let datum = compiled.encode_avro(&reading).expect("body encodes");
359        assert!(compiled.validate(&datum), "own encoding validates");
360        let value = compiled.decode(&datum).expect("datum decodes");
361        assert_eq!(
362            value.pointer("/host").and_then(|v| v.as_str()),
363            Some("node-7")
364        );
365        assert_eq!(value.pointer("/cpu").and_then(|v| v.as_i64()), Some(42));
366    }
367
368    #[test]
369    fn given_a_mismatched_body_when_encoded_then_should_error() {
370        #[derive(Serialize)]
371        struct Wrong {
372            unrelated: bool,
373        }
374        let compiled = CompiledSchema::compile(&avro_def()).expect("schema compiles");
375        let result = compiled.encode_avro(&Wrong { unrelated: true });
376        assert!(matches!(result, Err(LaserError::Codec(_))));
377    }
378
379    #[test]
380    fn given_garbage_bytes_when_validated_then_should_be_false() {
381        let compiled = CompiledSchema::compile(&avro_def()).expect("schema compiles");
382        assert!(!compiled.validate(b""));
383    }
384
385    #[test]
386    fn given_an_unparseable_avro_def_when_compiled_then_should_be_invalid() {
387        let def = SchemaDef {
388            id: 9,
389            source: SchemaSource::Avro {
390                schema: "{not valid".to_owned(),
391            },
392            name: None,
393            version: None,
394        };
395        assert!(matches!(
396            CompiledSchema::compile(&def),
397            Err(LaserError::Invalid(_))
398        ));
399    }
400
401    #[test]
402    fn given_a_json_schema_def_when_compiled_then_should_validate_and_decode_json() {
403        let def = SchemaDef {
404            id: 5,
405            source: SchemaSource::JsonSchema {
406                schema: r#"{
407                    "type":"object",
408                    "required":["host","cpu"],
409                    "properties":{
410                        "host":{"type":"string"},
411                        "cpu":{"type":"integer","minimum":0}
412                    }
413                }"#
414                .to_owned(),
415            },
416            name: None,
417            version: None,
418        };
419        let compiled = CompiledSchema::compile(&def).expect("schema compiles");
420        assert!(compiled.validate(br#"{"host":"node-7","cpu":42}"#));
421        assert!(!compiled.validate(br#"{"host":"node-7","cpu":"42"}"#));
422        assert!(!compiled.validate(b"not json"));
423        assert!(compiled.validate_value(&serde_json::json!({"host":"a","cpu":1})));
424        assert!(!compiled.validate_value(&serde_json::json!({"cpu":1})));
425        let value = compiled
426            .decode(br#"{"host":"node-7","cpu":42}"#)
427            .expect("valid payload decodes");
428        assert_eq!(
429            value.pointer("/host").and_then(|v| v.as_str()),
430            Some("node-7")
431        );
432        assert!(matches!(
433            compiled.decode(br#"{"cpu":42}"#),
434            Err(LaserError::Codec(_))
435        ));
436        assert!(matches!(
437            compiled.encode_avro(&serde_json::json!({})),
438            Err(LaserError::Invalid(_))
439        ));
440    }
441
442    #[test]
443    fn given_an_uncompilable_json_schema_def_when_compiled_then_should_be_invalid() {
444        let def = SchemaDef {
445            id: 6,
446            source: SchemaSource::JsonSchema {
447                schema: "{not json".to_owned(),
448            },
449            name: None,
450            version: None,
451        };
452        assert!(matches!(
453            CompiledSchema::compile(&def),
454            Err(LaserError::Invalid(_))
455        ));
456    }
457
458    #[test]
459    fn given_an_avro_def_when_value_validated_then_should_report_family_mismatch() {
460        let compiled = CompiledSchema::compile(&avro_def()).expect("schema compiles");
461        assert!(!compiled.validate_value(&serde_json::json!({"host":"a","cpu":1})));
462    }
463
464    #[test]
465    fn given_a_protobuf_def_when_compiled_then_should_validate_real_messages() {
466        use prost::Message;
467        let dir = tempfile::tempdir().expect("temp dir");
468        let proto = dir.path().join("reading.proto");
469        std::fs::write(
470            &proto,
471            "syntax = \"proto3\";\npackage fleet;\nmessage Reading { string host = 1; int64 cpu = 2; }\n",
472        )
473        .expect("proto written");
474        let descriptor_set = protox::compile([&proto], [dir.path()])
475            .expect("proto compiles")
476            .encode_to_vec();
477        let def = SchemaDef {
478            id: 3,
479            source: SchemaSource::Protobuf {
480                descriptor_set: descriptor_set.clone(),
481                message_type: "fleet.Reading".to_owned(),
482            },
483            name: None,
484            version: None,
485        };
486        let compiled = CompiledSchema::compile(&def).expect("schema compiles");
487
488        let pool = DescriptorPool::decode(descriptor_set.as_slice()).expect("pool decodes");
489        let descriptor = pool.get_message_by_name("fleet.Reading").expect("message");
490        let mut message = DynamicMessage::new(descriptor);
491        message.set_field_by_name("host", prost_reflect::Value::String("node-7".into()));
492        message.set_field_by_name("cpu", prost_reflect::Value::I64(42));
493        let payload = message.encode_to_vec();
494
495        assert!(compiled.validate(&payload));
496        assert!(!compiled.validate(b"\xff\xff\xff"));
497        let value = compiled.decode(&payload).expect("decodes");
498        assert_eq!(
499            value.pointer("/host").and_then(|v| v.as_str()),
500            Some("node-7")
501        );
502        let encode = compiled.encode_avro(&serde_json::json!({}));
503        assert!(matches!(encode, Err(LaserError::Invalid(_))));
504    }
505}