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/// The default log file used by the sdk to write logs
14const ETOPAY_LOGFILE: &str = "etopay_sdk.log";
15
16/// Struct to configure the SDK
17#[derive(Debug)]
18pub struct Config {
19    /// The root folder used to access the file system. It is assumed that we have full read and
20    /// write permissions to this folder and that it already exists.
21    pub path_prefix: Box<Path>,
22
23    /// value of the X-APP-NAME header used to select the OAuth provider in the backend.
24    pub auth_provider: String,
25
26    /// URL to access the backend.
27    pub backend_url: reqwest::Url,
28
29    /// Log level for filtering which log messages that end up in the log file.
30    pub log_level: log::LevelFilter,
31}
32
33/// Struct representing the  deserialized version of the config in JSON format.
34#[derive(Debug, serde::Deserialize)]
35#[cfg_attr(test, derive(PartialEq))]
36struct DeserializedConfig {
37    backend_url: String,
38
39    #[serde(default = "default_log_level")]
40    log_level: String,
41
42    #[serde(default = "default_storage_path")]
43    storage_path: String,
44
45    auth_provider: String,
46}
47
48#[cfg(test)]
49impl Default for DeserializedConfig {
50    /// Create a new [`DeserializedConfig`].
51    fn default() -> Self {
52        Self {
53            backend_url: "http://example.com".to_string(),
54            auth_provider: "standalone".to_string(),
55            log_level: default_log_level(),
56            storage_path: default_storage_path(),
57        }
58    }
59}
60
61fn default_log_level() -> String {
62    "INFO".to_string()
63}
64fn default_storage_path() -> String {
65    ".".to_string()
66}
67
68/// To be used by bindings to deserialize JSON to the [`DeserializedConfig`] struct.
69impl FromStr for DeserializedConfig {
70    type Err = crate::Error;
71
72    fn from_str(s: &str) -> Result<Self> {
73        serde_json::from_str(s)
74            .map_err(|e| crate::Error::SetConfig(format!("Could not deserialize JSON config: {e:#?}")))
75    }
76}
77
78impl TryFrom<DeserializedConfig> for Config {
79    type Error = Error;
80
81    fn try_from(value: DeserializedConfig) -> Result<Self> {
82        let path_prefix = Path::new(&value.storage_path);
83        #[cfg(not(target_arch = "wasm32"))]
84        if !path_prefix.is_dir() {
85            return Err(crate::Error::SetConfig(
86                "storage_path must be a valid existing directory".to_string(),
87            ));
88        }
89
90        if value.auth_provider.is_empty() {
91            return Err(crate::Error::SetConfig("auth_provider must not be empty".to_string()));
92        }
93
94        Ok(Self {
95            backend_url: reqwest::Url::parse(&value.backend_url).map_err(|e| crate::Error::SetConfig(e.to_string()))?,
96            log_level: log::LevelFilter::from_str(&value.log_level)
97                .map_err(|e| crate::Error::SetConfig(format!("Could not parse log level: {e:#?}")))?,
98            auth_provider: value.auth_provider,
99            path_prefix: path_prefix.into(),
100        })
101    }
102}
103
104impl Config {
105    /// Load the [`Config`] directly from a JSON-formatted [`String`] or [`str`].
106    pub fn from_json(json: impl AsRef<str>) -> Result<Self> {
107        let json = json.as_ref();
108        let deserialized_config: DeserializedConfig = json.parse()?;
109        Self::try_from(deserialized_config)
110    }
111}
112
113impl Sdk {
114    /// Set the [`Config`] needed by the SDK
115    pub fn set_config(&mut self, config: Config) -> Result<()> {
116        info!("Setting config: {config:?}");
117        // TODO: do any destructing things if config already exists
118
119        // initialize the logger if we are not on wasm
120        #[cfg(not(target_arch = "wasm32"))]
121        {
122            let log_file = config.path_prefix.join(ETOPAY_LOGFILE);
123            let log_file_str = log_file
124                .to_str()
125                .ok_or_else(|| crate::Error::SetConfig(format!("config file path is not valid utf-8: {log_file:?}")))?;
126            crate::logger::init_logger(config.log_level, log_file_str)?;
127        }
128
129        self.config = Some(config);
130
131        // now do any necessary setup steps
132        self.initialize_user_repository()?;
133
134        Ok(())
135    }
136
137    /// Set path prefix
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}