feat(awc): add ClientResponse::url() for final URL after redirects

Add a method to retrieve the final URL after following redirects.
The redirect middleware stores the resolved URL in the response's
extensions, and ClientResponse::url() provides convenient access.

Returns Some(uri) when the redirect middleware is active (default),
None when redirects are disabled.

Closes #3403
This commit is contained in:
Dhinesh K 2026-06-18 21:31:45 +05:30
parent 7ded7ee478
commit c382f83a0a
4 changed files with 72 additions and 4 deletions

View File

@ -2,6 +2,7 @@
## Unreleased ## 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] - 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 `hickory-resolver` dependency to `0.26.1`.
- Update `rand` dependency to `0.10`. - Update `rand` dependency to `0.10`.

View File

@ -6,7 +6,7 @@ use std::{
task::{Context, Poll}, 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 actix_service::Service;
use bytes::Bytes; use bytes::Bytes;
use futures_core::ready; use futures_core::ready;
@ -16,6 +16,7 @@ use crate::{
any_body::AnyBody, any_body::AnyBody,
client::{InvalidUrl, SendRequestError}, client::{InvalidUrl, SendRequestError},
connect::{ConnectRequest, ConnectResponse}, connect::{ConnectRequest, ConnectResponse},
responses::FinalUrl,
ClientResponse, ClientResponse,
}; };
@ -236,7 +237,12 @@ where
self.poll(cx) 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"), _ => unreachable!("ConnectRequest::Tunnel is not handled by Redirect"),
}, },
@ -625,6 +631,52 @@ mod tests {
assert_eq!(res.status().as_u16(), 400); 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] #[actix_rt::test]
async fn test_remove_sensitive_headers() { async fn test_remove_sensitive_headers() {
fn gen_headers() -> header::HeaderMap { fn gen_headers() -> header::HeaderMap {

View File

@ -1,6 +1,6 @@
use std::{future::Future, io, pin::Pin, task::Context}; 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; use actix_rt::time::Sleep;
mod json_body; mod json_body;
@ -12,6 +12,9 @@ mod response_body;
pub use self::response_body::{MessageBody, ResponseBody}; pub use self::response_body::{MessageBody, ResponseBody};
pub use self::{json_body::JsonBody, response::ClientResponse}; 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 /// Default body size limit: 2 MiB
const DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024; const DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024;

View File

@ -8,7 +8,7 @@ use std::{
use actix_http::{ use actix_http::{
error::PayloadError, header::HeaderMap, BoxedPayloadStream, Extensions, HttpMessage, Payload, error::PayloadError, header::HeaderMap, BoxedPayloadStream, Extensions, HttpMessage, Payload,
ResponseHead, StatusCode, Version, ResponseHead, StatusCode, Uri, Version,
}; };
use actix_rt::time::{sleep, Sleep}; use actix_rt::time::{sleep, Sleep};
use bytes::Bytes; use bytes::Bytes;
@ -66,6 +66,18 @@ impl<S> ClientResponse<S> {
&self.head().headers &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<Uri> {
self.extensions
.borrow()
.get::<super::FinalUrl>()
.map(|u| u.0.clone())
}
/// Map the current body type to another using a closure. Returns a new response. /// Map the current body type to another using a closure. Returns a new response.
/// ///
/// Closure receives the response head and the current body type. /// Closure receives the response head and the current body type.