Skip to main content

polyoxide_core/
response.rs

1//! The response an [`HttpTransport`](crate::HttpTransport) hands the send
2//! loop: an owned head and a lazy body.
3
4use std::fmt;
5
6use bytes::Bytes;
7use http::{HeaderMap, StatusCode};
8use polyoxide_rt::BoxFuture;
9use serde::de::DeserializeOwned;
10use url::Url;
11
12use crate::error::ApiError;
13use crate::transport::TransportError;
14
15/// A response, as a transport answers one: the status, headers and URL are
16/// here, and the body is read on demand.
17///
18/// The send loop decides on the head, releases the attempt's permit, and only
19/// then reads an error's body, so a slow body holds no slot. Reading consumes
20/// the response: [`bytes`](Self::bytes), [`text`](Self::text) and
21/// [`json`](Self::json) each return an [`ApiError`], a transport failure
22/// ([`ApiError::Transport`]) or, for the last, a body that does not parse
23/// ([`ApiError::Serialization`]).
24pub struct Response {
25    status: StatusCode,
26    headers: HeaderMap,
27    url: Url,
28    body: BoxFuture<Result<Bytes, TransportError>>,
29}
30
31impl Response {
32    /// A response with its head and a future that reads its body.
33    ///
34    /// `url` is the URL the response answers, after any redirect the
35    /// transport followed.
36    pub fn new(
37        status: StatusCode,
38        headers: HeaderMap,
39        url: Url,
40        body: BoxFuture<Result<Bytes, TransportError>>,
41    ) -> Self {
42        Self {
43            status,
44            headers,
45            url,
46            body,
47        }
48    }
49
50    /// The status.
51    pub fn status(&self) -> StatusCode {
52        self.status
53    }
54
55    /// The headers.
56    pub fn headers(&self) -> &HeaderMap {
57        &self.headers
58    }
59
60    /// The URL the response answers.
61    pub fn url(&self) -> &Url {
62        &self.url
63    }
64
65    /// Read the body whole.
66    ///
67    /// # Errors
68    ///
69    /// [`ApiError::Transport`] when the body could not be read.
70    pub async fn bytes(self) -> Result<Bytes, ApiError> {
71        self.body.await.map_err(ApiError::from)
72    }
73
74    /// Read the body as text: UTF-8, with each invalid sequence replaced by
75    /// `U+FFFD`.
76    ///
77    /// # Errors
78    ///
79    /// [`ApiError::Transport`] when the body could not be read.
80    pub async fn text(self) -> Result<String, ApiError> {
81        let bytes = self.bytes().await?;
82        Ok(String::from_utf8_lossy(&bytes).into_owned())
83    }
84
85    /// Read the body and parse it as JSON.
86    ///
87    /// # Errors
88    ///
89    /// [`ApiError::Transport`] when the body could not be read, and
90    /// [`ApiError::Serialization`] when it does not parse as `T`.
91    pub async fn json<T: DeserializeOwned>(self) -> Result<T, ApiError> {
92        let bytes = self.bytes().await?;
93        serde_json::from_slice(&bytes).map_err(ApiError::from)
94    }
95}
96
97/// Written by hand: the body is a future, which prints nothing useful, and a
98/// header's value (a token, a cookie) must never reach a log line.
99impl fmt::Debug for Response {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        let header_names: Vec<&str> = self.headers.keys().map(|name| name.as_str()).collect();
102        f.debug_struct("Response")
103            .field("status", &self.status)
104            .field("url", &self.url.as_str())
105            .field("headers", &header_names)
106            .finish_non_exhaustive()
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use http::HeaderValue;
113    use polyoxide_venue::{Class, Classify};
114
115    use super::*;
116    use crate::transport::TransportKind;
117
118    fn url() -> Url {
119        Url::parse("https://api.example.test/v1/rows").unwrap()
120    }
121
122    /// A 200 carrying `body`, with one header.
123    fn answering(body: &'static [u8]) -> Response {
124        let mut headers = HeaderMap::new();
125        headers.insert("x-served-by", HeaderValue::from_static("test"));
126        Response::new(
127            StatusCode::OK,
128            headers,
129            url(),
130            Box::pin(async move { Ok(Bytes::from_static(body)) }),
131        )
132    }
133
134    /// A 200 whose body never arrives.
135    fn cut_off() -> Response {
136        Response::new(
137            StatusCode::OK,
138            HeaderMap::new(),
139            url(),
140            Box::pin(async {
141                Err::<Bytes, _>(TransportError::new(TransportKind::Body, "cut off"))
142            }),
143        )
144    }
145
146    #[test]
147    fn the_head_reads_without_the_body() {
148        let response = answering(b"{}");
149        assert_eq!(response.status(), StatusCode::OK);
150        assert_eq!(response.url(), &url());
151        assert_eq!(
152            response
153                .headers()
154                .get("x-served-by")
155                .and_then(|value| value.to_str().ok()),
156            Some("test")
157        );
158    }
159
160    #[tokio::test]
161    async fn bytes_text_and_json_read_the_same_body() {
162        const BODY: &[u8] = br#"{"rows":[1,2]}"#;
163        assert_eq!(
164            answering(BODY).bytes().await.unwrap(),
165            Bytes::from_static(BODY)
166        );
167        assert_eq!(answering(BODY).text().await.unwrap(), r#"{"rows":[1,2]}"#);
168        assert_eq!(
169            answering(BODY).json::<serde_json::Value>().await.unwrap(),
170            serde_json::json!({"rows": [1, 2]})
171        );
172    }
173
174    #[tokio::test]
175    async fn text_replaces_invalid_utf8() {
176        assert_eq!(answering(b"h\xFFi").text().await.unwrap(), "h\u{FFFD}i");
177    }
178
179    #[tokio::test]
180    async fn a_body_that_is_not_json_is_a_serialization_error_of_the_decode_class() {
181        let err = answering(b"not json")
182            .json::<serde_json::Value>()
183            .await
184            .unwrap_err();
185        assert!(matches!(err, ApiError::Serialization(_)), "{err:?}");
186        assert_eq!(err.class(), Class::Decode);
187    }
188
189    #[tokio::test]
190    async fn a_body_that_fails_to_arrive_is_the_transport_error_whichever_way_it_is_read() {
191        for err in [
192            cut_off().bytes().await.unwrap_err(),
193            cut_off().text().await.unwrap_err(),
194            cut_off().json::<serde_json::Value>().await.unwrap_err(),
195        ] {
196            assert!(
197                matches!(&err, ApiError::Transport(e) if e.kind() == TransportKind::Body),
198                "{err:?}"
199            );
200            assert_eq!(err.class(), Class::Network);
201        }
202    }
203
204    #[test]
205    fn debug_names_the_headers_and_never_their_values() {
206        let mut headers = HeaderMap::new();
207        headers.insert("authorization", HeaderValue::from_static("Bearer s3cr3t"));
208        let response = Response::new(
209            StatusCode::OK,
210            headers,
211            url(),
212            Box::pin(async { Ok(Bytes::new()) }),
213        );
214        let debug = format!("{response:?}");
215        assert!(debug.contains("authorization"), "{debug}");
216        assert!(!debug.contains("s3cr3t"), "{debug}");
217        assert!(debug.contains("200"), "{debug}");
218    }
219}