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 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 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 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 _ => 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 _ => 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 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 #[tokio::test]
297 async fn test_ndjson_body_stream_streaming_split() {
298 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 let split_config = camel_api::StreamSplitConfig {
320 format: camel_api::StreamSplitFormat::Ndjson,
321 ..Default::default()
322 };
323
324 #[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 let mut splitter = StreamingSplitterService::new(
349 expression,
350 recorder,
351 AggregationStrategy::CollectAll,
352 true, );
354
355 let result = splitter
356 .ready()
357 .await
358 .expect("splitter ready")
359 .call(ex)
360 .await
361 .expect("splitter call");
362
363 let guard = fragments.lock().await;
365
366 assert_eq!(guard.len(), 3, "expected 3 NDJSON fragments");
368
369 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 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 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 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 assert!(
418 matches!(result.input.body, Body::Json(_)),
419 "aggregate body should be Json, not Stream"
420 );
421 }
422
423 #[tokio::test]
428 async fn test_ndjson_body_stream_empty_stream() {
429 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 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 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 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 #[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 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 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}