use std::fmt;
use bytes::Bytes;
use http::{HeaderMap, StatusCode};
use polyoxide_rt::BoxFuture;
use serde::de::DeserializeOwned;
use url::Url;
use crate::error::ApiError;
use crate::transport::TransportError;
pub struct Response {
status: StatusCode,
headers: HeaderMap,
url: Url,
body: BoxFuture<Result<Bytes, TransportError>>,
}
impl Response {
pub fn new(
status: StatusCode,
headers: HeaderMap,
url: Url,
body: BoxFuture<Result<Bytes, TransportError>>,
) -> Self {
Self {
status,
headers,
url,
body,
}
}
pub fn status(&self) -> StatusCode {
self.status
}
pub fn headers(&self) -> &HeaderMap {
&self.headers
}
pub fn url(&self) -> &Url {
&self.url
}
pub async fn bytes(self) -> Result<Bytes, ApiError> {
self.body.await.map_err(ApiError::from)
}
pub async fn text(self) -> Result<String, ApiError> {
let bytes = self.bytes().await?;
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
pub async fn json<T: DeserializeOwned>(self) -> Result<T, ApiError> {
let bytes = self.bytes().await?;
serde_json::from_slice(&bytes).map_err(ApiError::from)
}
}
impl fmt::Debug for Response {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let header_names: Vec<&str> = self.headers.keys().map(|name| name.as_str()).collect();
f.debug_struct("Response")
.field("status", &self.status)
.field("url", &self.url.as_str())
.field("headers", &header_names)
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use http::HeaderValue;
use polyoxide_venue::{Class, Classify};
use super::*;
use crate::transport::TransportKind;
fn url() -> Url {
Url::parse("https://api.example.test/v1/rows").unwrap()
}
fn answering(body: &'static [u8]) -> Response {
let mut headers = HeaderMap::new();
headers.insert("x-served-by", HeaderValue::from_static("test"));
Response::new(
StatusCode::OK,
headers,
url(),
Box::pin(async move { Ok(Bytes::from_static(body)) }),
)
}
fn cut_off() -> Response {
Response::new(
StatusCode::OK,
HeaderMap::new(),
url(),
Box::pin(async {
Err::<Bytes, _>(TransportError::new(TransportKind::Body, "cut off"))
}),
)
}
#[test]
fn the_head_reads_without_the_body() {
let response = answering(b"{}");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.url(), &url());
assert_eq!(
response
.headers()
.get("x-served-by")
.and_then(|value| value.to_str().ok()),
Some("test")
);
}
#[tokio::test]
async fn bytes_text_and_json_read_the_same_body() {
const BODY: &[u8] = br#"{"rows":[1,2]}"#;
assert_eq!(
answering(BODY).bytes().await.unwrap(),
Bytes::from_static(BODY)
);
assert_eq!(answering(BODY).text().await.unwrap(), r#"{"rows":[1,2]}"#);
assert_eq!(
answering(BODY).json::<serde_json::Value>().await.unwrap(),
serde_json::json!({"rows": [1, 2]})
);
}
#[tokio::test]
async fn text_replaces_invalid_utf8() {
assert_eq!(answering(b"h\xFFi").text().await.unwrap(), "h\u{FFFD}i");
}
#[tokio::test]
async fn a_body_that_is_not_json_is_a_serialization_error_of_the_decode_class() {
let err = answering(b"not json")
.json::<serde_json::Value>()
.await
.unwrap_err();
assert!(matches!(err, ApiError::Serialization(_)), "{err:?}");
assert_eq!(err.class(), Class::Decode);
}
#[tokio::test]
async fn a_body_that_fails_to_arrive_is_the_transport_error_whichever_way_it_is_read() {
for err in [
cut_off().bytes().await.unwrap_err(),
cut_off().text().await.unwrap_err(),
cut_off().json::<serde_json::Value>().await.unwrap_err(),
] {
assert!(
matches!(&err, ApiError::Transport(e) if e.kind() == TransportKind::Body),
"{err:?}"
);
assert_eq!(err.class(), Class::Network);
}
}
#[test]
fn debug_names_the_headers_and_never_their_values() {
let mut headers = HeaderMap::new();
headers.insert("authorization", HeaderValue::from_static("Bearer s3cr3t"));
let response = Response::new(
StatusCode::OK,
headers,
url(),
Box::pin(async { Ok(Bytes::new()) }),
);
let debug = format!("{response:?}");
assert!(debug.contains("authorization"), "{debug}");
assert!(!debug.contains("s3cr3t"), "{debug}");
assert!(debug.contains("200"), "{debug}");
}
}