crux_http/middleware/
redirect.rs1use crate::middleware::{Middleware, Next, Request};
21use crate::{Client, RawResponse, Result};
22use http::StatusCode;
23use url::ParseError;
24
25const 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#[derive(Debug)]
36pub struct Redirect {
37 attempts: u8,
38}
39
40impl Redirect {
41 #[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 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 #[futures_test::test]
141 async fn follows_absolute_redirect() {
142 let shell = FakeShell::default();
143 shell.provide_response(
145 HttpResponse::status(301)
146 .header("location", "https://example.com/new")
147 .build(),
148 );
149 shell.provide_response(HttpResponse::ok().build());
151 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 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 let shell = FakeShell::default();
275 shell.provide_response(HttpResponse::status(301).build()); shell.provide_response(HttpResponse::ok().build()); shell.provide_response(HttpResponse::ok().body("same url").build()); 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 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 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}