Skip to content

Commit 5efb7b9

Browse files
authored
feat(volo-http client): add redirect-following support (#653)
fix(volo-http client): make FollowRedirect compose correctly with HttpProxy
1 parent 52da49d commit 5efb7b9

11 files changed

Lines changed: 1247 additions & 33 deletions

File tree

examples/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ path = "src/http/example-http-server.rs"
116116
name = "example-http-client"
117117
path = "src/http/example-http-client.rs"
118118
[[bin]]
119+
name = "http-client-follow-redirects"
120+
path = "src/http/client-follow-redirects.rs"
121+
[[bin]]
119122
name = "http-tls-server"
120123
path = "src/http/http-tls-server.rs"
121124
required-features = ["__tls"]
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
use std::net::SocketAddr;
2+
3+
use tokio::net::TcpListener;
4+
use volo::net::DefaultIncoming;
5+
use volo_http::{
6+
body::BodyConversion,
7+
client::Client,
8+
error::BoxError,
9+
server::{
10+
Redirect, Server,
11+
route::{Router, get, post},
12+
},
13+
utils::Extension,
14+
};
15+
16+
#[derive(Clone)]
17+
struct RedirectTarget {
18+
addr: SocketAddr,
19+
}
20+
21+
async fn redirect_relative() -> Redirect {
22+
Redirect::found("/final")
23+
}
24+
25+
async fn redirect_cross_host(Extension(target): Extension<RedirectTarget>) -> Redirect {
26+
Redirect::found(&format!("http://{}/landing", target.addr))
27+
}
28+
29+
async fn redirect_post_to_get() -> Redirect {
30+
Redirect::see_other("/method")
31+
}
32+
33+
async fn final_relative() -> &'static str {
34+
"followed relative redirect"
35+
}
36+
37+
async fn landing() -> &'static str {
38+
"followed cross-host redirect"
39+
}
40+
41+
async fn method() -> &'static str {
42+
"POST became GET after 303"
43+
}
44+
45+
async fn bind_local() -> Result<(SocketAddr, DefaultIncoming), BoxError> {
46+
let listener = TcpListener::bind("127.0.0.1:0").await?;
47+
let addr = listener.local_addr()?;
48+
Ok((addr, DefaultIncoming::from(listener)))
49+
}
50+
51+
#[volo::main]
52+
async fn main() -> Result<(), BoxError> {
53+
tracing_subscriber::fmt()
54+
.with_max_level(tracing::Level::INFO)
55+
.try_init()
56+
.ok();
57+
58+
let (target_addr, target_incoming) = bind_local().await?;
59+
let target_app = Router::new()
60+
.route("/landing", get(landing))
61+
.route("/method", get(method));
62+
let target_server = tokio::spawn(Server::new(target_app).run(target_incoming));
63+
64+
let (entry_addr, entry_incoming) = bind_local().await?;
65+
let entry_app = Router::new()
66+
.route("/relative", get(redirect_relative))
67+
.route("/cross-host", get(redirect_cross_host))
68+
.route("/post-to-get", post(redirect_post_to_get))
69+
.route("/method", get(method))
70+
.route("/final", get(final_relative))
71+
.layer(Extension(RedirectTarget { addr: target_addr }));
72+
let entry_server = tokio::spawn(Server::new(entry_app).run(entry_incoming));
73+
74+
let client = Client::builder().follow_redirects(10).build()?;
75+
76+
let relative = client
77+
.get(format!("http://{entry_addr}/relative"))
78+
.send()
79+
.await?
80+
.into_string()
81+
.await?;
82+
let cross_host = client
83+
.get(format!("http://{entry_addr}/cross-host"))
84+
.send()
85+
.await?
86+
.into_string()
87+
.await?;
88+
let post_to_get = client
89+
.post(format!("http://{entry_addr}/post-to-get"))
90+
.data("payload that will be dropped")
91+
.send()
92+
.await?
93+
.into_string()
94+
.await?;
95+
96+
assert_eq!(relative, "followed relative redirect");
97+
assert_eq!(cross_host, "followed cross-host redirect");
98+
assert_eq!(post_to_get, "POST became GET after 303");
99+
100+
println!("relative: {relative}");
101+
println!("cross-host: {cross_host}");
102+
println!("post-to-get: {post_to_get}");
103+
104+
entry_server.abort();
105+
target_server.abort();
106+
107+
Ok(())
108+
}

volo-http/src/body.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,25 @@ impl Body {
9393
repr: BodyRepr::Body(BoxBody::new(body.map_err(Into::into))),
9494
}
9595
}
96+
97+
/// Try to clone the body.
98+
///
99+
/// Only in-memory bodies (created from bytes, string, [`Body::empty`], etc.) can be cloned,
100+
/// and the clone is cheap since the underlying [`Bytes`] is reference-counted (a shallow
101+
/// clone, the payload is not copied).
102+
///
103+
/// Streaming bodies ([`Body::from_stream`], [`Body::from_incoming`], [`Body::from_body`]) are
104+
/// one-shot and cannot be replayed, so this method returns [`None`] for them. This is mainly
105+
/// used by redirect-following, where a request may need to be re-sent: a request with a
106+
/// non-cloneable body will not be followed across redirects.
107+
pub fn try_clone(&self) -> Option<Self> {
108+
match &self.repr {
109+
BodyRepr::Full(full) => Some(Self {
110+
repr: BodyRepr::Full(full.clone()),
111+
}),
112+
BodyRepr::Hyper(_) | BodyRepr::Stream(_) | BodyRepr::Body(_) => None,
113+
}
114+
}
96115
}
97116

98117
impl http_body::Body for Body {
@@ -402,4 +421,26 @@ mod tests {
402421
let body = Body::from(bytes);
403422
assert_eq!(body.into_string().await.unwrap(), "Hello, world!");
404423
}
424+
425+
#[tokio::test]
426+
async fn test_try_clone_in_memory() {
427+
// In-memory bodies can be cloned, and the clone carries the same content.
428+
let body = Body::from("hello");
429+
let cloned = body.try_clone().expect("full body should be cloneable");
430+
assert_eq!(cloned.into_string().await.unwrap(), "hello");
431+
assert_eq!(body.into_string().await.unwrap(), "hello");
432+
433+
// Empty body is also cloneable.
434+
assert!(Body::empty().try_clone().is_some());
435+
}
436+
437+
#[test]
438+
fn test_try_clone_streaming_is_none() {
439+
// Streaming bodies are one-shot and cannot be cloned.
440+
let stream = futures_util::stream::empty::<
441+
Result<http_body::Frame<Bytes>, crate::error::BoxError>,
442+
>();
443+
let body = Body::from_stream(stream);
444+
assert!(body.try_clone().is_none());
445+
}
405446
}

volo-http/src/client/layer/http_proxy.rs

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ use volo::{client::Apply, context::Context};
1515
use crate::{
1616
client::{Target, target::RemoteHost, utils::is_default_port},
1717
context::ClientContext,
18-
error::{ClientError, client::request_error},
18+
error::{
19+
ClientError,
20+
client::{Result, request_error},
21+
},
1922
request::Request,
2023
};
2124

@@ -132,34 +135,38 @@ pub struct HttpProxyService<S> {
132135
}
133136

134137
impl<S> HttpProxyService<S> {
135-
fn update_req<B>(&self, cx: &mut ClientContext, req: &mut Request<B>) {
138+
fn update_req<B>(&self, cx: &mut ClientContext, req: &mut Request<B>) -> Option<Target> {
136139
let Some(target) = &self.target else {
137-
return;
140+
return None;
138141
};
142+
143+
// A configured proxy is a routing requirement. Unsupported requests must not silently
144+
// fall back to a direct connection.
139145
if req.version() != Version::HTTP_11 {
140146
tracing::info!("[Volo-HTTP] HttpProxy only works for HTTP/1.1");
141-
return;
147+
return None;
142148
}
143149
if let Some(scheme) = cx.target().scheme() {
144150
if scheme != &Scheme::HTTP {
145151
tracing::info!("[Volo-HTTP] HttpProxy only supports HTTP protocol");
146-
return;
152+
return None;
147153
}
148154
}
149155

150-
// Generate authority by old target, and then update request
156+
// Generate authority from the logical upstream target, then rewrite the HTTP/1.1
157+
// request-target to absolute-form for the proxy.
151158
let Some(authority) = gen_authority(cx.target()) else {
152159
tracing::warn!(
153160
"[Volo-HTTP] HttpProxy: failed to gen authority by {:?}",
154161
cx.target()
155162
);
156-
return;
163+
return None;
157164
};
158165
let authority = match Authority::from_maybe_shared(Bytes::from(authority)) {
159166
Ok(authority) => authority,
160167
Err(e) => {
161168
tracing::warn!("[Volo-HTTP] HttpProxy: failed to parse authority: {e}");
162-
return;
169+
return None;
163170
}
164171
};
165172
let mut parts = req.uri().to_owned().into_parts();
@@ -174,19 +181,20 @@ impl<S> HttpProxyService<S> {
174181
Ok(uri) => uri,
175182
Err(e) => {
176183
tracing::warn!("[Volo-HTTP] HttpProxy: failed to build uri: {e}");
177-
return;
184+
return None;
178185
}
179186
};
180187
*req.uri_mut() = uri;
181188

182-
// Clear callee and update proxy target to it
183-
// Note: we must apply target after updating request because `target.apply(cx)` will update
184-
// self to `cx.target`
189+
// Only the transport target becomes the proxy. HttpProxy::call owns restoring the logical
190+
// upstream after the inner call returns.
185191
cx.rpc_info_mut().callee_mut().clear();
186-
target
192+
let old_target = target
187193
.to_owned()
188-
.apply(cx)
194+
.apply_and_replace(cx)
189195
.expect("infallible: failed to parse target in HttpProxy");
196+
197+
Some(old_target)
190198
}
191199
}
192200

@@ -227,6 +235,11 @@ fn gen_authority(target: &Target) -> Option<String> {
227235
Some(host)
228236
}
229237

238+
fn restore_target(cx: &mut ClientContext, target: Target) -> Result<()> {
239+
cx.rpc_info_mut().callee_mut().clear();
240+
target.apply(cx)
241+
}
242+
230243
impl<B, S> Service<ClientContext, Request<B>> for HttpProxyService<S>
231244
where
232245
B: Send,
@@ -240,8 +253,17 @@ where
240253
cx: &mut ClientContext,
241254
mut req: Request<B>,
242255
) -> Result<Self::Response, Self::Error> {
243-
self.update_req(cx, &mut req);
244-
match self.inner.call(cx, req).await {
256+
let old_target = self.update_req(cx, &mut req);
257+
let result = self.inner.call(cx, req).await;
258+
259+
// HttpProxy only borrows cx.target() as a transport target. Restore the logical upstream
260+
// before returning to FollowRedirect or any other outer layer. Do this for both success and
261+
// error responses.
262+
if let Some(target) = old_target {
263+
restore_target(cx, target)?;
264+
};
265+
266+
match result {
245267
Ok(resp) => Ok(resp),
246268
Err(e) => {
247269
if let Some(target) = &self.target {

volo-http/src/client/layer/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ mod fail_on_status;
66
pub mod header;
77
#[cfg(feature = "http1")]
88
pub mod http_proxy;
9+
mod redirect;
910
mod timeout;
10-
mod utils;
11+
pub(crate) mod utils;
1112

1213
pub use self::{
1314
fail_on_status::{FailOnStatus, StatusCodeError},
15+
redirect::{FollowRedirect, RedirectPredicate},
1416
timeout::Timeout,
1517
utils::TargetLayer,
1618
};

0 commit comments

Comments
 (0)