use std::collections::HashMap;
use std::fmt;
use std::time::Duration;
use futures_util::future::BoxFuture;
use microvms_app::error::{Error, WireKind};
use microvms_app::session::http::{
ChunkSource, HttpBackend, HttpRequest, HttpResponse, OpenStream,
};
pub struct ReqwestBackend {
client: reqwest::Client,
base_url: String,
timeout: Duration,
}
impl ReqwestBackend {
pub fn new(base_url: &str, timeout: Duration) -> Result<Self, Error> {
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.map_err(|err| {
Error::new(
microvms_app::error::ErrorKind::Unexpected,
format!("could not build an HTTP client: {err}"),
)
.with_source(err)
})?;
Ok(Self {
client,
base_url: normalize_base_url(base_url),
timeout,
})
}
fn url(&self, path: &str) -> String {
format!("{}{path}", self.base_url)
}
fn build(&self, request: &HttpRequest) -> reqwest::RequestBuilder {
let mut builder = self
.client
.request(
reqwest::Method::from_bytes(request.method.as_bytes())
.unwrap_or(reqwest::Method::GET),
self.url(&request.path),
)
.timeout(request.timeout.unwrap_or(self.timeout));
for (name, value) in &request.headers {
builder = builder.header(name, value);
}
if !request.body.is_empty() {
builder = builder.body(request.body.clone());
}
builder
}
}
fn normalize_base_url(base_url: &str) -> String {
let with_scheme = if base_url.starts_with("http://") || base_url.starts_with("https://") {
base_url.to_string()
} else {
format!("https://{base_url}")
};
with_scheme.trim_end_matches('/').to_string()
}
fn transport_error(method: &str, path: &str, err: reqwest::Error) -> Error {
Error::wire(
WireKind::Transport,
format!("{method} {path} failed on the wire: {err}"),
)
.with_source(err)
}
fn collect_headers(response: &reqwest::Response) -> HashMap<String, String> {
response
.headers()
.iter()
.map(|(name, value)| {
(
name.as_str().to_string(),
String::from_utf8_lossy(value.as_bytes()).into_owned(),
)
})
.collect()
}
impl HttpBackend for ReqwestBackend {
fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, Error>> {
Box::pin(async move {
let response = self
.build(&request)
.send()
.await
.map_err(|err| transport_error(request.method, &request.path, err))?;
let status = response.status().as_u16();
let headers = collect_headers(&response);
let body = response
.bytes()
.await
.map_err(|err| transport_error(request.method, &request.path, err))?
.to_vec();
Ok(HttpResponse {
status,
headers,
body,
})
})
}
fn open_stream(
&self,
request: HttpRequest,
idle_timeout: Duration,
) -> BoxFuture<'_, Result<OpenStream, Error>> {
Box::pin(async move {
let response = self
.build(&request)
.timeout(Duration::MAX)
.send()
.await
.map_err(|err| transport_error(request.method, &request.path, err))?;
let status = response.status().as_u16();
let headers = collect_headers(&response);
if is_failure(status) {
let body = response
.bytes()
.await
.map_err(|err| transport_error(request.method, &request.path, err))?
.to_vec();
return Ok((
HttpResponse {
status,
headers,
body,
},
Box::new(EmptyChunks) as Box<dyn ChunkSource>,
));
}
let head = HttpResponse {
status,
headers,
body: Vec::new(),
};
let chunks = ReqwestChunks {
response: Some(response),
idle_timeout,
method: request.method,
path: request.path,
};
Ok((head, Box::new(chunks) as Box<dyn ChunkSource>))
})
}
}
fn is_failure(status: u16) -> bool {
!(200..300).contains(&status)
}
struct EmptyChunks;
impl ChunkSource for EmptyChunks {
fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
Box::pin(async { Ok(None) })
}
}
struct ReqwestChunks {
response: Option<reqwest::Response>,
idle_timeout: Duration,
method: &'static str,
path: String,
}
impl ChunkSource for ReqwestChunks {
fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
Box::pin(async move {
let Some(response) = self.response.as_mut() else {
return Ok(None);
};
match tokio::time::timeout(self.idle_timeout, response.chunk()).await {
Ok(Ok(Some(bytes))) => Ok(Some(bytes.to_vec())),
Ok(Ok(None)) => {
self.response = None;
Ok(None)
}
Ok(Err(err)) => {
self.response = None;
Err(transport_error(self.method, &self.path, err))
}
Err(_) => {
self.response = None;
Err(Error::wire(
WireKind::Transport,
format!(
"{} {} went silent for {}s, longer than the keepalive \
interval, so the connection is treated as dead",
self.method,
self.path,
self.idle_timeout.as_secs()
),
))
}
}
})
}
}
impl fmt::Debug for ReqwestBackend {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ReqwestBackend")
.field("base_url", &self.base_url)
.field("timeout", &self.timeout)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bare_endpoint_host_is_read_as_https() {
assert_eq!(
normalize_base_url("vm-abc.microvms.aws"),
"https://vm-abc.microvms.aws"
);
assert_eq!(
normalize_base_url("http://127.0.0.1:9000"),
"http://127.0.0.1:9000",
"an explicit scheme is honoured, so a local daemon is still reachable"
);
assert_eq!(
normalize_base_url("https://host/"),
"https://host",
"a trailing slash would produce a double one on every path"
);
}
#[test]
fn only_a_success_status_opens_a_stream() {
for status in [200, 204, 299] {
assert!(!is_failure(status), "{status}");
}
for status in [101, 199, 300, 404, 500] {
assert!(is_failure(status), "{status}");
}
}
}