Skip to main content

haystack_server/ops/
data.rs

1//! The `export` and `import` ops — bulk data import/export.
2
3use actix_web::{HttpRequest, HttpResponse, web};
4
5use haystack_core::data::{HCol, HDict, HGrid};
6use haystack_core::kinds::{Kind, Number};
7
8use crate::content;
9use crate::error::HaystackError;
10use crate::state::AppState;
11
12/// POST /api/export
13///
14/// Reads all entities from the graph and returns them as a grid.
15/// The Accept header determines the response encoding format.
16pub async fn handle_export(
17    req: HttpRequest,
18    state: web::Data<AppState>,
19) -> Result<HttpResponse, HaystackError> {
20    let accept = req
21        .headers()
22        .get("Accept")
23        .and_then(|v| v.to_str().ok())
24        .unwrap_or("");
25
26    let grid = state
27        .graph
28        .read(|g| g.to_grid(""))
29        .map_err(|e| HaystackError::internal(format!("export failed: {e}")))?;
30
31    let (encoded, ct) = content::encode_response_grid(&grid, accept)
32        .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
33
34    Ok(HttpResponse::Ok().content_type(ct).body(encoded))
35}
36
37/// POST /api/import
38///
39/// Decodes the request body as a grid and adds/updates each row as an entity.
40/// Each row must have an `id` tag with a Ref value.
41/// Existing entities are updated; new entities are added.
42/// Returns a grid with the count of imported entities.
43pub async fn handle_import(
44    req: HttpRequest,
45    body: String,
46    state: web::Data<AppState>,
47) -> Result<HttpResponse, HaystackError> {
48    let content_type = req
49        .headers()
50        .get("Content-Type")
51        .and_then(|v| v.to_str().ok())
52        .unwrap_or("");
53    let accept = req
54        .headers()
55        .get("Accept")
56        .and_then(|v| v.to_str().ok())
57        .unwrap_or("");
58
59    let request_grid = content::decode_request_grid(&body, content_type)
60        .map_err(|e| HaystackError::bad_request(format!("failed to decode request: {e}")))?;
61
62    let mut count: usize = 0;
63
64    for row in &request_grid.rows {
65        let ref_val = match row.id() {
66            Some(r) => r.val.clone(),
67            None => {
68                // Skip rows without a valid Ref id
69                continue;
70            }
71        };
72
73        if state.graph.contains(&ref_val) {
74            // Update existing entity
75            state.graph.update(&ref_val, row.clone()).map_err(|e| {
76                HaystackError::internal(format!("update failed for {ref_val}: {e}"))
77            })?;
78        } else {
79            // Add new entity
80            state
81                .graph
82                .add(row.clone())
83                .map_err(|e| HaystackError::internal(format!("add failed for {ref_val}: {e}")))?;
84        }
85
86        count += 1;
87    }
88
89    // Build response grid with count
90    let mut row = HDict::new();
91    row.set("count", Kind::Number(Number::new(count as f64, None)));
92
93    let cols = vec![HCol::new("count")];
94    let result_grid = HGrid::from_parts(HDict::new(), cols, vec![row]);
95
96    let (encoded, ct) = content::encode_response_grid(&result_grid, accept)
97        .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
98
99    Ok(HttpResponse::Ok().content_type(ct).body(encoded))
100}
101
102#[cfg(test)]
103mod tests {
104    use actix_web::App;
105    use actix_web::test as actix_test;
106    use actix_web::web;
107
108    use haystack_core::data::{HCol, HDict, HGrid};
109    use haystack_core::graph::{EntityGraph, SharedGraph};
110    use haystack_core::kinds::{HRef, Kind, Number};
111
112    use crate::actions::ActionRegistry;
113    use crate::auth::AuthManager;
114    use crate::his_store::HisStore;
115    use crate::state::AppState;
116    use crate::ws::WatchManager;
117
118    fn test_app_state() -> web::Data<AppState> {
119        web::Data::new(AppState {
120            graph: SharedGraph::new(EntityGraph::new()),
121            namespace: parking_lot::RwLock::new(haystack_core::ontology::DefNamespace::new()),
122            auth: AuthManager::empty(),
123            watches: WatchManager::new(),
124            actions: ActionRegistry::new(),
125            his: HisStore::new(),
126            started_at: std::time::Instant::now(),
127            federation: crate::federation::Federation::new(),
128        })
129    }
130
131    fn make_site(id: &str) -> HDict {
132        let mut d = HDict::new();
133        d.set("id", Kind::Ref(HRef::from_val(id)));
134        d.set("site", Kind::Marker);
135        d.set("dis", Kind::Str(format!("Site {id}")));
136        d.set(
137            "area",
138            Kind::Number(Number::new(4500.0, Some("ft\u{00b2}".into()))),
139        );
140        d
141    }
142
143    fn make_equip(id: &str, site_ref: &str) -> HDict {
144        let mut d = HDict::new();
145        d.set("id", Kind::Ref(HRef::from_val(id)));
146        d.set("equip", Kind::Marker);
147        d.set("dis", Kind::Str(format!("Equip {id}")));
148        d.set("siteRef", Kind::Ref(HRef::from_val(site_ref)));
149        d
150    }
151
152    fn encode_grid_zinc(grid: &HGrid) -> String {
153        let codec = haystack_core::codecs::codec_for("text/zinc").unwrap();
154        codec.encode_grid(grid).unwrap()
155    }
156
157    fn decode_grid_zinc(body: &str) -> HGrid {
158        let codec = haystack_core::codecs::codec_for("text/zinc").unwrap();
159        codec.decode_grid(body).unwrap()
160    }
161
162    #[actix_web::test]
163    async fn export_empty_graph() {
164        let state = test_app_state();
165        let app = actix_test::init_service(
166            App::new()
167                .app_data(state.clone())
168                .route("/api/export", web::post().to(super::handle_export)),
169        )
170        .await;
171
172        let req = actix_test::TestRequest::post()
173            .uri("/api/export")
174            .insert_header(("Accept", "text/zinc"))
175            .to_request();
176
177        let resp = actix_test::call_service(&app, req).await;
178        assert_eq!(resp.status(), 200);
179
180        let body = actix_test::read_body(resp).await;
181        let body_str = std::str::from_utf8(&body).unwrap();
182        let grid = decode_grid_zinc(body_str);
183        assert!(grid.is_empty());
184    }
185
186    #[actix_web::test]
187    async fn import_entities() {
188        let state = test_app_state();
189        let app = actix_test::init_service(
190            App::new()
191                .app_data(state.clone())
192                .route("/api/import", web::post().to(super::handle_import)),
193        )
194        .await;
195
196        // Build a grid with two entities
197        let site = make_site("site-1");
198        let equip = make_equip("equip-1", "site-1");
199        let cols = vec![
200            HCol::new("area"),
201            HCol::new("dis"),
202            HCol::new("equip"),
203            HCol::new("id"),
204            HCol::new("site"),
205            HCol::new("siteRef"),
206        ];
207        let import_grid = HGrid::from_parts(HDict::new(), cols, vec![site, equip]);
208        let body = encode_grid_zinc(&import_grid);
209
210        let req = actix_test::TestRequest::post()
211            .uri("/api/import")
212            .insert_header(("Content-Type", "text/zinc"))
213            .insert_header(("Accept", "text/zinc"))
214            .set_payload(body)
215            .to_request();
216
217        let resp = actix_test::call_service(&app, req).await;
218        assert_eq!(resp.status(), 200);
219
220        let resp_body = actix_test::read_body(resp).await;
221        let resp_str = std::str::from_utf8(&resp_body).unwrap();
222        let result_grid = decode_grid_zinc(resp_str);
223        assert_eq!(result_grid.len(), 1);
224
225        // Verify count
226        let count_row = result_grid.row(0).unwrap();
227        match count_row.get("count") {
228            Some(Kind::Number(n)) => assert_eq!(n.val as usize, 2),
229            other => panic!("expected Number count, got {other:?}"),
230        }
231
232        // Verify graph has entities
233        assert_eq!(state.graph.len(), 2);
234        assert!(state.graph.contains("site-1"));
235        assert!(state.graph.contains("equip-1"));
236    }
237
238    #[actix_web::test]
239    async fn import_updates_existing_entities() {
240        let state = test_app_state();
241
242        // Pre-populate the graph with a site
243        state.graph.add(make_site("site-1")).unwrap();
244
245        let app = actix_test::init_service(
246            App::new()
247                .app_data(state.clone())
248                .route("/api/import", web::post().to(super::handle_import)),
249        )
250        .await;
251
252        // Import an updated version of site-1
253        let mut updated_site = HDict::new();
254        updated_site.set("id", Kind::Ref(HRef::from_val("site-1")));
255        updated_site.set("site", Kind::Marker);
256        updated_site.set("dis", Kind::Str("Updated Site".into()));
257        updated_site.set(
258            "area",
259            Kind::Number(Number::new(9000.0, Some("ft\u{00b2}".into()))),
260        );
261
262        let cols = vec![
263            HCol::new("area"),
264            HCol::new("dis"),
265            HCol::new("id"),
266            HCol::new("site"),
267        ];
268        let import_grid = HGrid::from_parts(HDict::new(), cols, vec![updated_site]);
269        let body = encode_grid_zinc(&import_grid);
270
271        let req = actix_test::TestRequest::post()
272            .uri("/api/import")
273            .insert_header(("Content-Type", "text/zinc"))
274            .insert_header(("Accept", "text/zinc"))
275            .set_payload(body)
276            .to_request();
277
278        let resp = actix_test::call_service(&app, req).await;
279        assert_eq!(resp.status(), 200);
280
281        // Still only 1 entity (updated, not duplicated)
282        assert_eq!(state.graph.len(), 1);
283
284        // Verify the entity was updated
285        let entity = state.graph.get("site-1").unwrap();
286        assert_eq!(entity.get("dis"), Some(&Kind::Str("Updated Site".into())));
287    }
288
289    #[actix_web::test]
290    async fn import_then_export_roundtrip() {
291        let state = test_app_state();
292        let app = actix_test::init_service(
293            App::new()
294                .app_data(state.clone())
295                .route("/api/import", web::post().to(super::handle_import))
296                .route("/api/export", web::post().to(super::handle_export)),
297        )
298        .await;
299
300        // Import two entities
301        let site = make_site("site-1");
302        let equip = make_equip("equip-1", "site-1");
303        let cols = vec![
304            HCol::new("area"),
305            HCol::new("dis"),
306            HCol::new("equip"),
307            HCol::new("id"),
308            HCol::new("site"),
309            HCol::new("siteRef"),
310        ];
311        let import_grid = HGrid::from_parts(HDict::new(), cols, vec![site, equip]);
312        let body = encode_grid_zinc(&import_grid);
313
314        let import_req = actix_test::TestRequest::post()
315            .uri("/api/import")
316            .insert_header(("Content-Type", "text/zinc"))
317            .insert_header(("Accept", "text/zinc"))
318            .set_payload(body)
319            .to_request();
320
321        let import_resp = actix_test::call_service(&app, import_req).await;
322        assert_eq!(import_resp.status(), 200);
323
324        // Now export
325        let export_req = actix_test::TestRequest::post()
326            .uri("/api/export")
327            .insert_header(("Accept", "text/zinc"))
328            .to_request();
329
330        let export_resp = actix_test::call_service(&app, export_req).await;
331        assert_eq!(export_resp.status(), 200);
332
333        let export_body = actix_test::read_body(export_resp).await;
334        let export_str = std::str::from_utf8(&export_body).unwrap();
335        let exported_grid = decode_grid_zinc(export_str);
336
337        // Should have 2 rows
338        assert_eq!(exported_grid.len(), 2);
339
340        // Verify the exported grid has the expected columns
341        assert!(exported_grid.col("id").is_some());
342        assert!(exported_grid.col("dis").is_some());
343
344        // Verify we can find both entities by checking id refs in the rows
345        let mut ids: Vec<String> = exported_grid
346            .rows
347            .iter()
348            .filter_map(|r| r.id().map(|r| r.val.clone()))
349            .collect();
350        ids.sort();
351        assert_eq!(ids, vec!["equip-1", "site-1"]);
352    }
353}