Skip to main content

kasapay_core/
raw.rs

1//! The provider's own answer, kept whole.
2
3use std::fmt;
4
5/// What a provider actually sent, for everything kasapay does not model.
6///
7/// Every [`Charge`](crate::Charge) carries one. It is the escape hatch: a
8/// provider will always have a field somebody needs and this crate has not
9/// heard of, and the alternative to keeping the body is losing it.
10///
11/// # Why this is not a `serde_json::Value`
12///
13/// It used to be. That put `serde_json` in the public API of every provider
14/// adapter, including ones written outside this workspace — the day `serde_json`
15/// goes to 2.0, every one of them breaks for a reason its author did not cause.
16/// A provider that answers XML, or a form body, or a protobuf had nowhere to
17/// put it either.
18///
19/// So the body is held as text and parsed on request. [`Raw::json`] is the one
20/// place `serde_json` appears, and a provider that has no JSON can still build a
21/// `Raw` with [`Raw::from_text`].
22/// # It does not print itself
23///
24/// `Debug` shows the length and nothing else. A body from a payment provider
25/// carries whatever they chose to send — an IBAN, a masked card number, a
26/// buyer's address, an identity number — and `Raw` is on `Charge`, on every
27/// stored card and on every sub-merchant. One `tracing::debug!("{charge:?}")`
28/// would put all of it in a log file that outlives the request.
29///
30/// Reading it is deliberate: [`Raw::as_str`], [`Raw::json`], [`Raw::text_at`].
31#[derive(Clone, PartialEq, Eq, Default)]
32pub struct Raw(Box<str>);
33
34impl fmt::Debug for Raw {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        write!(f, "Raw({} bytes)", self.0.len())
37    }
38}
39
40impl Raw {
41    /// Keeps a response body exactly as it arrived.
42    pub fn from_text(body: impl Into<Box<str>>) -> Self {
43        Self(body.into())
44    }
45
46    /// Keeps a body a provider has already parsed.
47    #[must_use]
48    pub fn from_json(value: &serde_json::Value) -> Self {
49        Self(value.to_string().into_boxed_str())
50    }
51
52    /// The body as it arrived.
53    #[must_use]
54    pub fn as_str(&self) -> &str {
55        &self.0
56    }
57
58    /// Whether there is a body at all.
59    #[must_use]
60    pub fn is_empty(&self) -> bool {
61        self.0.is_empty()
62    }
63
64    /// Parses the body as JSON.
65    ///
66    /// Returns `None` for a body that is not JSON, including an empty one.
67    /// This is the only method that names `serde_json`; reach for it when a
68    /// field kasapay does not model is worth reading.
69    #[must_use]
70    pub fn json(&self) -> Option<serde_json::Value> {
71        serde_json::from_str(&self.0).ok()
72    }
73
74    /// Reads one string out of a JSON body by [RFC 6901] pointer.
75    ///
76    /// `raw.text_at("/transactionDetail/currencyCode")`. Returns `None` if the
77    /// body is not JSON, the pointer finds nothing, or what it finds is not a
78    /// string. Costs a parse, so [`Raw::json`] is better for reading several.
79    ///
80    /// [RFC 6901]: https://datatracker.ietf.org/doc/html/rfc6901
81    #[must_use]
82    pub fn text_at(&self, pointer: &str) -> Option<String> {
83        self.json()?
84            .pointer(pointer)?
85            .as_str()
86            .map(ToOwned::to_owned)
87    }
88}
89
90impl fmt::Display for Raw {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        f.write_str(&self.0)
93    }
94}
95
96impl From<&serde_json::Value> for Raw {
97    fn from(value: &serde_json::Value) -> Self {
98        Self::from_json(value)
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::Raw;
105
106    #[test]
107    fn a_json_body_is_readable_by_pointer_and_as_a_value() {
108        let raw = Raw::from_text(r#"{"transactionDetail":{"currencyCode":"TRY"}}"#);
109        assert_eq!(
110            raw.text_at("/transactionDetail/currencyCode").as_deref(),
111            Some("TRY")
112        );
113        assert!(raw.json().is_some());
114        assert!(!raw.is_empty());
115    }
116
117    #[test]
118    fn a_body_that_is_not_json_is_still_kept() {
119        let raw = Raw::from_text("<result><status>ok</status></result>");
120        assert!(raw.json().is_none());
121        assert!(raw.text_at("/status").is_none());
122        assert!(raw.as_str().starts_with("<result>"));
123    }
124
125    #[test]
126    fn a_pointer_at_something_that_is_not_a_string_finds_nothing() {
127        let raw = Raw::from_text(r#"{"amount":1499,"nested":{"a":1}}"#);
128        assert!(raw.text_at("/amount").is_none());
129        assert!(raw.text_at("/nested").is_none());
130        assert!(raw.text_at("/missing").is_none());
131    }
132
133    #[test]
134    fn an_empty_body_is_empty_and_not_json() {
135        let raw = Raw::default();
136        assert!(raw.is_empty());
137        assert!(raw.json().is_none());
138    }
139
140    /// A body reaches a log only when somebody asks for it by name.
141    #[test]
142    fn debug_shows_the_length_and_not_the_body() {
143        let raw = Raw::from_text(r#"{"iban":"TR330006100519786457841326"}"#);
144        let shown = format!("{raw:?}");
145        assert!(!shown.contains("TR33"), "the body reached Debug: {shown}");
146        assert_eq!(shown, format!("Raw({} bytes)", raw.as_str().len()));
147        // And the deliberate way still works.
148        assert_eq!(
149            raw.text_at("/iban").as_deref(),
150            Some("TR330006100519786457841326")
151        );
152    }
153}