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;