newton-core 0.7.2

newton protocol core sdk
//! Storage backend configuration.
//!
//! Selects and constructs the [`UserStorageBackend`] used for content-addressed
//! objects: a local filesystem tree in development and S3 in production. It
//! also carries limits enforced by the authenticated storage endpoint.

use std::sync::Arc;

use eyre::Result;
use serde::{Deserialize, Serialize};

use super::{
    backend::{LocalBackend, UserStorageBackend},
    resolver::PersistedImmutableDataResolver,
};

/// Default cap on a single uploaded object (16 MiB).
const fn default_max_object_bytes() -> usize {
    16 * 1024 * 1024
}

/// Default cap on the number of objects one API key may own.
const fn default_max_objects_per_owner() -> usize {
    256
}

/// Which storage backend to construct.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum StorageBackendConfig {
    /// Local filesystem tree rooted at `dir` (dev/test).
    Local {
        /// Root directory for stored objects.
        dir: std::path::PathBuf,
    },
    /// S3 bucket in `region` (prod). Requires the `persisted-immutable-data-s3` feature.
    S3 {
        /// Bucket name.
        bucket: String,
        /// AWS region.
        #[serde(default = "default_s3_region")]
        region: String,
    },
}

fn default_s3_region() -> String {
    "us-west-2".to_string()
}

impl Default for StorageBackendConfig {
    fn default() -> Self {
        Self::Local {
            dir: std::path::PathBuf::from("./data/persisted-immutable-data"),
        }
    }
}

/// Storage configuration: backend selection + endpoint limits.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct StorageConfig {
    /// Backend to construct.
    pub backend: StorageBackendConfig,
    /// Maximum size of a single uploaded object, in bytes.
    #[serde(default = "default_max_object_bytes")]
    pub max_object_bytes: usize,
    /// Maximum number of objects a single owner (API key) may store.
    #[serde(default = "default_max_objects_per_owner")]
    pub max_objects_per_owner: usize,
}

impl Default for StorageConfig {
    fn default() -> Self {
        Self {
            backend: StorageBackendConfig::default(),
            max_object_bytes: default_max_object_bytes(),
            max_objects_per_owner: default_max_objects_per_owner(),
        }
    }
}

impl StorageConfig {
    /// Construct the configured backend.
    ///
    /// S3 selection errors unless the crate was built with `persisted-immutable-data-s3`.
    pub async fn build_backend(&self) -> Result<Arc<dyn UserStorageBackend>> {
        match &self.backend {
            StorageBackendConfig::Local { dir } => Ok(Arc::new(LocalBackend::new(dir))),
            StorageBackendConfig::S3 { bucket, region } => {
                #[cfg(feature = "persisted-immutable-data-s3")]
                {
                    let backend = super::backend::S3Backend::new(bucket.clone(), region.clone()).await?;
                    Ok(Arc::new(backend))
                }
                #[cfg(not(feature = "persisted-immutable-data-s3"))]
                {
                    let _ = (bucket, region);
                    eyre::bail!("S3 storage backend requires the `persisted-immutable-data-s3` feature")
                }
            }
        }
    }

    /// Construct a content-addressed resolver over an already-built backend.
    pub fn build_resolver(&self, backend: Arc<dyn UserStorageBackend>) -> PersistedImmutableDataResolver {
        PersistedImmutableDataResolver::new(backend)
    }
}

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

    #[test]
    fn defaults_are_local() {
        let cfg = StorageConfig::default();
        assert!(matches!(cfg.backend, StorageBackendConfig::Local { .. }));
        assert_eq!(cfg.max_object_bytes, 16 * 1024 * 1024);
        assert_eq!(cfg.max_objects_per_owner, 256);
    }

    #[test]
    fn local_config_roundtrips() {
        let json = serde_json::json!({
            "max_object_bytes": 1024,
            "max_objects_per_owner": 8,
            "backend": { "kind": "local", "dir": "/var/newton/persisted-immutable-data" }
        });
        let cfg: StorageConfig = serde_json::from_value(json).unwrap();
        assert_eq!(cfg.max_object_bytes, 1024);
        assert_eq!(cfg.max_objects_per_owner, 8);
        assert_eq!(
            cfg.backend,
            StorageBackendConfig::Local {
                dir: "/var/newton/persisted-immutable-data".into()
            }
        );
    }

    #[test]
    fn s3_config_parses_with_default_region() {
        let json = serde_json::json!({
            "backend": { "kind": "s3", "bucket": "newton-persisted-immutable-data-prod" }
        });
        let cfg: StorageConfig = serde_json::from_value(json).unwrap();
        assert_eq!(
            cfg.backend,
            StorageBackendConfig::S3 {
                bucket: "newton-persisted-immutable-data-prod".into(),
                region: "us-west-2".into(),
            }
        );
    }

    #[tokio::test]
    async fn build_local_backend_works() {
        let tmp = tempfile::tempdir().unwrap();
        let cfg = StorageConfig {
            backend: StorageBackendConfig::Local {
                dir: tmp.path().to_path_buf(),
            },
            ..Default::default()
        };
        let backend = cfg.build_backend().await.unwrap();
        backend.put("k", b"v").await.unwrap();
        assert_eq!(backend.get("k").await.unwrap(), b"v");
    }
}