Skip to main content

camel_dataformat_protobuf/
lib.rs

1//! camel-dataformat-protobuf — Protobuf DataFormat for Apache Camel Rust.
2//!
3//! Provides marshal/unmarshal support for Protocol Buffers using dynamic message
4//! descriptors compiled at runtime via `prost-reflect`. JSON ↔ binary protobuf
5//! round-tripping is supported out of the box.
6//!
7//! TODO(PROTO-005): Schema registry integration (e.g. Confluent Schema Registry)
8//! is not yet implemented. When available, this will allow automatic schema
9//! lookup/registration by subject and version during marshal/unmarshal.
10
11use std::path::Path;
12
13use bytes::BytesMut;
14use camel_api::body::Body;
15use camel_api::data_format::DataFormat;
16use camel_api::error::CamelError;
17use camel_proto_compiler::{ProtoCache, compile_proto};
18use prost::Message;
19use prost_reflect::{DynamicMessage, MessageDescriptor};
20
21/// Default maximum input size accepted by `unmarshal`/`marshal` before
22/// `DynamicMessage::decode` (DoS cap, R5-M2 — closes the OOM vector).
23///
24/// Recursion-depth: prost 0.14 enforces a built-in RECURSION_LIMIT=100
25/// (active by default; the `no-recursion-limit` feature is NOT enabled in
26/// this workspace), and prost-reflect's decode routes nested messages through
27/// `prost::encoding::message::merge` which checks it. So deeply-nested /
28/// recursive-schema payloads return `Err(RecursionLimitReached)` at depth 100
29/// — the spec's max-depth requirement is satisfied at the dependency level.
30/// See `test_unmarshal_recursive_schema_hits_recursion_limit`.
31const DEFAULT_MAX_DECODE_BYTES: usize = 64 * 1024 * 1024; // 64 MiB
32
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub struct ProtobufConfig {
35    /// Optional content type format, e.g. `application/protobuf` or `application/json`.
36    /// When unset, binary protobuf is assumed.
37    pub content_type_format: Option<String>,
38    /// Optional fully-qualified class/type name used for deserialization.
39    pub instance_class: Option<String>,
40}
41
42impl ProtobufConfig {
43    pub fn validate(&self) -> Result<(), CamelError> {
44        if let Some(instance_class) = &self.instance_class
45            && instance_class.trim().is_empty()
46        {
47            return Err(CamelError::TypeConversionFailed(
48                "instance_class must be non-empty when set".to_string(),
49            ));
50        }
51
52        Ok(())
53    }
54}
55
56pub struct ProtobufDataFormat {
57    descriptor: MessageDescriptor,
58    max_decode_bytes: usize,
59}
60
61impl ProtobufDataFormat {
62    pub fn new<P: AsRef<Path>>(proto_path: P, message_name: &str) -> Result<Self, CamelError> {
63        let pool =
64            compile_proto(proto_path.as_ref(), std::iter::empty::<&Path>()).map_err(|e| {
65                CamelError::TypeConversionFailed(format!("failed to compile proto: {e}"))
66            })?;
67        let descriptor = pool.get_message_by_name(message_name).ok_or_else(|| {
68            CamelError::Config(format!("message descriptor not found: {message_name}"))
69        })?;
70        Ok(Self {
71            descriptor,
72            max_decode_bytes: DEFAULT_MAX_DECODE_BYTES,
73        })
74    }
75
76    pub fn new_with_cache<P: AsRef<Path>>(
77        proto_path: P,
78        message_name: &str,
79        cache: &ProtoCache,
80    ) -> Result<Self, CamelError> {
81        let pool = cache
82            .get_or_compile(proto_path.as_ref(), std::iter::empty::<&Path>())
83            .map_err(|e| {
84                CamelError::TypeConversionFailed(format!("failed to compile proto: {e}"))
85            })?;
86        let descriptor = pool.get_message_by_name(message_name).ok_or_else(|| {
87            CamelError::Config(format!("message descriptor not found: {message_name}"))
88        })?;
89        Ok(Self {
90            descriptor,
91            max_decode_bytes: DEFAULT_MAX_DECODE_BYTES,
92        })
93    }
94
95    pub fn descriptor(&self) -> &MessageDescriptor {
96        &self.descriptor
97    }
98
99    /// Maximum input size accepted before decode (default 64 MiB).
100    pub fn max_decode_bytes(&self) -> usize {
101        self.max_decode_bytes
102    }
103
104    /// Override the decode byte-size cap (builder style).
105    #[must_use]
106    pub fn with_max_decode_bytes(mut self, max: usize) -> Self {
107        self.max_decode_bytes = max;
108        self
109    }
110
111    pub fn json_to_dynamic(
112        &self,
113        json_val: serde_json::Value,
114    ) -> Result<DynamicMessage, CamelError> {
115        let json_str = serde_json::to_string(&json_val).map_err(|e| {
116            CamelError::TypeConversionFailed(format!("failed to serialize JSON: {e}"))
117        })?;
118        let mut de = serde_json::Deserializer::from_str(&json_str);
119        DynamicMessage::deserialize(self.descriptor.clone(), &mut de).map_err(|e| {
120            CamelError::TypeConversionFailed(format!("failed to parse JSON into protobuf: {e}"))
121        })
122    }
123
124    pub fn dynamic_to_json(&self, msg: DynamicMessage) -> Result<serde_json::Value, CamelError> {
125        serde_json::to_value(&msg).map_err(|e| {
126            CamelError::TypeConversionFailed(format!("failed to serialize protobuf to JSON: {e}"))
127        })
128    }
129}
130
131impl DataFormat for ProtobufDataFormat {
132    fn name(&self) -> &str {
133        "protobuf"
134    }
135
136    fn marshal(&self, body: Body) -> Result<Body, CamelError> {
137        match body {
138            Body::Json(val) => {
139                let msg = self.json_to_dynamic(val)?;
140                let mut buf = BytesMut::new();
141                msg.encode(&mut buf).map_err(|e| {
142                    CamelError::TypeConversionFailed(format!(
143                        "failed to encode protobuf message: {e}"
144                    ))
145                })?;
146                Ok(Body::Bytes(buf.freeze()))
147            }
148            Body::Text(text) => {
149                let val: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
150                    CamelError::TypeConversionFailed(format!(
151                        "invalid JSON text for protobuf marshal: {e}"
152                    ))
153                })?;
154                self.marshal(Body::Json(val))
155            }
156            Body::Bytes(bytes) => {
157                if bytes.len() > self.max_decode_bytes {
158                    return Err(CamelError::TypeConversionFailed(format!(
159                        "protobuf marshal rejected: {} bytes exceeds max_decode_bytes {}",
160                        bytes.len(),
161                        self.max_decode_bytes
162                    )));
163                }
164                DynamicMessage::decode(self.descriptor.clone(), bytes.as_ref()).map_err(|e| {
165                    CamelError::ProcessorError(format!(
166                        "protobuf marshal: invalid bytes for type {}: {e}",
167                        self.descriptor.full_name()
168                    ))
169                })?;
170                Ok(Body::Bytes(bytes))
171            }
172            Body::Empty => Err(CamelError::TypeConversionFailed(
173                "protobuf marshal does not support empty body".to_string(),
174            )),
175            Body::Stream(_) => Err(CamelError::TypeConversionFailed(
176                "protobuf marshal does not support stream body".to_string(),
177            )),
178            Body::Xml(_) => Err(CamelError::TypeConversionFailed(
179                "protobuf marshal does not support XML body".to_string(),
180            )),
181            _ => Err(CamelError::TypeConversionFailed(
182                "protobuf marshal does not support this body type".to_string(),
183            )),
184        }
185    }
186
187    fn unmarshal(&self, body: Body) -> Result<Body, CamelError> {
188        match body {
189            Body::Bytes(bytes) => {
190                if bytes.len() > self.max_decode_bytes {
191                    return Err(CamelError::TypeConversionFailed(format!(
192                        "protobuf unmarshal rejected: {} bytes exceeds max_decode_bytes {}",
193                        bytes.len(),
194                        self.max_decode_bytes
195                    )));
196                }
197                let msg = DynamicMessage::decode(self.descriptor.clone(), bytes.as_ref()).map_err(
198                    |e| {
199                        CamelError::TypeConversionFailed(format!(
200                            "failed to decode protobuf bytes: {e}"
201                        ))
202                    },
203                )?;
204                let json = self.dynamic_to_json(msg)?;
205                Ok(Body::Json(json))
206            }
207            Body::Json(val) => Ok(Body::Json(val)),
208            Body::Text(_) => Err(CamelError::TypeConversionFailed(
209                "protobuf unmarshal does not support text body".to_string(),
210            )),
211            Body::Stream(_) => Err(CamelError::TypeConversionFailed(
212                "protobuf unmarshal does not support stream body".to_string(),
213            )),
214            Body::Empty => Err(CamelError::TypeConversionFailed(
215                "protobuf unmarshal does not support empty body".to_string(),
216            )),
217            Body::Xml(_) => Err(CamelError::TypeConversionFailed(
218                "protobuf unmarshal does not support XML body".to_string(),
219            )),
220            _ => Err(CamelError::TypeConversionFailed(
221                "protobuf unmarshal does not support this body type".to_string(),
222            )),
223        }
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use std::path::PathBuf;
230
231    use bytes::Bytes;
232    use camel_api::body::Body;
233    use camel_api::data_format::DataFormat;
234    use camel_api::error::CamelError;
235    use serde_json::json;
236
237    use super::{ProtobufConfig, ProtobufDataFormat};
238
239    fn test_proto_path() -> PathBuf {
240        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
241            .join("tests")
242            .join("helloworld.proto")
243    }
244
245    fn data_format() -> ProtobufDataFormat {
246        ProtobufDataFormat::new(test_proto_path(), "helloworld.HelloRequest")
247            .expect("should load descriptor")
248    }
249
250    #[test]
251    fn test_name() {
252        let df = data_format();
253        assert_eq!(df.name(), "protobuf");
254    }
255
256    #[test]
257    fn test_marshal_json_to_bytes() {
258        let df = data_format();
259        let body = Body::Json(json!({ "name": "Alice" }));
260        let out = df.marshal(body).expect("marshal should succeed");
261        match out {
262            Body::Bytes(b) => assert!(!b.is_empty()),
263            other => panic!("expected bytes, got {other:?}"),
264        }
265    }
266
267    #[test]
268    fn test_unmarshal_bytes_to_json() {
269        let df = data_format();
270        let bytes = match df
271            .marshal(Body::Json(json!({ "name": "Alice" })))
272            .expect("marshal should succeed")
273        {
274            Body::Bytes(b) => b,
275            other => panic!("expected bytes, got {other:?}"),
276        };
277
278        let out = df
279            .unmarshal(Body::Bytes(bytes))
280            .expect("unmarshal should succeed");
281        match out {
282            Body::Json(v) => assert_eq!(v, json!({ "name": "Alice" })),
283            other => panic!("expected json, got {other:?}"),
284        }
285    }
286
287    #[test]
288    fn test_roundtrip_json_bytes_json() {
289        let df = data_format();
290        let input = json!({ "name": "Bob" });
291        let bytes = match df
292            .marshal(Body::Json(input.clone()))
293            .expect("marshal should succeed")
294        {
295            Body::Bytes(b) => b,
296            other => panic!("expected bytes, got {other:?}"),
297        };
298        let output = match df
299            .unmarshal(Body::Bytes(bytes))
300            .expect("unmarshal should succeed")
301        {
302            Body::Json(v) => v,
303            other => panic!("expected json, got {other:?}"),
304        };
305        assert_eq!(output, input);
306    }
307
308    #[test]
309    fn test_marshal_bytes_passthrough() {
310        let df = data_format();
311        let body = Body::Bytes(Bytes::from_static(b"raw"));
312        let err = df
313            .marshal(body)
314            .expect_err("invalid bytes should be rejected");
315        assert!(
316            err.to_string()
317                .contains("protobuf marshal: invalid bytes for type")
318        );
319    }
320
321    #[test]
322    fn test_marshal_valid_bytes_accepted() {
323        let df = data_format();
324        let bytes = match df
325            .marshal(Body::Json(json!({ "name": "Alice" })))
326            .expect("marshal should succeed")
327        {
328            Body::Bytes(b) => b,
329            other => panic!("expected bytes, got {other:?}"),
330        };
331        let out = df
332            .marshal(Body::Bytes(bytes.clone()))
333            .expect("valid protobuf bytes should be accepted");
334        assert_eq!(out, Body::Bytes(bytes));
335    }
336
337    #[test]
338    fn test_unmarshal_json_passthrough() {
339        let df = data_format();
340        let body = Body::Json(json!({ "name": "Passthrough" }));
341        let out = df
342            .unmarshal(body.clone())
343            .expect("unmarshal should pass through JSON");
344        assert_eq!(out, body);
345    }
346
347    #[test]
348    fn test_marshal_empty_rejected() {
349        let df = data_format();
350        let err = df.marshal(Body::Empty).expect_err("empty must be rejected");
351        assert!(format!("{err}").contains("empty"));
352    }
353
354    #[test]
355    fn test_unmarshal_empty_rejected() {
356        let df = data_format();
357        let err = df
358            .unmarshal(Body::Empty)
359            .expect_err("empty must be rejected");
360        assert!(format!("{err}").contains("empty"));
361    }
362
363    #[test]
364    fn test_message_not_found_error() {
365        let err = ProtobufDataFormat::new(test_proto_path(), "helloworld.DoesNotExist")
366            .err()
367            .expect("unknown message should fail");
368        assert!(matches!(err, CamelError::Config(_)));
369    }
370
371    #[test]
372    fn test_empty_instance_class_rejected() {
373        let config = ProtobufConfig {
374            instance_class: Some("".into()),
375            ..Default::default()
376        };
377        assert!(config.validate().is_err());
378    }
379
380    #[test]
381    fn test_valid_instance_class_accepted() {
382        let config = ProtobufConfig {
383            instance_class: Some("com.example.MyMessage".into()),
384            ..Default::default()
385        };
386        assert!(config.validate().is_ok());
387    }
388
389    #[test]
390    fn test_no_instance_class_valid() {
391        let config = ProtobufConfig::default();
392        assert!(config.validate().is_ok());
393    }
394
395    /// PROTO-003: Encode/decode roundtrip — marshal JSON to bytes, then unmarshal
396    /// those bytes back to JSON and verify the values match the original input.
397    #[test]
398    fn test_encode_decode_roundtrip() {
399        let df = data_format();
400        let original = json!({ "name": "RoundtripCharlie" });
401
402        // Encode: JSON → binary protobuf bytes
403        let encoded = match df
404            .marshal(Body::Json(original.clone()))
405            .expect("marshal should succeed")
406        {
407            Body::Bytes(b) => b,
408            other => panic!("expected bytes after marshal, got {other:?}"),
409        };
410        assert!(!encoded.is_empty(), "encoded bytes should not be empty");
411
412        // Decode: binary protobuf bytes → JSON
413        let decoded = match df
414            .unmarshal(Body::Bytes(encoded))
415            .expect("unmarshal should succeed")
416        {
417            Body::Json(v) => v,
418            other => panic!("expected json after unmarshal, got {other:?}"),
419        };
420
421        assert_eq!(decoded, original, "roundtrip should preserve field values");
422    }
423
424    #[test]
425    fn test_unmarshal_rejects_oversized_bytes() {
426        let df = data_format().with_max_decode_bytes(16);
427        // 64 raw bytes >> 16-byte cap.
428        let body = Body::Bytes(Bytes::from_static(
429            b"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
430        ));
431        let err = df.unmarshal(body).unwrap_err(); // allow-unwrap
432        assert!(
433            format!("{err}").contains("max_decode_bytes"),
434            "error should mention max_decode_bytes: {err}"
435        );
436    }
437
438    #[test]
439    fn test_unmarshal_default_cap_accepts_valid_bytes() {
440        let df = data_format(); // default 64 MiB cap
441        let bytes = match df
442            .marshal(Body::Json(json!({ "name": "Alice" })))
443            .unwrap() // allow-unwrap
444        {
445            Body::Bytes(b) => b,
446            _ => panic!("expected bytes"),
447        };
448        let out = df.unmarshal(Body::Bytes(bytes)).unwrap(); // allow-unwrap
449        assert!(matches!(out, Body::Json(_)));
450    }
451
452    #[test]
453    fn test_with_max_decode_bytes_overrides_default() {
454        let df = data_format().with_max_decode_bytes(1);
455        assert_eq!(df.max_decode_bytes(), 1);
456    }
457
458    /// Build a ProtobufDataFormat over the recursive `test.Node` schema, for the
459    /// recursion-limit regression test (R5-M2). Mirrors the existing `data_format()`
460    /// helper's compile path but points at tests/fixtures/recursive.proto.
461    fn recursive_node_data_format() -> ProtobufDataFormat {
462        ProtobufDataFormat::new(
463            PathBuf::from(env!("CARGO_MANIFEST_DIR"))
464                .join("tests")
465                .join("fixtures")
466                .join("recursive.proto"),
467            "test.Node",
468        )
469        .expect("recursive.proto must compile and expose test.Node") // allow-unwrap
470    }
471
472    #[test]
473    fn test_unmarshal_recursive_schema_hits_recursion_limit() {
474        // R5-M2: prost 0.14 enforces RECURSION_LIMIT=100 (DecodeContext::limit_reached
475        // → DecodeErrorKind::RecursionLimitReached), active because `no-recursion-limit`
476        // is NOT enabled. prost-reflect routes nested Value::Message through
477        // prost::encoding::message::merge, so the limit applies. This test PROVES the
478        // guard fires for a recursive schema AND doubles as the guard against a future
479        // `no-recursion-limit` feature flip (if flipped, the deep payload would decode
480        // instead of erroring → test fails).
481        //
482        // Schema (tests/fixtures/recursive.proto): message Node { Node child = 1; }
483        let df = recursive_node_data_format();
484        // Build 200 levels of nesting (>100 limit); innermost = empty message.
485        // Wire: field 1, wire type LEN(2) → tag 0x0a, then varint length, then inner.
486        let mut payload: Vec<u8> = Vec::new();
487        for _ in 0..200 {
488            let mut wrapped = vec![0x0a]; // tag: field 1, wire type LEN
489            let mut n = payload.len() as u64;
490            loop {
491                let mut byte = (n & 0x7f) as u8;
492                n >>= 7;
493                if n != 0 {
494                    byte |= 0x80;
495                }
496                wrapped.push(byte);
497                if n == 0 {
498                    break;
499                }
500            }
501            wrapped.extend_from_slice(&payload);
502            payload = wrapped;
503        }
504        assert!(
505            payload.len() < df.max_decode_bytes(),
506            "fixture must sit under byte cap"
507        );
508        let err = df
509            .unmarshal(Body::Bytes(Bytes::from(payload)))
510            .expect_err("deeply-nested recursive payload must be rejected"); // allow-unwrap
511        let msg = format!("{err}").to_lowercase();
512        assert!(
513            msg.contains("recursion") || msg.contains("limit"),
514            "decode must surface prost's recursion-limit error, got: {msg}"
515        );
516    }
517}