Skip to main content

haystack_server/ops/
system.rs

1//! System administration endpoints (admin-only).
2//!
3//! All routes under `/api/system/` require the "admin" permission, enforced by
4//! the auth middleware in `app.rs`.
5
6use actix_web::{HttpRequest, HttpResponse, web};
7
8use haystack_core::codecs::codec_for;
9use haystack_core::data::{HCol, HDict, HGrid};
10use haystack_core::kinds::{Kind, Number};
11
12use crate::error::HaystackError;
13use crate::state::AppState;
14
15/// GET /api/system/status
16///
17/// Returns a single-row grid with server status information:
18/// - `uptime`: seconds since the server started (with "s" unit)
19/// - `entityCount`: number of entities in the graph
20/// - `watchCount`: number of active watch subscriptions
21pub async fn handle_status(
22    req: HttpRequest,
23    state: web::Data<AppState>,
24) -> Result<HttpResponse, HaystackError> {
25    let accept = req
26        .headers()
27        .get("Accept")
28        .and_then(|v| v.to_str().ok())
29        .unwrap_or("");
30
31    let uptime_secs = state.started_at.elapsed().as_secs_f64();
32    let entity_count = state.graph.len();
33    let watch_count = state.watches.len();
34
35    let mut row = HDict::new();
36    row.set(
37        "uptime",
38        Kind::Number(Number::new(uptime_secs, Some("s".into()))),
39    );
40    row.set(
41        "entityCount",
42        Kind::Number(Number::unitless(entity_count as f64)),
43    );
44    row.set(
45        "watchCount",
46        Kind::Number(Number::unitless(watch_count as f64)),
47    );
48
49    let cols = vec![
50        HCol::new("uptime"),
51        HCol::new("entityCount"),
52        HCol::new("watchCount"),
53    ];
54    let grid = HGrid::from_parts(HDict::new(), cols, vec![row]);
55
56    let (encoded, ct) = crate::content::encode_response_grid(&grid, accept)
57        .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
58
59    Ok(HttpResponse::Ok().content_type(ct).body(encoded))
60}
61
62/// POST /api/system/backup
63///
64/// Exports all entities from the graph as JSON. The response body is a raw JSON
65/// string with Content-Type `application/json`, regardless of the Accept header.
66pub async fn handle_backup(
67    _req: HttpRequest,
68    state: web::Data<AppState>,
69) -> Result<HttpResponse, HaystackError> {
70    let grid = state
71        .graph
72        .read(|g| g.to_grid(""))
73        .map_err(|e| HaystackError::internal(format!("backup failed: {e}")))?;
74
75    let codec = codec_for("application/json")
76        .ok_or_else(|| HaystackError::internal("JSON codec not available"))?;
77
78    let json = codec
79        .encode_grid(&grid)
80        .map_err(|e| HaystackError::internal(format!("JSON encoding error: {e}")))?;
81
82    Ok(HttpResponse::Ok()
83        .content_type("application/json")
84        .body(json))
85}
86
87/// POST /api/system/restore
88///
89/// Imports entities from a JSON request body. Each row in the decoded grid is
90/// added to (or updated in) the entity graph. Returns a single-row grid with
91/// the count of entities loaded.
92pub async fn handle_restore(
93    req: HttpRequest,
94    body: String,
95    state: web::Data<AppState>,
96) -> Result<HttpResponse, HaystackError> {
97    let accept = req
98        .headers()
99        .get("Accept")
100        .and_then(|v| v.to_str().ok())
101        .unwrap_or("");
102
103    let codec = codec_for("application/json")
104        .ok_or_else(|| HaystackError::internal("JSON codec not available"))?;
105
106    let grid = codec
107        .decode_grid(&body)
108        .map_err(|e| HaystackError::bad_request(format!("failed to decode JSON body: {e}")))?;
109
110    let mut count: usize = 0;
111
112    for row in &grid.rows {
113        let ref_val = match row.id() {
114            Some(r) => r.val.clone(),
115            None => continue, // skip rows without a valid Ref id
116        };
117
118        if state.graph.contains(&ref_val) {
119            state.graph.update(&ref_val, row.clone()).map_err(|e| {
120                HaystackError::internal(format!("update failed for {ref_val}: {e}"))
121            })?;
122        } else {
123            state
124                .graph
125                .add(row.clone())
126                .map_err(|e| HaystackError::internal(format!("add failed for {ref_val}: {e}")))?;
127        }
128
129        count += 1;
130    }
131
132    let mut result_row = HDict::new();
133    result_row.set("count", Kind::Number(Number::unitless(count as f64)));
134
135    let cols = vec![HCol::new("count")];
136    let result_grid = HGrid::from_parts(HDict::new(), cols, vec![result_row]);
137
138    let (encoded, ct) = crate::content::encode_response_grid(&result_grid, accept)
139        .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
140
141    Ok(HttpResponse::Ok().content_type(ct).body(encoded))
142}
143
144#[cfg(test)]
145mod tests {
146    use actix_web::App;
147    use actix_web::test as actix_test;
148    use actix_web::web;
149
150    use haystack_core::codecs::codec_for;
151    use haystack_core::data::{HCol, HDict, HGrid};
152    use haystack_core::graph::{EntityGraph, SharedGraph};
153    use haystack_core::kinds::{HRef, Kind, Number};
154
155    use crate::actions::ActionRegistry;
156    use crate::auth::AuthManager;
157    use crate::his_store::HisStore;
158    use crate::state::AppState;
159    use crate::ws::WatchManager;
160
161    fn test_app_state() -> web::Data<AppState> {
162        web::Data::new(AppState {
163            graph: SharedGraph::new(EntityGraph::new()),
164            namespace: parking_lot::RwLock::new(haystack_core::ontology::DefNamespace::new()),
165            auth: AuthManager::empty(),
166            watches: WatchManager::new(),
167            actions: ActionRegistry::new(),
168            his: HisStore::new(),
169            started_at: std::time::Instant::now(),
170            federation: crate::federation::Federation::new(),
171        })
172    }
173
174    fn make_site(id: &str) -> HDict {
175        let mut d = HDict::new();
176        d.set("id", Kind::Ref(HRef::from_val(id)));
177        d.set("site", Kind::Marker);
178        d.set("dis", Kind::Str(format!("Site {id}")));
179        d.set(
180            "area",
181            Kind::Number(Number::new(4500.0, Some("ft\u{00b2}".into()))),
182        );
183        d
184    }
185
186    fn decode_grid_zinc(body: &str) -> HGrid {
187        let codec = codec_for("text/zinc").unwrap();
188        codec.decode_grid(body).unwrap()
189    }
190
191    fn decode_grid_json(body: &str) -> HGrid {
192        let codec = codec_for("application/json").unwrap();
193        codec.decode_grid(body).unwrap()
194    }
195
196    // ── Status ──
197
198    #[actix_web::test]
199    async fn status_returns_server_info() {
200        let state = test_app_state();
201
202        // Pre-populate graph with two entities
203        state.graph.add(make_site("site-1")).unwrap();
204        state.graph.add(make_site("site-2")).unwrap();
205
206        let app = actix_test::init_service(
207            App::new()
208                .app_data(state.clone())
209                .route("/api/system/status", web::get().to(super::handle_status)),
210        )
211        .await;
212
213        let req = actix_test::TestRequest::get()
214            .uri("/api/system/status")
215            .insert_header(("Accept", "text/zinc"))
216            .to_request();
217
218        let resp = actix_test::call_service(&app, req).await;
219        assert_eq!(resp.status(), 200);
220
221        let body = actix_test::read_body(resp).await;
222        let body_str = std::str::from_utf8(&body).unwrap();
223        let grid = decode_grid_zinc(body_str);
224        assert_eq!(grid.len(), 1);
225
226        let row = grid.row(0).unwrap();
227
228        // Check entityCount
229        match row.get("entityCount") {
230            Some(Kind::Number(n)) => assert_eq!(n.val as usize, 2),
231            other => panic!("expected Number entityCount, got {other:?}"),
232        }
233
234        // Check watchCount
235        match row.get("watchCount") {
236            Some(Kind::Number(n)) => assert_eq!(n.val as usize, 0),
237            other => panic!("expected Number watchCount, got {other:?}"),
238        }
239
240        // Check uptime is a non-negative number
241        match row.get("uptime") {
242            Some(Kind::Number(n)) => assert!(n.val >= 0.0),
243            other => panic!("expected Number uptime, got {other:?}"),
244        }
245    }
246
247    #[actix_web::test]
248    async fn status_empty_graph() {
249        let state = test_app_state();
250
251        let app = actix_test::init_service(
252            App::new()
253                .app_data(state.clone())
254                .route("/api/system/status", web::get().to(super::handle_status)),
255        )
256        .await;
257
258        let req = actix_test::TestRequest::get()
259            .uri("/api/system/status")
260            .insert_header(("Accept", "text/zinc"))
261            .to_request();
262
263        let resp = actix_test::call_service(&app, req).await;
264        assert_eq!(resp.status(), 200);
265
266        let body = actix_test::read_body(resp).await;
267        let body_str = std::str::from_utf8(&body).unwrap();
268        let grid = decode_grid_zinc(body_str);
269        assert_eq!(grid.len(), 1);
270
271        let row = grid.row(0).unwrap();
272        match row.get("entityCount") {
273            Some(Kind::Number(n)) => assert_eq!(n.val as usize, 0),
274            other => panic!("expected Number entityCount=0, got {other:?}"),
275        }
276    }
277
278    #[actix_web::test]
279    async fn status_reflects_watch_count() {
280        let state = test_app_state();
281
282        // Add two watches
283        state
284            .watches
285            .subscribe("admin", vec!["site-1".into()], 0)
286            .unwrap();
287        state
288            .watches
289            .subscribe("admin", vec!["site-2".into()], 0)
290            .unwrap();
291
292        let app = actix_test::init_service(
293            App::new()
294                .app_data(state.clone())
295                .route("/api/system/status", web::get().to(super::handle_status)),
296        )
297        .await;
298
299        let req = actix_test::TestRequest::get()
300            .uri("/api/system/status")
301            .insert_header(("Accept", "text/zinc"))
302            .to_request();
303
304        let resp = actix_test::call_service(&app, req).await;
305        assert_eq!(resp.status(), 200);
306
307        let body = actix_test::read_body(resp).await;
308        let body_str = std::str::from_utf8(&body).unwrap();
309        let grid = decode_grid_zinc(body_str);
310        let row = grid.row(0).unwrap();
311
312        match row.get("watchCount") {
313            Some(Kind::Number(n)) => assert_eq!(n.val as usize, 2),
314            other => panic!("expected watchCount=2, got {other:?}"),
315        }
316    }
317
318    // ── Backup ──
319
320    #[actix_web::test]
321    async fn backup_empty_graph() {
322        let state = test_app_state();
323
324        let app = actix_test::init_service(
325            App::new()
326                .app_data(state.clone())
327                .route("/api/system/backup", web::post().to(super::handle_backup)),
328        )
329        .await;
330
331        let req = actix_test::TestRequest::post()
332            .uri("/api/system/backup")
333            .to_request();
334
335        let resp = actix_test::call_service(&app, req).await;
336        assert_eq!(resp.status(), 200);
337
338        let ct = resp
339            .headers()
340            .get("content-type")
341            .and_then(|v| v.to_str().ok())
342            .unwrap_or("");
343        assert!(
344            ct.contains("application/json"),
345            "expected JSON content-type, got {ct}"
346        );
347
348        let body = actix_test::read_body(resp).await;
349        let body_str = std::str::from_utf8(&body).unwrap();
350        let grid = decode_grid_json(body_str);
351        assert!(grid.is_empty());
352    }
353
354    #[actix_web::test]
355    async fn backup_with_entities() {
356        let state = test_app_state();
357        state.graph.add(make_site("site-1")).unwrap();
358        state.graph.add(make_site("site-2")).unwrap();
359
360        let app = actix_test::init_service(
361            App::new()
362                .app_data(state.clone())
363                .route("/api/system/backup", web::post().to(super::handle_backup)),
364        )
365        .await;
366
367        let req = actix_test::TestRequest::post()
368            .uri("/api/system/backup")
369            .to_request();
370
371        let resp = actix_test::call_service(&app, req).await;
372        assert_eq!(resp.status(), 200);
373
374        let body = actix_test::read_body(resp).await;
375        let body_str = std::str::from_utf8(&body).unwrap();
376        let grid = decode_grid_json(body_str);
377        assert_eq!(grid.len(), 2);
378    }
379
380    // ── Restore ──
381
382    #[actix_web::test]
383    async fn restore_adds_entities() {
384        let state = test_app_state();
385
386        let app = actix_test::init_service(
387            App::new()
388                .app_data(state.clone())
389                .route("/api/system/restore", web::post().to(super::handle_restore)),
390        )
391        .await;
392
393        // Build a JSON-encoded grid with two entities
394        let site1 = make_site("site-1");
395        let site2 = make_site("site-2");
396        let cols = vec![
397            HCol::new("area"),
398            HCol::new("dis"),
399            HCol::new("id"),
400            HCol::new("site"),
401        ];
402        let grid = HGrid::from_parts(HDict::new(), cols, vec![site1, site2]);
403
404        let codec = codec_for("application/json").unwrap();
405        let json_body = codec.encode_grid(&grid).unwrap();
406
407        let req = actix_test::TestRequest::post()
408            .uri("/api/system/restore")
409            .insert_header(("Content-Type", "application/json"))
410            .insert_header(("Accept", "text/zinc"))
411            .set_payload(json_body)
412            .to_request();
413
414        let resp = actix_test::call_service(&app, req).await;
415        assert_eq!(resp.status(), 200);
416
417        let body = actix_test::read_body(resp).await;
418        let body_str = std::str::from_utf8(&body).unwrap();
419        let result_grid = decode_grid_zinc(body_str);
420        assert_eq!(result_grid.len(), 1);
421
422        // Verify count
423        let count_row = result_grid.row(0).unwrap();
424        match count_row.get("count") {
425            Some(Kind::Number(n)) => assert_eq!(n.val as usize, 2),
426            other => panic!("expected Number count=2, got {other:?}"),
427        }
428
429        // Verify graph has entities
430        assert_eq!(state.graph.len(), 2);
431        assert!(state.graph.contains("site-1"));
432        assert!(state.graph.contains("site-2"));
433    }
434
435    #[actix_web::test]
436    async fn restore_updates_existing_entities() {
437        let state = test_app_state();
438
439        // Pre-populate with one entity
440        state.graph.add(make_site("site-1")).unwrap();
441        assert_eq!(state.graph.len(), 1);
442
443        let app = actix_test::init_service(
444            App::new()
445                .app_data(state.clone())
446                .route("/api/system/restore", web::post().to(super::handle_restore)),
447        )
448        .await;
449
450        // Build an updated version
451        let mut updated = HDict::new();
452        updated.set("id", Kind::Ref(HRef::from_val("site-1")));
453        updated.set("site", Kind::Marker);
454        updated.set("dis", Kind::Str("Updated Site".into()));
455        updated.set(
456            "area",
457            Kind::Number(Number::new(9000.0, Some("ft\u{00b2}".into()))),
458        );
459
460        let cols = vec![
461            HCol::new("area"),
462            HCol::new("dis"),
463            HCol::new("id"),
464            HCol::new("site"),
465        ];
466        let grid = HGrid::from_parts(HDict::new(), cols, vec![updated]);
467
468        let codec = codec_for("application/json").unwrap();
469        let json_body = codec.encode_grid(&grid).unwrap();
470
471        let req = actix_test::TestRequest::post()
472            .uri("/api/system/restore")
473            .insert_header(("Content-Type", "application/json"))
474            .insert_header(("Accept", "text/zinc"))
475            .set_payload(json_body)
476            .to_request();
477
478        let resp = actix_test::call_service(&app, req).await;
479        assert_eq!(resp.status(), 200);
480
481        // Still only 1 entity
482        assert_eq!(state.graph.len(), 1);
483
484        // Verify it was updated
485        let entity = state.graph.get("site-1").unwrap();
486        assert_eq!(entity.get("dis"), Some(&Kind::Str("Updated Site".into())));
487    }
488
489    #[actix_web::test]
490    async fn backup_then_restore_roundtrip() {
491        let state = test_app_state();
492        state.graph.add(make_site("site-1")).unwrap();
493        state.graph.add(make_site("site-2")).unwrap();
494
495        let app = actix_test::init_service(
496            App::new()
497                .app_data(state.clone())
498                .route("/api/system/backup", web::post().to(super::handle_backup))
499                .route("/api/system/restore", web::post().to(super::handle_restore)),
500        )
501        .await;
502
503        // Backup
504        let backup_req = actix_test::TestRequest::post()
505            .uri("/api/system/backup")
506            .to_request();
507        let backup_resp = actix_test::call_service(&app, backup_req).await;
508        assert_eq!(backup_resp.status(), 200);
509
510        let backup_body = actix_test::read_body(backup_resp).await;
511        let backup_str = std::str::from_utf8(&backup_body).unwrap().to_string();
512
513        // Restore into a fresh state
514        let state2 = test_app_state();
515        let app2 = actix_test::init_service(
516            App::new()
517                .app_data(state2.clone())
518                .route("/api/system/restore", web::post().to(super::handle_restore)),
519        )
520        .await;
521
522        let restore_req = actix_test::TestRequest::post()
523            .uri("/api/system/restore")
524            .insert_header(("Content-Type", "application/json"))
525            .insert_header(("Accept", "text/zinc"))
526            .set_payload(backup_str)
527            .to_request();
528
529        let restore_resp = actix_test::call_service(&app2, restore_req).await;
530        assert_eq!(restore_resp.status(), 200);
531
532        // Verify the new state has the same entities
533        assert_eq!(state2.graph.len(), 2);
534        assert!(state2.graph.contains("site-1"));
535        assert!(state2.graph.contains("site-2"));
536    }
537
538    #[actix_web::test]
539    async fn restore_invalid_json_returns_400() {
540        let state = test_app_state();
541
542        let app = actix_test::init_service(
543            App::new()
544                .app_data(state.clone())
545                .route("/api/system/restore", web::post().to(super::handle_restore)),
546        )
547        .await;
548
549        let req = actix_test::TestRequest::post()
550            .uri("/api/system/restore")
551            .insert_header(("Content-Type", "application/json"))
552            .set_payload("{not valid json}")
553            .to_request();
554
555        let resp = actix_test::call_service(&app, req).await;
556        assert_eq!(resp.status(), 400);
557    }
558}