1use 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
15pub 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
62pub 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
87pub 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, };
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 #[actix_web::test]
199 async fn status_returns_server_info() {
200 let state = test_app_state();
201
202 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 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 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 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 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 #[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 #[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 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 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 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 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 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 assert_eq!(state.graph.len(), 1);
483
484 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 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 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 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}