dittolive-ditto 5.2.0-preview.1

Ditto is a peer to peer cross-platform database that allows mobile, web, IoT and server apps to sync with or without an internet connection.
use std::{collections::HashMap, path::PathBuf};

use serde::{Deserialize, Serialize};
use serde_with::{base64::Base64, serde_as};
use url::Url;

const SYSTEM_PARAMETER_DEPTH_SAFETY_CEILING: usize = 128;

/// Configuration options required to initialize a [`Ditto`] instance.
///
/// # Example
///
/// ```
/// # use dittolive_ditto::prelude::*;
/// const YOUR_DATABASE_ID: &str = "REPLACE ME WITH YOUR APP/DATABASE ID";
/// const YOUR_AUTH_URL: &str = "https://your-app.cloud.ditto.live";
/// const YOUR_PRIVATE_KEY: &str = "REPLACE ME WITH YOUR PRIVATE KEY";
///
/// let connect = DittoConfigConnect::Server {
///     url: YOUR_AUTH_URL.parse().unwrap(),
/// };
/// let connect = DittoConfigConnect::SmallPeersOnly {
///     private_key: Some(YOUR_PRIVATE_KEY.as_bytes().to_vec()),
/// };
/// let connect = DittoConfigConnect::SmallPeersOnly { private_key: None };
/// let config = DittoConfig::new(YOUR_DATABASE_ID, connect);
/// ```
///
/// [`Ditto`]: crate::Ditto
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DittoConfig {
    /// An ID used to distinguish between Ditto database instantiations.
    ///
    /// **Note**: This was formerly known as `appId`.
    pub database_id: String,

    /// The connection configuration this device uses to connect to a Ditto mesh.
    pub connect: DittoConfigConnect,

    /// The directory where Ditto will store its data.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub persistence_directory: Option<PathBuf>,

    #[doc(hidden)]
    #[serde(default)]
    pub experimental: DittoConfigExperimental,

    /// System parameter overrides applied before Ditto initializes its subsystems.
    ///
    /// Each key is a system parameter name. Names are case-insensitive. Each value must match the
    /// parameter's Core-defined type and validation rules. The supported [`serde_json::Value`]
    /// shapes are:
    ///
    /// - booleans and strings;
    /// - nonnegative integers through [`u64::MAX`], negative integers through [`i64::MIN`], and
    ///   finite floating-point numbers; and
    /// - arrays and string-keyed objects containing those values recursively.
    ///
    /// Nonnegative integers are normalized to Core's unsigned integer representation, regardless
    /// of whether they were constructed from a signed or unsigned Rust integer. JSON `null` is not
    /// a system parameter type and is rejected, including inside arrays and objects. Binary values
    /// and CBOR tags cannot be represented by [`serde_json::Value`].
    ///
    /// Unsupported SDK value shapes, such as JSON `null`, return
    /// [`ErrorKind::InvalidInput`](crate::error::ErrorKind::InvalidInput). Unknown parameter
    /// names, values of the wrong type, and values outside a parameter's valid range cause
    /// [`Ditto::open`](crate::Ditto::open) and
    /// [`Ditto::open_sync`](crate::Ditto::open_sync) to return
    /// [`CoreApiErrorKind::ValidationInvalidDittoConfig`](crate::error::CoreApiErrorKind::ValidationInvalidDittoConfig).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub system_parameters: HashMap<String, serde_json::Value>,
}

#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct InternalConfig {
    /// SDK-computed v4 default persistence directory path for v4→v5 migration fallback.
    ///
    /// This is set automatically by `Ditto::open()` / `Ditto::open_sync()` when
    /// `persistence_directory` is `None`. It should not normally be set by users.
    ///
    /// See: SDKS-3187
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) legacy_persistence_directory: Option<PathBuf>,
}

#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct ActualConfig {
    #[serde(flatten)]
    pub(crate) customer_facing: DittoConfig,
    #[serde(flatten)]
    pub(crate) internal: InternalConfig,
}

impl DittoConfig {
    /// Create a new [`DittoConfig`] instance from a database ID and connection configuration.
    pub fn new<S: Into<String>>(database_id: S, connect: DittoConfigConnect) -> Self {
        DittoConfig {
            database_id: database_id.into(),
            connect,
            persistence_directory: None,
            experimental: Default::default(),
            system_parameters: Default::default(),
        }
    }

    /// Optionally specify a custom persistence directory.
    pub fn with_persistence_directory<P: Into<PathBuf>>(mut self, path: P) -> Self {
        self.persistence_directory = Some(path.into());
        self
    }

    pub(crate) fn validate_system_parameter_value_shapes(&self) -> Result<(), String> {
        for (name, value) in &self.system_parameters {
            let mut pending = vec![(value, 1_usize)];

            while let Some((value, depth)) = pending.pop() {
                if depth > SYSTEM_PARAMETER_DEPTH_SAFETY_CEILING
                    && matches!(
                        value,
                        serde_json::Value::Array(_) | serde_json::Value::Object(_)
                    )
                {
                    return Err(format!("system parameter `{name}` is nested too deeply"));
                }

                match value {
                    serde_json::Value::Null => {
                        return Err(format!(
                            "system parameter `{name}` contains JSON null; expected a boolean, \
                             string, number, array, or object"
                        ));
                    }
                    serde_json::Value::Array(values) => {
                        pending.extend(values.iter().map(|value| (value, depth + 1)));
                    }
                    serde_json::Value::Object(values) => {
                        pending.extend(values.values().map(|value| (value, depth + 1)));
                    }
                    serde_json::Value::Bool(_)
                    | serde_json::Value::Number(_)
                    | serde_json::Value::String(_) => {}
                }
            }
        }

        Ok(())
    }
}

/// Specifies the connection mechanism to be used by the [`Ditto`] instance.
///
/// # Example
///
/// ```
/// use dittolive_ditto::prelude::*;
/// let connect_config = DittoConfigConnect::Server {
///     url: "https://example.com/your-server-url".parse().unwrap(),
/// };
/// let connect_config = DittoConfigConnect::SmallPeersOnly {
///     private_key: Some("REPLACE_ME_WITH_YOUR_PRIVATE_KEY".bytes().collect()),
/// };
/// let connect_config = DittoConfigConnect::SmallPeersOnly { private_key: None };
/// ```
///
/// [`Ditto`]: crate::Ditto
#[serde_as]
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum DittoConfigConnect {
    /// Connect to a Ditto "Big Peer" using the provided URL.
    ///
    /// When using `Server` mode, you _must_ provide an authentication expiration
    /// handler using [`ditto.auth()?.set_expiration_handler(...)`][0].
    ///
    /// [0]: crate::prelude::DittoAuthenticator::set_expiration_handler
    Server {
        /// The "Auth URL" from the Ditto Portal, if using a Ditto-hosted Big Peer.
        ///
        /// If using a self-hosted Big Peer, provide your custom authentication URL.
        ///
        /// # Example
        ///
        /// ```text
        /// // Where this subdomain is your Ditto `databaseId` (formerly `AppId`)
        /// https://00000000-0000-0000-0000-000000000000.cloud.ditto.live
        /// ```
        url: Url,
    },
    /// Connect only to other Ditto small peers, using the provided private key.
    ///
    /// If no private key is provided, sync traffic will be UNENCRYPTED. This
    /// mode should NOT be used in production, only for development and testing
    /// purposes.
    SmallPeersOnly {
        /// Optional private key for encrypting sync traffic between small peers.
        ///
        /// If no private key is provided, sync traffic will be unencrypted. This
        /// is meant only for development and testing use-cases. All production
        /// use-cases should provide a private key so traffic will be encrypted.
        #[serde_as(as = "Option<Base64>")]
        #[serde(skip_serializing_if = "Option::is_none")]
        private_key: Option<Vec<u8>>,
    },
}

#[doc(hidden)]
#[non_exhaustive]
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct DittoConfigExperimental {
    /// Retained for configuration compatibility. A non-null value is rejected when opening
    /// Ditto.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub passphrase: Option<String>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_config_json_serialization_basic() {
        let config = DittoConfig::new(
            "test-database-id",
            DittoConfigConnect::SmallPeersOnly { private_key: None },
        );

        let json = serde_json::to_string(&config).unwrap();
        assert!(!json.contains("system_parameters"));
        let deserialized: DittoConfig = serde_json::from_str(&json).unwrap();

        assert_eq!(config.database_id, deserialized.database_id);
        assert!(deserialized.system_parameters.is_empty());
        assert!(deserialized.persistence_directory.is_none());
    }

    #[test]
    fn test_config_json_with_system_parameters() {
        let json = r#"{
            "database_id": "test-database-id",
            "connect": {
                "type": "small_peers_only"
            },
            "system_parameters": {
                "my_system_parameter": 42
            }
        }"#;
        let deserialized: DittoConfig = serde_json::from_str(json).unwrap();

        assert_eq!(deserialized.database_id, "test-database-id");
        assert_eq!(
            deserialized.system_parameters.get("my_system_parameter"),
            Some(&serde_json::json!(42))
        );
    }

    #[test]
    fn test_config_cbor_from_default() {
        let payload = ffi_sdk::dittoffi_ditto_config_default();
        let _deserialized: DittoConfig = serde_cbor::from_slice(&payload).unwrap();
    }
}