1use 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
43pub 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
90pub 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
115pub 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, };
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 #[actix_web::test]
227 async fn status_returns_server_info() {
228 let state = test_app_state();
229
230 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 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 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 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 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 #[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 #[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 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 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 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 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 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 assert_eq!(state.graph.len(), 1);
511
512 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 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 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 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}