Skip to main content

better_fetch/
response.rs

1//! HTTP response wrapper with a **fully buffered** body.
2//!
3//! For incremental reads, use [`RequestBuilder::send_stream`](crate::RequestBuilder::send_stream)
4//! and the [`streaming`](crate::streaming) module. Prefer [`Response::into_json`] and
5//! [`Response::into_text`] on hot paths; async methods are aliases without extra I/O.
6
7use bytes::Bytes;
8use http::{HeaderMap, StatusCode};
9
10use http::header::CONTENT_TYPE;
11
12use crate::error::Error;
13use crate::Result;
14
15/// Parsed response body selected from `Content-Type`.
16#[derive(Debug, Clone)]
17pub enum ResponseBodyKind {
18    /// No body bytes.
19    Empty,
20    /// `text/*` or unknown UTF-8 text.
21    Text(String),
22    /// `application/json` (feature `json`).
23    #[cfg(feature = "json")]
24    Json(serde_json::Value),
25    /// Binary or non-JSON types.
26    Bytes(Bytes),
27}
28
29/// HTTP response wrapper.
30///
31/// The full body is already buffered in memory as [`Bytes`] when you receive this type.
32/// Methods named `into_*` perform parsing synchronously; the `async` counterparts
33/// (`text`, `json`, …) delegate to `into_*` without additional I/O and exist for
34/// ergonomics in async code (e.g. [`RequestBuilder::send_json`](crate::request::RequestBuilder::send_json)).
35/// Prefer `into_json`, `into_text`, and `into_bytes_checked` on hot paths.
36///
37/// This model suits typical JSON APIs. For large or chunked bodies, use
38/// [`StreamingResponse`](crate::StreamingResponse) via [`send_stream`](crate::RequestBuilder::send_stream).
39#[derive(Clone)]
40pub struct Response {
41    status: StatusCode,
42    headers: HeaderMap,
43    body: Bytes,
44    url: Option<url::Url>,
45    #[cfg(feature = "json")]
46    json_parser: Option<crate::json_parser::JsonParserFn>,
47}
48
49impl Response {
50    pub(crate) fn new(
51        status: StatusCode,
52        headers: HeaderMap,
53        body: Bytes,
54        url: Option<url::Url>,
55        #[cfg(feature = "json")] json_parser: Option<crate::json_parser::JsonParserFn>,
56    ) -> Self {
57        Self {
58            status,
59            headers,
60            body,
61            url,
62            #[cfg(feature = "json")]
63            json_parser,
64        }
65    }
66
67    /// HTTP status code.
68    pub fn status(&self) -> StatusCode {
69        self.status
70    }
71
72    /// Response headers.
73    pub fn headers(&self) -> &HeaderMap {
74        &self.headers
75    }
76
77    /// Returns a reference to the fully buffered response body.
78    pub fn bytes(&self) -> &Bytes {
79        &self.body
80    }
81
82    /// Final request URL when available.
83    pub fn url(&self) -> Option<&url::Url> {
84        self.url.as_ref()
85    }
86
87    /// Returns `true` for 2xx status codes.
88    pub fn is_success(&self) -> bool {
89        self.status.is_success()
90    }
91
92    /// Returns an error if the status is not success.
93    #[must_use = "call `?` or handle the error explicitly"]
94    pub fn error_for_status(&self) -> Result<()> {
95        if self.status.is_success() {
96            return Ok(());
97        }
98        Err(Error::http_with_status_text(
99            self.status,
100            self.status.canonical_reason().unwrap_or("request failed"),
101            self.status.canonical_reason().unwrap_or("request failed"),
102            Some(self.body.clone()),
103        ))
104    }
105
106    /// Reads the body as UTF-8 after checking for a success status.
107    ///
108    /// Prefer this over [`text`](Self::text) when you do not need an `.await` (no extra I/O).
109    pub fn into_text(self) -> Result<String> {
110        self.error_for_status()?;
111        Ok(String::from_utf8_lossy(&self.body).into_owned())
112    }
113
114    /// Async alias for [`into_text`](Self::into_text); does not perform additional I/O.
115    pub async fn text(self) -> Result<String> {
116        self.into_text()
117    }
118
119    /// Returns the body after checking for a success status.
120    ///
121    /// Prefer this over [`bytes_checked`](Self::bytes_checked) on hot paths.
122    pub fn into_bytes_checked(self) -> Result<Bytes> {
123        self.error_for_status()?;
124        Ok(self.body)
125    }
126
127    /// Async alias for [`into_bytes_checked`](Self::into_bytes_checked).
128    pub async fn bytes_checked(self) -> Result<Bytes> {
129        self.into_bytes_checked()
130    }
131
132    /// Deserializes JSON after checking for a success status, using the client or request
133    /// [`JsonParserFn`](crate::json_parser::JsonParserFn) when configured.
134    ///
135    /// Prefer this over [`json`](Self::json) on hot paths. See [`crate::json_parser`] for the
136    /// default single-step path vs a custom two-step parser.
137    #[cfg(feature = "json")]
138    pub fn into_json<T: serde::de::DeserializeOwned>(self) -> Result<T> {
139        self.error_for_status()?;
140        crate::json_parser::deserialize(&self.body, self.status, self.json_parser.as_ref())
141    }
142
143    /// Async alias for [`into_json`](Self::into_json).
144    #[cfg(feature = "json")]
145    pub async fn json<T: serde::de::DeserializeOwned>(self) -> Result<T> {
146        self.into_json()
147    }
148
149    /// Deserializes JSON in one step with a custom closure (`Bytes` → `T`).
150    ///
151    /// Ignores any client- or request-level [`JsonParserFn`](crate::json_parser::JsonParserFn).
152    /// Use this for BOM stripping or other transforms without the `Value` intermediate
153    /// required by [`ClientBuilder::json_parser`](crate::client::ClientBuilder::json_parser).
154    #[cfg(feature = "json")]
155    pub fn into_json_with<T, F>(self, parse: F) -> Result<T>
156    where
157        T: serde::de::DeserializeOwned,
158        F: FnOnce(&Bytes) -> std::result::Result<T, String>,
159    {
160        self.error_for_status()?;
161        parse(&self.body).map_err(|message| {
162            crate::json_parser::deserialize_error(self.status, message, &self.body)
163        })
164    }
165
166    /// Async alias for [`into_json_with`](Self::into_json_with).
167    #[cfg(feature = "json")]
168    pub async fn json_with<T, F>(self, parse: F) -> Result<T>
169    where
170        T: serde::de::DeserializeOwned,
171        F: FnOnce(&Bytes) -> std::result::Result<T, String>,
172    {
173        self.into_json_with(parse)
174    }
175
176    /// Deserializes JSON without checking HTTP status, using the configured [`JsonParserFn`](crate::json_parser::JsonParserFn) when set.
177    #[cfg(feature = "json")]
178    pub fn into_json_unchecked<T: serde::de::DeserializeOwned>(self) -> Result<T> {
179        crate::json_parser::deserialize(&self.body, self.status, self.json_parser.as_ref())
180    }
181
182    /// Async alias for [`into_json_unchecked`](Self::into_json_unchecked).
183    #[cfg(feature = "json")]
184    pub async fn json_unchecked<T: serde::de::DeserializeOwned>(self) -> Result<T> {
185        self.into_json_unchecked()
186    }
187
188    /// Deserialize JSON and run [`garde::Validate`] rules (feature `validate`).
189    #[cfg(feature = "validate")]
190    pub fn into_json_validated<T>(self) -> Result<T>
191    where
192        T: serde::de::DeserializeOwned + garde::Validate,
193        T::Context: Default,
194    {
195        self.error_for_status()?;
196        crate::validate_json::deserialize_validated(
197            &self.body,
198            self.status,
199            self.json_parser.as_ref(),
200        )
201    }
202
203    /// Deserialize JSON and run [`garde::Validate`] rules (feature `validate`).
204    #[cfg(feature = "validate")]
205    pub async fn json_validated<T>(self) -> Result<T>
206    where
207        T: serde::de::DeserializeOwned + garde::Validate,
208        T::Context: Default,
209    {
210        self.into_json_validated()
211    }
212
213    /// Like [`into_json_validated`](Self::into_json_validated) without checking HTTP status.
214    #[cfg(feature = "validate")]
215    pub fn into_json_validated_unchecked<T>(self) -> Result<T>
216    where
217        T: serde::de::DeserializeOwned + garde::Validate,
218        T::Context: Default,
219    {
220        crate::validate_json::deserialize_validated(
221            &self.body,
222            self.status,
223            self.json_parser.as_ref(),
224        )
225    }
226
227    /// Like [`into_json_validated`](Self::into_json_validated) without checking HTTP status.
228    #[cfg(feature = "validate")]
229    pub async fn json_validated_unchecked<T>(self) -> Result<T>
230    where
231        T: serde::de::DeserializeOwned + garde::Validate,
232        T::Context: Default,
233    {
234        self.into_json_validated_unchecked()
235    }
236
237    /// Splits into status, headers, and body.
238    pub fn into_parts(self) -> (StatusCode, HeaderMap, Bytes) {
239        (self.status, self.headers, self.body)
240    }
241
242    /// Classifies the body using the `Content-Type` header (does not check HTTP status).
243    pub fn body_by_content_type(&self) -> ResponseBodyKind {
244        if self.body.is_empty() {
245            return ResponseBodyKind::Empty;
246        }
247
248        let mime = self
249            .headers
250            .get(CONTENT_TYPE)
251            .and_then(|v| v.to_str().ok())
252            .unwrap_or("")
253            .split(';')
254            .next()
255            .unwrap_or("")
256            .trim()
257            .to_ascii_lowercase();
258
259        #[cfg(feature = "json")]
260        if mime.contains("json") {
261            if let Ok(value) = serde_json::from_slice(&self.body) {
262                return ResponseBodyKind::Json(value);
263            }
264        }
265
266        if mime.starts_with("text/") || mime == "application/xml" || mime == "application/xhtml+xml"
267        {
268            return ResponseBodyKind::Text(String::from_utf8_lossy(&self.body).into_owned());
269        }
270
271        if mime.is_empty() {
272            if let Ok(text) = std::str::from_utf8(&self.body) {
273                if text
274                    .chars()
275                    .all(|c| !c.is_control() || c == '\n' || c == '\r' || c == '\t')
276                {
277                    return ResponseBodyKind::Text(text.to_string());
278                }
279            }
280        }
281
282        ResponseBodyKind::Bytes(self.body.clone())
283    }
284
285    /// Like [`body_by_content_type`](Self::body_by_content_type) after verifying a 2xx status.
286    pub fn into_body_by_content_type(self) -> Result<ResponseBodyKind> {
287        self.error_for_status()?;
288        Ok(self.body_by_content_type())
289    }
290}
291
292impl std::fmt::Debug for Response {
293    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294        let mut debug = f.debug_struct("Response");
295        debug
296            .field("status", &self.status)
297            .field("headers", &self.headers)
298            .field("body", &self.body)
299            .field("url", &self.url);
300        #[cfg(feature = "json")]
301        if self.json_parser.is_some() {
302            debug.field("json_parser", &"<custom>");
303        }
304        debug.finish()
305    }
306}
307
308#[cfg(all(test, feature = "json"))]
309mod tests {
310    use super::*;
311    use serde::Deserialize;
312
313    #[derive(Debug, Deserialize, PartialEq)]
314    struct IdOnly {
315        id: u64,
316    }
317
318    #[test]
319    fn into_text_returns_body_on_success() {
320        let response = Response::new(
321            StatusCode::OK,
322            HeaderMap::new(),
323            Bytes::from_static(b"hello"),
324            None,
325            None,
326        );
327        assert_eq!(response.into_text().unwrap(), "hello");
328    }
329
330    #[test]
331    fn into_json_deserializes_without_async() {
332        let response = Response::new(
333            StatusCode::OK,
334            HeaderMap::new(),
335            Bytes::from_static(br#"{"id":7}"#),
336            None,
337            None,
338        );
339        assert_eq!(response.into_json::<IdOnly>().unwrap(), IdOnly { id: 7 });
340    }
341
342    #[test]
343    fn into_json_with_strips_bom_without_client_parser() {
344        let response = Response::new(
345            StatusCode::OK,
346            HeaderMap::new(),
347            Bytes::from_static(b"\xef\xbb\xbf{\"id\":3}"),
348            None,
349            None,
350        );
351        let parsed: IdOnly = response
352            .into_json_with(|body| {
353                let slice = body.strip_prefix(b"\xef\xbb\xbf").unwrap_or(body);
354                serde_json::from_slice(slice).map_err(|e| e.to_string())
355            })
356            .unwrap();
357        assert_eq!(parsed, IdOnly { id: 3 });
358    }
359}