mhome-app-facade-api 1.37.4

Canonical public App Facade protocol for MeowLink clients
Documentation
use serde::{Deserialize, Serialize};

pub const CONTRACT: &str = "mhome.space.topology.v1";

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TopologyGetRequest {}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TopologySnapshot {
    pub contract: String,
    pub scope_id: String,
    pub generation: String,
    pub revision: u64,
    pub observed_at_ms: i64,
    pub entities: Vec<TopologyEntity>,
    pub edges: Vec<TopologyEdge>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(
    tag = "kind",
    rename_all = "camelCase",
    rename_all_fields = "camelCase",
    deny_unknown_fields
)]
pub enum TopologyEntity {
    Host {
        id: String,
        host_id: String,
        display_name: String,
    },
    Hub {
        id: String,
        hub_id: String,
        host_id: String,
        display_name: String,
    },
    Node {
        id: String,
        node_id: String,
        node_type: String,
        host_id: String,
        display_name: String,
        binding_state: String,
        runtime: TopologyRuntime,
    },
    Client {
        id: String,
        app_client_id: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        cur_client_id: Option<String>,
        source: String,
        device_type: String,
        display_name: String,
        is_current: bool,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        route_kind: Option<TopologyRouteKind>,
        has_local_credential: bool,
    },
    Cloud {
        id: String,
        display_name: String,
    },
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TopologyRuntime {
    pub state: TopologyRuntimeState,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub updated_at_ms: Option<i64>,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum TopologyRuntimeState {
    Healthy,
    Degraded,
    Offline,
    Unknown,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum TopologyRouteKind {
    LocalDirect,
    CloudBridgeShadow,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TopologyEdge {
    pub id: String,
    pub source: String,
    pub target: String,
    pub relation: TopologyRelation,
    pub basis: TopologyRelationBasis,
    pub durability: TopologyDurability,
    pub observation: TopologyObservation,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum TopologyRelation {
    HubCloud,
    NodeHub,
    ClientHub,
    ClientCloud,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum TopologyRelationBasis {
    HubCommission,
    NodeBinding,
    LocalCredential,
    LiveRoute,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum TopologyDurability {
    Durable,
    Ephemeral,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TopologyObservation {
    pub state: TopologyObservationState,
    pub observed_at_ms: i64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum TopologyObservationState {
    Connected,
    Disconnected,
    Unknown,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TopologyChanged {
    pub scope_id: String,
    pub generation: String,
    pub revision: u64,
}

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

    #[test]
    fn topology_contract_uses_stable_wire_names() {
        let entity = TopologyEntity::Client {
            id: "client:a:desktop".to_string(),
            app_client_id: "a:desktop".to_string(),
            cur_client_id: Some("L:socket".to_string()),
            source: "desktop".to_string(),
            device_type: "desktop".to_string(),
            display_name: "Desktop".to_string(),
            is_current: true,
            route_kind: Some(TopologyRouteKind::LocalDirect),
            has_local_credential: true,
        };
        let value = serde_json::to_value(entity).unwrap();
        assert_eq!(value["kind"], "client");
        assert_eq!(value["routeKind"], "localDirect");

        let edge = serde_json::to_value(TopologyEdge {
            id: "client:a:desktop->hub:h".to_string(),
            source: "client:a:desktop".to_string(),
            target: "hub:h".to_string(),
            relation: TopologyRelation::ClientHub,
            basis: TopologyRelationBasis::LocalCredential,
            durability: TopologyDurability::Durable,
            observation: TopologyObservation {
                state: TopologyObservationState::Disconnected,
                observed_at_ms: 1,
                reason: None,
            },
        })
        .unwrap();
        assert_eq!(edge["relation"], "client-hub");
        assert_eq!(edge["basis"], "localCredential");
        assert_eq!(edge["observation"]["state"], "disconnected");
    }

    #[test]
    fn topology_contract_rejects_unknown_fields_at_every_object_boundary() {
        assert!(serde_json::from_value::<TopologySnapshot>(json!({
            "contract": CONTRACT,
            "scopeId": "space-1",
            "generation": "generation-1",
            "revision": 1,
            "observedAtMs": 1,
            "entities": [],
            "edges": [],
            "unexpected": true
        }))
        .is_err());

        assert!(serde_json::from_value::<TopologyEntity>(json!({
            "id": "host:machine",
            "kind": "host",
            "hostId": "machine",
            "displayName": "Machine",
            "unexpected": true
        }))
        .is_err());

        assert!(serde_json::from_value::<TopologyRuntime>(json!({
            "state": "healthy",
            "unexpected": true
        }))
        .is_err());

        assert!(serde_json::from_value::<TopologyEdge>(json!({
            "id": "hub:h->cloud:primary",
            "source": "hub:h",
            "target": "cloud:primary",
            "relation": "hub-cloud",
            "basis": "hubCommission",
            "durability": "durable",
            "observation": { "state": "connected", "observedAtMs": 1 },
            "unexpected": true
        }))
        .is_err());

        assert!(serde_json::from_value::<TopologyObservation>(json!({
            "state": "connected",
            "observedAtMs": 1,
            "unexpected": true
        }))
        .is_err());

        assert!(serde_json::from_value::<TopologyChanged>(json!({
            "scopeId": "space-1",
            "generation": "generation-1",
            "revision": 1,
            "unexpected": true
        }))
        .is_err());
    }
}