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