Skip to main content

haystack_server/
connector.rs

1//! Connector for fetching entities from a remote Haystack server.
2
3use parking_lot::RwLock;
4
5use haystack_client::HaystackClient;
6use haystack_core::data::HDict;
7use haystack_core::kinds::{HRef, Kind};
8
9/// Configuration for a remote Haystack server connection.
10#[derive(Debug, Clone, serde::Deserialize)]
11pub struct ConnectorConfig {
12    /// Display name for this connector.
13    pub name: String,
14    /// Base URL of the remote Haystack API (e.g. "http://remote:8080/api").
15    pub url: String,
16    /// Username for SCRAM authentication.
17    pub username: String,
18    /// Password for SCRAM authentication.
19    pub password: String,
20    /// Optional tag prefix to namespace remote entity IDs (e.g. "remote1-").
21    pub id_prefix: Option<String>,
22}
23
24/// A connector that can fetch entities from a remote Haystack server.
25pub struct Connector {
26    pub config: ConnectorConfig,
27    /// Cached entities from last sync.
28    cache: RwLock<Vec<HDict>>,
29}
30
31impl Connector {
32    /// Create a new connector with an empty cache.
33    pub fn new(config: ConnectorConfig) -> Self {
34        Self {
35            config,
36            cache: RwLock::new(Vec::new()),
37        }
38    }
39
40    /// Connect to the remote server, fetch all entities, apply id prefixing,
41    /// and store them in the cache. Returns the count of entities synced.
42    pub async fn sync(&self) -> Result<usize, String> {
43        let client = HaystackClient::connect(
44            &self.config.url,
45            &self.config.username,
46            &self.config.password,
47        )
48        .await
49        .map_err(|e| format!("connection failed: {e}"))?;
50
51        let grid = client
52            .read("*", None)
53            .await
54            .map_err(|e| format!("read failed: {e}"))?;
55
56        let mut entities: Vec<HDict> = grid.rows.into_iter().collect();
57
58        // Apply id prefix if configured.
59        if let Some(ref prefix) = self.config.id_prefix {
60            for entity in &mut entities {
61                prefix_refs(entity, prefix);
62            }
63        }
64
65        let count = entities.len();
66        *self.cache.write() = entities;
67        Ok(count)
68    }
69
70    /// Returns a clone of all cached entities.
71    pub fn cached_entities(&self) -> Vec<HDict> {
72        self.cache.read().clone()
73    }
74
75    /// Returns the number of cached entities.
76    pub fn entity_count(&self) -> usize {
77        self.cache.read().len()
78    }
79}
80
81/// Prefix all Ref values in an entity dict.
82///
83/// Prefixes the `id` tag and any tag whose name ends with `Ref`
84/// (e.g. `siteRef`, `equipRef`, `floorRef`, `spaceRef`).
85pub fn prefix_refs(entity: &mut HDict, prefix: &str) {
86    let tag_names: Vec<String> = entity.tag_names().map(|s| s.to_string()).collect();
87
88    for name in &tag_names {
89        let should_prefix = name == "id" || name.ends_with("Ref");
90        if !should_prefix {
91            continue;
92        }
93
94        if let Some(Kind::Ref(r)) = entity.get(name) {
95            let new_val = format!("{}{}", prefix, r.val);
96            let new_ref = HRef::new(new_val, r.dis.clone());
97            entity.set(name.as_str(), Kind::Ref(new_ref));
98        }
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use haystack_core::kinds::HRef;
106
107    #[test]
108    fn connector_new_empty_cache() {
109        let config = ConnectorConfig {
110            name: "test".to_string(),
111            url: "http://localhost:8080/api".to_string(),
112            username: "user".to_string(),
113            password: "pass".to_string(),
114            id_prefix: None,
115        };
116        let connector = Connector::new(config);
117        assert_eq!(connector.entity_count(), 0);
118        assert!(connector.cached_entities().is_empty());
119    }
120
121    #[test]
122    fn connector_config_deserialization() {
123        let json = r#"{
124            "name": "Remote Server",
125            "url": "http://remote:8080/api",
126            "username": "admin",
127            "password": "secret",
128            "id_prefix": "r1-"
129        }"#;
130        let config: ConnectorConfig = serde_json::from_str(json).unwrap();
131        assert_eq!(config.name, "Remote Server");
132        assert_eq!(config.url, "http://remote:8080/api");
133        assert_eq!(config.username, "admin");
134        assert_eq!(config.password, "secret");
135        assert_eq!(config.id_prefix, Some("r1-".to_string()));
136    }
137
138    #[test]
139    fn connector_config_deserialization_without_prefix() {
140        let json = r#"{
141            "name": "Remote",
142            "url": "http://remote:8080/api",
143            "username": "admin",
144            "password": "secret"
145        }"#;
146        let config: ConnectorConfig = serde_json::from_str(json).unwrap();
147        assert_eq!(config.id_prefix, None);
148    }
149
150    #[test]
151    fn id_prefix_application() {
152        let mut entity = HDict::new();
153        entity.set("id", Kind::Ref(HRef::from_val("site-1")));
154        entity.set("dis", Kind::Str("Main Site".to_string()));
155        entity.set("site", Kind::Marker);
156        entity.set("siteRef", Kind::Ref(HRef::from_val("site-1")));
157        entity.set("equipRef", Kind::Ref(HRef::from_val("equip-1")));
158        entity.set(
159            "floorRef",
160            Kind::Ref(HRef::new("floor-1", Some("Floor 1".to_string()))),
161        );
162
163        prefix_refs(&mut entity, "r1-");
164
165        // id should be prefixed
166        match entity.get("id") {
167            Some(Kind::Ref(r)) => assert_eq!(r.val, "r1-site-1"),
168            other => panic!("expected Ref, got {other:?}"),
169        }
170
171        // siteRef should be prefixed
172        match entity.get("siteRef") {
173            Some(Kind::Ref(r)) => assert_eq!(r.val, "r1-site-1"),
174            other => panic!("expected Ref, got {other:?}"),
175        }
176
177        // equipRef should be prefixed
178        match entity.get("equipRef") {
179            Some(Kind::Ref(r)) => assert_eq!(r.val, "r1-equip-1"),
180            other => panic!("expected Ref, got {other:?}"),
181        }
182
183        // floorRef should be prefixed, preserving dis
184        match entity.get("floorRef") {
185            Some(Kind::Ref(r)) => {
186                assert_eq!(r.val, "r1-floor-1");
187                assert_eq!(r.dis, Some("Floor 1".to_string()));
188            }
189            other => panic!("expected Ref, got {other:?}"),
190        }
191
192        // Non-ref tags should not be changed
193        assert_eq!(entity.get("dis"), Some(&Kind::Str("Main Site".to_string())));
194        assert_eq!(entity.get("site"), Some(&Kind::Marker));
195    }
196
197    #[test]
198    fn id_prefix_skips_non_ref_values() {
199        let mut entity = HDict::new();
200        entity.set("id", Kind::Ref(HRef::from_val("point-1")));
201        // A tag ending in "Ref" but whose value is not actually a Ref
202        entity.set("customRef", Kind::Str("not-a-ref".to_string()));
203
204        prefix_refs(&mut entity, "p-");
205
206        // id should be prefixed
207        match entity.get("id") {
208            Some(Kind::Ref(r)) => assert_eq!(r.val, "p-point-1"),
209            other => panic!("expected Ref, got {other:?}"),
210        }
211
212        // customRef is a Str, not a Ref, so it should be unchanged
213        assert_eq!(
214            entity.get("customRef"),
215            Some(&Kind::Str("not-a-ref".to_string()))
216        );
217    }
218}