Skip to main content

camel_processor/
streaming_splitter.rs

1use futures::{StreamExt, pin_mut};
2use std::future::Future;
3use std::pin::Pin;
4use std::task::{Context, Poll};
5use tokio_util::sync::CancellationToken;
6use tower::Service;
7
8use camel_api::{
9    AggregationStrategy, Body, BoxProcessor, CamelError, Exchange, StreamingSplitExpression, Value,
10};
11
12pub const CAMEL_SPLIT_INDEX: &str = "CamelSplitIndex";
13pub const CAMEL_SPLIT_SIZE: &str = "CamelSplitSize";
14pub const CAMEL_SPLIT_COMPLETE: &str = "CamelSplitComplete";
15
16#[derive(Clone)]
17pub struct StreamingSplitterService {
18    expression: StreamingSplitExpression,
19    sub_pipeline: BoxProcessor,
20    aggregation: AggregationStrategy,
21    stop_on_exception: bool,
22    cancel_token: CancellationToken,
23}
24
25impl StreamingSplitterService {
26    pub fn new(
27        expression: StreamingSplitExpression,
28        sub_pipeline: BoxProcessor,
29        aggregation: AggregationStrategy,
30        stop_on_exception: bool,
31    ) -> Self {
32        Self {
33            expression,
34            sub_pipeline,
35            aggregation,
36            stop_on_exception,
37            cancel_token: CancellationToken::new(),
38        }
39    }
40
41    pub fn cancel(&self) {
42        self.cancel_token.cancel();
43    }
44
45    pub fn is_cancelled(&self) -> bool {
46        self.cancel_token.is_cancelled()
47    }
48}
49
50impl Service<Exchange> for StreamingSplitterService {
51    type Response = Exchange;
52    type Error = CamelError;
53    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
54
55    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
56        self.sub_pipeline.poll_ready(cx)
57    }
58
59    fn call(&mut self, exchange: Exchange) -> Self::Future {
60        let mut original = exchange.clone();
61        if matches!(original.input.body, Body::Stream(_)) {
62            original.input.body = Body::Empty;
63        }
64        let expression = self.expression.clone();
65        let sub_pipeline = self.sub_pipeline.clone();
66        let aggregation = self.aggregation.clone();
67        let stop_on_exception = self.stop_on_exception;
68        let cancel_token = self.cancel_token.clone();
69
70        Box::pin(async move {
71            let stream = expression(exchange);
72            pin_mut!(stream);
73
74            let mut acc: Option<Exchange> = None;
75            let mut acc_bodies: Vec<Value> = Vec::new();
76            let mut index: u64 = 0;
77
78            // One-entry lookahead for CamelSplitComplete.
79            //
80            // Property (rc-n6xt investigation): to set CamelSplitComplete=true on
81            // the last fragment, we must peek stream.next() before running the
82            // sub-pipeline of fragment N. Consequence: fragment N's sub-pipeline
83            // invocation is delayed until fragment N+1 has arrived from the
84            // stream. For I/O-bound streams this shifts time-sensitive side
85            // effects (telemetry, log timestamps) by ~1 stream interval per
86            // fragment. Total wall time is unaffected (sequential either way);
87            // peak memory holds 1 extra Exchange (the peeked `next`) during the
88            // sub-pipeline call — irrelevant for the small fragments streaming
89            // splits are designed for. ADR-0044 backpressure is not impacted
90            // (route semaphore holds for the full call() either way).
91            //
92            // Alternative (eager emit, no lookahead) would break Camel C6
93            // CamelSplitComplete semantics. Not changing.
94            let mut current = stream.next().await;
95
96            while let Some(fragment_result) = current.take() {
97                if cancel_token.is_cancelled() {
98                    return Err(CamelError::ProcessorError(
99                        "StreamingSplitter cancelled".to_string(),
100                    ));
101                }
102
103                let fragment = fragment_result?;
104
105                // Peek next to know if this is the last entry
106                let next = stream.next().await;
107                let is_last = next.is_none();
108
109                let mut fragment = fragment;
110                fragment.set_property(CAMEL_SPLIT_INDEX, Value::from(index));
111                fragment.set_property(CAMEL_SPLIT_COMPLETE, Value::Bool(is_last));
112                // Match Camel C6 semantics: SPLIT_SIZE is null during the stream
113                // and only set on the last fragment (where total becomes known).
114                if is_last {
115                    fragment.set_property(CAMEL_SPLIT_SIZE, Value::from(index + 1));
116                }
117
118                let mut pipeline = sub_pipeline.clone();
119                let ready = tower::ServiceExt::ready(&mut pipeline).await;
120                let result = match ready {
121                    Ok(svc) => svc.call(fragment).await,
122                    Err(e) => Err(e),
123                };
124
125                match result {
126                    Ok(processed) => {
127                        match &aggregation {
128                            AggregationStrategy::CollectAll => {
129                                let v = match &processed.input.body {
130                                    Body::Text(s) => Value::String(s.clone()),
131                                    Body::Json(v) => v.clone(),
132                                    Body::Xml(s) => Value::String(s.clone()),
133                                    Body::Bytes(b) => {
134                                        Value::String(String::from_utf8_lossy(b).into_owned())
135                                    }
136                                    Body::Stream(_) => {
137                                        return Err(CamelError::TypeConversionFailed(
138                                            "StreamingSplitter CollectAll cannot aggregate Body::Stream — use 'stream_cache' or 'convert_body_to' before this step".to_string(),
139                                        ));
140                                    }
141                                    // Empty and future variants contribute no value.
142                                    _ => Value::Null,
143                                };
144                                acc_bodies.push(v);
145                            }
146                            AggregationStrategy::Custom(fold_fn) => {
147                                acc = Some(match acc {
148                                    Some(prev) => fold_fn(prev, processed),
149                                    None => processed,
150                                });
151                            }
152                            _ => {
153                                acc = Some(processed);
154                            }
155                        }
156                        index += 1;
157                    }
158                    Err(e) => {
159                        if stop_on_exception {
160                            return Err(e);
161                        }
162                        index += 1;
163                    }
164                }
165
166                current = next;
167            }
168
169            match &aggregation {
170                AggregationStrategy::Original => Ok(original),
171                AggregationStrategy::CollectAll => {
172                    let mut out = original;
173                    out.input.body = Body::Json(Value::Array(acc_bodies));
174                    Ok(out)
175                }
176                // LastWins, Custom, and any future variant return the accumulated
177                // exchange (or the original if nothing accumulated).
178                _ => Ok(acc.unwrap_or(original)),
179            }
180        })
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use bytes::Bytes;
188    use camel_api::{BoxProcessorExt, Message, StreamBody, StreamMetadata};
189    use futures::stream;
190    use std::sync::Arc;
191    use tokio::sync::Mutex;
192    use tower::ServiceExt;
193
194    use crate::stream_codec::{StreamSplitInput, resolve_format, resolve_incremental_codec};
195
196    fn passthrough_pipeline() -> BoxProcessor {
197        BoxProcessor::from_fn(|ex| Box::pin(async move { Ok(ex) }))
198    }
199
200    fn uppercase_pipeline() -> BoxProcessor {
201        BoxProcessor::from_fn(|mut ex: Exchange| {
202            Box::pin(async move {
203                if let Body::Text(s) = &ex.input.body {
204                    ex.input.body = Body::Text(s.to_uppercase());
205                }
206                Ok(ex)
207            })
208        })
209    }
210
211    fn make_exchange(text: &str) -> Exchange {
212        Exchange::new(Message::new(text))
213    }
214
215    fn test_expression(fragments: Vec<Exchange>) -> StreamingSplitExpression {
216        Arc::new(move |_| {
217            let frags = fragments.clone();
218            Box::pin(stream::iter(frags.into_iter().map(Ok)))
219        })
220    }
221
222    fn error_expression() -> StreamingSplitExpression {
223        Arc::new(|_| {
224            Box::pin(stream::iter(vec![Err(CamelError::ProcessorError(
225                "stream error".to_string(),
226            ))]))
227        })
228    }
229
230    /// Build a `StreamingSplitExpression` that reads from `Body::Stream` and
231    /// splits using the NdjsonCodec. Mirrors the resolution logic in
232    /// `step_resolution.rs`.
233    ///
234    /// The non-`Body::Stream` error arm mirrors the DeclarativeStreamSplit
235    /// compile arm in
236    /// `camel-core/src/lifecycle/adapters/step_compilers/splitting.rs` and
237    /// must stay in sync with it.
238    fn ndjson_stream_expression(config: camel_api::StreamSplitConfig) -> StreamingSplitExpression {
239        Arc::new(move |exchange: Exchange| {
240            let config = config.clone();
241            let (stream_body, parent) = match &exchange.input.body {
242                Body::Stream(sb) => (sb.clone(), {
243                    let mut p = exchange.clone();
244                    p.input.body = Body::Empty;
245                    p
246                }),
247                _ => {
248                    return Box::pin(futures::stream::once(async move {
249                        Err(camel_api::streaming_split_type_error(&exchange.input.body))
250                    }));
251                }
252            };
253
254            let stream = match stream_body.stream.try_lock() {
255                Ok(mut guard) => match guard.take() {
256                    Some(s) => s,
257                    None => {
258                        return Box::pin(futures::stream::once(async {
259                            Err(CamelError::ProcessorError(
260                                "stream body already consumed".into(),
261                            ))
262                        }));
263                    }
264                },
265                Err(_) => {
266                    return Box::pin(futures::stream::once(async {
267                        Err(CamelError::ProcessorError("stream body locked".into()))
268                    }));
269                }
270            };
271
272            let input = StreamSplitInput {
273                parent,
274                stream,
275                metadata: stream_body.metadata,
276            };
277
278            match resolve_format(&config.format, &input.metadata) {
279                Ok(f) => {
280                    let codec = resolve_incremental_codec(&f);
281                    let codec = match codec {
282                        Ok(c) => c,
283                        Err(e) => return Box::pin(futures::stream::once(async { Err(e) })),
284                    };
285                    codec.split(input, config)
286                }
287                Err(e) => Box::pin(futures::stream::once(async { Err(e) })),
288            }
289        })
290    }
291
292    // ---------------------------------------------------------------------------
293    // Integration test: Body::Stream NDJSON → streaming split → Body::Json fragments
294    // ---------------------------------------------------------------------------
295
296    #[tokio::test]
297    async fn test_ndjson_body_stream_streaming_split() {
298        // ── Arrange ────────────────────────────────────────────────────────────
299        // 3 lines of NDJSON as a byte stream
300        let ndjson_lines: Vec<Result<Bytes, CamelError>> = vec![
301            Ok(Bytes::from("{\"id\":1,\"name\":\"a\"}\n")),
302            Ok(Bytes::from("{\"id\":2,\"name\":\"b\"}\n")),
303            Ok(Bytes::from("{\"id\":3,\"name\":\"c\"}\n")),
304        ];
305        let byte_stream = futures::stream::iter(ndjson_lines);
306
307        let stream_body = StreamBody {
308            stream: Arc::new(Mutex::new(Some(Box::pin(byte_stream)))),
309            metadata: StreamMetadata {
310                content_type: Some("application/x-ndjson".into()),
311                size_hint: None,
312                origin: Some("test://ndjson".into()),
313            },
314        };
315
316        let ex = Exchange::new(Message::new(Body::Stream(stream_body)));
317
318        // Streaming split config — Ndjson format
319        let split_config = camel_api::StreamSplitConfig {
320            format: camel_api::StreamSplitFormat::Ndjson,
321            ..Default::default()
322        };
323
324        // Recorder sub-pipeline: captures per-fragment body + properties
325        #[allow(clippy::type_complexity)]
326        let fragments: Arc<
327            Mutex<Vec<(Option<serde_json::Value>, Option<Value>, Option<Value>)>>,
328        > = Arc::new(Mutex::new(Vec::new()));
329        let fragments_clone = Arc::clone(&fragments);
330        let recorder = BoxProcessor::from_fn(move |ex: Exchange| {
331            let frags = Arc::clone(&fragments_clone);
332            Box::pin(async move {
333                let body_json = match &ex.input.body {
334                    Body::Json(v) => Some(v.clone()),
335                    _ => None,
336                };
337                let split_index = ex.property(CAMEL_SPLIT_INDEX).cloned();
338                let split_complete = ex.property(CAMEL_SPLIT_COMPLETE).cloned();
339                let mut guard = frags.lock().await;
340                guard.push((body_json, split_index, split_complete));
341                Ok(ex)
342            })
343        });
344
345        let expression = ndjson_stream_expression(split_config);
346
347        // ── Act ────────────────────────────────────────────────────────────────
348        let mut splitter = StreamingSplitterService::new(
349            expression,
350            recorder,
351            AggregationStrategy::CollectAll,
352            true, // stop_on_exception
353        );
354
355        let result = splitter
356            .ready()
357            .await
358            .expect("splitter ready")
359            .call(ex)
360            .await
361            .expect("splitter call");
362
363        // ── Assert ─────────────────────────────────────────────────────────────
364        let guard = fragments.lock().await;
365
366        // 1. Three fragments were produced
367        assert_eq!(guard.len(), 3, "expected 3 NDJSON fragments");
368
369        // 2. Each fragment has Body::Json
370        for (i, (body_json, _idx, _complete)) in guard.iter().enumerate() {
371            assert!(
372                body_json.is_some(),
373                "fragment {i}: expected Body::Json body, got non-Json"
374            );
375        }
376
377        // 3. Each fragment has CamelSplitIndex property (0, 1, 2)
378        for (i, (_body, idx, _complete)) in guard.iter().enumerate() {
379            assert_eq!(
380                *idx,
381                Some(Value::Number(serde_json::Number::from(i as u64))),
382                "fragment {i}: CamelSplitIndex mismatch"
383            );
384        }
385
386        // 4. CamelSplitComplete: first two false, last one true
387        assert_eq!(
388            guard[0].2,
389            Some(Value::Bool(false)),
390            "first fragment: CamelSplitComplete should be false"
391        );
392        assert_eq!(
393            guard[1].2,
394            Some(Value::Bool(false)),
395            "second fragment: CamelSplitComplete should be false"
396        );
397        assert_eq!(
398            guard[2].2,
399            Some(Value::Bool(true)),
400            "last fragment: CamelSplitComplete should be true"
401        );
402
403        // 5. CollectAll aggregated into JSON array with correct values
404        match &result.input.body {
405            Body::Json(v) => {
406                let arr = v.as_array().expect("CollectAll result should be array");
407                assert_eq!(arr.len(), 3);
408                assert_eq!(arr[0], serde_json::json!({"id":1,"name":"a"}));
409                assert_eq!(arr[1], serde_json::json!({"id":2,"name":"b"}));
410                assert_eq!(arr[2], serde_json::json!({"id":3,"name":"c"}));
411            }
412            other => panic!("expected Body::Json from CollectAll, got {other:?}"),
413        }
414
415        // 6. Original stream body sanitized (already Empty, becomes part of aggregate)
416        //    The aggregate exchange's body is Json, not Stream
417        assert!(
418            matches!(result.input.body, Body::Json(_)),
419            "aggregate body should be Json, not Stream"
420        );
421    }
422
423    // ---------------------------------------------------------------------------
424    // Integration test: Empty Body::Stream → aggregate result is empty
425    // ---------------------------------------------------------------------------
426
427    #[tokio::test]
428    async fn test_ndjson_body_stream_empty_stream() {
429        // ── Arrange ────────────────────────────────────────────────────────────
430        // Empty byte stream
431        let byte_stream = futures::stream::iter(Vec::<Result<Bytes, CamelError>>::new());
432
433        let stream_body = StreamBody {
434            stream: Arc::new(Mutex::new(Some(Box::pin(byte_stream)))),
435            metadata: StreamMetadata {
436                content_type: Some("application/x-ndjson".into()),
437                size_hint: None,
438                origin: None,
439            },
440        };
441
442        let mut ex = Exchange::new(Message::new(Body::Stream(stream_body)));
443        ex.set_property("trace_id", Value::String("empty-test".into()));
444
445        let split_config = camel_api::StreamSplitConfig {
446            format: camel_api::StreamSplitFormat::Ndjson,
447            ..Default::default()
448        };
449
450        let expression = ndjson_stream_expression(split_config);
451
452        // ── Act ────────────────────────────────────────────────────────────────
453        let mut splitter = StreamingSplitterService::new(
454            expression,
455            passthrough_pipeline(),
456            AggregationStrategy::CollectAll,
457            true,
458        );
459
460        let result = splitter
461            .ready()
462            .await
463            .expect("splitter ready")
464            .call(ex)
465            .await
466            .expect("splitter call");
467
468        // ── Assert ─────────────────────────────────────────────────────────────
469        // Empty stream → CollectAll produces Body::Json([])
470        match &result.input.body {
471            Body::Json(v) => {
472                let arr = v.as_array().expect("CollectAll result should be array");
473                assert!(
474                    arr.is_empty(),
475                    "empty stream should produce empty array, got {arr:?}"
476                );
477            }
478            other => {
479                panic!("expected Body::Json([]) from CollectAll on empty stream, got {other:?}")
480            }
481        }
482
483        // Properties preserved
484        assert_eq!(
485            result.property("trace_id"),
486            Some(&Value::String("empty-test".into()))
487        );
488    }
489
490    #[tokio::test]
491    async fn test_streaming_sequential_last_wins() {
492        let expr = test_expression(vec![
493            make_exchange("a"),
494            make_exchange("b"),
495            make_exchange("c"),
496        ]);
497        let mut svc = StreamingSplitterService::new(
498            expr,
499            uppercase_pipeline(),
500            AggregationStrategy::LastWins,
501            true,
502        );
503
504        let result = svc
505            .ready()
506            .await
507            .unwrap()
508            .call(make_exchange("original"))
509            .await
510            .unwrap();
511        assert_eq!(result.input.body.as_text(), Some("C"));
512    }
513
514    #[tokio::test]
515    async fn test_streaming_sequential_original() {
516        let expr = test_expression(vec![make_exchange("a"), make_exchange("b")]);
517        let mut svc = StreamingSplitterService::new(
518            expr,
519            uppercase_pipeline(),
520            AggregationStrategy::Original,
521            true,
522        );
523
524        let result = svc
525            .ready()
526            .await
527            .unwrap()
528            .call(make_exchange("original"))
529            .await
530            .unwrap();
531        assert_eq!(result.input.body.as_text(), Some("original"));
532    }
533
534    #[tokio::test]
535    async fn test_streaming_stop_on_exception() {
536        let expr = test_expression(vec![make_exchange("a"), make_exchange("b")]);
537        let fail_pipeline = BoxProcessor::from_fn(|_| {
538            Box::pin(async { Err(CamelError::ProcessorError("boom".into())) })
539        });
540        let mut svc =
541            StreamingSplitterService::new(expr, fail_pipeline, AggregationStrategy::LastWins, true);
542
543        let result = svc
544            .ready()
545            .await
546            .unwrap()
547            .call(make_exchange("original"))
548            .await;
549        assert!(result.is_err());
550    }
551
552    #[tokio::test]
553    async fn test_streaming_empty_stream() {
554        let expr: StreamingSplitExpression = Arc::new(|_| Box::pin(futures::stream::empty()));
555        let mut svc = StreamingSplitterService::new(
556            expr,
557            passthrough_pipeline(),
558            AggregationStrategy::LastWins,
559            true,
560        );
561
562        let mut ex = make_exchange("original");
563        ex.set_property("marker", Value::Bool(true));
564        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
565        assert_eq!(result.input.body.as_text(), Some("original"));
566        assert_eq!(result.property("marker"), Some(&Value::Bool(true)));
567    }
568
569    #[tokio::test]
570    async fn test_streaming_error_in_expression() {
571        let mut svc = StreamingSplitterService::new(
572            error_expression(),
573            passthrough_pipeline(),
574            AggregationStrategy::LastWins,
575            true,
576        );
577
578        let result = svc
579            .ready()
580            .await
581            .unwrap()
582            .call(make_exchange("original"))
583            .await;
584        assert!(result.is_err());
585    }
586
587    #[tokio::test]
588    async fn test_streaming_cancellation() {
589        let expr = test_expression(vec![make_exchange("a"), make_exchange("b")]);
590        let slow_pipeline = BoxProcessor::from_fn(|ex| {
591            Box::pin(async move {
592                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
593                Ok(ex)
594            })
595        });
596        let svc =
597            StreamingSplitterService::new(expr, slow_pipeline, AggregationStrategy::LastWins, true);
598        svc.cancel();
599
600        let mut svc_clone = svc.clone();
601        let result = svc_clone
602            .ready()
603            .await
604            .unwrap()
605            .call(make_exchange("original"))
606            .await;
607        assert!(result.is_err());
608    }
609
610    #[tokio::test]
611    async fn test_streaming_sequential_collect_all() {
612        let expr = test_expression(vec![
613            make_exchange("a"),
614            make_exchange("b"),
615            make_exchange("c"),
616        ]);
617        let mut svc = StreamingSplitterService::new(
618            expr,
619            uppercase_pipeline(),
620            AggregationStrategy::CollectAll,
621            true,
622        );
623
624        let result = svc
625            .ready()
626            .await
627            .unwrap()
628            .call(make_exchange("original"))
629            .await
630            .unwrap();
631        let expected = serde_json::json!(["A", "B", "C"]);
632        match &result.input.body {
633            Body::Json(v) => assert_eq!(*v, expected),
634            other => panic!("expected JSON body, got {other:?}"),
635        }
636    }
637
638    #[tokio::test]
639    async fn test_streaming_sequential_custom_aggregation() {
640        let joiner: Arc<dyn Fn(Exchange, Exchange) -> Exchange + Send + Sync> =
641            Arc::new(|mut acc: Exchange, next: Exchange| {
642                let acc_text = acc.input.body.as_text().unwrap_or("").to_string();
643                let next_text = next.input.body.as_text().unwrap_or("").to_string();
644                acc.input.body = Body::Text(format!("{acc_text}+{next_text}"));
645                acc
646            });
647
648        let expr = test_expression(vec![
649            make_exchange("a"),
650            make_exchange("b"),
651            make_exchange("c"),
652        ]);
653        let mut svc = StreamingSplitterService::new(
654            expr,
655            uppercase_pipeline(),
656            AggregationStrategy::Custom(joiner),
657            true,
658        );
659
660        let result = svc
661            .ready()
662            .await
663            .unwrap()
664            .call(make_exchange("original"))
665            .await
666            .unwrap();
667        assert_eq!(result.input.body.as_text(), Some("A+B+C"));
668    }
669
670    #[tokio::test]
671    async fn test_streaming_error_continue_on_exception() {
672        let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
673        let count_clone = call_count.clone();
674        let fail_on_first = BoxProcessor::from_fn(move |ex: Exchange| {
675            let count = count_clone.clone();
676            Box::pin(async move {
677                let n = count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
678                if n == 0 {
679                    Err(CamelError::ProcessorError("first fails".into()))
680                } else {
681                    Ok(ex)
682                }
683            })
684        });
685
686        let expr = test_expression(vec![make_exchange("a"), make_exchange("b")]);
687        let mut svc = StreamingSplitterService::new(
688            expr,
689            fail_on_first,
690            AggregationStrategy::LastWins,
691            false,
692        );
693
694        let result = svc
695            .ready()
696            .await
697            .unwrap()
698            .call(make_exchange("original"))
699            .await
700            .unwrap();
701        assert_eq!(result.input.body.as_text(), Some("b"));
702        assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 2);
703    }
704
705    #[tokio::test]
706    async fn test_streaming_metadata_lookahead() {
707        let recorder = BoxProcessor::from_fn(|ex: Exchange| {
708            Box::pin(async move {
709                let idx = ex.property(CAMEL_SPLIT_INDEX).cloned();
710                let complete = ex.property(CAMEL_SPLIT_COMPLETE).cloned();
711                let body = serde_json::json!({
712                    "index": idx,
713                    "complete": complete,
714                });
715                let mut out = ex;
716                out.input.body = Body::Json(body);
717                Ok(out)
718            })
719        });
720
721        let expr = test_expression(vec![
722            make_exchange("x"),
723            make_exchange("y"),
724            make_exchange("z"),
725        ]);
726        let mut svc =
727            StreamingSplitterService::new(expr, recorder, AggregationStrategy::CollectAll, true);
728
729        let result = svc
730            .ready()
731            .await
732            .unwrap()
733            .call(make_exchange("original"))
734            .await
735            .unwrap();
736        let expected = serde_json::json!([
737            {"index": 0, "complete": false},
738            {"index": 1, "complete": false},
739            {"index": 2, "complete": true},
740        ]);
741        match &result.input.body {
742            Body::Json(v) => assert_eq!(*v, expected),
743            other => panic!("expected JSON body, got {other:?}"),
744        }
745    }
746
747    #[tokio::test]
748    async fn test_streaming_split_sanitizes_stream_body_in_original() {
749        let chunks = vec![Ok(Bytes::from("line1\n"))];
750        let stream = futures::stream::iter(chunks);
751        let sb = StreamBody {
752            stream: Arc::new(Mutex::new(Some(Box::pin(stream)))),
753            metadata: Default::default(),
754        };
755        let ex = Exchange::new(Message::new(Body::Stream(sb)));
756
757        let expression =
758            test_expression(vec![Exchange::new(Message::new(Body::Text("frag".into())))]);
759        let sub_pipeline = passthrough_pipeline();
760        let mut splitter = StreamingSplitterService::new(
761            expression,
762            sub_pipeline,
763            AggregationStrategy::Original,
764            true,
765        );
766
767        let result = splitter
768            .ready()
769            .await
770            .expect("ready")
771            .call(ex)
772            .await
773            .expect("call");
774        assert!(
775            matches!(result.input.body, Body::Empty),
776            "original body should be sanitized to Empty"
777        );
778    }
779
780    // ── CAMEL_SPLIT_SIZE: null during stream, set on last fragment ──────
781    // Inspiration: Camel SplitterTest C6 — in streaming mode the total count
782    // is unknown until end-of-stream; SPLIT_SIZE is therefore absent on
783    // intermediate fragments and present (= index+1) on the last one.
784    // Spike `camel-splitter-conformance-spike` G3.
785
786    #[tokio::test]
787    async fn test_streaming_split_size_set_only_on_last_fragment() {
788        let recorder = BoxProcessor::from_fn(|ex: Exchange| {
789            Box::pin(async move {
790                let idx = ex.property(CAMEL_SPLIT_INDEX).cloned();
791                let size = ex.property(CAMEL_SPLIT_SIZE).cloned();
792                let complete = ex.property(CAMEL_SPLIT_COMPLETE).cloned();
793                let body = serde_json::json!({
794                    "index": idx,
795                    "size": size,
796                    "complete": complete,
797                });
798                let mut out = ex;
799                out.input.body = Body::Json(body);
800                Ok(out)
801            })
802        });
803
804        let expr = test_expression(vec![
805            make_exchange("x"),
806            make_exchange("y"),
807            make_exchange("z"),
808        ]);
809        let mut svc =
810            StreamingSplitterService::new(expr, recorder, AggregationStrategy::CollectAll, true);
811
812        let result = svc
813            .ready()
814            .await
815            .unwrap()
816            .call(make_exchange("original"))
817            .await
818            .unwrap();
819
820        // size: null on intermediates, 3 (= index+1) on last
821        let expected = serde_json::json!([
822            {"index": 0, "size": serde_json::Value::Null, "complete": false},
823            {"index": 1, "size": serde_json::Value::Null, "complete": false},
824            {"index": 2, "size": 3, "complete": true},
825        ]);
826        match &result.input.body {
827            Body::Json(v) => assert_eq!(*v, expected),
828            other => panic!("expected JSON body, got {other:?}"),
829        }
830    }
831
832    #[tokio::test]
833    async fn test_streaming_splitter_non_stream_body_typed_error() {
834        // A non-stream body fed to the streaming split expression must surface
835        // as a typed TypeConversionFailed with the unmarshal hint, not be
836        // swallowed by the service.
837        let split_config = camel_api::StreamSplitConfig {
838            format: camel_api::StreamSplitFormat::Ndjson,
839            ..Default::default()
840        };
841        let expression = ndjson_stream_expression(split_config);
842        let mut svc = StreamingSplitterService::new(
843            expression,
844            passthrough_pipeline(),
845            AggregationStrategy::LastWins,
846            true,
847        );
848
849        let result = svc.ready().await.unwrap().call(make_exchange("x")).await;
850
851        let err = result.expect_err("non-stream body must fail loud, not pass through");
852        match err {
853            CamelError::TypeConversionFailed(msg) => {
854                for needle in [
855                    "streaming split",
856                    "text",
857                    "stream",
858                    "add an unmarshal step before split",
859                ] {
860                    assert!(msg.contains(needle), "message '{msg}' missing '{needle}'");
861                }
862            }
863            other => panic!("expected TypeConversionFailed, got {other:?}"),
864        }
865    }
866}