plugx_config/loader/
mod.rs

1//! Configuration loader trait and implementations.
2//!
3//! A configuration loader only loads contents of configurations for plugins. No parsing is done here.
4//! The result is just a `Vec<(String, ConfigurationEntity)>` with plugin names (in lowercase) as first element
5//! and [ConfigurationEntity] as values for each plugin.
6//! A loader also should try to set contents format for each plugin. For example [mod@fs] loader (that loads
7//! configurations from filesystem) guesses content formats from file extensions.
8//!
9//! Every configuration loader (every implementor of [Loader]) accepts a URL and maybe a
10//! whitelist of plugin names. It can parse the URL to detect and validate its own options. For example [mod@env] (that
11//! loads configuration from environment-variables) accepts a URL like `env://?prefix=MY_APP_NAME`.
12//!
13//! Also, a Loader can be mark some errors skippable! For more information refer to documentation of the loader itself.
14//!
15//! Note that generally you do not need to implement [Loader], provided [mod@closure] lets you make your
16//! own loader with just one [Fn] closure.
17
18use crate::entity::ConfigurationEntity;
19use serde::{de::IntoDeserializer, Deserialize, Deserializer, Serialize};
20use std::fmt;
21use std::fmt::{Debug, Display};
22use std::marker::PhantomData;
23use url::Url;
24
25pub mod closure;
26#[cfg(feature = "env")]
27pub mod env;
28#[cfg(feature = "fs")]
29pub mod fs;
30
31/// Load error type.
32#[derive(Debug, thiserror::Error)]
33pub enum Error {
34    /// An entity could not be found.
35    #[error("{loader} configuration loader could not found {item} from URL `{url}`")]
36    NotFound {
37        loader: String,
38        url: Url,
39        item: Box<String>,
40    },
41    /// Did not have enough permissions to read the contents.
42    #[error("{loader} configuration loader has no access to load configuration from `{url}`")]
43    NoAccess { loader: String, url: Url },
44    /// Got timeout when reading the contents.
45    #[error(
46        "{loader} configuration loader reached timeout `{timeout_in_seconds}s` to load `{url}`"
47    )]
48    Timeout {
49        loader: String,
50        url: Url,
51        timeout_in_seconds: usize,
52    },
53    /// The provided URL is invalid.
54    #[error("{loader} configuration loader got invalid URL `{url}`")]
55    InvalidUrl {
56        loader: String,
57        url: String,
58        source: anyhow::Error,
59    },
60    /// Could not found URL scheme.
61    #[error("Could not found configuration loader for scheme {scheme}")]
62    UrlSchemeNotFound { scheme: String },
63    /// Found more than one configuration with two different formats (extensions) for the same plugin.
64    #[error("{loader} configuration loader found duplicate configurations `{url}/{plugin}.({format_1}|{format_2})`")]
65    Duplicate {
66        loader: Box<String>,
67        url: Url,
68        plugin: Box<String>,
69        format_1: Box<String>,
70        format_2: Box<String>,
71    },
72    /// Could not load the configuration.
73    #[error("{loader} configuration loader could not {description} `{url}`")]
74    Load {
75        loader: String,
76        url: Url,
77        description: Box<String>,
78        source: anyhow::Error,
79    },
80    #[error("Could not found a loader that supports URL scheme `{scheme}` in given URL `{url}`")]
81    LoaderNotFound { scheme: String, url: Url },
82    #[error(transparent)]
83    Other(#[from] anyhow::Error),
84}
85
86/// Soft errors deserializer wrapper for URL query strings.
87///
88/// ### Example
89/// ```
90///
91/// use plugx_config::{
92///     loader::{SoftErrors, deserialize_query_string},
93///     ext::{url::Url, serde::Deserialize},
94/// };
95///
96/// // Define an enum for your own errors
97/// #[derive(Debug, PartialEq, Deserialize)]
98/// enum MySoftErrors {
99///     NotFound,
100///     Permission,
101///     Empty,
102/// }
103///
104/// // Define a struct for your own options
105/// // Include your own errors inside your options
106/// #[derive(Debug, PartialEq, Deserialize)]
107/// struct MyOptions {
108///     // The value should be string `all` or dot seperated values of `MySoftErrors`
109///     skip_errors: SoftErrors<MySoftErrors>,
110///     // Other options ...
111/// }
112///
113/// // `deserialize_query_string` function needs loader name to generate a good descriptive error
114/// let loader_name = "file-loader";
115///
116/// let url = Url::try_from("file://etc/config/file.toml?skip_errors=all").expect("Valid URL");
117/// let options: MyOptions = deserialize_query_string(loader_name, &url).expect("Parse options");
118/// assert_eq!(options, MyOptions{skip_errors: SoftErrors::new_all()});
119/// assert!(options.skip_errors.skip_all());
120///
121/// let url = Url::try_from("file://etc/config/file.toml?skip_errors=NotFound.Permission").expect("Valid URL");
122/// let options: MyOptions = deserialize_query_string(loader_name, &url).expect("Parse options");
123/// let skip_errors = options.skip_errors;
124/// assert!(skip_errors.contains(&MySoftErrors::NotFound));
125/// assert!(skip_errors.contains(&MySoftErrors::Permission));
126/// assert!(!skip_errors.contains(&MySoftErrors::Empty));
127/// assert!(!skip_errors.skip_all());
128/// assert_eq!(
129///     skip_errors.maybe_soft_error_list(),
130///       Some(&Vec::from([MySoftErrors::NotFound, MySoftErrors::Permission]))
131/// );
132/// ```
133#[derive(Debug, Clone, PartialEq, Serialize)]
134#[serde(rename_all = "kebab-case")]
135pub enum SoftErrors<T> {
136    All,
137    List(Vec<T>),
138}
139
140struct SoftErrorsVisitor<T> {
141    _marker: PhantomData<T>,
142}
143
144/// A trait to load configurations for one or more plugins.
145pub trait Loader: Send + Sync + Debug + Display {
146    /// List of URL schemes that this loader supports.
147    ///
148    /// Different URL may be assigned to this loader by their scheme value.
149    fn scheme_list(&self) -> Vec<String>;
150
151    /// Main method that actually loads configurations.
152    ///
153    /// * Checks the `url` and detects its own options from it.
154    /// * Checks whitelist to load just provided plugins configurations.
155    /// * Attempts to load configurations.
156    /// * Tries to set format for each [ConfigurationEntity].
157    fn load(
158        &self,
159        url: &Url,
160        maybe_whitelist: Option<&[String]>,
161        skip_soft_errors: bool,
162    ) -> Result<Vec<(String, ConfigurationEntity)>, Error>;
163}
164
165#[cfg(feature = "qs")]
166/// Checks query-string part of URL and tries to deserialize it to provided type. (`qs` Cargo feature)
167///
168/// For usage example see [SoftErrors].
169pub fn deserialize_query_string<T: serde::de::DeserializeOwned>(
170    loader_name: impl AsRef<str>,
171    url: &Url,
172) -> Result<T, Error> {
173    serde_qs::from_str(url.query().unwrap_or_default()).map_err(|error| Error::InvalidUrl {
174        loader: loader_name.as_ref().to_string(),
175        source: error.into(),
176        url: url.to_string(),
177    })
178}
179
180impl<'de, T: Deserialize<'de>> SoftErrors<T> {
181    pub fn new_all() -> Self {
182        Self::All
183    }
184
185    pub fn new_list() -> Self {
186        Self::List(Vec::with_capacity(0))
187    }
188
189    pub fn skip_all(&self) -> bool {
190        matches!(self, Self::All)
191    }
192
193    pub fn add_soft_error(&mut self, soft_error: T) {
194        if let Self::List(soft_errors) = self {
195            soft_errors.push(soft_error);
196        }
197    }
198
199    pub fn with_soft_error(mut self, soft_error: T) -> Self {
200        self.add_soft_error(soft_error);
201        self
202    }
203
204    pub fn maybe_soft_error_list(&self) -> Option<&Vec<T>> {
205        if let Self::List(soft_errors) = self {
206            Some(soft_errors)
207        } else {
208            None
209        }
210    }
211
212    pub fn maybe_soft_error_list_mut(&mut self) -> Option<&mut Vec<T>> {
213        if let Self::List(soft_errors) = self {
214            Some(soft_errors)
215        } else {
216            None
217        }
218    }
219}
220
221impl<'de, T: Deserialize<'de> + PartialEq> SoftErrors<T> {
222    pub fn contains(&self, soft_error: &T) -> bool {
223        if let Self::List(soft_errors) = self {
224            soft_errors.contains(soft_error)
225        } else {
226            true
227        }
228    }
229}
230
231impl<'de, T: Deserialize<'de>> Default for SoftErrors<T> {
232    fn default() -> Self {
233        Self::new_list()
234    }
235}
236
237impl<'de, T> serde::de::Visitor<'de> for SoftErrorsVisitor<T>
238where
239    T: Deserialize<'de>,
240{
241    type Value = SoftErrors<T>;
242
243    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
244        formatter.write_str("`all` or dot separated soft errors for configuration loader")
245    }
246
247    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
248    where
249        E: serde::de::Error,
250    {
251        let parts: Vec<_> = v
252            .split('.')
253            .filter(|item| !item.is_empty())
254            .map(String::from)
255            .collect();
256        if parts.contains(&"all".to_string()) {
257            Ok(SoftErrors::All)
258        } else {
259            Ok(SoftErrors::List(Vec::deserialize(
260                parts.into_deserializer(),
261            )?))
262        }
263    }
264
265    fn visit_borrowed_str<E>(self, v: &'de str) -> Result<Self::Value, E>
266    where
267        E: serde::de::Error,
268    {
269        self.visit_str(v)
270    }
271
272    fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
273    where
274        E: serde::de::Error,
275    {
276        self.visit_str(v.as_str())
277    }
278}
279
280impl<'de, T: Deserialize<'de>> Deserialize<'de> for SoftErrors<T> {
281    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
282    where
283        D: Deserializer<'de>,
284    {
285        deserializer.deserialize_str(SoftErrorsVisitor {
286            _marker: PhantomData,
287        })
288    }
289}