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}