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}