Skip to main content

tapes_client/
decode.rs

1//! One decode policy, for both surfaces.
2//!
3//! # The two halves, and why they are separate functions
4//!
5//! Decoding a tapes response is two decisions that used to live in two crates
6//! and disagree:
7//!
8//! 1. **Bytes to a document.** Is this a success? If not, the body is the error
9//!    message and must survive. Is the body empty? A 204 is a real answer, not
10//!    a decode failure. That is [`json`].
11//! 2. **Document to a caller's type.** That is [`typed`], and this crate takes
12//!    no view on what the type should be.
13//!
14//! Keeping them separate is what lets a consumer that already holds a decoded
15//! document — one whose own client did the fetching — reach the same typed
16//! decode the transport-driven path uses, instead of a second one that rounds
17//! differently.
18//!
19//! # Why `T` is the caller's choice
20//!
21//! The right answer is genuinely per-operation:
22//!
23//! - **Rendering operations** decode into models, so a client can lay fields
24//!   out rather than print a document.
25//! - **Fidelity operations** — export, raw turns — stay [`Value`]. A typed
26//!   decode there silently truncates the archive an old client writes of a
27//!   newer server's data, and it fails at the *response* level, so one
28//!   unmodelled field can blank a whole page.
29//!
30//! Whichever a consumer picks, the rule for anything this crate ever types is
31//! that enums carry a fallback variant: an added variant must never error an
32//! old client.
33
34use serde::de::DeserializeOwned;
35use serde_json::Value;
36use snafu::ResultExt;
37
38use crate::error::{Error, Result, error};
39use crate::transport::WireResponse;
40
41/// Turn one response's bytes into a JSON document.
42///
43/// A non-success status becomes [`Error::ApiStatus`] carrying the body, because
44/// every tapes error body is `{"error": "..."}` and the bare status never names
45/// the offending parameter.
46///
47/// A successful response with no body decodes to [`Value::Null`] rather than
48/// failing: cassette routes are free to answer 204, and a client that treated
49/// that as malformed would refuse a perfectly good answer.
50pub fn json(response: &WireResponse) -> Result<Value> {
51    if !response.is_success() {
52        return Err(Error::ApiStatus {
53            status: response.status,
54            endpoint: response.endpoint.clone(),
55            body: String::from_utf8_lossy(&response.body).into_owned(),
56        });
57    }
58    if response.body.iter().all(u8::is_ascii_whitespace) {
59        return Ok(Value::Null);
60    }
61    serde_json::from_slice(&response.body).context(error::DecodeSnafu)
62}
63
64/// Decode a document into the type the caller asked for.
65pub fn typed<T: DeserializeOwned>(value: Value) -> Result<T> {
66    serde_json::from_value(value).context(error::DecodeSnafu)
67}
68
69/// Both halves, for the common case.
70pub fn json_typed<T: DeserializeOwned>(response: &WireResponse) -> Result<T> {
71    typed(json(response)?)
72}
73
74#[cfg(test)]
75#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
76mod tests {
77    use super::*;
78    use serde::Deserialize;
79
80    fn response(status: u16, body: &str) -> WireResponse {
81        WireResponse::new(
82            status,
83            "http://127.0.0.1:8081/v1/sessions".to_owned(),
84            Vec::new(),
85            body.as_bytes().to_vec(),
86        )
87    }
88
89    #[test]
90    fn an_error_body_is_surfaced_with_the_status() {
91        // The bare status never names the offending parameter; the body does.
92        let err = json(&response(400, r#"{"error":"invalid cursor"}"#)).unwrap_err();
93        let rendered = err.to_string();
94        assert!(rendered.contains("400"), "got: {rendered}");
95        assert!(rendered.contains("invalid cursor"), "got: {rendered}");
96        assert!(rendered.contains("/v1/sessions"), "got: {rendered}");
97    }
98
99    #[test]
100    fn a_successful_empty_body_is_null_not_a_decode_failure() {
101        // Pinned from the cassette surface, and now the rule for both: a 204
102        // is an answer.
103        assert_eq!(json(&response(204, "")).unwrap(), Value::Null);
104        assert_eq!(json(&response(200, "  \n ")).unwrap(), Value::Null);
105    }
106
107    #[test]
108    fn the_untyped_decode_passes_unknown_fields_through() {
109        // The fidelity half of the per-operation policy: a field this build
110        // has never heard of must survive to the caller.
111        let got: Value = json_typed(&response(
112            200,
113            r#"{"items":[{"a_field_from_the_future":7}]}"#,
114        ))
115        .unwrap();
116        assert_eq!(got["items"][0]["a_field_from_the_future"], 7);
117    }
118
119    #[test]
120    fn a_typed_decode_reads_the_consumers_own_model() {
121        #[derive(Debug, Deserialize)]
122        struct Listing {
123            next_cursor: String,
124        }
125
126        let got: Listing =
127            json_typed(&response(200, r#"{"items":[],"next_cursor":"abc"}"#)).unwrap();
128        assert_eq!(got.next_cursor, "abc");
129    }
130
131    #[test]
132    fn a_body_that_is_not_json_is_a_decode_failure_and_not_a_status_one() {
133        let err = json(&response(200, "<html>nope</html>")).unwrap_err();
134        assert!(err.to_string().contains("could not decode"), "got: {err}",);
135    }
136}