use std::borrow::Cow;
use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct HttpRequest {
pub method: String,
pub url: String,
pub headers: Vec<(String, String)>,
pub body: Option<Vec<u8>>,
pub timeout: Option<Duration>,
}
impl HttpRequest {
#[must_use]
pub fn header(&self, name: &str) -> Option<&str> {
header(&self.headers, name)
}
#[must_use]
pub fn text(&self) -> Cow<'_, str> {
match &self.body {
Some(body) => String::from_utf8_lossy(body),
None => Cow::Borrowed(""),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct HttpResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
impl HttpResponse {
#[must_use]
pub fn json(status: u16, body: &serde_json::Value) -> Self {
Self {
status,
headers: vec![("content-type".to_owned(), "application/json".to_owned())],
body: body.to_string().into_bytes(),
}
}
#[must_use]
pub fn header(&self, name: &str) -> Option<&str> {
header(&self.headers, name)
}
#[must_use]
pub fn text(&self) -> Cow<'_, str> {
String::from_utf8_lossy(&self.body)
}
#[must_use]
pub fn body_as_json(&self) -> Option<serde_json::Value> {
serde_json::from_slice(&self.body).ok()
}
}
fn header<'h>(headers: &'h [(String, String)], name: &str) -> Option<&'h str> {
headers
.iter()
.find(|(header, _)| header.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct ClientError(pub String);
impl ClientError {
pub fn new(error: impl std::fmt::Display) -> Self {
Self(error.to_string())
}
}
pub trait HttpClient {
fn send(&mut self, request: &HttpRequest) -> Result<HttpResponse, ClientError>;
}
pub type SendFuture<'a> =
Pin<Box<dyn Future<Output = Result<HttpResponse, ClientError>> + Send + 'a>>;
pub type SleepFuture<'a> = Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
pub trait AsyncHttpClient {
fn send<'a>(&'a mut self, request: &'a HttpRequest) -> SendFuture<'a>;
fn sleep(&self, duration: Duration) -> SleepFuture<'_>;
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_header_is_found_whatever_case_it_was_written_in() {
let request = HttpRequest {
headers: vec![("Content-Type".to_owned(), "application/json".to_owned())],
..HttpRequest::default()
};
assert_eq!(request.header("content-type"), Some("application/json"));
assert_eq!(request.header("CONTENT-TYPE"), Some("application/json"));
assert_eq!(request.header("accept"), None);
}
#[test]
fn a_json_response_carries_its_body_both_ways() {
let response = HttpResponse::json(201, &json!({ "id": 7 }));
assert_eq!(response.status, 201);
assert_eq!(response.header("Content-Type"), Some("application/json"));
assert_eq!(response.body_as_json(), Some(json!({ "id": 7 })));
assert_eq!(response.text(), r#"{"id":7}"#);
}
#[test]
fn a_body_that_is_not_json_is_still_text() {
let response = HttpResponse {
status: 200,
headers: Vec::new(),
body: b"not json".to_vec(),
};
assert_eq!(response.body_as_json(), None);
assert_eq!(response.text(), "not json");
}
#[test]
fn a_request_without_a_body_reads_as_empty() {
assert_eq!(HttpRequest::default().text(), "");
}
#[test]
fn a_client_error_says_what_it_was_told() {
let error = ClientError::new(std::io::Error::other("connection reset"));
assert_eq!(error.to_string(), "connection reset");
}
}