Skip to main content

jerrycan_core/
response.rs

1//! Response model. Handlers return anything implementing [`IntoResponse`];
2//! `Result<T, Error>` renders errors as `{"code","message"}` JSON (spec §4.1).
3
4use crate::error::Error;
5use bytes::Bytes;
6use http::{HeaderValue, StatusCode, header};
7use http_body_util::{BodyExt, Full, combinators::BoxBody};
8use serde::Serialize;
9
10/// Mid-stream body failure. Reaching hyper as a body error aborts the
11/// connection, so the client sees a truncated (invalid) chunked stream rather
12/// than a clean end — truncation must be detectable.
13#[derive(Debug)]
14pub struct BodyError(String);
15
16impl BodyError {
17    pub(crate) fn new(message: impl Into<String>) -> Self {
18        Self(message.into())
19    }
20}
21
22impl std::fmt::Display for BodyError {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        f.write_str(&self.0)
25    }
26}
27
28impl std::error::Error for BodyError {}
29
30impl From<std::convert::Infallible> for BodyError {
31    fn from(e: std::convert::Infallible) -> Self {
32        match e {}
33    }
34}
35
36/// The response body: a fixed buffer for buffered handlers, or a stream when a
37/// handler returns [`StreamBody`] (downloads, exports). Wraps a `BoxBody` so the
38/// response type is stable whichever shape the body takes; its error channel is
39/// [`BodyError`], which a mid-stream failure rides to abort the connection.
40pub struct JcBody(BoxBody<Bytes, BodyError>);
41
42impl JcBody {
43    /// A complete, in-memory body.
44    pub fn full(bytes: impl Into<Bytes>) -> Self {
45        Self(Full::new(bytes.into()).map_err(BodyError::from).boxed())
46    }
47
48    /// An empty body (zero frames).
49    pub fn empty() -> Self {
50        Self::full(Bytes::new())
51    }
52
53    /// A streaming body. The handler drives `body`'s frames as the response is
54    /// written. `BoxBody` requires `Send + Sync` so the response stays usable
55    /// across hyper's `Send` service future.
56    pub fn stream<B>(body: B) -> Self
57    where
58        B: http_body::Body<Data = Bytes> + Send + Sync + 'static,
59        B::Error: Into<BodyError>,
60    {
61        Self(body.map_err(Into::into).boxed())
62    }
63}
64
65impl http_body::Body for JcBody {
66    type Data = Bytes;
67    type Error = BodyError;
68
69    fn poll_frame(
70        mut self: std::pin::Pin<&mut Self>,
71        cx: &mut std::task::Context<'_>,
72    ) -> std::task::Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
73        std::pin::Pin::new(&mut self.0).poll_frame(cx)
74    }
75
76    fn is_end_stream(&self) -> bool {
77        self.0.is_end_stream()
78    }
79
80    fn size_hint(&self) -> http_body::SizeHint {
81        self.0.size_hint()
82    }
83}
84
85/// The concrete response type. Streaming bodies ride the same `IntoResponse`
86/// seam as buffered ones, so handler signatures won't change.
87pub type Response = http::Response<JcBody>;
88
89/// Conversion of handler return values into HTTP responses.
90pub trait IntoResponse {
91    fn into_response(self) -> Response;
92}
93
94/// JSON body wrapper: `Json(value)` serializes with `application/json`.
95pub struct Json<T>(pub T);
96
97/// 201 Created with a JSON body.
98pub struct Created<T>(pub T);
99
100/// 204 No Content.
101pub struct NoContent;
102
103/// An HTTP redirect: an empty body plus a `Location` header and a 3xx status.
104/// Use the constructor that names the semantics you want — `to`/`see_other`/
105/// `temporary`/`permanent` — rather than hand-setting a status code.
106pub struct Redirect {
107    status: StatusCode,
108    location: String,
109}
110
111impl Redirect {
112    /// 302 Found — the default redirect. The method may change to GET on follow
113    /// (legacy behavior); prefer [`Redirect::see_other`] after a POST.
114    pub fn to(location: impl Into<String>) -> Self {
115        Self {
116            status: StatusCode::FOUND,
117            location: location.into(),
118        }
119    }
120
121    /// 303 See Other — redirect a POST/PUT to a GET (the POST-redirect-GET pattern).
122    pub fn see_other(location: impl Into<String>) -> Self {
123        Self {
124            status: StatusCode::SEE_OTHER,
125            location: location.into(),
126        }
127    }
128
129    /// 307 Temporary Redirect — preserves the method and body on follow.
130    pub fn temporary(location: impl Into<String>) -> Self {
131        Self {
132            status: StatusCode::TEMPORARY_REDIRECT,
133            location: location.into(),
134        }
135    }
136
137    /// 308 Permanent Redirect — preserves the method and body, and is cacheable.
138    pub fn permanent(location: impl Into<String>) -> Self {
139        Self {
140            status: StatusCode::PERMANENT_REDIRECT,
141            location: location.into(),
142        }
143    }
144}
145
146impl IntoResponse for Redirect {
147    fn into_response(self) -> Response {
148        // A control char (or other non-token byte) in the location can't go into
149        // a header value. That's a programming error in the handler, not a client
150        // fault, so surface it as a 500 rather than panicking the request task.
151        let value = match HeaderValue::from_str(&self.location) {
152            Ok(v) => v,
153            Err(_) => {
154                return Error::internal("redirect location is not a valid header value")
155                    .into_response();
156            }
157        };
158        let mut r = http::Response::new(JcBody::empty());
159        *r.status_mut() = self.status;
160        r.headers_mut().insert(header::LOCATION, value);
161        r
162    }
163}
164
165fn full(status: StatusCode, content_type: &'static str, body: impl Into<Bytes>) -> Response {
166    let mut r = http::Response::new(JcBody::full(body));
167    *r.status_mut() = status;
168    r.headers_mut()
169        .insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
170    r
171}
172
173fn json_body<T: Serialize>(status: StatusCode, value: &T) -> Response {
174    match serde_json::to_vec(value) {
175        Ok(bytes) => full(status, "application/json", bytes),
176        Err(e) => Error::internal(format!("response serialization failed: {e}")).into_response(),
177    }
178}
179
180impl IntoResponse for Response {
181    fn into_response(self) -> Response {
182        self
183    }
184}
185
186impl IntoResponse for &'static str {
187    fn into_response(self) -> Response {
188        full(
189            StatusCode::OK,
190            "text/plain; charset=utf-8",
191            self.as_bytes().to_vec(),
192        )
193    }
194}
195
196impl IntoResponse for String {
197    fn into_response(self) -> Response {
198        full(
199            StatusCode::OK,
200            "text/plain; charset=utf-8",
201            self.into_bytes(),
202        )
203    }
204}
205
206impl IntoResponse for StatusCode {
207    fn into_response(self) -> Response {
208        let mut r = http::Response::new(JcBody::empty());
209        *r.status_mut() = self;
210        r
211    }
212}
213
214impl<T: Serialize> IntoResponse for Json<T> {
215    fn into_response(self) -> Response {
216        json_body(StatusCode::OK, &self.0)
217    }
218}
219
220impl<T: Serialize> IntoResponse for Created<T> {
221    fn into_response(self) -> Response {
222        json_body(StatusCode::CREATED, &self.0)
223    }
224}
225
226impl IntoResponse for NoContent {
227    fn into_response(self) -> Response {
228        let mut r = http::Response::new(JcBody::empty());
229        *r.status_mut() = StatusCode::NO_CONTENT;
230        r
231    }
232}
233
234/// Render the inner value, then overwrite the status. This is what makes
235/// `(StatusCode::ACCEPTED, Json(body))` a 202-with-JSON and
236/// `(StatusCode::ACCEPTED, "queued")` a 202 text response — the body's own
237/// content type and bytes are kept, only the status line changes.
238impl<T: IntoResponse> IntoResponse for (StatusCode, T) {
239    fn into_response(self) -> Response {
240        let (status, inner) = self;
241        let mut r = inner.into_response();
242        *r.status_mut() = status;
243        r
244    }
245}
246
247#[derive(Serialize)]
248struct ErrorBody<'a> {
249    code: &'a str,
250    message: &'a str,
251    #[serde(skip_serializing_if = "Option::is_none")]
252    details: Option<&'a serde_json::Value>,
253}
254
255/// The rendered error's parts, stashed as a response EXTENSION so an app-level
256/// error-body mapper (`App::map_error_body`) can reshape EVERY framework-emitted
257/// error (guard 401, extractor 4xx, …) at the dispatch exit — the point that
258/// has the app's config. Absent from non-error responses, so the mapper only
259/// ever sees errors.
260#[derive(Clone)]
261pub(crate) struct ErrorParts {
262    pub(crate) code: &'static str,
263    pub(crate) message: String,
264}
265
266impl IntoResponse for Error {
267    fn into_response(self) -> Response {
268        let mut r = json_body(
269            self.status(),
270            &ErrorBody {
271                code: self.code(),
272                message: self.message(),
273                details: self.details(),
274            },
275        );
276        // Carry the parts so the app-level mapper can reshape this body later
277        // (the default flat body above stays when no mapper is registered).
278        r.extensions_mut().insert(ErrorParts {
279            code: self.code(),
280            message: self.message().to_string(),
281        });
282        r
283    }
284}
285
286impl<T: IntoResponse> IntoResponse for crate::Result<T> {
287    fn into_response(self) -> Response {
288        match self {
289            Ok(v) => v.into_response(),
290            Err(e) => e.into_response(),
291        }
292    }
293}
294
295/// A streaming response body: downloads, CSV exports, anything produced
296/// incrementally. Defaults: `application/octet-stream`, 200 OK, 30s frame
297/// timeout (a producer that stalls longer aborts the connection).
298pub struct StreamBody {
299    stream: std::pin::Pin<
300        Box<dyn futures_core::Stream<Item = Result<Bytes, Error>> + Send + Sync + 'static>,
301    >,
302    content_type: HeaderValue,
303    attachment: Option<HeaderValue>,
304    frame_timeout: std::time::Duration,
305}
306
307impl StreamBody {
308    /// Default per-frame producer deadline (see [`StreamBody::frame_timeout`]).
309    pub const DEFAULT_FRAME_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
310
311    /// Stream chunks from anything implementing `Stream` (SeaORM streaming
312    /// queries, hand-rolled producers). An `Err` item aborts the connection.
313    pub fn new<S>(stream: S) -> Self
314    where
315        S: futures_core::Stream<Item = Result<Bytes, Error>> + Send + Sync + 'static,
316    {
317        Self {
318            stream: Box::pin(stream),
319            content_type: HeaderValue::from_static("application/octet-stream"),
320            attachment: None,
321            frame_timeout: Self::DEFAULT_FRAME_TIMEOUT,
322        }
323    }
324
325    /// A channel-fed body for producers that push: returns the body and a
326    /// sender. Dropping the sender ends the stream cleanly; `fail` aborts it.
327    /// The channel is bounded, so `send` awaits while a slow client is behind.
328    pub fn channel() -> (Self, BodySender) {
329        // 16: bounded buffer — a slow client backpressures the producer instead of buffering unboundedly.
330        let (tx, rx) = tokio::sync::mpsc::channel::<Result<Bytes, Error>>(16);
331        (Self::new(ReceiverStream(rx)), BodySender(tx))
332    }
333
334    /// Sets the `content-type` header. Panics on a value that is not a valid
335    /// header value — that is a programming error, not request-dependent.
336    pub fn content_type(mut self, value: &str) -> Self {
337        self.content_type =
338            HeaderValue::from_str(value).expect("content_type must be a valid header value");
339        self
340    }
341
342    /// Marks the response as a download: `content-disposition: attachment` with
343    /// the given filename. Quotes/backslashes are stripped (header-injection
344    /// break-out) and control chars too — stripping the latter is what makes the
345    /// following `HeaderValue::from_str` infallible for any UTF-8 input. Non-ASCII
346    /// filenames pass through verbatim (no RFC 5987 `filename*` encoding).
347    pub fn attachment(mut self, filename: &str) -> Self {
348        let safe: String = filename
349            .chars()
350            .filter(|c| *c != '"' && *c != '\\' && !c.is_control())
351            .collect();
352        self.attachment = Some(
353            HeaderValue::from_str(&format!("attachment; filename=\"{safe}\""))
354                .expect("sanitized filename is a valid header value"),
355        );
356        self
357    }
358
359    /// Maximum time the producer may take between chunks before the
360    /// connection is aborted.
361    pub fn frame_timeout(mut self, timeout: std::time::Duration) -> Self {
362        self.frame_timeout = timeout;
363        self
364    }
365}
366
367/// Push side of [`StreamBody::channel`].
368pub struct BodySender(tokio::sync::mpsc::Sender<Result<Bytes, Error>>);
369
370impl BodySender {
371    /// Sends one chunk. Returns false when the client is gone (stop producing).
372    pub async fn send(&self, chunk: impl Into<Bytes>) -> bool {
373        self.0.send(Ok(chunk.into())).await.is_ok()
374    }
375    /// Aborts the response: the connection is reset so the client sees
376    /// truncation instead of a falsely-complete body.
377    pub async fn fail(self, error: Error) -> bool {
378        self.0.send(Err(error)).await.is_ok()
379    }
380}
381
382/// mpsc receiver as a Stream (hand-rolled: futures-util is not a dependency).
383struct ReceiverStream(tokio::sync::mpsc::Receiver<Result<Bytes, Error>>);
384
385impl futures_core::Stream for ReceiverStream {
386    type Item = Result<Bytes, Error>;
387    fn poll_next(
388        mut self: std::pin::Pin<&mut Self>,
389        cx: &mut std::task::Context<'_>,
390    ) -> std::task::Poll<Option<Self::Item>> {
391        self.0.poll_recv(cx)
392    }
393}
394
395/// Stream → Body adapter with a per-frame producer deadline. The deadline arms
396/// when a poll returns Pending and RESETS on every yielded frame, so steady
397/// producers of any total duration are unaffected; only stalls trip it.
398struct TimedFrames {
399    stream: std::pin::Pin<
400        Box<dyn futures_core::Stream<Item = Result<Bytes, Error>> + Send + Sync + 'static>,
401    >,
402    timeout: std::time::Duration,
403    sleep: Option<std::pin::Pin<Box<tokio::time::Sleep>>>,
404}
405
406impl http_body::Body for TimedFrames {
407    type Data = Bytes;
408    type Error = BodyError;
409
410    fn poll_frame(
411        mut self: std::pin::Pin<&mut Self>,
412        cx: &mut std::task::Context<'_>,
413    ) -> std::task::Poll<Option<Result<http_body::Frame<Bytes>, BodyError>>> {
414        use std::future::Future;
415        use std::task::Poll;
416        match self.stream.as_mut().poll_next(cx) {
417            Poll::Ready(Some(Ok(chunk))) => {
418                self.sleep = None;
419                Poll::Ready(Some(Ok(http_body::Frame::data(chunk))))
420            }
421            Poll::Ready(Some(Err(e))) => {
422                self.sleep = None;
423                Poll::Ready(Some(Err(BodyError::new(format!(
424                    "response stream failed: {e}"
425                )))))
426            }
427            Poll::Ready(None) => Poll::Ready(None),
428            Poll::Pending => {
429                let timeout = self.timeout;
430                let sleep = self
431                    .sleep
432                    .get_or_insert_with(|| Box::pin(tokio::time::sleep(timeout)));
433                match sleep.as_mut().poll(cx) {
434                    Poll::Ready(()) => {
435                        self.sleep = None;
436                        Poll::Ready(Some(Err(BodyError::new(
437                            "response stream timed out producing the next chunk",
438                        ))))
439                    }
440                    Poll::Pending => Poll::Pending,
441                }
442            }
443        }
444    }
445}
446
447impl IntoResponse for StreamBody {
448    fn into_response(self) -> Response {
449        let body = JcBody::stream(TimedFrames {
450            stream: self.stream,
451            timeout: self.frame_timeout,
452            sleep: None,
453        });
454        let mut r = http::Response::new(body);
455        r.headers_mut()
456            .insert(header::CONTENT_TYPE, self.content_type);
457        if let Some(disposition) = self.attachment {
458            r.headers_mut()
459                .insert(header::CONTENT_DISPOSITION, disposition);
460        }
461        r
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    fn body_of(r: Response) -> String {
470        let collected = futures_executor_lite(r.into_body());
471        String::from_utf8(collected.to_vec()).unwrap()
472    }
473
474    /// Minimal "block on a buffered body" helper so unit tests need no runtime.
475    /// The bodies built by `IntoResponse` are full buffers whose collect future
476    /// is immediately ready, so we poll it once by hand.
477    fn futures_executor_lite(body: JcBody) -> Bytes {
478        let fut = body.collect();
479        let mut fut = Box::pin(fut);
480        let waker = std::task::Waker::noop();
481        let mut cx = std::task::Context::from_waker(waker);
482        match fut.as_mut().poll(&mut cx) {
483            std::task::Poll::Ready(Ok(c)) => c.to_bytes(),
484            _ => panic!("buffered body was not immediately ready"),
485        }
486    }
487
488    #[test]
489    fn str_becomes_200_text() {
490        let r = "hello".into_response();
491        assert_eq!(r.status(), StatusCode::OK);
492        assert_eq!(
493            r.headers()[header::CONTENT_TYPE],
494            "text/plain; charset=utf-8"
495        );
496        assert_eq!(body_of(r), "hello");
497    }
498
499    #[test]
500    fn json_wrapper_sets_content_type() {
501        #[derive(Serialize)]
502        struct Todo {
503            id: u32,
504        }
505        let r = Json(Todo { id: 7 }).into_response();
506        assert_eq!(r.status(), StatusCode::OK);
507        assert_eq!(r.headers()[header::CONTENT_TYPE], "application/json");
508        assert_eq!(body_of(r), r#"{"id":7}"#);
509    }
510
511    #[test]
512    fn created_is_201_and_no_content_is_204() {
513        #[derive(Serialize)]
514        struct T {
515            ok: bool,
516        }
517        assert_eq!(
518            Created(T { ok: true }).into_response().status(),
519            StatusCode::CREATED
520        );
521        let r = NoContent.into_response();
522        assert_eq!(r.status(), StatusCode::NO_CONTENT);
523        assert_eq!(body_of(r), "");
524    }
525
526    #[test]
527    fn errors_render_code_and_message_json() {
528        let r = Error::not_found().into_response();
529        assert_eq!(r.status(), StatusCode::NOT_FOUND);
530        assert_eq!(body_of(r), r#"{"code":"JC0404","message":"not found"}"#);
531    }
532
533    #[test]
534    fn error_details_appear_in_the_body_only_when_present() {
535        let r = Error::not_found().into_response();
536        assert_eq!(body_of(r), r#"{"code":"JC0404","message":"not found"}"#);
537        let r = Error::unprocessable("validation failed")
538            .with_details(serde_json::json!([{ "field": "t" }]))
539            .into_response();
540        assert_eq!(
541            body_of(r),
542            r#"{"code":"JC0422","message":"validation failed","details":[{"field":"t"}]}"#
543        );
544    }
545
546    #[test]
547    fn result_renders_ok_or_err() {
548        let ok: crate::Result<&'static str> = Ok("fine");
549        assert_eq!(ok.into_response().status(), StatusCode::OK);
550        let err: crate::Result<&'static str> = Err(Error::bad_request("x"));
551        assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
552    }
553
554    #[test]
555    fn redirect_to_is_302_with_location_and_empty_body() {
556        let r = Redirect::to("/x").into_response();
557        assert_eq!(r.status(), StatusCode::FOUND);
558        assert_eq!(r.headers()[header::LOCATION], "/x");
559        assert_eq!(body_of(r), "");
560    }
561
562    #[test]
563    fn redirect_constructors_set_their_status_and_location() {
564        // Each named constructor encodes a distinct redirect semantic; a regression
565        // that collapses them to one status would change browser follow behavior.
566        for (build, status) in [
567            (Redirect::see_other("/a") as Redirect, StatusCode::SEE_OTHER),
568            (Redirect::temporary("/b"), StatusCode::TEMPORARY_REDIRECT),
569            (Redirect::permanent("/c"), StatusCode::PERMANENT_REDIRECT),
570        ] {
571            let r = build.into_response();
572            assert_eq!(r.status(), status);
573            assert!(r.headers().contains_key(header::LOCATION));
574        }
575    }
576
577    #[test]
578    fn redirect_with_invalid_location_is_a_non_panicking_500() {
579        // A control char can't be a header value; the handler shouldn't panic the
580        // request task, it should surface a 500 the connection can report.
581        let r = Redirect::to("/bad\nlocation").into_response();
582        assert_eq!(r.status(), StatusCode::INTERNAL_SERVER_ERROR);
583        assert!(r.headers().get(header::LOCATION).is_none());
584    }
585
586    #[test]
587    fn status_tuple_overrides_status_keeping_the_json_body() {
588        // (StatusCode, Json) must render the JSON body (content type + bytes) and
589        // only swap the status — that's what lets a 202 carry a payload.
590        #[derive(Serialize)]
591        struct Summary {
592            queued: u32,
593        }
594        let r = (StatusCode::ACCEPTED, Json(Summary { queued: 3 })).into_response();
595        assert_eq!(r.status(), StatusCode::ACCEPTED);
596        assert_eq!(r.headers()[header::CONTENT_TYPE], "application/json");
597        assert_eq!(body_of(r), r#"{"queued":3}"#);
598    }
599
600    #[test]
601    fn status_tuple_overrides_status_keeping_the_text_body() {
602        let r = (StatusCode::ACCEPTED, "queued").into_response();
603        assert_eq!(r.status(), StatusCode::ACCEPTED);
604        assert_eq!(
605            r.headers()[header::CONTENT_TYPE],
606            "text/plain; charset=utf-8"
607        );
608        assert_eq!(body_of(r), "queued");
609    }
610
611    #[tokio::test]
612    async fn boxed_bodies_stream_and_collect() {
613        // hand-rolled chunked Body over a VecDeque — no new deps
614        struct Chunks(std::collections::VecDeque<Bytes>);
615        impl http_body::Body for Chunks {
616            type Data = Bytes;
617            type Error = std::convert::Infallible;
618            fn poll_frame(
619                mut self: std::pin::Pin<&mut Self>,
620                _cx: &mut std::task::Context<'_>,
621            ) -> std::task::Poll<Option<Result<http_body::Frame<Bytes>, Self::Error>>> {
622                std::task::Poll::Ready(self.0.pop_front().map(|b| Ok(http_body::Frame::data(b))))
623            }
624        }
625        let body = JcBody::stream(Chunks(
626            [Bytes::from("ab"), Bytes::from("cd")].into_iter().collect(),
627        ));
628        use http_body_util::BodyExt;
629        let collected = body.collect().await.unwrap().to_bytes();
630        assert_eq!(collected, Bytes::from("abcd"));
631    }
632
633    #[tokio::test]
634    async fn stream_body_streams_with_content_type_and_disposition() {
635        let (body, tx) = StreamBody::channel();
636        let send = async move {
637            assert!(tx.send("a,b\n").await);
638            assert!(tx.send("1,2\n").await);
639        };
640        let r = body
641            .content_type("text/csv")
642            .attachment("export.csv")
643            .into_response();
644        assert_eq!(r.status(), StatusCode::OK);
645        assert_eq!(r.headers()[header::CONTENT_TYPE], "text/csv");
646        assert_eq!(
647            r.headers()[header::CONTENT_DISPOSITION],
648            "attachment; filename=\"export.csv\""
649        );
650        let (_, collected) = tokio::join!(send, r.into_body().collect());
651        assert_eq!(collected.unwrap().to_bytes(), Bytes::from("a,b\n1,2\n"));
652    }
653
654    #[tokio::test(start_paused = true)]
655    async fn stream_body_frame_timeout_errors_the_body() {
656        struct Never;
657        impl futures_core::Stream for Never {
658            type Item = Result<Bytes, Error>;
659            fn poll_next(
660                self: std::pin::Pin<&mut Self>,
661                _cx: &mut std::task::Context<'_>,
662            ) -> std::task::Poll<Option<Self::Item>> {
663                std::task::Poll::Pending
664            }
665        }
666        let body = StreamBody::new(Never)
667            .frame_timeout(std::time::Duration::from_millis(100))
668            .into_response()
669            .into_body();
670        use http_body_util::BodyExt;
671        let err = body
672            .collect()
673            .await
674            .expect_err("stall must error, not end cleanly");
675        assert!(err.to_string().contains("timed out"), "{err}");
676    }
677
678    #[tokio::test]
679    async fn channel_fail_surfaces_as_a_body_error_carrying_the_message() {
680        // The headline guarantee of the error channel: a producer that fails
681        // after some output must reach the client as a body ERROR (truncation),
682        // never a clean end. This is the test that fails if the `Err` branch of
683        // `TimedFrames::poll_frame` regresses to swallowing the error.
684        let (body, tx) = StreamBody::channel();
685        let produce = async move {
686            assert!(tx.send("first chunk").await, "client present");
687            assert!(tx.fail(Error::internal("boom")).await, "fail delivered");
688        };
689        let response = body.into_response();
690        use http_body_util::BodyExt;
691        let (_, collected) = tokio::join!(produce, response.into_body().collect());
692        let err = collected.expect_err("a failed producer must error the body, not end cleanly");
693        assert!(
694            err.to_string().contains("boom"),
695            "the propagated message must survive to the body error: {err}"
696        );
697    }
698
699    #[tokio::test]
700    async fn stream_body_composes_through_a_real_handler_dispatch() {
701        use crate::prelude::*;
702        async fn export() -> Result<StreamBody> {
703            let (body, tx) = StreamBody::channel();
704            tokio::spawn(async move {
705                tx.send("id,name\n").await;
706                tx.send("1,ada\n").await;
707            });
708            Ok(body.content_type("text/csv"))
709        }
710        let t = App::new().route("/export", get(export)).into_test();
711        let r = t.get("/export").await;
712        assert_eq!(r.status(), StatusCode::OK);
713        assert_eq!(r.headers()[header::CONTENT_TYPE], "text/csv");
714        assert_eq!(r.text(), "id,name\n1,ada\n");
715    }
716}