dittolive_ditto/ditto/init/
config.rs

1use std::{collections::HashMap, path::PathBuf};
2
3use serde::{Deserialize, Serialize};
4use serde_with::{base64::Base64, serde_as};
5use url::Url;
6
7const SYSTEM_PARAMETER_DEPTH_SAFETY_CEILING: usize = 128;
8
9/// Configuration options required to initialize a [`Ditto`] instance.
10///
11/// # Example
12///
13/// ```
14/// # use dittolive_ditto::prelude::*;
15/// const YOUR_DATABASE_ID: &str = "REPLACE ME WITH YOUR APP/DATABASE ID";
16/// const YOUR_AUTH_URL: &str = "https://your-app.cloud.ditto.live";
17/// const YOUR_PRIVATE_KEY: &str = "REPLACE ME WITH YOUR PRIVATE KEY";
18///
19/// let connect = DittoConfigConnect::Server {
20///     url: YOUR_AUTH_URL.parse().unwrap(),
21/// };
22/// let connect = DittoConfigConnect::SmallPeersOnly {
23///     private_key: Some(YOUR_PRIVATE_KEY.as_bytes().to_vec()),
24/// };
25/// let connect = DittoConfigConnect::SmallPeersOnly { private_key: None };
26/// let config = DittoConfig::new(YOUR_DATABASE_ID, connect);
27/// ```
28///
29/// [`Ditto`]: crate::Ditto
30#[non_exhaustive]
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct DittoConfig {
33    /// An ID used to distinguish between Ditto database instantiations.
34    ///
35    /// **Note**: This was formerly known as `appId`.
36    pub database_id: String,
37
38    /// The connection configuration this device uses to connect to a Ditto mesh.
39    pub connect: DittoConfigConnect,
40
41    /// The directory where Ditto will store its data.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub persistence_directory: Option<PathBuf>,
44
45    #[doc(hidden)]
46    #[serde(default)]
47    pub experimental: DittoConfigExperimental,
48
49    /// System parameter overrides applied before Ditto initializes its subsystems.
50    ///
51    /// Each key is a system parameter name. Names are case-insensitive. Each value must match the
52    /// parameter's Core-defined type and validation rules. The supported [`serde_json::Value`]
53    /// shapes are:
54    ///
55    /// - booleans and strings;
56    /// - nonnegative integers through [`u64::MAX`], negative integers through [`i64::MIN`], and
57    ///   finite floating-point numbers; and
58    /// - arrays and string-keyed objects containing those values recursively.
59    ///
60    /// Nonnegative integers are normalized to Core's unsigned integer representation, regardless
61    /// of whether they were constructed from a signed or unsigned Rust integer. JSON `null` is not
62    /// a system parameter type and is rejected, including inside arrays and objects. Binary values
63    /// and CBOR tags cannot be represented by [`serde_json::Value`].
64    ///
65    /// Unsupported SDK value shapes, such as JSON `null`, return
66    /// [`ErrorKind::InvalidInput`](crate::error::ErrorKind::InvalidInput). Unknown parameter
67    /// names, values of the wrong type, and values outside a parameter's valid range cause
68    /// [`Ditto::open`](crate::Ditto::open) and
69    /// [`Ditto::open_sync`](crate::Ditto::open_sync) to return
70    /// [`CoreApiErrorKind::ValidationInvalidDittoConfig`](crate::error::CoreApiErrorKind::ValidationInvalidDittoConfig).
71    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
72    pub system_parameters: HashMap<String, serde_json::Value>,
73}
74
75#[derive(Debug, Serialize, Deserialize)]
76pub(crate) struct InternalConfig {
77    /// SDK-computed v4 default persistence directory path for v4→v5 migration fallback.
78    ///
79    /// This is set automatically by `Ditto::open()` / `Ditto::open_sync()` when
80    /// `persistence_directory` is `None`. It should not normally be set by users.
81    ///
82    /// See: SDKS-3187
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub(crate) legacy_persistence_directory: Option<PathBuf>,
85}
86
87#[derive(Debug, Serialize, Deserialize)]
88pub(crate) struct ActualConfig {
89    #[serde(flatten)]
90    pub(crate) customer_facing: DittoConfig,
91    #[serde(flatten)]
92    pub(crate) internal: InternalConfig,
93}
94
95impl DittoConfig {
96    /// Create a new [`DittoConfig`] instance from a database ID and connection configuration.
97    pub fn new<S: Into<String>>(database_id: S, connect: DittoConfigConnect) -> Self {
98        DittoConfig {
99            database_id: database_id.into(),
100            connect,
101            persistence_directory: None,
102            experimental: Default::default(),
103            system_parameters: Default::default(),
104        }
105    }
106
107    /// Optionally specify a custom persistence directory.
108    pub fn with_persistence_directory<P: Into<PathBuf>>(mut self, path: P) -> Self {
109        self.persistence_directory = Some(path.into());
110        self
111    }
112
113    pub(crate) fn validate_system_parameter_value_shapes(&self) -> Result<(), String> {
114        for (name, value) in &self.system_parameters {
115            let mut pending = vec![(value, 1_usize)];
116
117            while let Some((value, depth)) = pending.pop() {
118                if depth > SYSTEM_PARAMETER_DEPTH_SAFETY_CEILING
119                    && matches!(
120                        value,
121                        serde_json::Value::Array(_) | serde_json::Value::Object(_)
122                    )
123                {
124                    return Err(format!("system parameter `{name}` is nested too deeply"));
125                }
126
127                match value {
128                    serde_json::Value::Null => {
129                        return Err(format!(
130                            "system parameter `{name}` contains JSON null; expected a boolean, \
131                             string, number, array, or object"
132                        ));
133                    }
134                    serde_json::Value::Array(values) => {
135                        pending.extend(values.iter().map(|value| (value, depth + 1)));
136                    }
137                    serde_json::Value::Object(values) => {
138                        pending.extend(values.values().map(|value| (value, depth + 1)));
139                    }
140                    serde_json::Value::Bool(_)
141                    | serde_json::Value::Number(_)
142                    | serde_json::Value::String(_) => {}
143                }
144            }
145        }
146
147        Ok(())
148    }
149}
150
151/// Specifies the connection mechanism to be used by the [`Ditto`] instance.
152///
153/// # Example
154///
155/// ```
156/// use dittolive_ditto::prelude::*;
157/// let connect_config = DittoConfigConnect::Server {
158///     url: "https://example.com/your-server-url".parse().unwrap(),
159/// };
160/// let connect_config = DittoConfigConnect::SmallPeersOnly {
161///     private_key: Some("REPLACE_ME_WITH_YOUR_PRIVATE_KEY".bytes().collect()),
162/// };
163/// let connect_config = DittoConfigConnect::SmallPeersOnly { private_key: None };
164/// ```
165///
166/// [`Ditto`]: crate::Ditto
167#[serde_as]
168#[non_exhaustive]
169#[derive(Debug, Clone, Serialize, Deserialize)]
170#[serde(tag = "type", rename_all = "snake_case")]
171pub enum DittoConfigConnect {
172    /// Connect to a Ditto "Big Peer" using the provided URL.
173    ///
174    /// When using `Server` mode, you _must_ provide an authentication expiration
175    /// handler using [`ditto.auth()?.set_expiration_handler(...)`][0].
176    ///
177    /// [0]: crate::prelude::DittoAuthenticator::set_expiration_handler
178    Server {
179        /// The "Auth URL" from the Ditto Portal, if using a Ditto-hosted Big Peer.
180        ///
181        /// If using a self-hosted Big Peer, provide your custom authentication URL.
182        ///
183        /// # Example
184        ///
185        /// ```text
186        /// // Where this subdomain is your Ditto `databaseId` (formerly `AppId`)
187        /// https://00000000-0000-0000-0000-000000000000.cloud.ditto.live
188        /// ```
189        url: Url,
190    },
191    /// Connect only to other Ditto small peers, using the provided private key.
192    ///
193    /// If no private key is provided, sync traffic will be UNENCRYPTED. This
194    /// mode should NOT be used in production, only for development and testing
195    /// purposes.
196    SmallPeersOnly {
197        /// Optional private key for encrypting sync traffic between small peers.
198        ///
199        /// If no private key is provided, sync traffic will be unencrypted. This
200        /// is meant only for development and testing use-cases. All production
201        /// use-cases should provide a private key so traffic will be encrypted.
202        #[serde_as(as = "Option<Base64>")]
203        #[serde(skip_serializing_if = "Option::is_none")]
204        private_key: Option<Vec<u8>>,
205    },
206}
207
208#[doc(hidden)]
209#[non_exhaustive]
210#[derive(Debug, Default, Clone, Serialize, Deserialize)]
211pub struct DittoConfigExperimental {
212    /// Retained for configuration compatibility. A non-null value is rejected when opening
213    /// Ditto.
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub passphrase: Option<String>,
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn test_config_json_serialization_basic() {
224        let config = DittoConfig::new(
225            "test-database-id",
226            DittoConfigConnect::SmallPeersOnly { private_key: None },
227        );
228
229        let json = serde_json::to_string(&config).unwrap();
230        assert!(!json.contains("system_parameters"));
231        let deserialized: DittoConfig = serde_json::from_str(&json).unwrap();
232
233        assert_eq!(config.database_id, deserialized.database_id);
234        assert!(deserialized.system_parameters.is_empty());
235        assert!(deserialized.persistence_directory.is_none());
236    }
237
238    #[test]
239    fn test_config_json_with_system_parameters() {
240        let json = r#"{
241            "database_id": "test-database-id",
242            "connect": {
243                "type": "small_peers_only"
244            },
245            "system_parameters": {
246                "my_system_parameter": 42
247            }
248        }"#;
249        let deserialized: DittoConfig = serde_json::from_str(json).unwrap();
250
251        assert_eq!(deserialized.database_id, "test-database-id");
252        assert_eq!(
253            deserialized.system_parameters.get("my_system_parameter"),
254            Some(&serde_json::json!(42))
255        );
256    }
257
258    #[test]
259    fn test_config_cbor_from_default() {
260        let payload = ffi_sdk::dittoffi_ditto_config_default();
261        let _deserialized: DittoConfig = serde_cbor::from_slice(&payload).unwrap();
262    }
263}