Skip to main content

typesafe_sdk/
content.rs

1//! The value the API calls "string, object or array" content.
2//!
3//! [`Content`] is what carries free-form JSON in both directions: a question's
4//! instructions and criteria on the way out, a score legend's labels on the way
5//! back. The API accepts exactly three JSON shapes there - a string, an object
6//! or an array - so a number, a boolean or `null` is rejected where the value
7//! is built rather than where the server answers.
8//!
9//! A borrowed string is held as a borrow. Anything else is held as the JSON
10//! text it was encoded from, so a shape this version does not know still
11//! round-trips unchanged.
12
13use std::borrow::Cow;
14
15use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
16use thiserror::Error;
17
18use crate::codec::{self, DecodeError, EncodeError, RawJson};
19
20/// A value could not be used as [`Content`].
21#[derive(Debug, Clone, PartialEq, Eq, Error)]
22#[non_exhaustive]
23pub enum ContentError {
24    /// The value encoded to a number, a boolean or `null`. The API takes a
25    /// string, an object or an array.
26    #[error("content must be a JSON string, object or array")]
27    Shape,
28    /// The value could not be encoded as JSON at all.
29    #[error(transparent)]
30    Encode(#[from] EncodeError),
31    /// A string arrived with escape sequences that do not decode.
32    #[error(transparent)]
33    Decode(#[from] DecodeError),
34}
35
36/// Text, or a JSON object or array.
37///
38/// The lifetime is the input the value borrows from: `Content<'static>` owns
39/// everything it holds, which is what a decoded response produces, while a
40/// request can be built from a `&str` the caller already has without copying
41/// it.
42///
43/// Text is written out as a JSON string by any serializer. A JSON object or
44/// array is spliced in byte for byte by the SDK and written out as data by
45/// every other serializer, which is described on [`RawJson`].
46///
47/// Reading a `Content` back always takes the value as JSON text. A JSON codec
48/// (sonic-rs, `serde_json`) hands over that text, so any `Content` round-trips
49/// through one; a non-JSON serde format hands over a text value as a bare
50/// string, which is then parsed as JSON, so plain text such as `low` fails to
51/// read back there.
52///
53/// ```
54/// use typesafe_sdk::Content;
55///
56/// let borrowed = Content::text("payments or invoices");
57/// assert_eq!(borrowed.as_text(), Some("payments or invoices"));
58///
59/// let structured = Content::json(&[1, 2, 3])?;
60/// assert_eq!(structured.as_json().map(|raw| raw.as_str()), Some("[1,2,3]"));
61/// # Ok::<(), typesafe_sdk::ContentError>(())
62/// ```
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Content<'a> {
65    repr: Repr<'a>,
66}
67
68/// The two shapes a [`Content`] can take, kept private so that the variants
69/// are not part of the public API.
70#[derive(Debug, Clone, PartialEq, Eq)]
71enum Repr<'a> {
72    /// A JSON string, held decoded: the escapes are gone and the quotes with
73    /// them.
74    Text(Cow<'a, str>),
75    /// A JSON object or array, held as the text it arrived as.
76    Json(RawJson),
77}
78
79impl<'a> Content<'a> {
80    /// Wraps text, borrowing it when the caller owns it elsewhere.
81    ///
82    /// A caller who wants the JSON shape of a value calls [`Content::json`].
83    #[must_use]
84    pub fn text(text: impl Into<Cow<'a, str>>) -> Self {
85        Self { repr: Repr::Text(text.into()) }
86    }
87
88    /// Encodes `value` and keeps the result.
89    ///
90    /// A value that encodes to a JSON string becomes text, so
91    /// `Content::json(&"hello")` and `Content::text("hello")` are equal.
92    ///
93    /// # Errors
94    ///
95    /// Returns [`ContentError::Shape`] when the value encodes to a number, a
96    /// boolean or `null`, and [`ContentError::Encode`] when it cannot be
97    /// encoded at all.
98    pub fn json<T>(value: &T) -> Result<Self, ContentError>
99    where
100        T: Serialize + ?Sized,
101    {
102        let mut buffer = Vec::new();
103        codec::encode_into(&mut buffer, value)?;
104        let text = String::from_utf8(buffer).expect("invariant: the codec emits UTF-8");
105        Self::from_raw(Cow::Owned(text))
106    }
107
108    /// The text, when this is a string.
109    #[must_use]
110    pub fn as_text(&self) -> Option<&str> {
111        match &self.repr {
112            Repr::Text(text) => Some(text),
113            Repr::Json(_) => None,
114        }
115    }
116
117    /// The raw JSON, when this is an object or an array.
118    #[must_use]
119    pub fn as_json(&self) -> Option<&RawJson> {
120        match &self.repr {
121            Repr::Text(_) => None,
122            Repr::Json(raw) => Some(raw),
123        }
124    }
125
126    /// Detaches the value from whatever it borrows, copying only if it has to.
127    #[must_use]
128    pub fn into_owned(self) -> Content<'static> {
129        Content {
130            repr: match self.repr {
131                Repr::Text(text) => Repr::Text(Cow::Owned(text.into_owned())),
132                Repr::Json(raw) => Repr::Json(raw),
133            },
134        }
135    }
136
137    /// Builds a value from the raw JSON text of one value.
138    ///
139    /// The text has already been validated by the codec, either by encoding it
140    /// or by parsing it out of a document.
141    fn from_raw(raw: Cow<'a, str>) -> Result<Self, ContentError> {
142        match raw.as_bytes().first() {
143            Some(b'"') => match raw {
144                Cow::Borrowed(text) => Ok(Self::text(unquote(text)?)),
145                Cow::Owned(text) => Ok(Self::text(unquote(&text)?.into_owned())),
146            },
147            Some(b'{' | b'[') => {
148                Ok(Self { repr: Repr::Json(RawJson::from_text(raw.into_owned())) })
149            }
150            _ => Err(ContentError::Shape),
151        }
152    }
153}
154
155/// Builds the text shape, borrowing `text`; see [`Content::text`].
156impl<'a> From<&'a str> for Content<'a> {
157    fn from(text: &'a str) -> Self {
158        Self::text(text)
159    }
160}
161
162/// Builds the text shape, owning `text`; see [`Content::text`].
163impl From<String> for Content<'_> {
164    fn from(text: String) -> Self {
165        Self::text(text)
166    }
167}
168
169/// Builds the text shape, borrowing or owning as `text` does; see
170/// [`Content::text`].
171impl<'a> From<Cow<'a, str>> for Content<'a> {
172    fn from(text: Cow<'a, str>) -> Self {
173        Self::text(text)
174    }
175}
176
177/// Turns the raw text of a JSON string, quotes and all, into its value.
178///
179/// Text without a backslash is the input minus its two quotes, so the common
180/// case borrows; an escape sequence has to be decoded and therefore allocates.
181fn unquote(raw: &str) -> Result<Cow<'_, str>, ContentError> {
182    let inner = raw.get(1..raw.len().saturating_sub(1)).ok_or(ContentError::Shape)?;
183    if inner.as_bytes().contains(&b'\\') {
184        Ok(Cow::Owned(codec::decode::<String>(raw.as_bytes())?))
185    } else {
186        Ok(Cow::Borrowed(inner))
187    }
188}
189
190impl Serialize for Content<'_> {
191    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
192    where
193        S: Serializer,
194    {
195        match &self.repr {
196            Repr::Text(text) => serializer.serialize_str(text),
197            Repr::Json(raw) => raw.serialize(serializer),
198        }
199    }
200}
201
202impl<'de: 'a, 'a> Deserialize<'de> for Content<'a> {
203    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
204    where
205        D: Deserializer<'de>,
206    {
207        let raw = codec::deserialize_raw(deserializer)?;
208        Self::from_raw(raw).map_err(de::Error::custom)
209    }
210}
211
212#[cfg(test)]
213#[path = "content_tests.rs"]
214mod tests;