Skip to main content

scconfig_rs/
document.rs

1use std::collections::BTreeMap;
2
3use serde::de::DeserializeOwned;
4use serde_json::Value;
5
6use crate::{
7    Error, Result, ScalarCoercion,
8    binding::{coerce_json_value, deserialize_json_value, nested_value_from_string_map},
9    properties,
10};
11
12/// Supported structured document kinds.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum DocumentFormat {
15    /// JSON content.
16    Json,
17    /// YAML or YML content.
18    Yaml,
19    /// TOML content.
20    Toml,
21    /// Java properties content.
22    Properties,
23    /// UTF-8 text with an unknown structure.
24    Text,
25    /// Opaque binary content.
26    Binary,
27}
28
29impl DocumentFormat {
30    /// Returns a human-readable format name.
31    pub fn as_str(self) -> &'static str {
32        match self {
33            Self::Json => "JSON",
34            Self::Yaml => "YAML",
35            Self::Toml => "TOML",
36            Self::Properties => "Java properties",
37            Self::Text => "text",
38            Self::Binary => "binary",
39        }
40    }
41
42    pub(crate) fn from_path(path: &str) -> Option<Self> {
43        let (_, extension) = path.rsplit_once('.')?;
44        match extension.to_ascii_lowercase().as_str() {
45            "json" => Some(Self::Json),
46            "yaml" | "yml" => Some(Self::Yaml),
47            "toml" => Some(Self::Toml),
48            "properties" | "props" => Some(Self::Properties),
49            _ => None,
50        }
51    }
52
53    pub(crate) fn from_content_type(content_type: &str) -> Option<Self> {
54        let content_type = content_type.to_ascii_lowercase();
55        if content_type.contains("json") {
56            Some(Self::Json)
57        } else if content_type.contains("yaml") || content_type.contains("yml") {
58            Some(Self::Yaml)
59        } else if content_type.contains("toml") {
60            Some(Self::Toml)
61        } else if content_type.contains("properties") {
62            Some(Self::Properties)
63        } else if content_type.contains("octet-stream") {
64            Some(Self::Binary)
65        } else if content_type.starts_with("text/") {
66            Some(Self::Text)
67        } else {
68            None
69        }
70    }
71}
72
73/// Parsed configuration content returned by the library.
74#[derive(Debug, Clone, PartialEq)]
75pub enum ConfigDocument {
76    /// Parsed JSON payload.
77    Json(Value),
78    /// Parsed YAML payload converted into a JSON-like value.
79    Yaml(Value),
80    /// Parsed TOML payload converted into a JSON-like value.
81    Toml(Value),
82    /// Parsed Java properties payload.
83    Properties(PropertiesDocument),
84    /// UTF-8 text with no built-in structure.
85    Text(String),
86    /// Raw binary content.
87    Binary(Vec<u8>),
88}
89
90impl ConfigDocument {
91    /// Returns the document format.
92    pub fn format(&self) -> DocumentFormat {
93        match self {
94            Self::Json(_) => DocumentFormat::Json,
95            Self::Yaml(_) => DocumentFormat::Yaml,
96            Self::Toml(_) => DocumentFormat::Toml,
97            Self::Properties(_) => DocumentFormat::Properties,
98            Self::Text(_) => DocumentFormat::Text,
99            Self::Binary(_) => DocumentFormat::Binary,
100        }
101    }
102
103    /// Converts the document into a JSON-like nested value without scalar coercion.
104    pub fn to_value(&self) -> Result<Value> {
105        self.to_value_with_coercion(ScalarCoercion::None)
106    }
107
108    /// Converts the document into a JSON-like nested value.
109    pub fn to_value_with_coercion(&self, coercion: ScalarCoercion) -> Result<Value> {
110        match self {
111            Self::Json(value) | Self::Yaml(value) | Self::Toml(value) => {
112                Ok(coerce_json_value(value.clone(), coercion))
113            }
114            Self::Properties(document) => Ok(document.to_value_with_coercion(coercion)),
115            Self::Text(_) => Err(Error::UnsupportedBindingFormat { format: "text" }),
116            Self::Binary(_) => Err(Error::UnsupportedBindingFormat { format: "binary" }),
117        }
118    }
119
120    /// Deserializes the document into a Rust type using smart scalar coercion.
121    pub fn deserialize<T>(&self) -> Result<T>
122    where
123        T: DeserializeOwned,
124    {
125        self.deserialize_with_coercion(ScalarCoercion::Smart)
126    }
127
128    /// Deserializes the document into a Rust type using the requested coercion mode.
129    pub fn deserialize_with_coercion<T>(&self, coercion: ScalarCoercion) -> Result<T>
130    where
131        T: DeserializeOwned,
132    {
133        deserialize_json_value(
134            self.to_value_with_coercion(coercion)?,
135            format!("{} document", self.format().as_str()),
136        )
137    }
138
139    /// Deserializes the document without scalar coercion.
140    pub fn deserialize_strict<T>(&self) -> Result<T>
141    where
142        T: DeserializeOwned,
143    {
144        self.deserialize_with_coercion(ScalarCoercion::None)
145    }
146
147    pub(crate) fn from_text(origin: &str, format: DocumentFormat, text: String) -> Result<Self> {
148        match format {
149            DocumentFormat::Json => serde_json::from_str::<Value>(&text)
150                .map(Self::Json)
151                .map_err(|source| Error::Json {
152                    url: origin.to_string(),
153                    source,
154                }),
155            DocumentFormat::Yaml => serde_yaml::from_str::<Value>(&text)
156                .map(Self::Yaml)
157                .map_err(|source| Error::Yaml {
158                    url: origin.to_string(),
159                    source,
160                }),
161            DocumentFormat::Toml => {
162                let value = toml::from_str::<toml::Value>(&text).map_err(|source| Error::Toml {
163                    url: origin.to_string(),
164                    source,
165                })?;
166
167                Ok(Self::Toml(
168                    serde_json::to_value(value).expect("serializing TOML value should succeed"),
169                ))
170            }
171            DocumentFormat::Properties => {
172                Ok(Self::Properties(PropertiesDocument::parse(origin, &text)?))
173            }
174            DocumentFormat::Text => Ok(Self::Text(text)),
175            DocumentFormat::Binary => Ok(Self::Binary(text.into_bytes())),
176        }
177    }
178}
179
180/// A parsed Java properties document.
181#[derive(Debug, Clone, PartialEq)]
182pub struct PropertiesDocument {
183    entries: BTreeMap<String, String>,
184}
185
186impl PropertiesDocument {
187    /// Parses a Java properties document from text.
188    pub fn parse(origin: &str, text: &str) -> Result<Self> {
189        Ok(Self {
190            entries: properties::parse(text, origin)?,
191        })
192    }
193
194    /// Returns the flattened key-value entries.
195    pub fn entries(&self) -> &BTreeMap<String, String> {
196        &self.entries
197    }
198
199    /// Consumes the document and returns the flattened key-value entries.
200    pub fn into_entries(self) -> BTreeMap<String, String> {
201        self.entries
202    }
203
204    /// Converts the properties document into a nested JSON-like value without scalar coercion.
205    pub fn to_value(&self) -> Value {
206        self.to_value_with_coercion(ScalarCoercion::None)
207    }
208
209    /// Converts the properties document into a nested JSON-like value.
210    pub fn to_value_with_coercion(&self, coercion: ScalarCoercion) -> Value {
211        nested_value_from_string_map(
212            self.entries
213                .iter()
214                .map(|(key, value)| (key.clone(), value.clone())),
215            coercion,
216        )
217    }
218
219    /// Deserializes the properties document into a Rust type using smart scalar coercion.
220    pub fn deserialize<T>(&self) -> Result<T>
221    where
222        T: DeserializeOwned,
223    {
224        self.deserialize_with_coercion(ScalarCoercion::Smart)
225    }
226
227    /// Deserializes the properties document into a Rust type using the requested coercion mode.
228    pub fn deserialize_with_coercion<T>(&self, coercion: ScalarCoercion) -> Result<T>
229    where
230        T: DeserializeOwned,
231    {
232        deserialize_json_value(self.to_value_with_coercion(coercion), "properties document")
233    }
234
235    /// Deserializes the properties document without scalar coercion.
236    pub fn deserialize_strict<T>(&self) -> Result<T>
237    where
238        T: DeserializeOwned,
239    {
240        self.deserialize_with_coercion(ScalarCoercion::None)
241    }
242}
243
244/// Raw resource content fetched from the plain-text Spring Config endpoint.
245#[derive(Debug, Clone, PartialEq)]
246pub struct ConfigResource {
247    path: String,
248    url: String,
249    content_type: Option<String>,
250    bytes: Vec<u8>,
251}
252
253impl ConfigResource {
254    pub(crate) fn new(
255        path: String,
256        url: String,
257        content_type: Option<String>,
258        bytes: Vec<u8>,
259    ) -> Self {
260        Self {
261            path,
262            url,
263            content_type,
264            bytes,
265        }
266    }
267
268    /// Returns the logical path requested from Spring Config Server.
269    pub fn path(&self) -> &str {
270        &self.path
271    }
272
273    /// Returns the final resource URL used to fetch this payload.
274    pub fn url(&self) -> &str {
275        &self.url
276    }
277
278    /// Returns the response content type, when present.
279    pub fn content_type(&self) -> Option<&str> {
280        self.content_type.as_deref()
281    }
282
283    /// Returns the raw bytes.
284    pub fn bytes(&self) -> &[u8] {
285        &self.bytes
286    }
287
288    /// Consumes the resource and returns the raw bytes.
289    pub fn into_bytes(self) -> Vec<u8> {
290        self.bytes
291    }
292
293    /// Decodes the resource as UTF-8 text.
294    pub fn text(&self) -> Result<String> {
295        String::from_utf8(self.bytes.clone()).map_err(|source| Error::Utf8 {
296            url: self.url.clone(),
297            source,
298        })
299    }
300
301    /// Guesses the document format from the resource path, content type, and payload.
302    pub fn format(&self) -> DocumentFormat {
303        detect_format(&self.path, self.content_type(), &self.bytes)
304    }
305
306    /// Parses the resource into a [`ConfigDocument`].
307    pub fn parse(&self) -> Result<ConfigDocument> {
308        let format = self.format();
309        match format {
310            DocumentFormat::Binary => Ok(ConfigDocument::Binary(self.bytes.clone())),
311            other => ConfigDocument::from_text(&self.url, other, self.text()?),
312        }
313    }
314
315    /// Parses and deserializes the resource into a Rust type.
316    pub fn deserialize<T>(&self) -> Result<T>
317    where
318        T: DeserializeOwned,
319    {
320        self.parse()?.deserialize()
321    }
322}
323
324fn detect_format(path: &str, content_type: Option<&str>, bytes: &[u8]) -> DocumentFormat {
325    if let Some(format) = DocumentFormat::from_path(path) {
326        return format;
327    }
328
329    if let Some(content_type) = content_type {
330        if let Some(format) = DocumentFormat::from_content_type(content_type) {
331            if format != DocumentFormat::Binary || String::from_utf8(bytes.to_vec()).is_err() {
332                return format;
333            }
334        }
335    }
336
337    if String::from_utf8(bytes.to_vec()).is_ok() {
338        DocumentFormat::Text
339    } else {
340        DocumentFormat::Binary
341    }
342}