Skip to main content

crux_http/middleware/
redirect.rs

1//! HTTP Redirect middleware.
2//!
3//! # Examples
4//!
5//! ```no_run
6//! # use crux_core::macros::effect;
7//! # use crux_http::HttpRequest;
8//! # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Vec<u8>>>) }
9//! # #[effect]
10//! # #[allow(unused)]
11//! # enum Effect { Http(HttpRequest) }
12//! # type Http = crux_http::Http<Effect, Event>;
13//!
14//! Http::get("https://httpbin.org/redirect/2")
15//!     .middleware(crux_http::middleware::Redirect::default())
16//!     .build()
17//!     .then_send(Event::ReceiveResponse);
18//! ```
19
20use crate::middleware::{Middleware, Next, Request};
21use crate::{Client, RawResponse, Result};
22use http::StatusCode;
23use url::ParseError;
24
25// List of acceptable 300-series redirect codes.
26const REDIRECT_CODES: &[StatusCode] = &[
27    StatusCode::MOVED_PERMANENTLY,
28    StatusCode::FOUND,
29    StatusCode::SEE_OTHER,
30    StatusCode::TEMPORARY_REDIRECT,
31    StatusCode::PERMANENT_REDIRECT,
32];
33
34/// A middleware which attempts to follow HTTP redirects.
35#[derive(Debug)]
36pub struct Redirect {
37    attempts: u8,
38}
39
40impl Redirect {
41    /// Create a new instance of the Redirect middleware, which attempts to follow redirects
42    /// up to `attempts` times.
43    ///
44    /// Consider using [`Redirect::default`] for the default of 3 redirect attempts.
45    ///
46    /// This middleware follows redirects from the `Location` header when the server returns
47    /// any of the following status codes:
48    /// - 301 Moved Permanently
49    /// - 302 Found
50    /// - 303 See Other
51    /// - 307 Temporary Redirect
52    /// - 308 Permanent Redirect
53    ///
54    /// # Errors
55    ///
56    /// Returns an error if the `Location` header value is not a valid URL, or if it contains
57    /// non-ASCII bytes (e.g. a UTF-8 encoded path).
58    ///
59    /// # Examples
60    ///
61    /// ```no_run
62    /// # use crux_core::macros::effect;
63    /// # use crux_http::HttpRequest;
64    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Vec<u8>>>) }
65    /// # #[effect]
66    /// # #[allow(unused)]
67    /// # enum Effect { Http(HttpRequest) }
68    /// # type Http = crux_http::Http<Effect, Event>;
69    ///
70    /// Http::get("https://httpbin.org/redirect/2")
71    ///     .middleware(crux_http::middleware::Redirect::default())
72    ///     .build()
73    ///     .then_send(Event::ReceiveResponse);
74    /// ```
75    #[must_use]
76    #[allow(clippy::missing_const_for_fn)]
77    pub fn new(attempts: u8) -> Self {
78        Self { attempts }
79    }
80}
81
82#[async_trait::async_trait]
83impl Middleware for Redirect {
84    async fn handle(
85        &self,
86        mut request: Request,
87        client: Client,
88        next: Next<'_>,
89    ) -> Result<RawResponse> {
90        let mut redirect_count: u8 = 0;
91        let mut base_url = request.url().clone();
92
93        while redirect_count < self.attempts {
94            redirect_count += 1;
95            let r: Request = request.clone();
96            let res: RawResponse = client.send(r).await?;
97            if REDIRECT_CODES.contains(&res.status()) {
98                if let Some(location) = res.header(http::header::LOCATION) {
99                    let location_str = location.to_str().map_err(|_| {
100                        crate::HttpError::Io("redirect Location header is not valid ASCII".into())
101                    })?;
102                    *request.url_mut() = match url::Url::parse(location_str) {
103                        Ok(valid_url) => {
104                            base_url = valid_url;
105                            base_url.clone()
106                        }
107                        Err(ParseError::RelativeUrlWithoutBase) => base_url.join(location_str)?,
108                        Err(e) => return Err(e.into()),
109                    };
110                }
111            } else {
112                break;
113            }
114        }
115
116        Ok(next.run(request, client).await?)
117    }
118}
119
120impl Default for Redirect {
121    /// Create a new instance of the Redirect middleware with the default of 3 redirect attempts.
122    fn default() -> Self {
123        Self { attempts: 3 }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::client::Client;
131    use crate::protocol::HttpResponse;
132    use crate::testing::FakeShell;
133
134    // The redirect middleware always makes one extra request via `next.run` after the
135    // loop completes (whether it broke early or ran out of attempts).  Each iteration
136    // inside the loop calls `client.send`, then `next.run` fires once at the end.
137    // For N redirects followed by a terminal response the shell therefore receives
138    // N+1 (loop) + 1 (next.run) = N+2 requests total.
139
140    #[futures_test::test]
141    async fn follows_absolute_redirect() {
142        let shell = FakeShell::default();
143        // Request 1 (loop iter 1): 301 → update URL
144        shell.provide_response(
145            HttpResponse::status(301)
146                .header("location", "https://example.com/new")
147                .build(),
148        );
149        // Request 2 (loop iter 2): 200 → break
150        shell.provide_response(HttpResponse::ok().build());
151        // Request 3 (next.run): the actual final response returned to the caller
152        shell.provide_response(HttpResponse::ok().body("final").build());
153
154        let client = Client::new(shell.clone()).with(Redirect::new(3));
155        let mut response = client.get("https://example.com/old").await.unwrap();
156
157        assert_eq!(response.body_string().unwrap(), "final");
158        let reqs = shell.take_requests_received();
159        assert_eq!(reqs.len(), 3);
160        assert_eq!(reqs[0].url, "https://example.com/old");
161        assert_eq!(reqs[1].url, "https://example.com/new");
162        assert_eq!(reqs[2].url, "https://example.com/new");
163    }
164
165    #[futures_test::test]
166    async fn follows_relative_redirect() {
167        let shell = FakeShell::default();
168        shell.provide_response(
169            HttpResponse::status(302)
170                .header("location", "/other")
171                .build(),
172        );
173        shell.provide_response(HttpResponse::ok().build());
174        shell.provide_response(HttpResponse::ok().body("done").build());
175
176        let client = Client::new(shell.clone()).with(Redirect::new(3));
177        let mut response = client.get("https://example.com/start").await.unwrap();
178
179        assert_eq!(response.body_string().unwrap(), "done");
180        let reqs = shell.take_requests_received();
181        assert_eq!(reqs[1].url, "https://example.com/other");
182        assert_eq!(reqs[2].url, "https://example.com/other");
183    }
184
185    #[futures_test::test]
186    async fn non_ascii_location_header_returns_io_error() {
187        // "é" encodes to UTF-8 bytes [0xC3, 0xA9].  Both are opaque bytes (>= 0x80)
188        // that HeaderValue::from_str accepts but to_str() rejects.  Our fix maps
189        // that to_str() failure to HttpError::Io rather than silently looping.
190        let shell = FakeShell::default();
191        shell.provide_response(
192            HttpResponse::status(301)
193                .header("location", "é/other")
194                .build(),
195        );
196
197        let client = Client::new(shell.clone()).with(Redirect::new(3));
198        let result = client.get("https://example.com/start").await;
199
200        assert!(
201            matches!(result, Err(crate::HttpError::Io(_))),
202            "non-ASCII Location header must return HttpError::Io, got: {result:?}"
203        );
204    }
205
206    #[futures_test::test]
207    async fn follows_303_redirect() {
208        let shell = FakeShell::default();
209        shell.provide_response(
210            HttpResponse::status(303)
211                .header("location", "https://example.com/new")
212                .build(),
213        );
214        shell.provide_response(HttpResponse::ok().build());
215        shell.provide_response(HttpResponse::ok().body("303 done").build());
216
217        let client = Client::new(shell.clone()).with(Redirect::new(3));
218        let mut response = client.get("https://example.com/old").await.unwrap();
219
220        assert_eq!(response.body_string().unwrap(), "303 done");
221        assert_eq!(
222            shell.take_requests_received()[1].url,
223            "https://example.com/new"
224        );
225    }
226
227    #[futures_test::test]
228    async fn follows_307_redirect() {
229        let shell = FakeShell::default();
230        shell.provide_response(
231            HttpResponse::status(307)
232                .header("location", "https://example.com/new")
233                .build(),
234        );
235        shell.provide_response(HttpResponse::ok().build());
236        shell.provide_response(HttpResponse::ok().body("307 done").build());
237
238        let client = Client::new(shell.clone()).with(Redirect::new(3));
239        let mut response = client.get("https://example.com/old").await.unwrap();
240
241        assert_eq!(response.body_string().unwrap(), "307 done");
242        assert_eq!(
243            shell.take_requests_received()[1].url,
244            "https://example.com/new"
245        );
246    }
247
248    #[futures_test::test]
249    async fn follows_308_redirect() {
250        let shell = FakeShell::default();
251        shell.provide_response(
252            HttpResponse::status(308)
253                .header("location", "https://example.com/new")
254                .build(),
255        );
256        shell.provide_response(HttpResponse::ok().build());
257        shell.provide_response(HttpResponse::ok().body("308 done").build());
258
259        let client = Client::new(shell.clone()).with(Redirect::new(3));
260        let mut response = client.get("https://example.com/old").await.unwrap();
261
262        assert_eq!(response.body_string().unwrap(), "308 done");
263        assert_eq!(
264            shell.take_requests_received()[1].url,
265            "https://example.com/new"
266        );
267    }
268
269    #[futures_test::test]
270    async fn redirect_with_no_location_header_keeps_original_url() {
271        // A 301 with no Location header: the middleware silently skips URL rewriting
272        // and the loop continues, eventually falling through to next.run with the
273        // original URL unchanged.
274        let shell = FakeShell::default();
275        shell.provide_response(HttpResponse::status(301).build()); // no Location
276        shell.provide_response(HttpResponse::ok().build()); // loop iter 2 breaks
277        shell.provide_response(HttpResponse::ok().body("same url").build()); // next.run
278
279        let client = Client::new(shell.clone()).with(Redirect::new(3));
280        let mut response = client.get("https://example.com/start").await.unwrap();
281
282        assert_eq!(response.body_string().unwrap(), "same url");
283        let reqs = shell.take_requests_received();
284        // All three requests go to the original URL — no rewrite happened.
285        assert!(reqs.iter().all(|r| r.url == "https://example.com/start"));
286    }
287
288    #[futures_test::test]
289    async fn stops_after_max_attempts() {
290        let shell = FakeShell::default();
291        // With attempts=2: loop runs twice (both 301), then next.run fires once.
292        shell.provide_response(
293            HttpResponse::status(301)
294                .header("location", "https://example.com/loop")
295                .build(),
296        );
297        shell.provide_response(
298            HttpResponse::status(301)
299                .header("location", "https://example.com/loop")
300                .build(),
301        );
302        shell.provide_response(HttpResponse::ok().body("gave up").build());
303
304        let client = Client::new(shell.clone()).with(Redirect::new(2));
305        let mut res = client.get("https://example.com/start").await.unwrap();
306
307        assert_eq!(res.body_string().unwrap(), "gave up");
308        assert_eq!(shell.take_requests_received().len(), 3);
309    }
310}