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}