use crate::middleware::{Middleware, Next, Request};
use crate::{Client, RawResponse, Result};
use http::StatusCode;
use url::ParseError;
const REDIRECT_CODES: &[StatusCode] = &[
StatusCode::MOVED_PERMANENTLY,
StatusCode::FOUND,
StatusCode::SEE_OTHER,
StatusCode::TEMPORARY_REDIRECT,
StatusCode::PERMANENT_REDIRECT,
];
#[derive(Debug)]
pub struct Redirect {
attempts: u8,
}
impl Redirect {
#[must_use]
#[allow(clippy::missing_const_for_fn)]
pub fn new(attempts: u8) -> Self {
Self { attempts }
}
}
#[async_trait::async_trait]
impl Middleware for Redirect {
async fn handle(
&self,
mut request: Request,
client: Client,
next: Next<'_>,
) -> Result<RawResponse> {
let mut redirect_count: u8 = 0;
let mut base_url = request.url().clone();
while redirect_count < self.attempts {
redirect_count += 1;
let r: Request = request.clone();
let res: RawResponse = client.send(r).await?;
if REDIRECT_CODES.contains(&res.status()) {
if let Some(location) = res.header(http::header::LOCATION) {
let location_str = location.to_str().map_err(|_| {
crate::HttpError::Io("redirect Location header is not valid ASCII".into())
})?;
*request.url_mut() = match url::Url::parse(location_str) {
Ok(valid_url) => {
base_url = valid_url;
base_url.clone()
}
Err(ParseError::RelativeUrlWithoutBase) => base_url.join(location_str)?,
Err(e) => return Err(e.into()),
};
}
} else {
break;
}
}
Ok(next.run(request, client).await?)
}
}
impl Default for Redirect {
fn default() -> Self {
Self { attempts: 3 }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::Client;
use crate::protocol::HttpResponse;
use crate::testing::FakeShell;
#[futures_test::test]
async fn follows_absolute_redirect() {
let shell = FakeShell::default();
shell.provide_response(
HttpResponse::status(301)
.header("location", "https://example.com/new")
.build(),
);
shell.provide_response(HttpResponse::ok().build());
shell.provide_response(HttpResponse::ok().body("final").build());
let client = Client::new(shell.clone()).with(Redirect::new(3));
let mut response = client.get("https://example.com/old").await.unwrap();
assert_eq!(response.body_string().unwrap(), "final");
let reqs = shell.take_requests_received();
assert_eq!(reqs.len(), 3);
assert_eq!(reqs[0].url, "https://example.com/old");
assert_eq!(reqs[1].url, "https://example.com/new");
assert_eq!(reqs[2].url, "https://example.com/new");
}
#[futures_test::test]
async fn follows_relative_redirect() {
let shell = FakeShell::default();
shell.provide_response(
HttpResponse::status(302)
.header("location", "/other")
.build(),
);
shell.provide_response(HttpResponse::ok().build());
shell.provide_response(HttpResponse::ok().body("done").build());
let client = Client::new(shell.clone()).with(Redirect::new(3));
let mut response = client.get("https://example.com/start").await.unwrap();
assert_eq!(response.body_string().unwrap(), "done");
let reqs = shell.take_requests_received();
assert_eq!(reqs[1].url, "https://example.com/other");
assert_eq!(reqs[2].url, "https://example.com/other");
}
#[futures_test::test]
async fn non_ascii_location_header_returns_io_error() {
let shell = FakeShell::default();
shell.provide_response(
HttpResponse::status(301)
.header("location", "é/other")
.build(),
);
let client = Client::new(shell.clone()).with(Redirect::new(3));
let result = client.get("https://example.com/start").await;
assert!(
matches!(result, Err(crate::HttpError::Io(_))),
"non-ASCII Location header must return HttpError::Io, got: {result:?}"
);
}
#[futures_test::test]
async fn follows_303_redirect() {
let shell = FakeShell::default();
shell.provide_response(
HttpResponse::status(303)
.header("location", "https://example.com/new")
.build(),
);
shell.provide_response(HttpResponse::ok().build());
shell.provide_response(HttpResponse::ok().body("303 done").build());
let client = Client::new(shell.clone()).with(Redirect::new(3));
let mut response = client.get("https://example.com/old").await.unwrap();
assert_eq!(response.body_string().unwrap(), "303 done");
assert_eq!(
shell.take_requests_received()[1].url,
"https://example.com/new"
);
}
#[futures_test::test]
async fn follows_307_redirect() {
let shell = FakeShell::default();
shell.provide_response(
HttpResponse::status(307)
.header("location", "https://example.com/new")
.build(),
);
shell.provide_response(HttpResponse::ok().build());
shell.provide_response(HttpResponse::ok().body("307 done").build());
let client = Client::new(shell.clone()).with(Redirect::new(3));
let mut response = client.get("https://example.com/old").await.unwrap();
assert_eq!(response.body_string().unwrap(), "307 done");
assert_eq!(
shell.take_requests_received()[1].url,
"https://example.com/new"
);
}
#[futures_test::test]
async fn follows_308_redirect() {
let shell = FakeShell::default();
shell.provide_response(
HttpResponse::status(308)
.header("location", "https://example.com/new")
.build(),
);
shell.provide_response(HttpResponse::ok().build());
shell.provide_response(HttpResponse::ok().body("308 done").build());
let client = Client::new(shell.clone()).with(Redirect::new(3));
let mut response = client.get("https://example.com/old").await.unwrap();
assert_eq!(response.body_string().unwrap(), "308 done");
assert_eq!(
shell.take_requests_received()[1].url,
"https://example.com/new"
);
}
#[futures_test::test]
async fn redirect_with_no_location_header_keeps_original_url() {
let shell = FakeShell::default();
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));
let mut response = client.get("https://example.com/start").await.unwrap();
assert_eq!(response.body_string().unwrap(), "same url");
let reqs = shell.take_requests_received();
assert!(reqs.iter().all(|r| r.url == "https://example.com/start"));
}
#[futures_test::test]
async fn stops_after_max_attempts() {
let shell = FakeShell::default();
shell.provide_response(
HttpResponse::status(301)
.header("location", "https://example.com/loop")
.build(),
);
shell.provide_response(
HttpResponse::status(301)
.header("location", "https://example.com/loop")
.build(),
);
shell.provide_response(HttpResponse::ok().body("gave up").build());
let client = Client::new(shell.clone()).with(Redirect::new(2));
let mut res = client.get("https://example.com/start").await.unwrap();
assert_eq!(res.body_string().unwrap(), "gave up");
assert_eq!(shell.take_requests_received().len(), 3);
}
}