use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use futures_util::future::BoxFuture;
use crate::error::{Error, WireKind};
#[derive(Clone)]
pub struct HttpRequest {
pub method: &'static str,
pub path: String,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
pub timeout: Option<Duration>,
}
impl HttpRequest {
pub fn new(method: &'static str, path: impl Into<String>) -> Self {
Self {
method,
path: path.into(),
headers: Vec::new(),
body: Vec::new(),
timeout: None,
}
}
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
}
impl fmt::Debug for HttpRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let names: Vec<&str> = self.headers.iter().map(|(name, _)| name.as_str()).collect();
f.debug_struct("HttpRequest")
.field("method", &self.method)
.field("path", &self.path)
.field("headers", &names)
.field("body_len", &self.body.len())
.field("timeout", &self.timeout)
.finish()
}
}
#[derive(Clone, Debug)]
pub struct HttpResponse {
pub status: u16,
pub headers: HashMap<String, String>,
pub body: Vec<u8>,
}
impl HttpResponse {
pub fn error_for_status(&self, method: &str, path: &str) -> Result<(), Error> {
if (200..300).contains(&self.status) {
return Ok(());
}
let detail = String::from_utf8_lossy(&self.body[..self.body.len().min(512)])
.trim()
.to_string();
let message = if detail.is_empty() {
format!("{method} {path} -> {}", self.status)
} else {
format!("{method} {path} -> {}: {detail}", self.status)
};
match crate::error::WireKind::from_status(self.status) {
Some(wire) => Err(Error::wire(wire, message)),
None => Err(Error::new(crate::error::ErrorKind::Protocol, message)),
}
}
}
pub trait ChunkSource: Send {
fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>>;
}
pub type OpenStream = (HttpResponse, Box<dyn ChunkSource>);
pub trait HttpBackend: Send + Sync {
fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, Error>>;
fn open_stream(
&self,
request: HttpRequest,
idle_timeout: Duration,
) -> BoxFuture<'_, Result<OpenStream, Error>>;
}
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(
crate::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 !(200..300).contains(&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>))
})
}
}
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()
}
}
pub type SharedBackend = Arc<dyn HttpBackend>;
#[cfg(test)]
mod tests {
use super::*;
use crate::error::ErrorKind;
#[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 a_response_status_becomes_the_wire_kind_the_daemon_meant() {
let response = |status: u16| HttpResponse {
status,
headers: HashMap::new(),
body: b"{\"error\":\"unknown_exec\",\"detail\":\"e1\"}".to_vec(),
};
assert!(response(200).error_for_status("GET", "/v1/health").is_ok());
let err = response(404)
.error_for_status("GET", "/v1/exec/e1")
.expect_err("404 is an error");
assert_eq!(err.wire_kind(), Some(WireKind::NotFound));
assert!(
err.to_string().contains("unknown_exec"),
"the daemon's detail must survive into the message: {err}"
);
let err = response(429)
.error_for_status("GET", "/v1/health")
.expect_err("an unmapped status is still an error");
assert_eq!(err.kind(), ErrorKind::Protocol);
assert_eq!(
err.wire_kind(),
None,
"a status the daemon never chooses must not be given an invented meaning"
);
}
#[test]
fn an_enormous_error_body_is_capped_in_the_message() {
let response = HttpResponse {
status: 500,
headers: HashMap::new(),
body: vec![b'x'; 100_000],
};
let err = response
.error_for_status("PUT", "/v1/fs/file")
.expect_err("500 is an error");
assert!(err.to_string().len() < 700, "{}", err.to_string().len());
}
#[test]
fn a_request_header_is_found_whatever_case_it_was_set_in() {
let mut request = HttpRequest::new("GET", "/v1/health");
request
.headers
.push(("X-Aws-Proxy-Port".into(), "9000".into()));
assert_eq!(request.header("x-aws-proxy-port"), Some("9000"));
assert_eq!(request.header("authorization"), None);
}
#[test]
fn a_request_debug_prints_header_names_and_never_their_values() {
let mut request = HttpRequest::new("POST", "/v1/exec/e1/stdin");
request.headers.push((
"authorization".into(),
"Bearer super-secret-agent-token".into(),
));
request
.headers
.push(("X-aws-proxy-auth".into(), "eyJhbGciOi-secret-jwe".into()));
request.body = b"secret-body-bytes".to_vec();
request.timeout = Some(Duration::from_secs(7));
let rendered = format!("{request:?}");
assert!(rendered.contains("authorization"), "{rendered}");
assert!(rendered.contains("X-aws-proxy-auth"), "{rendered}");
assert!(rendered.contains("/v1/exec/e1/stdin"), "{rendered}");
assert!(rendered.contains("POST"), "{rendered}");
assert!(rendered.contains('7'), "the timeout survives: {rendered}");
assert!(
rendered.contains("body_len"),
"the body's size is diagnostic: {rendered}"
);
assert!(
!rendered.contains("super-secret-agent-token"),
"the agent token reached a Debug string: {rendered}"
);
assert!(
!rendered.contains("eyJhbGciOi-secret-jwe"),
"the minted proxy token reached a Debug string: {rendered}"
);
assert!(
!rendered.contains("secret-body-bytes"),
"the body reached a Debug string: {rendered}"
);
}
}