diff --git a/awc/CHANGES.md b/awc/CHANGES.md index 9888c66c3..a2b2c70f1 100644 --- a/awc/CHANGES.md +++ b/awc/CHANGES.md @@ -2,6 +2,7 @@ ## Unreleased +- Add `ClientResponse::url()` to retrieve the final URL after following redirects. - Add camel-case header controls to `WebsocketsRequest` via `camel_case_headers()` and `set_camel_case_headers()`. [#3953] - Update `hickory-resolver` dependency to `0.26.1`. - Update `rand` dependency to `0.10`. diff --git a/awc/src/middleware/redirect.rs b/awc/src/middleware/redirect.rs index b2cf9c45b..220093d92 100644 --- a/awc/src/middleware/redirect.rs +++ b/awc/src/middleware/redirect.rs @@ -6,7 +6,7 @@ use std::{ task::{Context, Poll}, }; -use actix_http::{header, Method, RequestHead, RequestHeadType, StatusCode, Uri}; +use actix_http::{header, HttpMessage as _, Method, RequestHead, RequestHeadType, StatusCode, Uri}; use actix_service::Service; use bytes::Bytes; use futures_core::ready; @@ -16,6 +16,7 @@ use crate::{ any_body::AnyBody, client::{InvalidUrl, SendRequestError}, connect::{ConnectRequest, ConnectResponse}, + responses::FinalUrl, ClientResponse, }; @@ -236,7 +237,12 @@ where self.poll(cx) } - _ => Poll::Ready(Ok(ConnectResponse::Client(res))), + _ => { + if let Some(final_uri) = uri.take() { + res.extensions_mut().insert(FinalUrl(final_uri)); + } + Poll::Ready(Ok(ConnectResponse::Client(res))) + } }, _ => unreachable!("ConnectRequest::Tunnel is not handled by Redirect"), }, @@ -625,6 +631,52 @@ mod tests { assert_eq!(res.status().as_u16(), 400); } + #[actix_rt::test] + async fn test_redirect_final_url() { + let client = ClientBuilder::new().finish(); + + let srv = actix_test::start(|| { + App::new() + .service(web::resource("/final").route(web::to(|| async { + Ok::<_, Error>(HttpResponse::Ok().body("done")) + }))) + .service(web::resource("/").route(web::to(|| async { + Ok::<_, Error>( + HttpResponse::Found() + .append_header(("location", "/final")) + .finish(), + ) + }))) + }); + + let res = client.get(srv.url("/")).send().await.unwrap(); + assert_eq!(res.status().as_u16(), 200); + + let final_url = res.url().expect("final URL should be set after redirect"); + assert_eq!( + final_url.path(), + "/final", + "final URL should be the redirected path" + ); + } + + #[actix_rt::test] + async fn test_no_redirect_final_url() { + let client = ClientBuilder::new().finish(); + + let srv = actix_test::start(|| { + App::new().service(web::resource("/").route(web::to(|| async { + Ok::<_, Error>(HttpResponse::Ok().body("no redirect")) + }))) + }); + + let res = client.get(srv.url("/")).send().await.unwrap(); + assert_eq!(res.status().as_u16(), 200); + + let final_url = res.url().expect("final URL should be set even without redirect"); + assert_eq!(final_url.path(), "/"); + } + #[actix_rt::test] async fn test_remove_sensitive_headers() { fn gen_headers() -> header::HeaderMap { diff --git a/awc/src/responses/mod.rs b/awc/src/responses/mod.rs index 95a078093..339a7d48f 100644 --- a/awc/src/responses/mod.rs +++ b/awc/src/responses/mod.rs @@ -1,6 +1,6 @@ use std::{future::Future, io, pin::Pin, task::Context}; -use actix_http::error::PayloadError; +use actix_http::{error::PayloadError, Uri}; use actix_rt::time::Sleep; mod json_body; @@ -12,6 +12,9 @@ mod response_body; pub use self::response_body::{MessageBody, ResponseBody}; pub use self::{json_body::JsonBody, response::ClientResponse}; +/// Stores the final URL in response extensions after following redirects. +pub(crate) struct FinalUrl(pub(crate) Uri); + /// Default body size limit: 2 MiB const DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024; diff --git a/awc/src/responses/response.rs b/awc/src/responses/response.rs index 0eafcff0a..350bc6687 100644 --- a/awc/src/responses/response.rs +++ b/awc/src/responses/response.rs @@ -8,7 +8,7 @@ use std::{ use actix_http::{ error::PayloadError, header::HeaderMap, BoxedPayloadStream, Extensions, HttpMessage, Payload, - ResponseHead, StatusCode, Version, + ResponseHead, StatusCode, Uri, Version, }; use actix_rt::time::{sleep, Sleep}; use bytes::Bytes; @@ -66,6 +66,18 @@ impl ClientResponse { &self.head().headers } + /// Returns the final URL of this response. + /// + /// When redirects are followed, this returns the URL of the final response (after all + /// redirects). Returns `None` if the redirect middleware was not used (e.g., redirects + /// were disabled). + pub fn url(&self) -> Option { + self.extensions + .borrow() + .get::() + .map(|u| u.0.clone()) + } + /// Map the current body type to another using a closure. Returns a new response. /// /// Closure receives the response head and the current body type.