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        }
182    }
183
184    fn unmarshal(&self, body: Body) -> Result<Body, CamelError> {
185        match body {
186            Body::Bytes(bytes) => {
187                if bytes.len() > self.max_decode_bytes {
188                    return Err(CamelError::TypeConversionFailed(format!(
189                        "protobuf unmarshal rejected: {} bytes exceeds max_decode_bytes {}",
190                        bytes.len(),
191                        self.max_decode_bytes
192                    )));
193                }
194                let msg = DynamicMessage::decode(self.descriptor.clone(), bytes.as_ref()).map_err(
195                    |e| {
196                        CamelError::TypeConversionFailed(format!(
197                            "failed to decode protobuf bytes: {e}"
198                        ))
199                    },
200                )?;
201                let json = self.dynamic_to_json(msg)?;
202                Ok(Body::Json(json))
203            }
204            Body::Json(val) => Ok(Body::Json(val)),
205            Body::Text(_) => Err(CamelError::TypeConversionFailed(
206                "protobuf unmarshal does not support text body".to_string(),
207            )),
208            Body::Stream(_) => Err(CamelError::TypeConversionFailed(
209                "protobuf unmarshal does not support stream body".to_string(),
210            )),
211            Body::Empty => Err(CamelError::TypeConversionFailed(
212                "protobuf unmarshal does not support empty body".to_string(),
213            )),
214            Body::Xml(_) => Err(CamelError::TypeConversionFailed(
215                "protobuf unmarshal does not support XML body".to_string(),
216            )),
217        }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use std::path::PathBuf;
224
225    use bytes::Bytes;
226    use camel_api::body::Body;
227    use camel_api::data_format::DataFormat;
228    use camel_api::error::CamelError;
229    use serde_json::json;
230
231    use super::{ProtobufConfig, ProtobufDataFormat};
232
233    fn test_proto_path() -> PathBuf {
234        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
235            .join("tests")
236            .join("helloworld.proto")
237    }
238
239    fn data_format() -> ProtobufDataFormat {
240        ProtobufDataFormat::new(test_proto_path(), "helloworld.HelloRequest")
241            .expect("should load descriptor")
242    }
243
244    #[test]
245    fn test_name() {
246        let df = data_format();
247        assert_eq!(df.name(), "protobuf");
248    }
249
250    #[test]
251    fn test_marshal_json_to_bytes() {
252        let df = data_format();
253        let body = Body::Json(json!({ "name": "Alice" }));
254        let out = df.marshal(body).expect("marshal should succeed");
255        match out {
256            Body::Bytes(b) => assert!(!b.is_empty()),
257            other => panic!("expected bytes, got {other:?}"),
258        }
259    }
260
261    #[test]
262    fn test_unmarshal_bytes_to_json() {
263        let df = data_format();
264        let bytes = match df
265            .marshal(Body::Json(json!({ "name": "Alice" })))
266            .expect("marshal should succeed")
267        {
268            Body::Bytes(b) => b,
269            other => panic!("expected bytes, got {other:?}"),
270        };
271
272        let out = df
273            .unmarshal(Body::Bytes(bytes))
274            .expect("unmarshal should succeed");
275        match out {
276            Body::Json(v) => assert_eq!(v, json!({ "name": "Alice" })),
277            other => panic!("expected json, got {other:?}"),
278        }
279    }
280
281    #[test]
282    fn test_roundtrip_json_bytes_json() {
283        let df = data_format();
284        let input = json!({ "name": "Bob" });
285        let bytes = match df
286            .marshal(Body::Json(input.clone()))
287            .expect("marshal should succeed")
288        {
289            Body::Bytes(b) => b,
290            other => panic!("expected bytes, got {other:?}"),
291        };
292        let output = match df
293            .unmarshal(Body::Bytes(bytes))
294            .expect("unmarshal should succeed")
295        {
296            Body::Json(v) => v,
297            other => panic!("expected json, got {other:?}"),
298        };
299        assert_eq!(output, input);
300    }
301
302    #[test]
303    fn test_marshal_bytes_passthrough() {
304        let df = data_format();
305        let body = Body::Bytes(Bytes::from_static(b"raw"));
306        let err = df
307            .marshal(body)
308            .expect_err("invalid bytes should be rejected");
309        assert!(
310            err.to_string()
311                .contains("protobuf marshal: invalid bytes for type")
312        );
313    }
314
315    #[test]
316    fn test_marshal_valid_bytes_accepted() {
317        let df = data_format();
318        let bytes = match df
319            .marshal(Body::Json(json!({ "name": "Alice" })))
320            .expect("marshal should succeed")
321        {
322            Body::Bytes(b) => b,
323            other => panic!("expected bytes, got {other:?}"),
324        };
325        let out = df
326            .marshal(Body::Bytes(bytes.clone()))
327            .expect("valid protobuf bytes should be accepted");
328        assert_eq!(out, Body::Bytes(bytes));
329    }
330
331    #[test]
332    fn test_unmarshal_json_passthrough() {
333        let df = data_format();
334        let body = Body::Json(json!({ "name": "Passthrough" }));
335        let out = df
336            .unmarshal(body.clone())
337            .expect("unmarshal should pass through JSON");
338        assert_eq!(out, body);
339    }
340
341    #[test]
342    fn test_marshal_empty_rejected() {
343        let df = data_format();
344        let err = df.marshal(Body::Empty).expect_err("empty must be rejected");
345        assert!(format!("{err}").contains("empty"));
346    }
347
348    #[test]
349    fn test_unmarshal_empty_rejected() {
350        let df = data_format();
351        let err = df
352            .unmarshal(Body::Empty)
353            .expect_err("empty must be rejected");
354        assert!(format!("{err}").contains("empty"));
355    }
356
357    #[test]
358    fn test_message_not_found_error() {
359        let err = ProtobufDataFormat::new(test_proto_path(), "helloworld.DoesNotExist")
360            .err()
361            .expect("unknown message should fail");
362        assert!(matches!(err, CamelError::Config(_)));
363    }
364
365    #[test]
366    fn test_empty_instance_class_rejected() {
367        let config = ProtobufConfig {
368            instance_class: Some("".into()),
369            ..Default::default()
370        };
371        assert!(config.validate().is_err());
372    }
373
374    #[test]
375    fn test_valid_instance_class_accepted() {
376        let config = ProtobufConfig {
377            instance_class: Some("com.example.MyMessage".into()),
378            ..Default::default()
379        };
380        assert!(config.validate().is_ok());
381    }
382
383    #[test]
384    fn test_no_instance_class_valid() {
385        let config = ProtobufConfig::default();
386        assert!(config.validate().is_ok());
387    }
388
389    /// PROTO-003: Encode/decode roundtrip — marshal JSON to bytes, then unmarshal
390    /// those bytes back to JSON and verify the values match the original input.
391    #[test]
392    fn test_encode_decode_roundtrip() {
393        let df = data_format();
394        let original = json!({ "name": "RoundtripCharlie" });
395
396        // Encode: JSON → binary protobuf bytes
397        let encoded = match df
398            .marshal(Body::Json(original.clone()))
399            .expect("marshal should succeed")
400        {
401            Body::Bytes(b) => b,
402            other => panic!("expected bytes after marshal, got {other:?}"),
403        };
404        assert!(!encoded.is_empty(), "encoded bytes should not be empty");
405
406        // Decode: binary protobuf bytes → JSON
407        let decoded = match df
408            .unmarshal(Body::Bytes(encoded))
409            .expect("unmarshal should succeed")
410        {
411            Body::Json(v) => v,
412            other => panic!("expected json after unmarshal, got {other:?}"),
413        };
414
415        assert_eq!(decoded, original, "roundtrip should preserve field values");
416    }
417
418    #[test]
419    fn test_unmarshal_rejects_oversized_bytes() {
420        let df = data_format().with_max_decode_bytes(16);
421        // 64 raw bytes >> 16-byte cap.
422        let body = Body::Bytes(Bytes::from_static(
423            b"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
424        ));
425        let err = df.unmarshal(body).unwrap_err(); // allow-unwrap
426        assert!(
427            format!("{err}").contains("max_decode_bytes"),
428            "error should mention max_decode_bytes: {err}"
429        );
430    }
431
432    #[test]
433    fn test_unmarshal_default_cap_accepts_valid_bytes() {
434        let df = data_format(); // default 64 MiB cap
435        let bytes = match df
436            .marshal(Body::Json(json!({ "name": "Alice" })))
437            .unwrap() // allow-unwrap
438        {
439            Body::Bytes(b) => b,
440            _ => panic!("expected bytes"),
441        };
442        let out = df.unmarshal(Body::Bytes(bytes)).unwrap(); // allow-unwrap
443        assert!(matches!(out, Body::Json(_)));
444    }
445
446    #[test]
447    fn test_with_max_decode_bytes_overrides_default() {
448        let df = data_format().with_max_decode_bytes(1);
449        assert_eq!(df.max_decode_bytes(), 1);
450    }
451
452    /// Build a ProtobufDataFormat over the recursive `test.Node` schema, for the
453    /// recursion-limit regression test (R5-M2). Mirrors the existing `data_format()`
454    /// helper's compile path but points at tests/fixtures/recursive.proto.
455    fn recursive_node_data_format() -> ProtobufDataFormat {
456        ProtobufDataFormat::new(
457            PathBuf::from(env!("CARGO_MANIFEST_DIR"))
458                .join("tests")
459                .join("fixtures")
460                .join("recursive.proto"),
461            "test.Node",
462        )
463        .expect("recursive.proto must compile and expose test.Node") // allow-unwrap
464    }
465
466    #[test]
467    fn test_unmarshal_recursive_schema_hits_recursion_limit() {
468        // R5-M2: prost 0.14 enforces RECURSION_LIMIT=100 (DecodeContext::limit_reached
469        // → DecodeErrorKind::RecursionLimitReached), active because `no-recursion-limit`
470        // is NOT enabled. prost-reflect routes nested Value::Message through
471        // prost::encoding::message::merge, so the limit applies. This test PROVES the
472        // guard fires for a recursive schema AND doubles as the guard against a future
473        // `no-recursion-limit` feature flip (if flipped, the deep payload would decode
474        // instead of erroring → test fails).
475        //
476        // Schema (tests/fixtures/recursive.proto): message Node { Node child = 1; }
477        let df = recursive_node_data_format();
478        // Build 200 levels of nesting (>100 limit); innermost = empty message.
479        // Wire: field 1, wire type LEN(2) → tag 0x0a, then varint length, then inner.
480        let mut payload: Vec<u8> = Vec::new();
481        for _ in 0..200 {
482            let mut wrapped = vec![0x0a]; // tag: field 1, wire type LEN
483            let mut n = payload.len() as u64;
484            loop {
485                let mut byte = (n & 0x7f) as u8;
486                n >>= 7;
487                if n != 0 {
488                    byte |= 0x80;
489                }
490                wrapped.push(byte);
491                if n == 0 {
492                    break;
493                }
494            }
495            wrapped.extend_from_slice(&payload);
496            payload = wrapped;
497        }
498        assert!(
499            payload.len() < df.max_decode_bytes(),
500            "fixture must sit under byte cap"
501        );
502        let err = df
503            .unmarshal(Body::Bytes(Bytes::from(payload)))
504            .expect_err("deeply-nested recursive payload must be rejected"); // allow-unwrap
505        let msg = format!("{err}").to_lowercase();
506        assert!(
507            msg.contains("recursion") || msg.contains("limit"),
508            "decode must surface prost's recursion-limit error, got: {msg}"
509        );
510    }
511}