1use std::collections::HashMap;
8use std::fmt;
9use std::time::Duration;
10
11use futures_util::future::BoxFuture;
12
13use microvms_app::error::{Error, WireKind};
14use microvms_app::session::http::{
15 ChunkSource, HttpBackend, HttpRequest, HttpResponse, OpenStream,
16};
17
18pub struct ReqwestBackend {
24 client: reqwest::Client,
25 base_url: String,
26 timeout: Duration,
27}
28
29impl ReqwestBackend {
30 pub fn new(base_url: &str, timeout: Duration) -> Result<Self, Error> {
36 let client = reqwest::Client::builder()
37 .timeout(timeout)
38 .build()
39 .map_err(|err| {
40 Error::new(
41 microvms_app::error::ErrorKind::Unexpected,
42 format!("could not build an HTTP client: {err}"),
43 )
44 .with_source(err)
45 })?;
46 Ok(Self {
47 client,
48 base_url: normalize_base_url(base_url),
49 timeout,
50 })
51 }
52
53 fn url(&self, path: &str) -> String {
54 format!("{}{path}", self.base_url)
55 }
56
57 fn build(&self, request: &HttpRequest) -> reqwest::RequestBuilder {
58 let mut builder = self
59 .client
60 .request(
61 reqwest::Method::from_bytes(request.method.as_bytes())
62 .unwrap_or(reqwest::Method::GET),
63 self.url(&request.path),
64 )
65 .timeout(request.timeout.unwrap_or(self.timeout));
66 for (name, value) in &request.headers {
67 builder = builder.header(name, value);
68 }
69 if !request.body.is_empty() {
70 builder = builder.body(request.body.clone());
71 }
72 builder
73 }
74}
75
76fn normalize_base_url(base_url: &str) -> String {
79 let with_scheme = if base_url.starts_with("http://") || base_url.starts_with("https://") {
80 base_url.to_string()
81 } else {
82 format!("https://{base_url}")
83 };
84 with_scheme.trim_end_matches('/').to_string()
85}
86
87fn transport_error(method: &str, path: &str, err: reqwest::Error) -> Error {
89 Error::wire(
90 WireKind::Transport,
91 format!("{method} {path} failed on the wire: {err}"),
92 )
93 .with_source(err)
94}
95
96fn collect_headers(response: &reqwest::Response) -> HashMap<String, String> {
97 response
98 .headers()
99 .iter()
100 .map(|(name, value)| {
101 (
102 name.as_str().to_string(),
103 String::from_utf8_lossy(value.as_bytes()).into_owned(),
104 )
105 })
106 .collect()
107}
108
109impl HttpBackend for ReqwestBackend {
110 fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, Error>> {
111 Box::pin(async move {
112 let response = self
113 .build(&request)
114 .send()
115 .await
116 .map_err(|err| transport_error(request.method, &request.path, err))?;
117 let status = response.status().as_u16();
118 let headers = collect_headers(&response);
119 let body = response
120 .bytes()
121 .await
122 .map_err(|err| transport_error(request.method, &request.path, err))?
123 .to_vec();
124 Ok(HttpResponse {
125 status,
126 headers,
127 body,
128 })
129 })
130 }
131
132 fn open_stream(
133 &self,
134 request: HttpRequest,
135 idle_timeout: Duration,
136 ) -> BoxFuture<'_, Result<OpenStream, Error>> {
137 Box::pin(async move {
138 let response = self
139 .build(&request)
140 .timeout(Duration::MAX)
144 .send()
145 .await
146 .map_err(|err| transport_error(request.method, &request.path, err))?;
147 let status = response.status().as_u16();
148 let headers = collect_headers(&response);
149
150 if is_failure(status) {
153 let body = response
154 .bytes()
155 .await
156 .map_err(|err| transport_error(request.method, &request.path, err))?
157 .to_vec();
158 return Ok((
159 HttpResponse {
160 status,
161 headers,
162 body,
163 },
164 Box::new(EmptyChunks) as Box<dyn ChunkSource>,
165 ));
166 }
167
168 let head = HttpResponse {
169 status,
170 headers,
171 body: Vec::new(),
172 };
173 let chunks = ReqwestChunks {
174 response: Some(response),
175 idle_timeout,
176 method: request.method,
177 path: request.path,
178 };
179 Ok((head, Box::new(chunks) as Box<dyn ChunkSource>))
180 })
181 }
182}
183
184fn is_failure(status: u16) -> bool {
190 !(200..300).contains(&status)
191}
192
193struct EmptyChunks;
195
196impl ChunkSource for EmptyChunks {
197 fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
198 Box::pin(async { Ok(None) })
199 }
200}
201
202struct ReqwestChunks {
203 response: Option<reqwest::Response>,
204 idle_timeout: Duration,
205 method: &'static str,
206 path: String,
207}
208
209impl ChunkSource for ReqwestChunks {
210 fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
211 Box::pin(async move {
212 let Some(response) = self.response.as_mut() else {
213 return Ok(None);
214 };
215 match tokio::time::timeout(self.idle_timeout, response.chunk()).await {
216 Ok(Ok(Some(bytes))) => Ok(Some(bytes.to_vec())),
217 Ok(Ok(None)) => {
218 self.response = None;
219 Ok(None)
220 }
221 Ok(Err(err)) => {
222 self.response = None;
223 Err(transport_error(self.method, &self.path, err))
224 }
225 Err(_) => {
226 self.response = None;
227 Err(Error::wire(
230 WireKind::Transport,
231 format!(
232 "{} {} went silent for {}s, longer than the keepalive \
233 interval, so the connection is treated as dead",
234 self.method,
235 self.path,
236 self.idle_timeout.as_secs()
237 ),
238 ))
239 }
240 }
241 })
242 }
243}
244
245impl fmt::Debug for ReqwestBackend {
246 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247 f.debug_struct("ReqwestBackend")
248 .field("base_url", &self.base_url)
249 .field("timeout", &self.timeout)
250 .finish()
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[test]
261 fn a_bare_endpoint_host_is_read_as_https() {
262 assert_eq!(
263 normalize_base_url("vm-abc.microvms.aws"),
264 "https://vm-abc.microvms.aws"
265 );
266 assert_eq!(
267 normalize_base_url("http://127.0.0.1:9000"),
268 "http://127.0.0.1:9000",
269 "an explicit scheme is honoured, so a local daemon is still reachable"
270 );
271 assert_eq!(
272 normalize_base_url("https://host/"),
273 "https://host",
274 "a trailing slash would produce a double one on every path"
275 );
276 }
277
278 #[test]
281 fn only_a_success_status_opens_a_stream() {
282 for status in [200, 204, 299] {
283 assert!(!is_failure(status), "{status}");
284 }
285 for status in [101, 199, 300, 404, 500] {
286 assert!(is_failure(status), "{status}");
287 }
288 }
289}