mhome-core-api 1.2.0

Shared wire types and service contracts for the mHome core
Documentation
use serde::{Deserialize, Serialize};
use std::fmt;

pub const STORAGE_NODE_TYPE: &str = "storage";
pub const STORAGE_PROTOCOL_VERSION: &str = "storage.v1";

pub const STORAGE_DESCRIBE_ROUTE: &str = "/storage/describe";
pub const STORAGE_ENSURE_NAMESPACE_ROUTE: &str = "/storage/namespace/ensure";
pub const STORAGE_NAMESPACE_STATUS_ROUTE: &str = "/storage/namespace/status";
pub const STORAGE_UPDATE_NAMESPACE_POLICY_ROUTE: &str = "/storage/namespace/update-policy";
pub const STORAGE_LIST_NAMESPACES_ROUTE: &str = "/storage/namespaces/list";
pub const STORAGE_ISSUE_SESSION_ROUTE: &str = "/storage/session/issue";
pub const STORAGE_STATS_ROUTE: &str = "/storage/stats";
pub const STORAGE_READINESS_ROUTE: &str = "/storage/readiness";

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum StorageOverflowStrategy {
    #[default]
    Reject,
    EvictOldest,
}

impl StorageOverflowStrategy {
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Reject => "reject",
            Self::EvictOldest => "evictOldest",
        }
    }

    pub fn parse(value: &str) -> Result<Self, UnsupportedStorageOverflowStrategy> {
        match value {
            "reject" => Ok(Self::Reject),
            "evictOldest" => Ok(Self::EvictOldest),
            _ => Err(UnsupportedStorageOverflowStrategy(value.to_string())),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnsupportedStorageOverflowStrategy(String);

impl fmt::Display for UnsupportedStorageOverflowStrategy {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            formatter,
            "unsupported storage overflow strategy: {}",
            self.0
        )
    }
}

impl std::error::Error for UnsupportedStorageOverflowStrategy {}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum StoragePermission {
    Read,
    Write,
    Delete,
    List,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StorageEnsureNamespaceRequest {
    pub name: String,
    pub created_by_node_type: String,
    pub created_by_node_id: String,
    pub max_bytes: u64,
    #[serde(default)]
    pub overflow_strategy: StorageOverflowStrategy,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StorageNamespacePolicyUpdate {
    pub namespace_id: String,
    pub expected_revision: u64,
    pub max_bytes: u64,
    pub overflow_strategy: StorageOverflowStrategy,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StorageNamespaceIdRequest {
    pub namespace_id: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StorageSessionRequest {
    pub namespace_id: String,
    #[serde(default)]
    pub permissions: Vec<StoragePermission>,
    #[serde(default)]
    pub ttl_seconds: Option<u64>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StorageNamespacePolicy {
    pub max_bytes: u64,
    pub overflow_strategy: StorageOverflowStrategy,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StorageNamespace {
    pub id: String,
    pub name: String,
    pub created_by_node_type: String,
    pub created_by_node_id: String,
    pub max_bytes: u64,
    pub overflow_strategy: StorageOverflowStrategy,
    pub used_bytes: u64,
    pub reserved_bytes: u64,
    pub object_count: u64,
    pub evicted_object_count: u64,
    pub evicted_bytes: u64,
    pub rejected_write_count: u64,
    pub revision: u64,
    pub created_at_ms: u64,
    pub updated_at_ms: u64,
}

impl StorageNamespace {
    pub const fn policy(&self) -> StorageNamespacePolicy {
        StorageNamespacePolicy {
            max_bytes: self.max_bytes,
            overflow_strategy: self.overflow_strategy,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StorageNamespaceList {
    pub namespaces: Vec<StorageNamespace>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StorageSession {
    pub protocol_version: String,
    pub repository_id: String,
    pub namespace: StorageNamespace,
    pub endpoint: String,
    pub access_token: String,
    pub expires_at_ms: u64,
    pub permissions: Vec<StoragePermission>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StorageAvailability {
    pub available: bool,
    pub protocol_version: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StorageObject {
    pub key: String,
    pub object_id: String,
    pub size_bytes: u64,
    pub sha256: String,
    pub content_type: Option<String>,
    pub committed_at_ms: u64,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StorageObjectList {
    pub objects: Vec<StorageObject>,
    pub next_cursor: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StorageRepositoryStats {
    pub repository_id: String,
    pub total_bytes: u64,
    pub available_bytes: u64,
    pub namespace_count: u64,
    pub object_count: u64,
    pub used_bytes: u64,
    pub reserved_bytes: u64,
    pub active_upload_count: u64,
    pub pending_deletion_count: u64,
}

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

    #[test]
    fn storage_request_contracts_are_closed_and_camel_case() {
        let ensure: StorageEnsureNamespaceRequest = serde_json::from_value(json!({
            "name": "recordings",
            "createdByNodeType": "camera",
            "createdByNodeId": "camera-1",
            "maxBytes": 1024,
            "overflowStrategy": "evictOldest"
        }))
        .unwrap();
        assert_eq!(
            ensure.overflow_strategy,
            StorageOverflowStrategy::EvictOldest
        );
        assert!(
            serde_json::from_value::<StorageEnsureNamespaceRequest>(json!({
                "name": "recordings",
                "createdByNodeType": "camera",
                "createdByNodeId": "camera-1",
                "maxBytes": 1024,
                "futureField": true
            }))
            .is_err()
        );

        let update: StorageNamespacePolicyUpdate = serde_json::from_value(json!({
            "namespaceId": "6f082e5e-b8bb-4d6a-bd53-8e87f58258b0",
            "expectedRevision": 2,
            "maxBytes": 2048,
            "overflowStrategy": "reject"
        }))
        .unwrap();
        assert_eq!(update.expected_revision, 2);

        let session: StorageSessionRequest = serde_json::from_value(json!({
            "namespaceId": "6f082e5e-b8bb-4d6a-bd53-8e87f58258b0",
            "permissions": ["read", "write", "delete", "list"],
            "ttlSeconds": 900
        }))
        .unwrap();
        assert_eq!(session.permissions.len(), 4);
    }

    #[test]
    fn storage_responses_are_additive_for_consumers() {
        let namespace: StorageNamespace = serde_json::from_value(json!({
            "id": "6f082e5e-b8bb-4d6a-bd53-8e87f58258b0",
            "name": "recordings",
            "createdByNodeType": "camera",
            "createdByNodeId": "camera-1",
            "maxBytes": 1024,
            "overflowStrategy": "reject",
            "usedBytes": 0,
            "reservedBytes": 0,
            "objectCount": 0,
            "evictedObjectCount": 0,
            "evictedBytes": 0,
            "rejectedWriteCount": 0,
            "revision": 1,
            "createdAtMs": 100,
            "updatedAtMs": 100,
            "futureField": true
        }))
        .unwrap();
        assert_eq!(namespace.policy().max_bytes, 1024);

        let stats: StorageRepositoryStats = serde_json::from_value(json!({
            "repositoryId": "repository-1",
            "totalBytes": 4096,
            "availableBytes": 2048,
            "namespaceCount": 1,
            "objectCount": 2,
            "usedBytes": 1024,
            "reservedBytes": 0,
            "activeUploadCount": 0,
            "pendingDeletionCount": 1,
            "futureField": true
        }))
        .unwrap();
        assert_eq!(stats.pending_deletion_count, 1);
    }

    #[test]
    fn storage_route_and_strategy_constants_are_canonical() {
        assert_eq!(STORAGE_NODE_TYPE, "storage");
        assert_eq!(STORAGE_PROTOCOL_VERSION, "storage.v1");
        assert_eq!(
            STORAGE_UPDATE_NAMESPACE_POLICY_ROUTE,
            "/storage/namespace/update-policy"
        );
        assert_eq!(StorageOverflowStrategy::Reject.as_str(), "reject");
        assert_eq!(
            StorageOverflowStrategy::parse("evictOldest").unwrap(),
            StorageOverflowStrategy::EvictOldest
        );
        assert!(StorageOverflowStrategy::parse("deleteOldest").is_err());
    }
}