Skip to main content

http_cache/
body.rs

1//! HTTP body types for streaming cache support.
2//!
3//! This module provides the [`StreamingBody`] type which allows HTTP cache middleware
4//! to handle both cached (buffered) responses and streaming responses from upstream
5//! servers without requiring full buffering of large responses.
6//!
7//! # Variants
8//!
9//! - **Buffered**: Contains cached response data that can be sent immediately
10//! - **Streaming**: Wraps an upstream body for streaming responses
11//! - **File**: Streams from a [`tokio::fs::File`] in 64KB chunks (only with `streaming` feature)
12//!
13//! # Example
14//!
15//! ```rust
16//! use http_cache::StreamingBody;
17//! use bytes::Bytes;
18//! use http_body_util::Full;
19//!
20//! // Cached response - sent immediately from memory
21//! let cached: StreamingBody<Full<Bytes>> = StreamingBody::buffered(Bytes::from("Hello!"));
22//!
23//! // Streaming response - passed through from upstream
24//! let upstream = Full::new(Bytes::from("From upstream"));
25//! let streaming: StreamingBody<Full<Bytes>> = StreamingBody::streaming(upstream);
26//! ```
27
28// Note: pin_project_lite does not support doc comments on enum variant fields,
29// so we allow missing_docs for the generated enum variants and fields.
30// The module-level and enum-level documentation provides full coverage.
31#![allow(missing_docs)]
32
33use std::{
34    fmt,
35    pin::Pin,
36    task::{Context, Poll},
37};
38
39use bytes::Bytes;
40#[cfg(feature = "streaming")]
41use bytes::BytesMut;
42use http_body::{Body, Frame};
43use pin_project_lite::pin_project;
44
45use crate::error::StreamingError;
46
47/// Default buffer size for streaming from disk (64KB).
48///
49/// This size is optimized for modern SSDs and NVMe drives, reducing syscall
50/// overhead while maintaining reasonable memory usage.
51#[cfg(feature = "streaming")]
52const STREAM_BUFFER_SIZE: usize = 64 * 1024;
53
54/// End-of-stream integrity check for the `File` variant.
55#[cfg(feature = "streaming")]
56pub struct FileCheck {
57    hasher: blake3::Hasher,
58    expected: [u8; 32],
59    on_corrupt: Box<dyn FnOnce() + Send>,
60}
61
62#[cfg(feature = "streaming")]
63impl FileCheck {
64    /// Compares the streamed hash against the expected one; fires
65    /// `on_corrupt` and returns the error on mismatch.
66    fn finish(self) -> Result<(), StreamingError> {
67        if self.hasher.finalize().as_bytes() == &self.expected {
68            return Ok(());
69        }
70        (self.on_corrupt)();
71        Err(StreamingError::new(Box::new(std::io::Error::new(
72            std::io::ErrorKind::InvalidData,
73            "cached body checksum mismatch",
74        ))))
75    }
76
77    /// Fires `on_corrupt` without a hash compare (truncated stream).
78    fn corrupt(self) {
79        (self.on_corrupt)();
80    }
81}
82
83#[cfg(feature = "streaming")]
84impl fmt::Debug for FileCheck {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        f.debug_struct("FileCheck")
87            .field("expected", &self.expected)
88            .finish_non_exhaustive()
89    }
90}
91
92// When streaming feature is enabled, include the File variant
93#[cfg(feature = "streaming")]
94pin_project! {
95    /// A body type that can represent either buffered data from cache or streaming body from upstream.
96    ///
97    /// This enum allows the HTTP cache middleware to efficiently handle:
98    /// - Cached responses (buffered data)
99    /// - Cache misses (streaming from upstream)
100    /// - Disk-cached responses (streaming from file)
101    ///
102    /// # Variants
103    ///
104    /// - **Buffered**: Contains cached response data that can be sent immediately
105    /// - **Streaming**: Wraps an upstream body for streaming responses
106    /// - **File**: Streams from a [`tokio::fs::File`] in 64KB chunks (only with `streaming` feature)
107    #[project = StreamingBodyProj]
108    pub enum StreamingBody<B> {
109        Buffered {
110            data: Option<Bytes>,
111        },
112        Streaming {
113            #[pin]
114            inner: B,
115        },
116        File {
117            #[pin]
118            reader: tokio::fs::File,
119            buffer: BytesMut,
120            done: bool,
121            size: u64,
122            check: Option<Box<FileCheck>>,
123        },
124    }
125}
126
127// When streaming feature is disabled, no File variant
128#[cfg(not(feature = "streaming"))]
129pin_project! {
130    /// A body type that can represent either buffered data from cache or streaming body from upstream.
131    ///
132    /// This enum allows the HTTP cache middleware to efficiently handle:
133    /// - Cached responses (buffered data)
134    /// - Cache misses (streaming from upstream)
135    ///
136    /// # Variants
137    ///
138    /// - **Buffered**: Contains cached response data that can be sent immediately
139    /// - **Streaming**: Wraps an upstream body for streaming responses
140    #[project = StreamingBodyProj]
141    pub enum StreamingBody<B> {
142        Buffered {
143            data: Option<Bytes>,
144        },
145        Streaming {
146            #[pin]
147            inner: B,
148        },
149    }
150}
151
152impl<B> StreamingBody<B> {
153    /// Create a new buffered body from bytes.
154    ///
155    /// The bytes are consumed on the first poll and sent as a single frame.
156    #[must_use]
157    pub fn buffered(data: Bytes) -> Self {
158        Self::Buffered { data: Some(data) }
159    }
160
161    /// Create a new streaming body from an upstream body.
162    ///
163    /// The upstream body is passed through without additional buffering.
164    #[must_use]
165    pub fn streaming(body: B) -> Self {
166        Self::Streaming { inner: body }
167    }
168
169    /// Create a new file-streaming body from a [`tokio::fs::File`] with known size.
170    ///
171    /// This allows streaming large cached responses from disk without
172    /// loading the entire body into memory. Data is read in 64KB chunks.
173    ///
174    /// # Cursor contract
175    ///
176    /// The caller must position `file` at the start of the body bytes before
177    /// calling this function. Exactly `size` bytes are streamed from the
178    /// current cursor position; trailing file content is ignored and a file
179    /// shorter than `size` yields a stream error.
180    ///
181    /// `size` is used to provide accurate size hints to downstream consumers.
182    #[cfg(feature = "streaming")]
183    #[must_use]
184    pub fn from_file_with_size(file: tokio::fs::File, size: u64) -> Self {
185        Self::File {
186            reader: file,
187            buffer: BytesMut::with_capacity(STREAM_BUFFER_SIZE),
188            done: false,
189            size,
190            check: None,
191        }
192    }
193
194    /// Like [`from_file_with_size`](Self::from_file_with_size), but hashes
195    /// the streamed bytes and compares against `checksum` when the stream
196    /// completes. On mismatch the final frame is replaced with an error and
197    /// `on_corrupt` is invoked once.
198    #[cfg(feature = "streaming")]
199    #[must_use]
200    pub fn from_file_verified(
201        file: tokio::fs::File,
202        size: u64,
203        checksum: [u8; 32],
204        on_corrupt: impl FnOnce() + Send + 'static,
205    ) -> Self {
206        Self::File {
207            reader: file,
208            buffer: BytesMut::with_capacity(STREAM_BUFFER_SIZE),
209            done: false,
210            size,
211            check: Some(Box::new(FileCheck {
212                hasher: blake3::Hasher::new(),
213                expected: checksum,
214                on_corrupt: Box::new(on_corrupt),
215            })),
216        }
217    }
218}
219
220#[cfg(feature = "streaming")]
221impl<B> Body for StreamingBody<B>
222where
223    B: Body + Unpin,
224    B::Error: Into<StreamingError>,
225    B::Data: Into<Bytes>,
226{
227    type Data = Bytes;
228    type Error = StreamingError;
229
230    fn poll_frame(
231        mut self: Pin<&mut Self>,
232        cx: &mut Context<'_>,
233    ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
234        match self.as_mut().project() {
235            StreamingBodyProj::Buffered { data } => {
236                if let Some(bytes) = data.take() {
237                    if bytes.is_empty() {
238                        Poll::Ready(None)
239                    } else {
240                        Poll::Ready(Some(Ok(Frame::data(bytes))))
241                    }
242                } else {
243                    Poll::Ready(None)
244                }
245            }
246            StreamingBodyProj::Streaming { inner } => {
247                inner.poll_frame(cx).map(|opt| {
248                    opt.map(|res| {
249                        res.map(|frame| frame.map_data(Into::into))
250                            .map_err(Into::into)
251                    })
252                })
253            }
254            StreamingBodyProj::File { reader, buffer, done, size, check } => {
255                if *done {
256                    return Poll::Ready(None);
257                }
258                if *size == 0 {
259                    *done = true;
260                    if let Some(c) = check.take() {
261                        if let Err(e) = c.finish() {
262                            return Poll::Ready(Some(Err(e)));
263                        }
264                    }
265                    return Poll::Ready(None);
266                }
267
268                use tokio::io::AsyncRead;
269
270                // Resize buffer to full capacity for reading (this is safe - fills with zeros)
271                buffer.resize(STREAM_BUFFER_SIZE, 0);
272
273                let mut read_buf = tokio::io::ReadBuf::new(buffer.as_mut());
274
275                match reader.poll_read(cx, &mut read_buf) {
276                    Poll::Ready(Ok(())) => {
277                        let filled_len = read_buf.filled().len();
278                        if filled_len == 0 {
279                            *done = true;
280                            buffer.clear();
281                            if let Some(c) = check.take() {
282                                c.corrupt();
283                            }
284                            Poll::Ready(Some(Err(StreamingError::new(
285                                Box::new(std::io::Error::new(
286                                    std::io::ErrorKind::UnexpectedEof,
287                                    "cached body file shorter than expected",
288                                )),
289                            ))))
290                        } else {
291                            // Never yield more than the remaining body bytes.
292                            let take = (*size).min(filled_len as u64) as usize;
293                            buffer.truncate(take);
294                            *size -= take as u64;
295                            if let Some(c) = check.as_deref_mut() {
296                                c.hasher.update(&buffer[..take]);
297                            }
298                            if *size == 0 {
299                                *done = true;
300                                if let Some(c) = check.take() {
301                                    if let Err(e) = c.finish() {
302                                        buffer.clear();
303                                        return Poll::Ready(Some(Err(e)));
304                                    }
305                                }
306                            }
307                            let bytes = buffer.split().freeze();
308                            Poll::Ready(Some(Ok(Frame::data(bytes))))
309                        }
310                    }
311                    Poll::Ready(Err(e)) => {
312                        *done = true;
313                        buffer.clear();
314                        Poll::Ready(Some(Err(StreamingError::new(Box::new(e)))))
315                    }
316                    Poll::Pending => Poll::Pending,
317                }
318            }
319        }
320    }
321
322    fn is_end_stream(&self) -> bool {
323        match self {
324            StreamingBody::Buffered { data } => data.is_none(),
325            StreamingBody::Streaming { inner } => inner.is_end_stream(),
326            StreamingBody::File { done, .. } => *done,
327        }
328    }
329
330    fn size_hint(&self) -> http_body::SizeHint {
331        match self {
332            StreamingBody::Buffered { data } => {
333                if let Some(bytes) = data {
334                    let len = bytes.len() as u64;
335                    http_body::SizeHint::with_exact(len)
336                } else {
337                    http_body::SizeHint::with_exact(0)
338                }
339            }
340            StreamingBody::Streaming { inner } => inner.size_hint(),
341            StreamingBody::File { size, .. } => {
342                http_body::SizeHint::with_exact(*size)
343            }
344        }
345    }
346}
347
348#[cfg(not(feature = "streaming"))]
349impl<B> Body for StreamingBody<B>
350where
351    B: Body + Unpin,
352    B::Error: Into<StreamingError>,
353    B::Data: Into<Bytes>,
354{
355    type Data = Bytes;
356    type Error = StreamingError;
357
358    fn poll_frame(
359        mut self: Pin<&mut Self>,
360        cx: &mut Context<'_>,
361    ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
362        match self.as_mut().project() {
363            StreamingBodyProj::Buffered { data } => {
364                if let Some(bytes) = data.take() {
365                    if bytes.is_empty() {
366                        Poll::Ready(None)
367                    } else {
368                        Poll::Ready(Some(Ok(Frame::data(bytes))))
369                    }
370                } else {
371                    Poll::Ready(None)
372                }
373            }
374            StreamingBodyProj::Streaming { inner } => {
375                inner.poll_frame(cx).map(|opt| {
376                    opt.map(|res| {
377                        res.map(|frame| frame.map_data(Into::into))
378                            .map_err(Into::into)
379                    })
380                })
381            }
382        }
383    }
384
385    fn is_end_stream(&self) -> bool {
386        match self {
387            StreamingBody::Buffered { data } => data.is_none(),
388            StreamingBody::Streaming { inner } => inner.is_end_stream(),
389        }
390    }
391
392    fn size_hint(&self) -> http_body::SizeHint {
393        match self {
394            StreamingBody::Buffered { data } => {
395                if let Some(bytes) = data {
396                    let len = bytes.len() as u64;
397                    http_body::SizeHint::with_exact(len)
398                } else {
399                    http_body::SizeHint::with_exact(0)
400                }
401            }
402            StreamingBody::Streaming { inner } => inner.size_hint(),
403        }
404    }
405}
406
407impl<B> From<Bytes> for StreamingBody<B> {
408    fn from(bytes: Bytes) -> Self {
409        Self::buffered(bytes)
410    }
411}
412
413#[cfg(feature = "streaming")]
414impl<B: fmt::Debug> fmt::Debug for StreamingBody<B> {
415    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
416        match self {
417            Self::Buffered { data } => f
418                .debug_struct("StreamingBody::Buffered")
419                .field("has_data", &data.is_some())
420                .field("len", &data.as_ref().map(|b| b.len()))
421                .finish(),
422            Self::Streaming { inner } => f
423                .debug_struct("StreamingBody::Streaming")
424                .field("inner", inner)
425                .finish(),
426            Self::File { done, size, .. } => f
427                .debug_struct("StreamingBody::File")
428                .field("done", done)
429                .field("size", &size)
430                .finish_non_exhaustive(),
431        }
432    }
433}
434
435#[cfg(not(feature = "streaming"))]
436impl<B: fmt::Debug> fmt::Debug for StreamingBody<B> {
437    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
438        match self {
439            Self::Buffered { data } => f
440                .debug_struct("StreamingBody::Buffered")
441                .field("has_data", &data.is_some())
442                .field("len", &data.as_ref().map(|b| b.len()))
443                .finish(),
444            Self::Streaming { inner } => f
445                .debug_struct("StreamingBody::Streaming")
446                .field("inner", inner)
447                .finish(),
448        }
449    }
450}
451
452#[cfg(feature = "streaming")]
453impl<B> StreamingBody<B>
454where
455    B: Body + Unpin + Send,
456    B::Error: Into<StreamingError>,
457    B::Data: Into<Bytes>,
458{
459    /// Convert this streaming body into a stream of Bytes.
460    ///
461    /// This method allows for streaming without collecting the entire body into memory first.
462    pub fn into_bytes_stream(
463        self,
464    ) -> impl futures_util::Stream<
465        Item = Result<Bytes, Box<dyn std::error::Error + Send + Sync>>,
466    > + Send {
467        use futures_util::TryStreamExt;
468
469        http_body_util::BodyStream::new(self)
470            .map_ok(|frame| {
471                // Extract data from frame, StreamingBody always produces Bytes
472                frame.into_data().unwrap_or_else(|_| Bytes::new())
473            })
474            .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
475                Box::new(std::io::Error::other(format!("Stream error: {e}")))
476            })
477    }
478}
479
480#[cfg(all(test, feature = "streaming"))]
481mod tests {
482    use super::*;
483    use http_body_util::BodyExt;
484    use tokio::io::AsyncWriteExt;
485
486    async fn file_with(content: &[u8]) -> (tokio::fs::File, tempfile::TempDir) {
487        let dir = tempfile::tempdir().unwrap();
488        let path = dir.path().join("body.bin");
489        let mut f = tokio::fs::File::create(&path).await.unwrap();
490        f.write_all(content).await.unwrap();
491        f.sync_all().await.unwrap();
492        drop(f);
493        (tokio::fs::File::open(&path).await.unwrap(), dir)
494    }
495
496    #[tokio::test]
497    async fn file_body_stops_at_size() {
498        let (f, _dir) = file_with(b"0123456789trailing-garbage").await;
499        let body: StreamingBody<http_body_util::Empty<Bytes>> =
500            StreamingBody::from_file_with_size(f, 10);
501        let collected = body.collect().await.unwrap().to_bytes();
502        assert_eq!(collected.as_ref(), b"0123456789");
503    }
504
505    #[tokio::test]
506    async fn file_body_errors_on_truncated_file() {
507        let (f, _dir) = file_with(b"short").await;
508        let body: StreamingBody<http_body_util::Empty<Bytes>> =
509            StreamingBody::from_file_with_size(f, 100);
510        assert!(body.collect().await.is_err());
511    }
512
513    #[tokio::test]
514    async fn file_body_size_hint_reports_remaining() {
515        use http_body::Body;
516        let payload = vec![7u8; STREAM_BUFFER_SIZE + 100];
517        let (f, _dir) = file_with(&payload).await;
518        let mut body: StreamingBody<http_body_util::Empty<Bytes>> =
519            StreamingBody::from_file_with_size(f, payload.len() as u64);
520        assert_eq!(body.size_hint().exact(), Some(payload.len() as u64));
521        let frame =
522            std::future::poll_fn(|cx| Pin::new(&mut body).poll_frame(cx))
523                .await
524                .unwrap()
525                .unwrap();
526        let n = frame.into_data().unwrap().len() as u64;
527        assert_eq!(body.size_hint().exact(), Some(payload.len() as u64 - n));
528    }
529}