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, ResponseAsync, Result};
22use http_types::{StatusCode, Url, headers};
23
24// List of acceptable 300-series redirect codes.
25const REDIRECT_CODES: &[StatusCode] = &[
26    StatusCode::MovedPermanently,
27    StatusCode::Found,
28    StatusCode::SeeOther,
29    StatusCode::TemporaryRedirect,
30    StatusCode::PermanentRedirect,
31];
32
33/// A middleware which attempts to follow HTTP redirects.
34#[derive(Debug)]
35pub struct Redirect {
36    attempts: u8,
37}
38
39impl Redirect {
40    /// Create a new instance of the Redirect middleware, which attempts to follow redirects
41    /// up to as many times as specified.
42    ///
43    /// Consider using `Redirect::default()` for the default number of redirect attempts.
44    ///
45    /// This middleware will follow redirects from the `Location` header if the server returns
46    /// any of the following http response codes:
47    /// - 301 Moved Permanently
48    /// - 302 Found
49    /// - 303 See other
50    /// - 307 Temporary Redirect
51    /// - 308 Permanent Redirect
52    ///
53    /// # Errors
54    ///
55    /// An error will be passed through the middleware stack if the value of the `Location`
56    /// header is not a validly parsing url.
57    ///
58    /// # Caveats
59    ///
60    /// This will presently make at least one additional HTTP request before the actual request to
61    /// determine if there is a redirect that should be followed, so as to preserve any request body.
62    ///
63    /// # Examples
64    ///
65    /// ```no_run
66    /// # use crux_core::macros::effect;
67    /// # use crux_http::HttpRequest;
68    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Vec<u8>>>) }
69    /// # #[effect]
70    /// # #[allow(unused)]
71    /// # enum Effect { Http(HttpRequest) }
72    /// # type Http = crux_http::Http<Effect, Event>;
73    ///
74    /// Http::get("https://httpbin.org/redirect/2")
75    ///     .middleware(crux_http::middleware::Redirect::default())
76    ///     .build()
77    ///     .then_send(Event::ReceiveResponse);
78    /// ```
79    #[must_use]
80    pub fn new(attempts: u8) -> Self {
81        Redirect { attempts }
82    }
83}
84
85#[async_trait::async_trait]
86impl Middleware for Redirect {
87    async fn handle(
88        &self,
89        mut request: Request,
90        client: Client,
91        next: Next<'_>,
92    ) -> Result<ResponseAsync> {
93        let mut redirect_count: u8 = 0;
94
95        // Note(Jeremiah): This is not ideal.
96        //
97        // HttpClient is currently too limiting for efficient redirects.
98        // We do not want to make unnecessary full requests, but it is
99        // presently required due to how Body streams work.
100        //
101        // Ideally we'd have methods to send a partial request stream,
102        // without sending the body, that would potentially be able to
103        // get a server status before we attempt to send the body.
104        //
105        // As a work around we clone the request first (without the body),
106        // and try sending it until we get some status back that is not a
107        // redirect.
108
109        let mut base_url = request.url().clone();
110
111        while redirect_count < self.attempts {
112            redirect_count += 1;
113            let r: Request = request.clone();
114            let res: ResponseAsync = client.send(r).await?;
115            if REDIRECT_CODES.contains(&res.status()) {
116                if let Some(location) = res.header(headers::LOCATION) {
117                    let http_req: &mut http_types::Request = request.as_mut();
118                    *http_req.url_mut() = match Url::parse(location.last().as_str()) {
119                        Ok(valid_url) => {
120                            base_url = valid_url;
121                            base_url.clone()
122                        }
123                        Err(e) => match e {
124                            http_types::url::ParseError::RelativeUrlWithoutBase => {
125                                base_url.join(location.last().as_str())?
126                            }
127                            e => return Err(e.into()),
128                        },
129                    };
130                }
131            } else {
132                break;
133            }
134        }
135
136        Ok(next.run(request, client).await?)
137    }
138}
139
140impl Default for Redirect {
141    /// Create a new instance of the Redirect middleware, which attempts to follow up to
142    /// 3 redirects (not including the actual request).
143    fn default() -> Self {
144        Self { attempts: 3 }
145    }
146}