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    #[allow(clippy::missing_const_for_fn)]
81    pub fn new(attempts: u8) -> Self {
82        Self { attempts }
83    }
84}
85
86#[async_trait::async_trait]
87impl Middleware for Redirect {
88    async fn handle(
89        &self,
90        mut request: Request,
91        client: Client,
92        next: Next<'_>,
93    ) -> Result<ResponseAsync> {
94        let mut redirect_count: u8 = 0;
95
96        // Note(Jeremiah): This is not ideal.
97        //
98        // HttpClient is currently too limiting for efficient redirects.
99        // We do not want to make unnecessary full requests, but it is
100        // presently required due to how Body streams work.
101        //
102        // Ideally we'd have methods to send a partial request stream,
103        // without sending the body, that would potentially be able to
104        // get a server status before we attempt to send the body.
105        //
106        // As a work around we clone the request first (without the body),
107        // and try sending it until we get some status back that is not a
108        // redirect.
109
110        let mut base_url = request.url().clone();
111
112        while redirect_count < self.attempts {
113            redirect_count += 1;
114            let r: Request = request.clone();
115            let res: ResponseAsync = client.send(r).await?;
116            if REDIRECT_CODES.contains(&res.status()) {
117                if let Some(location) = res.header(headers::LOCATION) {
118                    let http_req: &mut http_types::Request = request.as_mut();
119                    *http_req.url_mut() = match Url::parse(location.last().as_str()) {
120                        Ok(valid_url) => {
121                            base_url = valid_url;
122                            base_url.clone()
123                        }
124                        Err(e) => match e {
125                            http_types::url::ParseError::RelativeUrlWithoutBase => {
126                                base_url.join(location.last().as_str())?
127                            }
128                            e => return Err(e.into()),
129                        },
130                    };
131                }
132            } else {
133                break;
134            }
135        }
136
137        Ok(next.run(request, client).await?)
138    }
139}
140
141impl Default for Redirect {
142    /// Create a new instance of the Redirect middleware, which attempts to follow up to
143    /// 3 redirects (not including the actual request).
144    fn default() -> Self {
145        Self { attempts: 3 }
146    }
147}