etopay_sdk/core/
config.rs

1//! Configuration for SDK
2//!
3//!
4
5use super::Sdk;
6use crate::error::{Error, Result};
7use crate::user::UserRepo;
8use crate::user::repository::UserRepoImpl;
9use log::info;
10use std::path::Path;
11use std::str::FromStr;
12
13/// Struct to configure the SDK
14#[derive(Debug)]
15pub struct Config {
16    /// The root folder used to access the file system. It is assumed that we have full read and
17    /// write permissions to this folder and that it already exists.
18    pub path_prefix: Box<Path>,
19
20    /// value of the X-APP-NAME header used to select the OAuth provider in the backend.
21    pub auth_provider: String,
22
23    /// URL to access the backend.
24    pub backend_url: reqwest::Url,
25
26    /// Log level for filtering which log messages that end up in the log file.
27    pub log_level: log::LevelFilter,
28}
29
30/// Struct representing the  deserialized version of the config in JSON format.
31#[derive(Debug, serde::Deserialize)]
32#[cfg_attr(test, derive(PartialEq))]
33struct DeserializedConfig {
34    backend_url: String,
35
36    #[serde(default = "default_log_level")]
37    log_level: String,
38
39    #[serde(default = "default_storage_path")]
40    storage_path: String,
41
42    auth_provider: String,
43}
44
45#[cfg(test)]
46impl Default for DeserializedConfig {
47    /// Create a new [`DeserializedConfig`].
48    fn default() -> Self {
49        Self {
50            backend_url: "http://example.com".to_string(),
51            auth_provider: "standalone".to_string(),
52            log_level: default_log_level(),
53            storage_path: default_storage_path(),
54        }
55    }
56}
57
58fn default_log_level() -> String {
59    "INFO".to_string()
60}
61fn default_storage_path() -> String {
62    ".".to_string()
63}
64
65/// To be used by bindings to deserialize JSON to the [`DeserializedConfig`] struct.
66impl FromStr for DeserializedConfig {
67    type Err = crate::Error;
68
69    fn from_str(s: &str) -> Result<Self> {
70        serde_json::from_str(s)
71            .map_err(|e| crate::Error::SetConfig(format!("Could not deserialize JSON config: {e:#?}")))
72    }
73}
74
75impl TryFrom<DeserializedConfig> for Config {
76    type Error = Error;
77
78    fn try_from(value: DeserializedConfig) -> Result<Self> {
79        let path_prefix = Path::new(&value.storage_path);
80        #[cfg(not(target_arch = "wasm32"))]
81        if !path_prefix.is_dir() {
82            return Err(crate::Error::SetConfig(
83                "storage_path must be a valid existing directory".to_string(),
84            ));
85        }
86
87        if value.auth_provider.is_empty() {
88            return Err(crate::Error::SetConfig("auth_provider must not be empty".to_string()));
89        }
90
91        Ok(Self {
92            backend_url: reqwest::Url::parse(&value.backend_url).map_err(|e| crate::Error::SetConfig(e.to_string()))?,
93            log_level: log::LevelFilter::from_str(&value.log_level)
94                .map_err(|e| crate::Error::SetConfig(format!("Could not parse log level: {e:#?}")))?,
95            auth_provider: value.auth_provider,
96            path_prefix: path_prefix.into(),
97        })
98    }
99}
100
101impl Config {
102    /// Load the [`Config`] directly from a JSON-formatted [`String`] or [`str`].
103    #[allow(clippy::result_large_err)]
104    pub fn from_json(json: impl AsRef<str>) -> Result<Self> {
105        let json = json.as_ref();
106        let deserialized_config: DeserializedConfig = json.parse()?;
107        Self::try_from(deserialized_config)
108    }
109}
110
111impl Sdk {
112    /// Set the [`Config`] needed by the SDK
113    #[allow(clippy::result_large_err)]
114    pub fn set_config(&mut self, config: Config) -> Result<()> {
115        info!("Setting config: {config:?}");
116        // TODO: do any destructing things if config already exists
117
118        // initialize the logger if we are not on wasm
119        #[cfg(not(target_arch = "wasm32"))]
120        {
121            let log_file = config.path_prefix.join("etopay_sdk.log");
122            let log_file_str = log_file
123                .to_str()
124                .ok_or_else(|| crate::Error::SetConfig(format!("config file path is not valid utf-8: {log_file:?}")))?;
125            crate::logger::init_logger(config.log_level, log_file_str)?;
126        }
127
128        self.config = Some(config);
129
130        // now do any necessary setup steps
131        self.initialize_user_repository()?;
132
133        Ok(())
134    }
135
136    /// Set path prefix
137    #[allow(clippy::result_large_err)]
138    fn initialize_user_repository(&mut self) -> Result<()> {
139        // initialize jammdb
140        #[cfg(feature = "jammdb_repo")]
141        let repo: Box<dyn UserRepo + Send + Sync> = {
142            let config = self.config.as_ref().ok_or(crate::Error::MissingConfig)?;
143            Box::new(UserRepoImpl::new(crate::user::file_storage::FileUserStorage::new(
144                &config.path_prefix,
145            )?))
146        };
147
148        // for wasm: try browser, and fallback to in-memory if it fails!
149        #[cfg(target_arch = "wasm32")]
150        let repo: Box<dyn UserRepo + Send + Sync> = {
151            // try to access to browser local storage
152            let browser_storage = crate::user::web_storage::BrowserLocalStorage::new();
153            if browser_storage.is_available() {
154                Box::new(UserRepoImpl::new(browser_storage))
155            } else {
156                log::warn!("Browser Local Storage is not available, falling back to in-memory user storage!");
157                Box::new(UserRepoImpl::new(crate::user::memory_storage::MemoryUserStorage::new()))
158            }
159        };
160
161        // if we are not compiling for wasm and the jammdb_repo feature is not active, use an
162        // in-memory storage.
163        #[cfg(all(not(target_arch = "wasm32"), not(feature = "jammdb_repo")))]
164        let repo: Box<dyn UserRepo + Send + Sync> =
165            Box::new(UserRepoImpl::new(crate::user::memory_storage::MemoryUserStorage::new()));
166
167        self.repo = Some(repo);
168        Ok(())
169    }
170}
171
172#[cfg(test)]
173#[allow(clippy::expect_used)] // this is testing code so expect is fine
174impl Config {
175    /// Create a new [`Config`] with a [`testing::CleanUp`] as the path_prefix.
176    pub fn new_test_with_cleanup() -> (Self, testing::CleanUp) {
177        let cleanup = testing::CleanUp::default();
178        (
179            Self {
180                backend_url: reqwest::Url::parse("http://example.com").expect("should be a valid url"),
181                path_prefix: Path::new(&cleanup.path_prefix).into(),
182                auth_provider: "standalone".to_string(),
183                log_level: log::LevelFilter::Debug,
184            },
185            cleanup,
186        )
187    }
188
189    /// Create a new [`Config`] with the specified backend url and a [`testing::CleanUp`] as the path_prefix.
190    pub fn new_test_with_cleanup_url(url: &str) -> (Self, testing::CleanUp) {
191        let cleanup = testing::CleanUp::default();
192        (
193            Self {
194                backend_url: url.parse().expect("should be a valid url"),
195                path_prefix: Path::new(&cleanup.path_prefix).into(),
196                auth_provider: "standalone".to_string(),
197                log_level: log::LevelFilter::Debug,
198            },
199            cleanup,
200        )
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use rstest::rstest;
208
209    fn valid_deserialized_config() -> DeserializedConfig {
210        DeserializedConfig {
211            backend_url: "http://example.com".to_string(),
212            log_level: "INFO".to_string(),
213            storage_path: ".".to_string(),
214            auth_provider: "nonempty".to_string(),
215        }
216    }
217
218    #[test]
219    fn test_valid_config() {
220        Config::try_from(valid_deserialized_config()).unwrap();
221    }
222
223    #[test]
224    fn test_path_prefix_error() {
225        let mut config = valid_deserialized_config();
226        config.storage_path = "nonexistent_file.txt".to_string();
227
228        Config::try_from(config).unwrap_err();
229    }
230
231    #[test]
232    fn test_empty_auth_provider_error() {
233        let mut config = valid_deserialized_config();
234        config.auth_provider = "".to_string();
235
236        Config::try_from(config).unwrap_err();
237    }
238
239    #[test]
240    fn test_invalid_backend_url_error() {
241        let mut config = valid_deserialized_config();
242        config.backend_url = "invalid url".to_string();
243
244        Config::try_from(config).unwrap_err();
245    }
246
247    #[test]
248    fn test_default_log_level() {
249        let log_level = default_log_level();
250        assert_eq!(log_level, "INFO".to_string())
251    }
252
253    #[test]
254    fn test_default_storage_path() {
255        let storage_path = default_storage_path();
256        assert_eq!(storage_path, ".".to_string())
257    }
258
259    #[rstest]
260    #[case(
261        r#"{
262            "backend_url": "http://example.com",
263            "storage_path": ".",
264            "log_level": "INFO",
265            "auth_provider": "standalone"
266          }"#,
267        Ok(DeserializedConfig::default())
268    )]
269    #[case(
270        r#"{
271            "backend_url": "http://example.com
272          }"#,
273        Err(crate::Error::SetConfig("Could not deserialize JSON config: ...".to_string())),
274    )]
275    fn test_deserialized_config_from_str(#[case] input: &str, #[case] expected: Result<DeserializedConfig>) {
276        let result = DeserializedConfig::from_str(input);
277        match expected {
278            Ok(resp) => {
279                assert_eq!(result.unwrap(), resp);
280            }
281            Err(_expected_err) => {
282                result.unwrap_err();
283            }
284        }
285    }
286}