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 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 type SharedBackend = Arc<dyn HttpBackend>;
#[cfg(test)]
mod tests {
use super::*;
use crate::error::ErrorKind;
#[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}"
);
}
}