Skip to main content

crux_http/response/
response.rs

1use super::decode::decode_body;
2use crate::{HttpError, Result};
3use http::{HeaderMap, HeaderName, HeaderValue, StatusCode, Version};
4use serde::de::DeserializeOwned;
5use std::{fmt, ops::Index};
6
7/// An HTTP Response that will be passed to an app's update function.
8///
9/// # A `Response` never carries an error status
10///
11/// Holding one of these means the server did **not** reject the request. `crux_http`
12/// converts every 4xx and 5xx response into an [`HttpError::Http`](crate::HttpError::Http)
13/// — keeping the headers and body — and delivers it on the `Err` side, so
14/// [`status`](Self::status) is always a 1xx, 2xx or 3xx. A `match` arm that checks it for
15/// failure is dead code:
16///
17/// ```
18/// # use crux_http::{HttpError, Response};
19/// # fn saved() {}
20/// # fn show_error(_message: &str) {}
21/// fn on_result(result: crux_http::Result<Response<Vec<u8>>>) {
22///     match result {
23///         // this arm cannot see a 4xx or 5xx, so don't test the status here
24///         Ok(_response) => saved(),
25///         Err(error) => {
26///             // the server's own message, e.g. {"error": "…"}, not just "409 Conflict"
27///             let message = error
28///                 .body_json::<serde_json::Value>()
29///                 .ok()
30///                 .and_then(|body| body["error"].as_str().map(str::to_string))
31///                 .unwrap_or_else(|| error.to_string());
32///             show_error(&message);
33///         }
34///     }
35/// }
36///
37/// // a rejection, as a feature receives it
38/// on_result(crux_http::testing::rejection(409, r#"{"error":"already booked"}"#));
39/// ```
40///
41/// The matching testing rule: build success cases with
42/// [`ResponseBuilder`](crate::testing::ResponseBuilder), and rejections with
43/// [`rejection`](crate::testing::rejection).
44#[derive(Clone, serde::Serialize, serde::Deserialize)]
45pub struct Response<Body> {
46    #[serde(skip, default)]
47    version: Option<Version>,
48    #[serde(with = "status_serde")]
49    status: StatusCode,
50    #[serde(with = "header_serde")]
51    headers: HeaderMap,
52    body: Option<Body>,
53}
54
55impl<Body> Response<Body> {
56    /// Create a new instance.
57    ///
58    /// A 4xx or 5xx status is not a response as far as the app is concerned: it becomes
59    /// [`HttpError::Http`], carrying the headers and body so the caller can still read what
60    /// the server said. This is the single place that invariant is established — see the
61    /// [type docs](Response) for what it means for app and test code.
62    pub(crate) fn new(mut res: super::RawResponse) -> Result<Response<Vec<u8>>> {
63        let body = res.body_bytes()?;
64        let status = res.status();
65        let headers = res.as_ref().clone();
66
67        if status.is_client_error() || status.is_server_error() {
68            return Err(HttpError::Http {
69                code: status.as_u16(),
70                message: status.to_string(),
71                headers: Box::new(headers),
72                body,
73            });
74        }
75
76        Ok(Response {
77            status,
78            headers,
79            version: res.version(),
80            body: Some(body),
81        })
82    }
83
84    /// Get the HTTP status code.
85    ///
86    /// Never a client (4xx) or server (5xx) error: those are delivered as an
87    /// [`HttpError::Http`](crate::HttpError::Http) on the `Err` side, not as a `Response`,
88    /// so there is nothing to be learned by testing this for failure. Handle rejections
89    /// there instead — see the [type docs](Response).
90    ///
91    /// # Examples
92    ///
93    /// ```
94    /// # let res = crux_http::testing::ResponseBuilder::ok().build();
95    /// assert_eq!(res.status(), 200);
96    /// ```
97    #[allow(clippy::missing_const_for_fn)]
98    pub fn status(&self) -> StatusCode {
99        self.status
100    }
101
102    /// Get the HTTP protocol version.
103    ///
104    /// # Examples
105    ///
106    /// ```no_run
107    /// # let res = crux_http::testing::ResponseBuilder::ok().build();
108    /// use crux_http::http::Version;
109    /// assert_eq!(res.version(), Some(Version::HTTP_11));
110    /// ```
111    #[allow(clippy::missing_const_for_fn)]
112    pub fn version(&self) -> Option<Version> {
113        self.version
114    }
115
116    /// Get all values for a header name.
117    pub fn header_all(
118        &self,
119        name: impl http::header::AsHeaderName,
120    ) -> http::header::GetAll<'_, HeaderValue> {
121        self.headers.get_all(name)
122    }
123
124    /// Get a header value.
125    ///
126    /// # Examples
127    ///
128    /// ```no_run
129    /// # let res = crux_http::testing::ResponseBuilder::ok()
130    /// #   .header("Content-Length", "1")
131    /// #   .build();
132    /// assert!(res.header("Content-Length").is_some());
133    /// ```
134    pub fn header(&self, name: impl http::header::AsHeaderName) -> Option<&HeaderValue> {
135        self.headers.get(name)
136    }
137
138    /// Get an HTTP header mutably.
139    pub fn header_mut(
140        &mut self,
141        name: impl http::header::AsHeaderName,
142    ) -> Option<&mut HeaderValue> {
143        self.headers.get_mut(name)
144    }
145
146    /// Remove a header.
147    pub fn remove_header(&mut self, name: impl http::header::AsHeaderName) -> Option<HeaderValue> {
148        self.headers.remove(name)
149    }
150
151    /// Insert an HTTP header, replacing any existing value.
152    ///
153    /// Returns the previous value for that header name, if any.
154    pub fn insert_header(
155        &mut self,
156        name: impl http::header::IntoHeaderName,
157        value: HeaderValue,
158    ) -> Option<HeaderValue> {
159        self.headers.insert(name, value)
160    }
161
162    /// Append an HTTP header, keeping any existing values.
163    ///
164    /// Returns `true` if the value was appended to an existing entry, `false` if it was the first
165    /// value for that name.
166    pub fn append_header(
167        &mut self,
168        name: impl http::header::IntoHeaderName,
169        value: HeaderValue,
170    ) -> bool {
171        self.headers.append(name, value)
172    }
173
174    /// An iterator visiting all header (name, value) pairs in arbitrary order.
175    #[must_use]
176    pub fn iter(&self) -> http::header::Iter<'_, HeaderValue> {
177        self.headers.iter()
178    }
179
180    /// An iterator visiting all header (name, value) pairs with mutable values.
181    #[must_use]
182    pub fn iter_mut(&mut self) -> http::header::IterMut<'_, HeaderValue> {
183        self.headers.iter_mut()
184    }
185
186    /// An iterator visiting all header names in arbitrary order.
187    #[must_use]
188    pub fn header_names(&self) -> http::header::Keys<'_, HeaderValue> {
189        self.headers.keys()
190    }
191
192    /// An iterator visiting all header values in arbitrary order.
193    #[must_use]
194    pub fn header_values(&self) -> http::header::Values<'_, HeaderValue> {
195        self.headers.values()
196    }
197
198    /// Get the response content type as a `Mime`.
199    ///
200    /// # Examples
201    ///
202    /// ```
203    /// # let res = crux_http::testing::ResponseBuilder::ok()
204    /// #   .header("Content-Type", "application/json")
205    /// #   .build();
206    /// assert_eq!(res.content_type(), Some(mime::APPLICATION_JSON));
207    /// ```
208    pub fn content_type(&self) -> Option<mime::Mime> {
209        self.headers
210            .get(http::header::CONTENT_TYPE)?
211            .to_str()
212            .ok()?
213            .parse()
214            .ok()
215    }
216
217    #[allow(clippy::missing_const_for_fn)]
218    pub fn body(&self) -> Option<&Body> {
219        self.body.as_ref()
220    }
221
222    #[allow(clippy::missing_const_for_fn)]
223    pub fn take_body(&mut self) -> Option<Body> {
224        self.body.take()
225    }
226
227    pub fn with_body<NewBody>(self, body: NewBody) -> Response<NewBody> {
228        Response {
229            body: Some(body),
230            headers: self.headers,
231            status: self.status,
232            version: self.version,
233        }
234    }
235}
236
237impl<'a, Body> IntoIterator for &'a Response<Body> {
238    type Item = (&'a HeaderName, &'a HeaderValue);
239    type IntoIter = http::header::Iter<'a, HeaderValue>;
240    fn into_iter(self) -> Self::IntoIter {
241        self.iter()
242    }
243}
244
245impl<'a, Body> IntoIterator for &'a mut Response<Body> {
246    type Item = (&'a HeaderName, &'a mut HeaderValue);
247    type IntoIter = http::header::IterMut<'a, HeaderValue>;
248    fn into_iter(self) -> Self::IntoIter {
249        self.iter_mut()
250    }
251}
252
253impl Response<Vec<u8>> {
254    pub(crate) fn new_with_status(status: StatusCode) -> Self {
255        Self {
256            status,
257            headers: HeaderMap::new(),
258            version: None,
259            body: None,
260        }
261    }
262
263    /// Reads the entire request body into a byte buffer.
264    ///
265    /// # Errors
266    ///
267    /// Returns [`HttpError::BodyAlreadyTaken`] if the body has already been taken — this and
268    /// the other `body_*` readers each take it, so only the first call can succeed.
269    ///
270    /// # Examples
271    ///
272    /// ```
273    /// # fn main() -> crux_http::Result<()> {
274    /// # let mut res = crux_http::testing::ResponseBuilder::ok()
275    /// #   .header("Content-Type", "application/json")
276    /// #   .body(vec![0u8, 1])
277    /// #   .build();
278    /// let bytes: Vec<u8> = res.body_bytes()?;
279    /// # Ok(()) }
280    /// ```
281    pub fn body_bytes(&mut self) -> Result<Vec<u8>> {
282        self.body.take().ok_or(HttpError::BodyAlreadyTaken)
283    }
284
285    /// Reads the entire response body into a string.
286    ///
287    /// # Errors
288    ///
289    /// Returns an error if the body has already been taken or if it contains invalid UTF-8.
290    ///
291    /// # Examples
292    ///
293    /// ```
294    /// # fn main() -> crux_http::Result<()> {
295    /// # let mut res = crux_http::testing::ResponseBuilder::ok()
296    /// #   .header("Content-Type", "application/json")
297    /// #   .body("hello".to_string().into_bytes())
298    /// #   .build();
299    /// let string: String = res.body_string()?;
300    /// assert_eq!(string, "hello");
301    /// # Ok(()) }
302    /// ```
303    pub fn body_string(&mut self) -> Result<String> {
304        let bytes = self.body_bytes()?;
305        let mime = self.content_type();
306        let claimed_encoding = mime
307            .as_ref()
308            .and_then(|m| m.get_param(mime::CHARSET))
309            .map(|name| name.as_str().to_owned());
310        Ok(decode_body(bytes, claimed_encoding.as_deref())?)
311    }
312
313    /// Reads and deserializes the entire response body from JSON.
314    ///
315    /// # Errors
316    ///
317    /// Returns an error if the body has already been taken or if deserialisation fails.
318    ///
319    /// # Examples
320    ///
321    /// ```
322    /// # use serde::{Deserialize, Serialize};
323    /// # fn main() -> crux_http::Result<()> {
324    /// # let mut res = crux_http::testing::ResponseBuilder::ok()
325    /// #   .header("Content-Type", "application/json")
326    /// #   .body("{\"ip\": \"127.0.0.1\"}".to_string().into_bytes())
327    /// #   .build();
328    /// #[derive(Deserialize, Serialize)]
329    /// struct Ip { ip: String }
330    /// let Ip { ip } = res.body_json()?;
331    /// assert_eq!(ip, "127.0.0.1");
332    /// # Ok(()) }
333    /// ```
334    pub fn body_json<T: DeserializeOwned>(&mut self) -> Result<T> {
335        let body_bytes = self.body_bytes()?;
336        serde_json::from_slice(&body_bytes).map_err(HttpError::from)
337    }
338}
339
340impl<Body> AsRef<HeaderMap> for Response<Body> {
341    fn as_ref(&self) -> &HeaderMap {
342        &self.headers
343    }
344}
345
346impl<Body> AsMut<HeaderMap> for Response<Body> {
347    fn as_mut(&mut self) -> &mut HeaderMap {
348        &mut self.headers
349    }
350}
351
352impl<Body> fmt::Debug for Response<Body> {
353    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
354        f.debug_struct("Response")
355            .field("version", &self.version)
356            .field("status", &self.status)
357            .field("headers", &self.headers)
358            .finish_non_exhaustive()
359    }
360}
361
362impl<Body> Index<&str> for Response<Body> {
363    type Output = HeaderValue;
364
365    /// Returns a reference to the value corresponding to the supplied name.
366    ///
367    /// # Panics
368    ///
369    /// Panics if the name is not present in `Response`.
370    #[inline]
371    fn index(&self, name: &str) -> &HeaderValue {
372        &self.headers[name]
373    }
374}
375
376impl<Body> PartialEq for Response<Body>
377where
378    Body: PartialEq,
379{
380    fn eq(&self, other: &Self) -> bool {
381        self.status == other.status && self.headers == other.headers && self.body == other.body
382    }
383}
384
385impl<Body> Eq for Response<Body> where Body: Eq {}
386
387impl<Body> TryFrom<Response<Body>> for http::Response<Body> {
388    type Error = ();
389
390    fn try_from(res: Response<Body>) -> std::result::Result<Self, ()> {
391        let body = res.body.ok_or(())?;
392        let mut builder = http::Response::builder().status(res.status);
393        if let Some(v) = res.version {
394            builder = builder.version(v);
395        }
396        for (name, value) in &res.headers {
397            builder = builder.header(name, value);
398        }
399        builder.body(body).map_err(|_| ())
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use http::{HeaderMap, StatusCode};
406
407    use crate::{
408        HttpError, HttpResponse, RawResponse, response::Response, testing::ResponseBuilder,
409    };
410
411    #[test]
412    fn status_is_http_status_code() {
413        let res = ResponseBuilder::ok().build();
414        assert_eq!(res.status(), StatusCode::OK);
415        assert_eq!(res.status().as_u16(), 200);
416    }
417
418    #[test]
419    fn headers_are_http_header_map() {
420        let res = ResponseBuilder::ok().header("x-custom", "hello").build();
421        let map: &HeaderMap = res.as_ref();
422        assert_eq!(map["x-custom"], "hello");
423    }
424
425    #[test]
426    fn header_all_returns_multiple_values() {
427        let res = ResponseBuilder::ok()
428            .header("accept", "text/html")
429            .append_header("accept", "application/json")
430            .build();
431        let values: Vec<&str> = res
432            .header_all("accept")
433            .iter()
434            .map(|v| v.to_str().unwrap())
435            .collect();
436        assert_eq!(values, ["text/html", "application/json"]);
437    }
438
439    #[test]
440    fn native_try_from_into_http_response() {
441        use std::convert::TryFrom;
442        let res: Response<Vec<u8>> = ResponseBuilder::ok()
443            .header("x-foo", "bar")
444            .body(b"hello".to_vec())
445            .build();
446        let http_res = http::Response::<Vec<u8>>::try_from(res).unwrap();
447        assert_eq!(http_res.status(), StatusCode::OK);
448        assert_eq!(http_res.headers()["x-foo"], "bar");
449        assert_eq!(http_res.body(), b"hello");
450    }
451
452    /// Round-trip: `HttpResponse` → `crux_http::Response<Vec<u8>>` → `http::Response<Vec<u8>>`
453    #[futures_test::test]
454    async fn http_response_round_trip() {
455        use crate::protocol::HttpResponse;
456        use std::convert::TryFrom;
457
458        let http_response = HttpResponse::ok()
459            .header("content-type", "application/json")
460            .json(serde_json::json!({"data": 42}))
461            .build();
462
463        // Step 1: HttpResponse → RawResponse (via TryFrom impl in raw_response.rs)
464        let response_async = RawResponse::try_from(http_response).expect("valid status");
465
466        // Step 2: RawResponse → Response<Vec<u8>> (the path the command executor takes)
467        let response = Response::<Vec<u8>>::new(response_async).expect("should decode");
468
469        assert_eq!(response.status().as_u16(), 200);
470        assert_eq!(response.content_type(), Some(mime::APPLICATION_JSON));
471
472        // Step 3: Response<Vec<u8>> → http::Response<Vec<u8>> (native lossless conversion)
473        let http_resp = http::Response::<Vec<u8>>::try_from(response).unwrap();
474        assert_eq!(http_resp.status(), 200);
475        assert_eq!(http_resp.headers()["content-type"], "application/json");
476        let parsed: serde_json::Value = serde_json::from_slice(http_resp.body()).unwrap();
477        assert_eq!(parsed["data"], 42);
478    }
479
480    #[test]
481    fn response_status_serde_roundtrip() {
482        let res: Response<Vec<u8>> = ResponseBuilder::ok().body(vec![42u8]).build();
483        let json = serde_json::to_string(&res).expect("should serialize");
484        let back: Response<Vec<u8>> = serde_json::from_str(&json).expect("should deserialize");
485        assert_eq!(back.status().as_u16(), 200);
486        assert_eq!(back.body().unwrap(), &[42u8]);
487    }
488
489    #[test]
490    fn non_standard_status_499_becomes_http_error() {
491        // 499 is a non-standard client error (client closed connection).
492        // It arrives as HttpResponse from the shell, is converted to RawResponse,
493        // and Response::new() converts it to HttpError::Http with the original code.
494        let http_response = HttpResponse::status(499)
495            .body(b"client closed connection".to_vec())
496            .build();
497        let raw = RawResponse::try_from(http_response).expect("499 is a valid status code");
498        let result = Response::<Vec<u8>>::new(raw);
499
500        assert!(result.is_err());
501        let err = result.unwrap_err();
502        assert!(matches!(err, HttpError::Http { code, .. } if code == 499));
503    }
504
505    #[test]
506    fn non_standard_status_599_becomes_http_error() {
507        let http_response = HttpResponse::status(599)
508            .body(b"custom server error".to_vec())
509            .build();
510        let raw = RawResponse::try_from(http_response).expect("599 is a valid status code");
511        let result = Response::<Vec<u8>>::new(raw);
512
513        assert!(result.is_err());
514        let err = result.unwrap_err();
515        assert!(matches!(err, HttpError::Http { code, .. } if code == 599));
516    }
517
518    #[test]
519    fn non_standard_4xx_status_preserves_code_in_error() {
520        for status in [490, 491, 492, 493, 494, 495, 496, 497, 498, 499] {
521            let http_response = HttpResponse::status(status).body(b"".to_vec()).build();
522            let raw = RawResponse::try_from(http_response)
523                .unwrap_or_else(|_| panic!("{status} is a valid status code"));
524            let result = Response::<Vec<u8>>::new(raw);
525
526            let err = result.expect_err("should be an error");
527            assert!(
528                matches!(err, HttpError::Http { code, .. } if code == status),
529                "Expected status {status} to be preserved in HttpError, got: {err:?}"
530            );
531        }
532    }
533
534    #[test]
535    fn response_serde_roundtrip_with_non_standard_status() {
536        // 299 is non-standard but not an error, so it is a status a Response can hold —
537        // a non-standard 4xx/5xx becomes HttpError::Http instead (see the tests above).
538        let res: Response<Vec<u8>> = ResponseBuilder::with_status(299)
539            .body(b"test".to_vec())
540            .build();
541        let json = serde_json::to_string(&res).expect("should serialize");
542        let back: Response<Vec<u8>> = serde_json::from_str(&json).expect("should deserialize");
543        assert_eq!(back.status().as_u16(), 299);
544    }
545
546    #[test]
547    fn body_bytes_returns_error_when_body_already_taken() {
548        let mut res: Response<Vec<u8>> = ResponseBuilder::ok().body(b"hello".to_vec()).build();
549        let _ = res.body_bytes().unwrap();
550        let err = res.body_bytes().expect_err("second call must fail");
551
552        // Not a rejection: the server answered 200. It used to report itself as
553        // `Http { code: 200, .. }`, which made `code()` unusable as a rejection test.
554        assert!(matches!(err, HttpError::BodyAlreadyTaken), "got: {err:?}");
555        assert_eq!(err.code(), None);
556    }
557
558    #[test]
559    fn try_from_response_with_no_body_returns_err() {
560        // `new_with_status` produces body: None (used for e.g. HEAD responses).
561        let res = Response::<Vec<u8>>::new_with_status(StatusCode::OK);
562        let result = http::Response::<Vec<u8>>::try_from(res);
563        assert!(result.is_err(), "TryFrom must return Err when body is None");
564    }
565
566    #[test]
567    fn multi_value_headers_survive_serde_roundtrip() {
568        let res: Response<Vec<u8>> = ResponseBuilder::ok()
569            .header("set-cookie", "a=1")
570            .append_header("set-cookie", "b=2")
571            .body(b"".to_vec())
572            .build();
573
574        let json = serde_json::to_string(&res).expect("should serialize");
575        let back: Response<Vec<u8>> = serde_json::from_str(&json).expect("should deserialize");
576
577        let values: Vec<&str> = back
578            .header_all("set-cookie")
579            .iter()
580            .map(|v| v.to_str().unwrap())
581            .collect();
582        assert_eq!(
583            values.len(),
584            2,
585            "both Set-Cookie values must survive serde: {values:?}"
586        );
587        assert!(values.contains(&"a=1"));
588        assert!(values.contains(&"b=2"));
589    }
590}
591
592/// Custom serde for `http::StatusCode` (serialized as `u16`).
593mod status_serde {
594    use http::StatusCode;
595    use serde::{Deserialize, Deserializer, Serializer};
596
597    #[allow(clippy::trivially_copy_pass_by_ref)]
598    pub fn serialize<S: Serializer>(status: &StatusCode, ser: S) -> Result<S::Ok, S::Error> {
599        ser.serialize_u16(status.as_u16())
600    }
601
602    pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<StatusCode, D::Error> {
603        let n = u16::deserialize(de)?;
604        StatusCode::from_u16(n).map_err(serde::de::Error::custom)
605    }
606}
607
608mod header_serde {
609    use http::{HeaderMap, HeaderName, HeaderValue};
610    use serde::{Deserializer, Serializer, de::Error};
611    use std::str::FromStr;
612
613    pub fn serialize<S>(headers: &HeaderMap, serializer: S) -> Result<S::Ok, S::Error>
614    where
615        S: Serializer,
616    {
617        // Group values by name, preserving insertion order via collect_map.
618        // Headers with multiple values each appear as separate entries.
619        // We build a BTreeMap so the output is deterministic.
620        let mut map: std::collections::BTreeMap<&str, Vec<&str>> =
621            std::collections::BTreeMap::new();
622        for (name, value) in headers {
623            map.entry(name.as_str())
624                .or_default()
625                .push(value.to_str().unwrap_or(""));
626        }
627        serializer.collect_map(map.iter())
628    }
629
630    pub fn deserialize<'de, D>(deserializer: D) -> Result<HeaderMap, D::Error>
631    where
632        D: Deserializer<'de>,
633    {
634        // The serialiser emits a JSON object (map); use HashMap to match.
635        let strs =
636            <std::collections::HashMap<String, Vec<String>> as serde::Deserialize>::deserialize(
637                deserializer,
638            )?;
639        let mut headers = HeaderMap::new();
640        for (name, values) in strs {
641            let name = HeaderName::from_str(&name).map_err(D::Error::custom)?;
642            for value in values {
643                let value = HeaderValue::from_str(&value).map_err(D::Error::custom)?;
644                headers.append(name.clone(), value);
645            }
646        }
647        Ok(headers)
648    }
649}