Skip to main content

cratefield_core/
stream.rs

1//! Opt-in streamed request and response bodies (issue #585).
2//!
3//! The buffered half of the harness — the [`crate::Json`]/[`crate::Form`]
4//! extractors and the 64 KiB `/v1/*` [`MAX_BODY_BYTES`] ceiling — reads a
5//! whole body into memory before a handler sees it. That is the right
6//! default (it is what lets a route answer problem+json before doing any
7//! work) but it cannot carry a body larger than memory, in either
8//! direction.
9//!
10//! A module opts a route out by naming it in
11//! [`Module::streaming_routes`](crate::Module::streaming_routes). On such a
12//! route:
13//!
14//! - the handler extracts a [`RequestStream`] instead of a buffered body (a
15//!   [`crate::Json`]/[`crate::Form`] extractor would see an empty body), and
16//!   reads it in chunks with the route's [`StreamRoute::max_bytes`] enforced
17//!   as it goes: the chunk that would cross the ceiling is refused with
18//!   [`StreamError::TooLarge`] rather than delivered, and the stream is fused
19//!   and its source dropped;
20//! - a declared `content-length` over the ceiling is refused with the same
21//!   `413 request-too-large` every buffered route gives, answered by the
22//!   router before any byte is read;
23//! - the handler may answer with a [`ResponseStream`], which the runtime
24//!   bridges to the wire without buffering it first.
25//!
26//! Every other route keeps the buffered behaviour it always had.
27
28use std::fmt;
29use std::pin::Pin;
30use std::task::{Context, Poll};
31
32use axum::body::Body;
33use axum::extract::FromRequestParts;
34use axum::http::Method;
35use axum::http::request::Parts;
36use axum::response::{IntoResponse, Response};
37use bytes::Bytes;
38use futures_core::Stream;
39
40use crate::problem::Problem;
41use crate::scope::Scope;
42
43/// The shared one-shot cell both stream types wrap: whoever polls the body
44/// or calls [`ResponseStream::take`] first gets the underlying stream, and
45/// the other side then sees an empty stream. `Mutex` because the two sides
46/// live in different parts of one request and the cell has to be `Sync`;
47/// this is request-scoped state carried in the request/response, not the
48/// ambient state ADR 0007 forbids.
49#[allow(clippy::disallowed_types)]
50type Slot =
51    std::sync::Arc<std::sync::Mutex<Option<BoxStream<'static, Result<Bytes, StreamError>>>>>;
52
53/// Builds the shared one-shot cell. The single place the `std::sync::Mutex`
54/// [`Slot`] names is constructed, so the disallowed-types allow lives here
55/// and the two stream types never name it themselves.
56#[allow(clippy::disallowed_types)]
57fn slot(inner: Option<BoxStream<'static, Result<Bytes, StreamError>>>) -> Slot {
58    std::sync::Arc::new(std::sync::Mutex::new(inner))
59}
60
61/// A type-erased, `Send` stream of body chunks: the shape a runtime bridges
62/// to the wire, and what [`ResponseStream::take`] hands one. Same definition
63/// as `futures_util::stream::BoxStream`, written here because core depends
64/// on `futures-core` only.
65pub type BoxStream<'a, T> = Pin<Box<dyn Stream<Item = T> + Send + 'a>>;
66
67/// One route a module serves in streaming mode (issue #585). `path` is
68/// relative to the module's `/v1/<name>` mount and uses axum's pattern
69/// syntax: a literal segment, `{param}` for exactly one segment, or
70/// `{*rest}` for the remaining one-or-more (`/upload`, `/files/{id}`,
71/// `/files/{*rest}`).
72///
73/// `max_bytes` is this route's own ceiling, enforced chunk by chunk by
74/// [`RequestStream`] and consulted by the router for the declared
75/// `content-length` pre-check. It replaces [`Module::max_body_bytes`] on
76/// this route, and may be raised or lowered freely: unlike the buffered
77/// ceiling it is the precise per-route enforcer, not a coarse guard in front
78/// of the router.
79///
80/// [`Module::max_body_bytes`]: crate::Module::max_body_bytes
81#[derive(Debug, Clone)]
82pub struct StreamRoute {
83    pub method: Method,
84    pub path: &'static str,
85    pub max_bytes: usize,
86}
87
88impl StreamRoute {
89    /// A `POST` route. `const` so a module's routes are one `static`
90    /// slice, the way its migrations are.
91    #[must_use]
92    pub const fn post(path: &'static str, max_bytes: usize) -> Self {
93        Self {
94            method: Method::POST,
95            path,
96            max_bytes,
97        }
98    }
99
100    /// A `PUT` route.
101    #[must_use]
102    pub const fn put(path: &'static str, max_bytes: usize) -> Self {
103        Self {
104            method: Method::PUT,
105            path,
106            max_bytes,
107        }
108    }
109
110    /// A `GET` route (a streamed response with no request body).
111    #[must_use]
112    pub const fn get(path: &'static str, max_bytes: usize) -> Self {
113        Self {
114            method: Method::GET,
115            path,
116            max_bytes,
117        }
118    }
119}
120
121/// Why a [`RequestStream`] ended early.
122#[derive(Debug, Clone)]
123pub enum StreamError {
124    /// The body crossed the route's [`StreamRoute::max_bytes`]. The chunk
125    /// that would have crossed it is refused, not delivered, and the stream
126    /// is fused from here on.
127    TooLarge,
128    /// The underlying transport failed mid-stream. The text is for logs and
129    /// never reaches a response body.
130    Transport(String),
131}
132
133impl fmt::Display for StreamError {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        match self {
136            Self::TooLarge => f.write_str("streamed body exceeded its route ceiling"),
137            Self::Transport(detail) => write!(f, "stream transport error: {detail}"),
138        }
139    }
140}
141
142impl std::error::Error for StreamError {}
143
144/// A mid-stream failure is a [`Problem`]: a ceiling breach is exactly the
145/// `413 request-too-large` a buffered route answers with, and a transport
146/// failure is a server-side fault the caller cannot fix, so it becomes the
147/// generic `500 internal` (the same mapping [`crate::ports::DbError`] gets —
148/// no internals in the body, the error only logged).
149impl From<StreamError> for Problem {
150    fn from(error: StreamError) -> Self {
151        match error {
152            StreamError::TooLarge => Self::request_too_large(),
153            StreamError::Transport(detail) => {
154                tracing::error!(error = %detail, "stream transport error mapped to internal problem");
155                // wasm has no tracing dispatcher (it hangs the isolate), so
156                // also forward to the runtime's sink, as the DbError mapping
157                // does (issue #107).
158                crate::logging::forward_internal_error(&format!(
159                    "stream transport error: {detail}"
160                ));
161                Self::internal()
162            }
163        }
164    }
165}
166
167/// A streamed request body (issue #585). Extracted by a handler on a route
168/// its module declared in
169/// [`Module::streaming_routes`](crate::Module::streaming_routes); the router
170/// has already put it in the request extensions and emptied the buffered
171/// body.
172///
173/// It is `Clone`, and clones share one cursor: the router's request layer
174/// inserts one handle and a handler may clone it, but they read the same
175/// underlying stream. It is one-shot — once the source ends (or is refused),
176/// every later poll answers `None` forever.
177#[derive(Clone)]
178pub struct RequestStream {
179    slot: Slot,
180}
181
182impl RequestStream {
183    /// Wraps `stream`, enforcing `max_bytes` as chunks are read. Accepts any
184    /// `Send` stream of `Result<O, E>` where `O` converts into [`Bytes`] and
185    /// `E` renders — an axum `Body::into_data_stream()`, a runtime's own
186    /// body stream, or a test's synthetic one.
187    #[must_use]
188    pub fn new<S, O, E>(stream: S, max_bytes: usize) -> Self
189    where
190        S: Stream<Item = Result<O, E>> + Send + 'static,
191        O: Into<Bytes> + 'static,
192        E: fmt::Display + 'static,
193    {
194        let capped: BoxStream<'static, Result<Bytes, StreamError>> = Box::pin(Capped {
195            inner: Some(map_stream(stream)),
196            delivered: 0,
197            max: max_bytes,
198        });
199        Self {
200            slot: slot(Some(capped)),
201        }
202    }
203
204    /// A stream that ends immediately. The empty exchange a test or a
205    /// runtime hands a route that has no body of its own.
206    #[must_use]
207    pub fn empty() -> Self {
208        Self { slot: slot(None) }
209    }
210
211    /// The next chunk, or `None` once the source has ended (or was refused).
212    /// A convenience for core callers, who have no `StreamExt`: it is
213    /// [`futures_core::Stream::poll_next`] behind `std::future::poll_fn`.
214    pub async fn next_chunk(&mut self) -> Option<Result<Bytes, StreamError>> {
215        std::future::poll_fn(|cx| Pin::new(&mut *self).poll_next(cx)).await
216    }
217}
218
219impl Stream for RequestStream {
220    type Item = Result<Bytes, StreamError>;
221
222    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
223        let this = self.get_mut();
224        let mut slot = this.slot.lock().expect("stream slot lock uncontended");
225        match slot.as_mut() {
226            Some(stream) => stream.as_mut().poll_next(cx),
227            None => Poll::Ready(None),
228        }
229    }
230}
231
232impl<S: Send + Sync> FromRequestParts<S> for RequestStream {
233    type Rejection = Problem;
234
235    /// Removes the router-inserted stream from the request extensions. A
236    /// route that was not declared in `Module::streaming_routes` has none,
237    /// and extracting here is a programming error — never a client one — so
238    /// it answers a `500 internal` naming the declaration that is missing.
239    // The trait method is `async`; this impl never awaits, which the lint
240    // reads as a mistake but the signature does not allow otherwise.
241    #[allow(clippy::unused_async_trait_impl)]
242    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
243        if let Some(stream) = parts.extensions.remove::<RequestStream>() {
244            return Ok(stream);
245        }
246        let mut problem = Problem::internal().with_detail(
247            "RequestStream is only available on a route declared in the owning module's \
248             streaming_routes()",
249        );
250        if let Some(scope) = parts.extensions.get::<Scope>() {
251            problem = problem.instance(&scope.request_id);
252        }
253        Err(problem)
254    }
255}
256
257/// A streamed response body (issue #585).
258///
259/// Answering with one bridges the chunks to the wire without buffering them:
260/// on native (and in the test kit) the [`axum`] body is polled directly, so
261/// nothing runtime-specific is needed. A runtime that cannot bridge an axum
262/// body — `runtime-cloudflare` — instead calls [`ResponseStream::take`] to
263/// pull the stream out of the response extensions. Whoever takes first wins;
264/// the other side then sees an empty body.
265#[derive(Clone)]
266pub struct ResponseStream {
267    slot: Slot,
268}
269
270impl ResponseStream {
271    /// Wraps `stream` as a response body. No ceiling: a streamed response is
272    /// bounded by the handler that produced it, not by a request limit.
273    #[must_use]
274    pub fn new<S, O, E>(stream: S) -> Self
275    where
276        S: Stream<Item = Result<O, E>> + Send + 'static,
277        O: Into<Bytes> + 'static,
278        E: fmt::Display + 'static,
279    {
280        Self {
281            slot: slot(Some(map_stream(stream))),
282        }
283    }
284
285    /// Takes the underlying stream, for a runtime that bridges it to its own
286    /// response type rather than polling the axum body — `runtime-cloudflare`
287    /// builds a Worker response from it, because converting an axum body into
288    /// a Worker `Response` hangs the isolate. `None` once the body has
289    /// already been polled (or taken).
290    ///
291    /// # Panics
292    ///
293    /// Panics if the slot's lock is poisoned — only reachable if a prior
294    /// poll panicked while holding it.
295    #[must_use]
296    pub fn take(&self) -> Option<BoxStream<'static, Result<Bytes, StreamError>>> {
297        self.slot
298            .lock()
299            .expect("stream slot lock uncontended")
300            .take()
301    }
302}
303
304impl IntoResponse for ResponseStream {
305    fn into_response(self) -> Response {
306        let body = Body::from_stream(BodySource {
307            slot: std::sync::Arc::clone(&self.slot),
308            inner: None,
309        });
310        let mut response = Response::new(body);
311        // The handle rides along on the response so a runtime can `take` the
312        // stream out of it instead of polling the axum body.
313        response.extensions_mut().insert(self);
314        response
315    }
316}
317
318/// The axum-body side of a [`ResponseStream`]: takes the inner stream from
319/// the shared slot the first time it is polled, then delegates to it. If a
320/// runtime took it first the slot is already empty and this yields `None`.
321struct BodySource {
322    slot: Slot,
323    inner: Option<BoxStream<'static, Result<Bytes, StreamError>>>,
324}
325
326impl Stream for BodySource {
327    type Item = Result<Bytes, StreamError>;
328
329    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
330        let this = self.get_mut();
331        if this.inner.is_none() {
332            this.inner = this
333                .slot
334                .lock()
335                .expect("stream slot lock uncontended")
336                .take();
337        }
338        match this.inner.as_mut() {
339            Some(stream) => stream.as_mut().poll_next(cx),
340            None => Poll::Ready(None),
341        }
342    }
343}
344
345/// Enforces a byte ceiling on an already-mapped chunk stream: the chunk that
346/// would cross `max` is replaced by [`StreamError::TooLarge`] (it is not
347/// delivered), the source is dropped, and every later poll answers `None`.
348struct Capped {
349    inner: Option<BoxStream<'static, Result<Bytes, StreamError>>>,
350    delivered: usize,
351    max: usize,
352}
353
354impl Stream for Capped {
355    type Item = Result<Bytes, StreamError>;
356
357    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
358        let this = self.get_mut();
359        let polled = match this.inner.as_mut() {
360            Some(stream) => stream.as_mut().poll_next(cx),
361            None => return Poll::Ready(None),
362        };
363        match polled {
364            Poll::Ready(Some(Ok(chunk))) => {
365                if this.delivered.saturating_add(chunk.len()) > this.max {
366                    // Drop the source and fuse: nothing further is read.
367                    this.inner = None;
368                    Poll::Ready(Some(Err(StreamError::TooLarge)))
369                } else {
370                    this.delivered += chunk.len();
371                    Poll::Ready(Some(Ok(chunk)))
372                }
373            }
374            Poll::Ready(Some(Err(err))) => Poll::Ready(Some(Err(err))),
375            Poll::Ready(None) => {
376                this.inner = None;
377                Poll::Ready(None)
378            }
379            Poll::Pending => Poll::Pending,
380        }
381    }
382}
383
384/// Type-erases a `Stream<Item = Result<O, E>>` into the `BoxStream` shape,
385/// mapping each item into [`Bytes`] and each error into
386/// [`StreamError::Transport`]. The inner stream is boxed first, so the
387/// adapter itself is `Unpin` and needs no `pin_project`.
388fn map_stream<S, O, E>(stream: S) -> BoxStream<'static, Result<Bytes, StreamError>>
389where
390    S: Stream<Item = Result<O, E>> + Send + 'static,
391    O: Into<Bytes> + 'static,
392    E: fmt::Display + 'static,
393{
394    Box::pin(MapStream {
395        inner: Box::pin(stream),
396    })
397}
398
399struct MapStream<T, E> {
400    inner: Pin<Box<dyn Stream<Item = Result<T, E>> + Send + 'static>>,
401}
402
403impl<T, E> Stream for MapStream<T, E>
404where
405    T: Into<Bytes> + 'static,
406    E: fmt::Display + 'static,
407{
408    type Item = Result<Bytes, StreamError>;
409
410    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
411        let this = self.get_mut();
412        match this.inner.as_mut().poll_next(cx) {
413            Poll::Ready(Some(Ok(item))) => Poll::Ready(Some(Ok(item.into()))),
414            Poll::Ready(Some(Err(err))) => {
415                Poll::Ready(Some(Err(StreamError::Transport(err.to_string()))))
416            }
417            Poll::Ready(None) => Poll::Ready(None),
418            Poll::Pending => Poll::Pending,
419        }
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    // A tiny `Stream` over a `Vec`, written out because the crate has no
428    // `futures-util` and the tests need nothing more than this.
429    struct VecStream<T> {
430        items: std::vec::IntoIter<T>,
431    }
432
433    impl<T: std::marker::Unpin> Stream for VecStream<T> {
434        type Item = T;
435
436        fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<T>> {
437            Poll::Ready(self.get_mut().items.next())
438        }
439    }
440
441    /// A `Send` chunk stream whose items are `Ok(Vec<u8>)`/`Err(String)`,
442    /// the shape a runtime's body stream maps to.
443    fn chunks(
444        items: Vec<Result<&'static [u8], &'static str>>,
445    ) -> impl Stream<Item = Result<Vec<u8>, String>> + Send {
446        VecStream {
447            items: items
448                .into_iter()
449                .map(|item| item.map(<[u8]>::to_vec).map_err(str::to_owned))
450                .collect::<Vec<_>>()
451                .into_iter(),
452        }
453    }
454
455    #[pollster::test]
456    async fn the_crossing_chunk_is_refused_and_the_stream_fuses() {
457        let mut stream = RequestStream::new(
458            chunks(vec![Ok(b"12345"), Ok(b"67890"), Ok(b"unreachable")]),
459            8,
460        );
461        assert_eq!(stream.next_chunk().await.unwrap().unwrap(), "12345");
462        // 5 + 5 crosses 8 on the second chunk: refused, not delivered.
463        assert!(matches!(
464            stream.next_chunk().await.unwrap(),
465            Err(StreamError::TooLarge)
466        ));
467        assert!(stream.next_chunk().await.is_none(), "fused after refusal");
468    }
469
470    #[pollster::test]
471    async fn a_body_exactly_at_the_ceiling_is_allowed() {
472        let mut stream = RequestStream::new(chunks(vec![Ok(b"1234"), Ok(b"5678")]), 8);
473        assert_eq!(stream.next_chunk().await.unwrap().unwrap(), "1234");
474        assert_eq!(stream.next_chunk().await.unwrap().unwrap(), "5678");
475        assert!(
476            stream.next_chunk().await.is_none(),
477            "clean end at the limit"
478        );
479    }
480
481    #[pollster::test]
482    async fn a_transport_error_is_mapped_not_swallowed() {
483        let mut stream = RequestStream::new(chunks(vec![Ok(b"hi"), Err("boom")]), 1024);
484        assert_eq!(stream.next_chunk().await.unwrap().unwrap(), "hi");
485        match stream.next_chunk().await.unwrap() {
486            Err(StreamError::Transport(detail)) => assert!(detail.contains("boom"), "{detail}"),
487            other => panic!("expected a transport error, got {other:?}"),
488        }
489    }
490
491    #[pollster::test]
492    async fn an_empty_stream_ends_at_once() {
493        let mut stream = RequestStream::empty();
494        assert!(stream.next_chunk().await.is_none());
495    }
496
497    #[pollster::test]
498    async fn all_response_chunks_reach_the_body() {
499        let response =
500            ResponseStream::new(chunks(vec![Ok(b"one"), Ok(b"two"), Ok(b"three")])).into_response();
501        let body = axum::body::to_bytes(response.into_parts().1, 1024)
502            .await
503            .expect("body reads");
504        assert_eq!(&body[..], b"onetwothree");
505    }
506
507    #[pollster::test]
508    async fn take_after_the_body_was_consumed_answers_none() {
509        let stream = ResponseStream::new(chunks(vec![Ok(b"one")]));
510        let response = stream.clone().into_response();
511        let body = axum::body::to_bytes(response.into_parts().1, 1024)
512            .await
513            .expect("body reads");
514        assert_eq!(&body[..], b"one");
515        assert!(stream.take().is_none(), "the body already took the stream");
516    }
517
518    #[pollster::test]
519    async fn take_first_leaves_the_axum_body_empty() {
520        let stream = ResponseStream::new(chunks(vec![Ok(b"one")]));
521        assert!(stream.take().is_some(), "the runtime takes the stream");
522        let response = stream.into_response();
523        let body = axum::body::to_bytes(response.into_parts().1, 1024)
524            .await
525            .expect("body reads");
526        assert!(body.is_empty(), "whoever takes first wins");
527    }
528}