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}