1use crate::json::{
14 edge_history_result_json, node_edges_json, node_history_json, node_info_json, params_from_json,
15 parse_ingest_edges, result_set_json, rule_def_from_json,
16};
17use crate::{AppState, AuthIdentity};
18use arrow_bridge::to_ipc_bytes;
19use axum::extract::{Extension, Path, Query, Request, State};
20use axum::http::{header, HeaderValue, Method, StatusCode};
21use axum::middleware::{self, Next};
22use axum::response::{IntoResponse, Response};
23use axum::routing::{get, post};
24use axum::{Json, Router};
25use core_api::{
26 is_write_query, json_to_rows, json_to_value, AutoFk, BackupReport, BatchOp, DegreeConfig, Dir,
27 GraphError, IngestOptions, MaskMode, NodeMask, PageRankConfig, ResultSet, SharedDb,
28 SuggestConfig, Value, WccConfig, SUGGEST_DEFAULT_SEED,
29};
30use serde_json::{json, Value as Js};
31use std::collections::{BTreeMap, HashMap};
32use std::net::SocketAddr;
33use std::path::PathBuf;
34use tower_http::services::ServeDir;
35
36pub fn router(db: SharedDb) -> Router {
56 router_with_auth(db, None)
57}
58
59pub fn router_with_auth(db: SharedDb, token: Option<String>) -> Router {
65 build_app(
66 db,
67 token,
68 HashMap::new(),
69 UiFallback::None,
70 default_advertise_addr(),
71 )
72}
73
74pub fn router_with_role_tokens(
80 db: SharedDb,
81 token: Option<String>,
82 role_tokens: HashMap<String, String>,
83) -> Router {
84 build_app(
85 db,
86 token,
87 role_tokens,
88 UiFallback::None,
89 default_advertise_addr(),
90 )
91}
92
93pub fn router_with_ui(
98 db: SharedDb,
99 ui_dir: impl AsRef<std::path::Path>,
100 token: Option<String>,
101) -> Router {
102 build_app(
103 db,
104 token,
105 HashMap::new(),
106 UiFallback::Dir(ui_dir.as_ref().to_path_buf()),
107 default_advertise_addr(),
108 )
109}
110
111#[cfg(feature = "embed-ui")]
112static EMBEDDED_UI: include_dir::Dir<'_> =
113 include_dir::include_dir!("$CARGO_MANIFEST_DIR/../../ui/dist");
114
115#[cfg(feature = "embed-ui")]
117pub fn router_with_embedded_ui(db: SharedDb) -> Router {
118 build_app(
119 db,
120 None,
121 HashMap::new(),
122 UiFallback::Embedded,
123 default_advertise_addr(),
124 )
125}
126
127#[cfg(feature = "embed-ui")]
128async fn embedded_fallback(uri: axum::http::Uri) -> Response {
129 let rel = if uri.path() == "/" || uri.path().is_empty() {
130 "index.html"
131 } else {
132 uri.path().trim_start_matches('/')
133 };
134 if rel.split('/').any(|seg| seg == "..") {
135 return StatusCode::NOT_FOUND.into_response();
136 }
137 match EMBEDDED_UI.get_file(rel) {
138 Some(file) => (
139 StatusCode::OK,
140 [(header::CONTENT_TYPE, embedded_ctype(rel))],
141 file.contents(),
142 )
143 .into_response(),
144 None => StatusCode::NOT_FOUND.into_response(),
145 }
146}
147
148#[cfg(feature = "embed-ui")]
149fn embedded_ctype(path: &str) -> &'static str {
150 if path.ends_with(".html") {
151 "text/html; charset=utf-8"
152 } else if path.ends_with(".js") {
153 "application/javascript; charset=utf-8"
154 } else if path.ends_with(".css") {
155 "text/css; charset=utf-8"
156 } else if path.ends_with(".woff2") {
157 "font/woff2"
158 } else if path.ends_with(".svg") {
159 "image/svg+xml"
160 } else if path.ends_with(".ico") {
161 "image/x-icon"
162 } else if path.ends_with(".txt") {
163 "text/plain; charset=utf-8"
164 } else {
165 "application/octet-stream"
166 }
167}
168
169#[deprecated(
177 since = "0.2.0",
178 note = "Use `serve_with_role_tokens` instead; this variant silently ignores role-token configuration."
179)]
180#[doc(hidden)]
181pub async fn serve(
182 db: SharedDb,
183 addr: SocketAddr,
184 ready: tokio::sync::oneshot::Sender<SocketAddr>,
185 token: Option<String>,
186) -> std::io::Result<()> {
187 serve_inner(db, addr, ready, UiFallback::None, token, HashMap::new()).await
188}
189
190pub async fn serve_with_role_tokens(
195 db: SharedDb,
196 addr: SocketAddr,
197 ready: tokio::sync::oneshot::Sender<SocketAddr>,
198 token: Option<String>,
199 role_tokens: HashMap<String, String>,
200) -> std::io::Result<()> {
201 serve_inner(db, addr, ready, UiFallback::None, token, role_tokens).await
202}
203
204#[deprecated(
209 since = "0.2.0",
210 note = "Use `serve_with_ui_and_role_tokens` instead; this variant silently ignores role-token configuration."
211)]
212#[doc(hidden)]
213pub async fn serve_with_ui(
214 db: SharedDb,
215 addr: SocketAddr,
216 ready: tokio::sync::oneshot::Sender<SocketAddr>,
217 ui_dir: PathBuf,
218 token: Option<String>,
219) -> std::io::Result<()> {
220 serve_inner(
221 db,
222 addr,
223 ready,
224 UiFallback::Dir(ui_dir),
225 token,
226 HashMap::new(),
227 )
228 .await
229}
230
231pub async fn serve_with_ui_and_role_tokens(
233 db: SharedDb,
234 addr: SocketAddr,
235 ready: tokio::sync::oneshot::Sender<SocketAddr>,
236 ui_dir: PathBuf,
237 token: Option<String>,
238 role_tokens: HashMap<String, String>,
239) -> std::io::Result<()> {
240 serve_inner(db, addr, ready, UiFallback::Dir(ui_dir), token, role_tokens).await
241}
242
243#[cfg(feature = "embed-ui")]
248pub async fn serve_with_embedded_ui(
249 db: SharedDb,
250 addr: SocketAddr,
251 ready: tokio::sync::oneshot::Sender<SocketAddr>,
252 token: Option<String>,
253 role_tokens: HashMap<String, String>,
254) -> std::io::Result<()> {
255 serve_inner(db, addr, ready, UiFallback::Embedded, token, role_tokens).await
256}
257
258enum UiFallback {
259 None,
260 Dir(PathBuf),
261 #[cfg(feature = "embed-ui")]
262 Embedded,
263}
264
265async fn serve_inner(
266 db: SharedDb,
267 addr: SocketAddr,
268 ready: tokio::sync::oneshot::Sender<SocketAddr>,
269 ui: UiFallback,
270 token: Option<String>,
271 role_tokens: HashMap<String, String>,
272) -> std::io::Result<()> {
273 let listener = tokio::net::TcpListener::bind(addr).await?;
274 let local = listener.local_addr()?;
275 if ready.send(local).is_err() {
276 eprintln!("serve: readiness receiver dropped before bind notify");
278 }
279 let app = build_app(db, token, role_tokens, ui, local);
280 axum::serve(listener, app).await
281}
282
283fn default_advertise_addr() -> SocketAddr {
284 SocketAddr::from(([127, 0, 0, 1], 8080))
285}
286
287fn build_app(
288 db: SharedDb,
289 token: Option<String>,
290 role_tokens: HashMap<String, String>,
291 ui: UiFallback,
292 addr: SocketAddr,
293) -> Router {
294 debug_assert!(
295 !db.read().has_event_sink(),
296 "router() must be called at most once per SharedDb; a second call \
297 replaces the sink and terminates all existing /watch subscribers \
298 with RecvError::Closed"
299 );
300 let (tx, _) = tokio::sync::broadcast::channel(1024);
301 {
302 let tx = tx.clone();
303 db.write().set_event_sink(Box::new(move |ev| {
304 let _ = tx.send(ev);
305 }));
306 }
307 let state = AppState {
308 db,
309 watch: tx,
310 token,
311 role_tokens,
312 addr,
313 };
314 let app = Router::new()
315 .route("/health", get(health))
316 .route("/query", post(query))
317 .route("/stats", get(stats))
318 .route("/ingest", post(ingest))
319 .route("/rules", post(create_rule))
320 .route("/suggest", get(suggest))
321 .route("/explain", get(explain))
322 .route("/node/{key}", get(node_info))
323 .route("/node/{key}", axum::routing::delete(delete_node))
324 .route("/node/{key}/edges", get(node_edges))
325 .route("/node/{key}/neighborhood", get(neighborhood))
326 .route("/node/{key}/history", get(node_history_handler))
327 .route("/history/edge", get(edge_history_handler))
328 .route("/history/was_linked", get(was_linked_handler))
329 .route(
330 "/node/{key}/prop/{field}",
331 axum::routing::put(set_node_prop),
332 )
333 .route(
334 "/node/{key}/prop/{field}",
335 axum::routing::delete(remove_node_prop),
336 )
337 .route("/nodes", post(create_node))
341 .route("/nodes/{key}/rename", post(rename_node))
342 .route("/edges", post(create_edge))
343 .route("/edges/upsert", post(upsert_edge))
344 .route(
345 "/edges/{etype}/{src}/{dst}",
346 axum::routing::delete(delete_edge),
347 )
348 .route("/algo/pagerank", post(algo_pagerank))
349 .route("/algo/wcc", post(algo_wcc))
350 .route("/algo/degree", post(algo_degree))
351 .route("/backup", post(backup))
352 .route("/watch", get(crate::ws::watch))
353 .route("/subscribe", get(crate::subscribe::subscribe))
354 .with_state(state.clone());
355 let app = match ui {
356 UiFallback::None => app,
357 UiFallback::Dir(dir) => app.fallback_service(ServeDir::new(dir)),
358 #[cfg(feature = "embed-ui")]
359 UiFallback::Embedded => app.fallback(embedded_fallback),
360 };
361 app.layer(middleware::from_fn_with_state(state, auth_middleware))
362}
363
364async fn health(State(state): State<AppState>) -> Response {
365 let (nodes, edges) = {
366 let g = state.db.read();
367 let s = g.stats();
368 (s.nodes_live, s.edges)
369 };
370 json_ok(json!({
371 "ok": true,
372 "nodes": nodes,
373 "edges": edges,
374 "addr": state.addr.to_string(),
375 }))
376}
377
378async fn blocking_write<T, F>(f: F) -> std::result::Result<T, Response>
381where
382 T: Send + 'static,
383 F: FnOnce() -> core_api::Result<T> + Send + 'static,
384{
385 match tokio::task::spawn_blocking(f).await {
386 Ok(Ok(v)) => Ok(v),
387 Ok(Err(e)) => Err(graph_err(e)),
388 Err(_) => Err(err_response("write task panicked")),
389 }
390}
391
392const TOKEN_COOKIE: &str = "mushroomdb_token";
393
394async fn auth_middleware(State(state): State<AppState>, mut req: Request, next: Next) -> Response {
395 if state.token.is_none() && state.role_tokens.is_empty() {
397 req.extensions_mut().insert(AuthIdentity::Full);
398 return next.run(req).await;
399 }
400
401 if req.method() == Method::GET && req.uri().path() == "/health" {
403 req.extensions_mut().insert(AuthIdentity::Full);
404 return next.run(req).await;
405 }
406
407 let presented = request_token(&req);
408
409 if let Some(ref full_tok) = state.token.clone().filter(|s| !s.is_empty()) {
411 if presented.as_deref() == Some(full_tok.as_str()) {
412 let set_cookie = presented_bearer_or_query(&req).as_deref() == Some(full_tok.as_str());
413 req.extensions_mut().insert(AuthIdentity::Full);
414 let mut res = next.run(req).await;
415 if set_cookie && is_html_response(&res) {
416 attach_token_cookie(&mut res, full_tok);
417 }
418 return res;
419 }
420 }
421
422 if let Some(tok) = presented.as_deref() {
424 if let Some(role_name) = state.role_tokens.get(tok) {
425 let path = req.uri().path();
431 if path == "/subscribe" || path == "/watch" {
432 return forbidden("role-bound token: this endpoint is not permitted");
433 }
434 req.extensions_mut()
435 .insert(AuthIdentity::Role(role_name.clone()));
436 return next.run(req).await;
437 }
438 }
439
440 unauthorized()
442}
443
444fn request_token(req: &Request) -> Option<String> {
445 presented_bearer_or_query(req).or_else(|| presented_cookie(req))
446}
447
448fn presented_bearer_or_query(req: &Request) -> Option<String> {
449 if let Some(header) = req
450 .headers()
451 .get(header::AUTHORIZATION)
452 .and_then(|v| v.to_str().ok())
453 {
454 if let Some(value) = bearer_token(header) {
455 return Some(value.to_string());
456 }
457 }
458 query_param(req.uri().query().unwrap_or(""), "token")
459}
460
461fn presented_cookie(req: &Request) -> Option<String> {
462 let header = req.headers().get(header::COOKIE)?.to_str().ok()?;
463 cookie_named(header, TOKEN_COOKIE).map(str::to_string)
464}
465
466fn cookie_named<'a>(header: &'a str, name: &str) -> Option<&'a str> {
467 for part in header.split(';') {
468 let part = part.trim();
469 let Some((k, v)) = part.split_once('=') else {
470 continue;
471 };
472 if k.trim() == name {
473 return Some(v.trim());
474 }
475 }
476 None
477}
478
479fn is_html_response(res: &Response) -> bool {
480 res.headers()
481 .get(header::CONTENT_TYPE)
482 .and_then(|v| v.to_str().ok())
483 .is_some_and(|ct| {
484 ct.split(';')
485 .next()
486 .unwrap_or("")
487 .trim()
488 .eq_ignore_ascii_case("text/html")
489 })
490}
491
492fn attach_token_cookie(res: &mut Response, token: &str) {
493 let value = format!("{TOKEN_COOKIE}={token}; Path=/; SameSite=Lax; HttpOnly");
494 if let Ok(hv) = HeaderValue::from_str(&value) {
495 res.headers_mut().insert(header::SET_COOKIE, hv);
496 }
497}
498
499fn bearer_token(header: &str) -> Option<&str> {
500 let (scheme, value) = header.split_once(' ')?;
501 if scheme.eq_ignore_ascii_case("Bearer") {
502 Some(value.trim())
503 } else {
504 None
505 }
506}
507
508fn query_param(query: &str, key: &str) -> Option<String> {
509 for pair in query.split('&') {
510 if pair.is_empty() {
511 continue;
512 }
513 match pair.split_once('=') {
514 Some((k, v)) if k == key => return percent_decode_plus(v),
515 None if pair == key => return Some(String::new()),
516 _ => {}
517 }
518 }
519 None
520}
521
522fn percent_decode_plus(s: &str) -> Option<String> {
524 let bytes = s.as_bytes();
525 let mut out = Vec::with_capacity(bytes.len());
526 let mut i = 0;
527 while i < bytes.len() {
528 match bytes[i] {
529 b'+' => {
530 out.push(b' ');
531 i += 1;
532 }
533 b'%' => {
534 if i + 2 >= bytes.len() {
535 return None;
536 }
537 let hi = from_hex(bytes[i + 1])?;
538 let lo = from_hex(bytes[i + 2])?;
539 out.push((hi << 4) | lo);
540 i += 3;
541 }
542 c => {
543 out.push(c);
544 i += 1;
545 }
546 }
547 }
548 String::from_utf8(out).ok()
549}
550
551fn from_hex(b: u8) -> Option<u8> {
552 match b {
553 b'0'..=b'9' => Some(b - b'0'),
554 b'a'..=b'f' => Some(b - b'a' + 10),
555 b'A'..=b'F' => Some(b - b'A' + 10),
556 _ => None,
557 }
558}
559
560fn unauthorized() -> Response {
561 (
562 StatusCode::UNAUTHORIZED,
563 Json(json!({"error": "unauthorized"})),
564 )
565 .into_response()
566}
567
568fn forbidden(detail: &str) -> Response {
570 (StatusCode::FORBIDDEN, Json(json!({"error": detail}))).into_response()
571}
572
573fn role_mask_err(e: GraphError) -> Response {
581 match e {
582 GraphError::Corrupt { detail } => (
583 StatusCode::INTERNAL_SERVER_ERROR,
584 Json(json!({"error": format!("roles misconfigured: {detail}")})),
585 )
586 .into_response(),
587 GraphError::KeyNotFound { key } if key.starts_with("role:") => unauthorized(),
588 other => graph_err(other),
589 }
590}
591
592fn err_response(detail: impl Into<String>) -> Response {
593 (
594 StatusCode::BAD_REQUEST,
595 Json(json!({"error": detail.into()})),
596 )
597 .into_response()
598}
599
600fn graph_err(e: GraphError) -> Response {
601 match e {
602 GraphError::RoleWriteDenied { reason } => forbidden(&reason),
605 GraphError::QueryError { detail } | GraphError::IngestError { detail } => {
606 err_response(detail)
607 }
608 other => err_response(other.to_string()),
609 }
610}
611
612fn key_not_found(key: String) -> Response {
613 (
614 StatusCode::NOT_FOUND,
615 Json(json!({"error": GraphError::KeyNotFound { key }.to_string()})),
616 )
617 .into_response()
618}
619
620fn conflict_response(key: String) -> Response {
621 (
622 StatusCode::CONFLICT,
623 Json(json!({"error": GraphError::DuplicateKey { key }.to_string()})),
624 )
625 .into_response()
626}
627
628fn json_ok(value: Js) -> Response {
629 (StatusCode::OK, Json(value)).into_response()
630}
631
632fn ingest_options(v: Option<&Js>) -> Result<IngestOptions, String> {
633 let Some(v) = v else {
634 return Ok(IngestOptions::default());
635 };
636 if v.is_null() {
637 return Ok(IngestOptions::default());
638 }
639 let obj = v
640 .as_object()
641 .ok_or_else(|| "options must be an object".to_string())?;
642 let mut opts = IngestOptions::default();
643 if let Some(kf) = obj.get("key_field") {
644 opts.key_field = kf
645 .as_str()
646 .ok_or_else(|| "options.key_field must be a string".to_string())?
647 .to_string();
648 }
649 if let Some(fk) = obj.get("auto_fk") {
650 if fk == &Js::Bool(false) || fk.as_str() == Some("off") {
651 opts.auto_fk = AutoFk::Off;
652 } else if let Some(m) = fk.as_object() {
653 let suf = m
654 .get("suffix")
655 .and_then(Js::as_str)
656 .ok_or_else(|| "options.auto_fk.suffix must be a string".to_string())?;
657 opts.auto_fk = AutoFk::Auto {
658 suffix: suf.to_string(),
659 };
660 } else {
661 return Err("options.auto_fk must be false, \"off\", or {suffix}".into());
662 }
663 }
664 Ok(opts)
665}
666
667fn format_query_result(rs: ResultSet, format: &str) -> Response {
669 match format {
670 "" => match to_ipc_bytes(&rs) {
671 Ok(bytes) => (
672 StatusCode::OK,
673 [(header::CONTENT_TYPE, "application/vnd.apache.arrow.stream")],
674 bytes,
675 )
676 .into_response(),
677 Err(e) => err_response(e),
678 },
679 "json" => json_ok(result_set_json(&rs)),
680 other => err_response(format!("unknown format: {other}")),
681 }
682}
683
684async fn query(
685 State(state): State<AppState>,
686 Extension(identity): Extension<AuthIdentity>,
687 Query(qs): Query<BTreeMap<String, String>>,
688 Json(body): Json<Js>,
689) -> Response {
690 let cypher = match body.get("cypher").and_then(Js::as_str) {
691 Some(s) => s.to_string(),
692 None => return err_response("missing cypher"),
693 };
694 let params = match params_from_json(body.get("params")) {
695 Ok(p) => p,
696 Err(e) => return err_response(e),
697 };
698 let format = qs.get("format").map(String::as_str).unwrap_or("");
699
700 let mask_keys: Option<Vec<String>> = match body.get("mask") {
702 None | Some(Js::Null) => None,
703 Some(Js::Array(arr)) => {
704 let mut keys = Vec::with_capacity(arr.len());
705 for v in arr {
706 match v.as_str() {
707 Some(s) => keys.push(s.to_string()),
708 None => return err_response("mask must be an array of strings"),
709 }
710 }
711 Some(keys)
712 }
713 Some(_) => return err_response("mask must be an array of strings"),
714 };
715
716 if let AuthIdentity::Role(ref role_name) = identity {
720 let is_write = match is_write_query(&cypher) {
721 Ok(b) => b,
722 Err(e) => return err_response(e),
723 };
724 if is_write {
725 let role = role_name.clone();
726 let cypher_c = cypher.clone();
727 let params_c = params.clone();
728 let db = state.db.clone();
729 return match blocking_write(move || {
730 db.write().query_write_authz(&role, &cypher_c, ¶ms_c)
731 })
732 .await
733 {
734 Ok(rs) => format_query_result(rs, format),
735 Err(resp) => resp,
736 };
737 }
738 let snap = state.db.reader();
739 let role_mask = match snap.mask_for_role(role_name) {
740 Ok(m) => m,
741 Err(e) => return role_mask_err(e),
742 };
743 let effective_mask = if let Some(ref keys) = mask_keys {
744 let client_mask = NodeMask::from_ids(keys.iter().filter_map(|k| snap.resolve_key(k)));
746 role_mask.intersect(&client_mask)
747 } else {
748 role_mask
749 };
750 return match snap.query_masked(&cypher, ¶ms, &effective_mask) {
751 Ok(rs) => format_query_result(rs, format),
752 Err(GraphError::MaskedReadOnly) => (
753 StatusCode::BAD_REQUEST,
754 Json(json!({"error": "masked queries are read-only"})),
755 )
756 .into_response(),
757 Err(e) => graph_err(e),
758 };
759 }
760
761 if let Some(ref keys) = mask_keys {
771 let stub_hidden = body
772 .get("stub_hidden")
773 .and_then(|v| v.as_bool())
774 .unwrap_or(false);
775 let db = state.db.read();
776 let mask = {
777 let m = NodeMask::from_keys(&*db, keys.iter().map(String::as_str));
778 if stub_hidden {
779 m.with_mode(MaskMode::Stub)
780 } else {
781 m
782 }
783 };
784 return match db.query_masked(&cypher, ¶ms, &mask) {
785 Ok(rs) => format_query_result(rs, format),
786 Err(GraphError::MaskedReadOnly) => (
787 StatusCode::BAD_REQUEST,
788 Json(json!({"error": "masked queries are read-only"})),
789 )
790 .into_response(),
791 Err(e) => graph_err(e),
792 };
793 }
794
795 let is_write = match is_write_query(&cypher) {
800 Ok(b) => b,
801 Err(e) => return err_response(e),
802 };
803
804 let rs = if is_write {
805 let db = state.db.clone();
806 match blocking_write(move || db.write().query_write(&cypher, ¶ms)).await {
807 Ok(rs) => rs,
808 Err(resp) => return resp,
809 }
810 } else {
811 match state.db.read().query(&cypher, ¶ms) {
812 Ok(rs) => rs,
813 Err(e) => return graph_err(e),
814 }
815 };
816
817 format_query_result(rs, format)
818}
819
820async fn stats(
821 State(state): State<AppState>,
822 Extension(identity): Extension<AuthIdentity>,
823) -> Response {
824 if let AuthIdentity::Role(_) = identity {
826 return forbidden("role-bound token: /stats requires a full-access token");
827 }
828 let snap = {
829 let g = state.db.read();
830 g.stats()
831 };
832 match serde_json::to_value(&snap) {
833 Ok(v) => json_ok(v),
834 Err(e) => err_response(e.to_string()),
835 }
836}
837
838async fn ingest(
839 State(state): State<AppState>,
840 Extension(identity): Extension<AuthIdentity>,
841 Json(body): Json<Js>,
842) -> Response {
843 let label = match body.get("label").and_then(Js::as_str) {
844 Some(s) => s.to_string(),
845 None => return err_response("missing label"),
846 };
847 let rows = match body.get("rows") {
848 Some(r) => r,
849 None => return err_response("missing rows"),
850 };
851 let mut converted = match json_to_rows(rows) {
852 Ok(c) => c,
853 Err(e) => return graph_err(e),
854 };
855 let opts = match ingest_options(body.get("options")) {
856 Ok(o) => o,
857 Err(e) => return err_response(e),
858 };
859 let taken = std::mem::take(&mut converted.rows);
860 let edges = match body.get("edges") {
861 None | Some(Js::Null) => Vec::new(),
862 Some(raw) => match parse_ingest_edges(raw) {
863 Ok(e) => e,
864 Err(e) => return err_response(e),
865 },
866 };
867 let db = state.db.clone();
868
869 if let AuthIdentity::Role(role_name) = &identity {
873 let role = role_name.clone();
874 return match blocking_write(move || {
875 db.write()
876 .ingest_with_edges_authz(&role, &label, taken, &opts, &edges)
877 })
878 .await
879 {
880 Ok(r) => {
881 let report = converted.into_report(r);
882 match serde_json::to_value(&report) {
883 Ok(v) => json_ok(v),
884 Err(e) => err_response(e.to_string()),
885 }
886 }
887 Err(resp) => resp,
888 };
889 }
890
891 let report =
892 match blocking_write(move || db.write().ingest_with_edges(&label, taken, &opts, &edges))
893 .await
894 {
895 Ok(r) => converted.into_report(r),
896 Err(resp) => return resp,
897 };
898 match serde_json::to_value(&report) {
899 Ok(v) => json_ok(v),
900 Err(e) => err_response(e.to_string()),
901 }
902}
903
904async fn suggest(
915 State(state): State<AppState>,
916 Extension(identity): Extension<AuthIdentity>,
917) -> Response {
918 if let AuthIdentity::Role(_) = identity {
921 return forbidden("role-bound token: /suggest requires a full-access token");
922 }
923 let db = state.db.clone();
924 match tokio::task::spawn_blocking(move || {
925 let config = SuggestConfig::default();
926 db.read()
927 .suggest_rules_with_config(&config, SUGGEST_DEFAULT_SEED)
928 })
929 .await
930 {
931 Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
932 Err(_) => err_response("suggest task panicked"),
933 }
934}
935
936async fn create_rule(
937 State(state): State<AppState>,
938 Extension(identity): Extension<AuthIdentity>,
939 Json(body): Json<Js>,
940) -> Response {
941 if let AuthIdentity::Role(_) = identity {
942 return forbidden("role-bound token: writes are not permitted");
943 }
944 let def = match rule_def_from_json(body) {
945 Ok(d) => d,
946 Err(e) => return err_response(e),
947 };
948 let name = def.name.clone();
949 let db = state.db.clone();
950 match blocking_write(move || db.write().create_rule(def)).await {
951 Ok(()) => json_ok(json!({"ok": true, "name": name})),
952 Err(resp) => resp,
953 }
954}
955
956async fn explain(
957 State(state): State<AppState>,
958 Extension(identity): Extension<AuthIdentity>,
959 Query(qs): Query<BTreeMap<String, String>>,
960) -> Response {
961 if let AuthIdentity::Role(_) = identity {
963 return forbidden(
964 "role-bound token: /explain requires a full-access token \
965 (v1: explain may reveal hidden-node linkage; revisit when stubs land)",
966 );
967 }
968 let a = match qs.get("a") {
969 Some(s) if !s.is_empty() => s.clone(),
970 _ => return err_response("missing query param a"),
971 };
972 let b = match qs.get("b") {
973 Some(s) if !s.is_empty() => s.clone(),
974 _ => return err_response("missing query param b"),
975 };
976 let out = {
977 let g = state.db.read();
978 g.explain(&a, &b)
979 };
980 match out {
981 Ok(v) => match serde_json::to_value(&v) {
982 Ok(j) => json_ok(j),
983 Err(e) => err_response(e.to_string()),
984 },
985 Err(e) => graph_err(e),
986 }
987}
988
989async fn node_info(
990 State(state): State<AppState>,
991 Extension(identity): Extension<AuthIdentity>,
992 Path(key): Path<String>,
993 Query(qs): Query<BTreeMap<String, String>>,
994) -> Response {
995 if let AuthIdentity::Role(ref role_name) = identity {
996 let snap = state.db.reader();
1000 let role_mask = match snap.mask_for_role(role_name) {
1001 Ok(m) => m,
1002 Err(e) => return role_mask_err(e),
1003 };
1004 if !snap
1005 .resolve_key(&key)
1006 .is_some_and(|id| role_mask.contains_id(id))
1007 {
1008 return key_not_found(key);
1009 }
1010 return match snap.node_info(&key) {
1011 Some(info) => json_ok(node_info_json(&info)),
1012 None => key_not_found(key),
1013 };
1014 }
1015
1016 let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1020 if !mask_param.is_empty() {
1021 let stub_hidden = qs
1022 .get("stub_hidden")
1023 .map(|v| v == "true" || v == "1")
1024 .unwrap_or(false);
1025 let g = state.db.read();
1026 let mask = {
1027 let keys = mask_param
1028 .split(',')
1029 .map(str::trim)
1030 .filter(|s| !s.is_empty());
1031 let m = NodeMask::from_keys(&*g, keys);
1032 if stub_hidden {
1033 m.with_mode(MaskMode::Stub)
1034 } else {
1035 m
1036 }
1037 };
1038 return match g.node_info_masked(&key, &mask) {
1039 Some(core_api::MaskedNodeResult::Visible(info)) => json_ok(node_info_json(&info)),
1040 Some(core_api::MaskedNodeResult::Restricted) => {
1041 json_ok(crate::json::stub_node_json(&key))
1042 }
1043 None => key_not_found(key),
1044 };
1045 }
1046
1047 let info = {
1048 let g = state.db.read();
1049 g.node_info(&key)
1050 };
1051 match info {
1052 Some(info) => json_ok(node_info_json(&info)),
1053 None => key_not_found(key),
1054 }
1055}
1056
1057async fn node_edges(
1058 State(state): State<AppState>,
1059 Extension(identity): Extension<AuthIdentity>,
1060 Path(key): Path<String>,
1061 Query(qs): Query<BTreeMap<String, String>>,
1062) -> Response {
1063 if let AuthIdentity::Role(ref role_name) = identity {
1064 let snap = state.db.reader();
1067 let role_mask = match snap.mask_for_role(role_name) {
1068 Ok(m) => m,
1069 Err(e) => return role_mask_err(e),
1070 };
1071 if !snap
1072 .resolve_key(&key)
1073 .is_some_and(|id| role_mask.contains_id(id))
1074 {
1075 return key_not_found(key);
1076 }
1077 return match snap.node_edges(&key) {
1078 Ok(edges) => {
1079 let visible: Vec<_> = edges
1083 .into_iter()
1084 .filter(|e| {
1085 let other = if e.src_key == key {
1086 &e.dst_key
1087 } else {
1088 &e.src_key
1089 };
1090 snap.resolve_key(other)
1091 .is_some_and(|id| role_mask.contains_id(id))
1092 })
1093 .collect();
1094 json_ok(node_edges_json(&visible))
1095 }
1096 Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1097 Err(e) => graph_err(e),
1098 };
1099 }
1100
1101 let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1103 if !mask_param.is_empty() {
1104 let stub_hidden = qs
1105 .get("stub_hidden")
1106 .map(|v| v == "true" || v == "1")
1107 .unwrap_or(false);
1108 let g = state.db.read();
1109 let mask = {
1110 let keys = mask_param
1111 .split(',')
1112 .map(str::trim)
1113 .filter(|s| !s.is_empty());
1114 let m = NodeMask::from_keys(&*g, keys);
1115 if stub_hidden {
1116 m.with_mode(MaskMode::Stub)
1117 } else {
1118 m
1119 }
1120 };
1121 return match g.node_edges_masked(&key, &mask) {
1122 Ok(edges) => json_ok(crate::json::masked_edges_json(&edges)),
1123 Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1124 Err(e) => graph_err(e),
1125 };
1126 }
1127
1128 let out = {
1129 let g = state.db.read();
1130 g.node_edges(&key)
1131 };
1132 match out {
1133 Ok(edges) => json_ok(node_edges_json(&edges)),
1134 Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1135 Err(e) => graph_err(e),
1136 }
1137}
1138
1139async fn neighborhood(
1140 State(state): State<AppState>,
1141 Extension(identity): Extension<AuthIdentity>,
1142 Path(key): Path<String>,
1143 Query(qs): Query<BTreeMap<String, String>>,
1144) -> Response {
1145 let depth = match qs.get("depth") {
1146 None => 1u32,
1147 Some(s) => match s.parse() {
1148 Ok(d) => d,
1149 Err(_) => return err_response("depth must be an integer"),
1150 },
1151 };
1152 let dir = match qs.get("dir").map(String::as_str).unwrap_or("both") {
1153 s if s.eq_ignore_ascii_case("out") => Dir::Out,
1154 s if s.eq_ignore_ascii_case("in") => Dir::In,
1155 s if s.eq_ignore_ascii_case("both") => Dir::Both,
1156 other => return err_response(format!("unknown dir: {other}")),
1157 };
1158 let edge_type_names: Option<Vec<String>> = qs.get("edge_types").map(|s| {
1159 s.split(',')
1160 .map(str::trim)
1161 .filter(|t| !t.is_empty())
1162 .map(str::to_string)
1163 .collect()
1164 });
1165 let etype_refs: Option<Vec<&str>> = edge_type_names
1166 .as_ref()
1167 .map(|v| v.iter().map(String::as_str).collect());
1168 if let AuthIdentity::Role(ref role_name) = identity {
1169 let snap = state.db.reader();
1172 let role_mask = match snap.mask_for_role(role_name) {
1173 Ok(m) => m,
1174 Err(e) => return role_mask_err(e),
1175 };
1176 if !snap
1177 .resolve_key(&key)
1178 .is_some_and(|id| role_mask.contains_id(id))
1179 {
1180 return key_not_found(key);
1181 }
1182 let rs = match snap.neighborhood_masked(&key, depth, etype_refs.as_deref(), dir, &role_mask)
1185 {
1186 Some(rs) => rs,
1187 None => return key_not_found(key),
1188 };
1189 return json_ok(result_set_json(&rs));
1190 }
1191 let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1197 if !mask_param.is_empty() {
1198 let stub_hidden = qs
1199 .get("stub_hidden")
1200 .map(|v| v == "true" || v == "1")
1201 .unwrap_or(false);
1202 let g = state.db.read();
1203 let mask = {
1204 let keys = mask_param
1205 .split(',')
1206 .map(str::trim)
1207 .filter(|s| !s.is_empty());
1208 let m = NodeMask::from_keys(&*g, keys);
1209 if stub_hidden {
1210 m.with_mode(MaskMode::Stub)
1211 } else {
1212 m
1213 }
1214 };
1215 return match g.neighborhood_masked(&key, depth, etype_refs.as_deref(), dir, &mask) {
1216 Some(rs) => json_ok(result_set_json(&rs)),
1217 None => graph_err(GraphError::KeyNotFound { key: key.clone() }),
1218 };
1219 }
1220
1221 let rs = {
1223 let g = state.db.read();
1224 match g.node_ref(&key) {
1225 Some(n) => Ok(n.neighborhood(depth, etype_refs.as_deref(), dir)),
1226 None => Err(GraphError::KeyNotFound { key: key.clone() }),
1227 }
1228 };
1229 match rs {
1230 Ok(rs) => json_ok(result_set_json(&rs)),
1231 Err(e) => graph_err(e),
1232 }
1233}
1234
1235async fn algo_pagerank(
1244 State(state): State<AppState>,
1245 Extension(identity): Extension<AuthIdentity>,
1246 Json(body): Json<serde_json::Value>,
1247) -> Response {
1248 if let AuthIdentity::Role(_) = identity {
1250 return forbidden("role-bound token: /algo/* requires a full-access token");
1251 }
1252 let config: PageRankConfig = match serde_json::from_value(body) {
1253 Ok(c) => c,
1254 Err(e) => return err_response(format!("invalid pagerank config: {e}")),
1255 };
1256 let db = state.db.clone();
1257 match tokio::task::spawn_blocking(move || db.read().pagerank(&config)).await {
1258 Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1259 Err(_) => err_response("pagerank task panicked"),
1260 }
1261}
1262
1263async fn algo_wcc(
1268 State(state): State<AppState>,
1269 Extension(identity): Extension<AuthIdentity>,
1270 Json(body): Json<serde_json::Value>,
1271) -> Response {
1272 if let AuthIdentity::Role(_) = identity {
1273 return forbidden("role-bound token: /algo/* requires a full-access token");
1274 }
1275 let config: WccConfig = match serde_json::from_value(body) {
1276 Ok(c) => c,
1277 Err(e) => return err_response(format!("invalid wcc config: {e}")),
1278 };
1279 let db = state.db.clone();
1280 match tokio::task::spawn_blocking(move || db.read().connected_components(&config)).await {
1281 Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1282 Err(_) => err_response("wcc task panicked"),
1283 }
1284}
1285
1286async fn algo_degree(
1290 State(state): State<AppState>,
1291 Extension(identity): Extension<AuthIdentity>,
1292 Json(body): Json<serde_json::Value>,
1293) -> Response {
1294 if let AuthIdentity::Role(_) = identity {
1295 return forbidden("role-bound token: /algo/* requires a full-access token");
1296 }
1297 let config: DegreeConfig = match serde_json::from_value(body) {
1298 Ok(c) => c,
1299 Err(e) => return err_response(format!("invalid degree config: {e}")),
1300 };
1301 let db = state.db.clone();
1302 match tokio::task::spawn_blocking(move || db.read().degree_centrality(&config)).await {
1303 Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1304 Err(_) => err_response("degree task panicked"),
1305 }
1306}
1307
1308fn props_from_json_obj(v: &serde_json::Value) -> Result<Vec<(String, Value)>, String> {
1321 let obj = match v.as_object() {
1322 Some(o) => o,
1323 None => return Err("props must be a JSON object".into()),
1324 };
1325 let mut out = Vec::with_capacity(obj.len());
1326 for (k, val) in obj {
1327 if let Some(v) = json_to_value(val.clone()) {
1328 out.push((k.clone(), v));
1329 }
1330 }
1331 Ok(out)
1332}
1333
1334async fn create_node(
1338 State(state): State<AppState>,
1339 Extension(identity): Extension<AuthIdentity>,
1340 Json(body): Json<Js>,
1341) -> Response {
1342 let label = match body.get("label").and_then(Js::as_str) {
1343 Some(s) => s.to_string(),
1344 None => return err_response("missing label"),
1345 };
1346 let key = match body.get("key").and_then(Js::as_str) {
1347 Some(s) => s.to_string(),
1348 None => return err_response("missing key"),
1349 };
1350 let props = match body.get("props") {
1351 None | Some(Js::Null) => vec![],
1352 Some(v) => match props_from_json_obj(v) {
1353 Ok(p) => p,
1354 Err(e) => return err_response(e),
1355 },
1356 };
1357 let db = state.db.clone();
1358 if let AuthIdentity::Role(role_name) = &identity {
1359 let role = role_name.clone();
1360 return match blocking_write(move || {
1361 db.submit_batch_authz(role, vec![BatchOp::InsertNode { label, key, props }])
1362 })
1363 .await
1364 {
1365 Ok((nodes, edges)) => json_ok(json!({"ok": true, "nodes": nodes, "edges": edges})),
1366 Err(resp) => resp,
1367 };
1368 }
1369 match blocking_write(move || db.submit_batch(vec![BatchOp::InsertNode { label, key, props }]))
1370 .await
1371 {
1372 Ok((nodes, edges)) => json_ok(json!({"ok": true, "nodes": nodes, "edges": edges})),
1373 Err(resp) => resp,
1374 }
1375}
1376
1377async fn delete_node(
1379 State(state): State<AppState>,
1380 Extension(identity): Extension<AuthIdentity>,
1381 Path(key): Path<String>,
1382) -> Response {
1383 let db = state.db.clone();
1384 if let AuthIdentity::Role(role_name) = &identity {
1385 let role = role_name.clone();
1386 return match blocking_write(move || {
1387 db.submit_batch_authz(role, vec![BatchOp::DeleteNode { key }])
1388 })
1389 .await
1390 {
1391 Ok(_) => json_ok(json!({"ok": true})),
1392 Err(resp) => resp,
1393 };
1394 }
1395 match blocking_write(move || db.submit_batch(vec![BatchOp::DeleteNode { key }])).await {
1396 Ok(_) => json_ok(json!({"ok": true})),
1397 Err(resp) => resp,
1398 }
1399}
1400
1401async fn create_edge(
1405 State(state): State<AppState>,
1406 Extension(identity): Extension<AuthIdentity>,
1407 Json(body): Json<Js>,
1408) -> Response {
1409 let edge_type = match body.get("type").and_then(Js::as_str) {
1410 Some(s) => s.to_string(),
1411 None => return err_response("missing type"),
1412 };
1413 let src = match body.get("src").and_then(Js::as_str) {
1414 Some(s) => s.to_string(),
1415 None => return err_response("missing src"),
1416 };
1417 let dst = match body.get("dst").and_then(Js::as_str) {
1418 Some(s) => s.to_string(),
1419 None => return err_response("missing dst"),
1420 };
1421 let db = state.db.clone();
1422 if let AuthIdentity::Role(role_name) = &identity {
1423 let role = role_name.clone();
1424 return match blocking_write(move || {
1425 db.submit_batch_authz(
1426 role,
1427 vec![BatchOp::InsertEdge {
1428 edge_type,
1429 src_key: src,
1430 dst_key: dst,
1431 }],
1432 )
1433 })
1434 .await
1435 {
1436 Ok(_) => json_ok(json!({"ok": true})),
1437 Err(resp) => resp,
1438 };
1439 }
1440 match blocking_write(move || {
1441 db.submit_batch(vec![BatchOp::InsertEdge {
1442 edge_type,
1443 src_key: src,
1444 dst_key: dst,
1445 }])
1446 })
1447 .await
1448 {
1449 Ok(_) => json_ok(json!({"ok": true})),
1450 Err(resp) => resp,
1451 }
1452}
1453
1454async fn delete_edge(
1456 State(state): State<AppState>,
1457 Extension(identity): Extension<AuthIdentity>,
1458 Path((etype, src, dst)): Path<(String, String, String)>,
1459) -> Response {
1460 let db = state.db.clone();
1461 if let AuthIdentity::Role(role_name) = &identity {
1462 let role = role_name.clone();
1463 return match blocking_write(move || {
1464 db.submit_batch_authz(
1465 role,
1466 vec![BatchOp::DeleteEdge {
1467 edge_type: etype,
1468 src_key: src,
1469 dst_key: dst,
1470 }],
1471 )
1472 })
1473 .await
1474 {
1475 Ok(_) => json_ok(json!({"ok": true})),
1476 Err(resp) => resp,
1477 };
1478 }
1479 match blocking_write(move || {
1480 db.submit_batch(vec![BatchOp::DeleteEdge {
1481 edge_type: etype,
1482 src_key: src,
1483 dst_key: dst,
1484 }])
1485 })
1486 .await
1487 {
1488 Ok(_) => json_ok(json!({"ok": true})),
1489 Err(resp) => resp,
1490 }
1491}
1492
1493async fn rename_node(
1498 State(state): State<AppState>,
1499 Extension(identity): Extension<AuthIdentity>,
1500 Path(key): Path<String>,
1501 Json(body): Json<Js>,
1502) -> Response {
1503 if let AuthIdentity::Role(_) = identity {
1504 return forbidden("role-bound token: writes are not permitted");
1505 }
1506 let new_key = match body.get("new_key").and_then(Js::as_str) {
1507 Some(s) => s.to_string(),
1508 None => return err_response("missing new_key"),
1509 };
1510 let db = state.db.clone();
1511 match tokio::task::spawn_blocking(move || {
1512 db.submit_batch(vec![BatchOp::RenameNode {
1513 old_key: key,
1514 new_key,
1515 }])
1516 })
1517 .await
1518 {
1519 Ok(Ok(_)) => json_ok(json!({"ok": true})),
1520 Ok(Err(GraphError::KeyNotFound { key })) => key_not_found(key),
1521 Ok(Err(GraphError::DuplicateKey { key })) => conflict_response(key),
1522 Ok(Err(e)) => graph_err(e),
1523 Err(_) => err_response("write task panicked"),
1524 }
1525}
1526
1527async fn upsert_edge(
1532 State(state): State<AppState>,
1533 Extension(identity): Extension<AuthIdentity>,
1534 Json(body): Json<Js>,
1535) -> Response {
1536 let edge_type = match body.get("edge_type").and_then(Js::as_str) {
1537 Some(s) => s.to_string(),
1538 None => return err_response("missing edge_type"),
1539 };
1540 let src_key = match body.get("src_key").and_then(Js::as_str) {
1541 Some(s) => s.to_string(),
1542 None => return err_response("missing src_key"),
1543 };
1544 let dst_key = match body.get("dst_key").and_then(Js::as_str) {
1545 Some(s) => s.to_string(),
1546 None => return err_response("missing dst_key"),
1547 };
1548 let placeholder_label = match body.get("placeholder_label").and_then(Js::as_str) {
1549 Some(s) => s.to_string(),
1550 None => return err_response("missing placeholder_label"),
1551 };
1552 let db = state.db.clone();
1553 if let AuthIdentity::Role(role_name) = &identity {
1554 let role = role_name.clone();
1555 return match blocking_write(move || {
1556 db.submit_batch_authz(
1557 role,
1558 vec![BatchOp::InsertEdgeUpsert {
1559 edge_type,
1560 src_key,
1561 dst_key,
1562 placeholder_label,
1563 }],
1564 )
1565 })
1566 .await
1567 {
1568 Ok((nodes, edges)) => json_ok(json!({
1569 "nodes_created": nodes,
1570 "edge_inserted": edges > 0,
1571 })),
1572 Err(resp) => resp,
1573 };
1574 }
1575 match blocking_write(move || {
1576 db.submit_batch(vec![BatchOp::InsertEdgeUpsert {
1577 edge_type,
1578 src_key,
1579 dst_key,
1580 placeholder_label,
1581 }])
1582 })
1583 .await
1584 {
1585 Ok((nodes, edges)) => json_ok(json!({
1586 "nodes_created": nodes,
1587 "edge_inserted": edges > 0,
1588 })),
1589 Err(resp) => resp,
1590 }
1591}
1592
1593async fn set_node_prop(
1597 State(state): State<AppState>,
1598 Extension(identity): Extension<AuthIdentity>,
1599 Path((key, field)): Path<(String, String)>,
1600 Json(body): Json<Js>,
1601) -> Response {
1602 let value = match body.get("value").and_then(|v| json_to_value(v.clone())) {
1603 Some(v) => v,
1604 None => return err_response("missing or null value"),
1605 };
1606 let db = state.db.clone();
1607 if let AuthIdentity::Role(role_name) = &identity {
1608 let role = role_name.clone();
1609 return match blocking_write(move || {
1610 db.submit_batch_authz(role, vec![BatchOp::SetProp { key, field, value }])
1611 })
1612 .await
1613 {
1614 Ok(_) => json_ok(json!({"ok": true})),
1615 Err(resp) => resp,
1616 };
1617 }
1618 match blocking_write(move || db.submit_batch(vec![BatchOp::SetProp { key, field, value }]))
1619 .await
1620 {
1621 Ok(_) => json_ok(json!({"ok": true})),
1622 Err(resp) => resp,
1623 }
1624}
1625
1626async fn node_history_handler(
1642 State(state): State<AppState>,
1643 Extension(identity): Extension<AuthIdentity>,
1644 Path(key): Path<String>,
1645) -> Response {
1646 if let AuthIdentity::Role(ref role_name) = identity {
1647 let g = state.db.read();
1648 let role_mask = match g.mask_for_role(role_name) {
1649 Ok(m) => m,
1650 Err(e) => return role_mask_err(e),
1651 };
1652 if !role_mask.contains_node(&*g, &key) {
1654 return key_not_found(key);
1655 }
1656 let entries = match g.node_history(&key) {
1657 Ok(e) => e,
1658 Err(e) => return graph_err(e),
1659 };
1660 let total_commits = match g.wal_total_commits() {
1661 Ok(n) => n,
1662 Err(e) => return graph_err(e),
1663 };
1664 use core_api::HistoryChange;
1668 let visible: Vec<_> = entries
1669 .into_iter()
1670 .filter(|entry| match &entry.change {
1671 HistoryChange::EdgeAdded { other, .. }
1672 | HistoryChange::EdgeRemoved { other, .. } => role_mask.contains_node(&*g, other),
1673 _ => true,
1674 })
1675 .collect();
1676 return json_ok(node_history_json(&key, &visible, total_commits));
1677 }
1678 let g = state.db.read();
1681 if !g.has_node(&key) {
1682 return key_not_found(key);
1683 }
1684 let entries = match g.node_history(&key) {
1685 Ok(e) => e,
1686 Err(e) => return graph_err(e),
1687 };
1688 let total_commits = match g.wal_total_commits() {
1689 Ok(n) => n,
1690 Err(e) => return graph_err(e),
1691 };
1692 json_ok(node_history_json(&key, &entries, total_commits))
1693}
1694
1695async fn edge_history_handler(
1701 State(state): State<AppState>,
1702 Extension(identity): Extension<AuthIdentity>,
1703 Query(qs): Query<BTreeMap<String, String>>,
1704) -> Response {
1705 let a = match qs.get("a").filter(|s| !s.is_empty()) {
1706 Some(s) => s.clone(),
1707 None => return err_response("missing query param a"),
1708 };
1709 let b = match qs.get("b").filter(|s| !s.is_empty()) {
1710 Some(s) => s.clone(),
1711 None => return err_response("missing query param b"),
1712 };
1713 if let AuthIdentity::Role(ref role_name) = identity {
1714 let g = state.db.read();
1715 let role_mask = match g.mask_for_role(role_name) {
1716 Ok(m) => m,
1717 Err(e) => return role_mask_err(e),
1718 };
1719 if !role_mask.contains_node(&*g, &a) {
1721 return key_not_found(a);
1722 }
1723 if !role_mask.contains_node(&*g, &b) {
1724 return key_not_found(b);
1725 }
1726 let result = match g.edge_history(&a, &b) {
1727 Ok(r) => r,
1728 Err(e) => return graph_err(e),
1729 };
1730 return json_ok(edge_history_result_json(&a, &b, &result));
1731 }
1732 let g = state.db.read();
1734 let result = match g.edge_history(&a, &b) {
1735 Ok(r) => r,
1736 Err(e) => return graph_err(e),
1737 };
1738 json_ok(edge_history_result_json(&a, &b, &result))
1739}
1740
1741async fn was_linked_handler(
1747 State(state): State<AppState>,
1748 Extension(identity): Extension<AuthIdentity>,
1749 Query(qs): Query<BTreeMap<String, String>>,
1750) -> Response {
1751 let a = match qs.get("a").filter(|s| !s.is_empty()) {
1752 Some(s) => s.clone(),
1753 None => return err_response("missing query param a"),
1754 };
1755 let b = match qs.get("b").filter(|s| !s.is_empty()) {
1756 Some(s) => s.clone(),
1757 None => return err_response("missing query param b"),
1758 };
1759 let edge_type = match qs.get("edge_type").filter(|s| !s.is_empty()) {
1760 Some(s) => s.clone(),
1761 None => return err_response("missing query param edge_type"),
1762 };
1763 let at_commit: u64 = match qs.get("at_commit") {
1764 Some(s) => match s.parse() {
1765 Ok(n) => n,
1766 Err(_) => return err_response("at_commit must be a non-negative integer"),
1767 },
1768 None => return err_response("missing query param at_commit"),
1769 };
1770
1771 if let AuthIdentity::Role(ref role_name) = identity {
1772 let g = state.db.read();
1773 let role_mask = match g.mask_for_role(role_name) {
1774 Ok(m) => m,
1775 Err(e) => return role_mask_err(e),
1776 };
1777 if !role_mask.contains_node(&*g, &a) {
1778 return key_not_found(a);
1779 }
1780 if !role_mask.contains_node(&*g, &b) {
1781 return key_not_found(b);
1782 }
1783 return match g.was_linked(&a, &b, &edge_type, at_commit) {
1784 Ok(linked) => json_ok(json!({
1785 "a": a, "b": b, "edge_type": edge_type,
1786 "at_commit": at_commit, "linked": linked,
1787 })),
1788 Err(GraphError::CommitOutOfRange { .. }) => (
1789 StatusCode::BAD_REQUEST,
1790 Json(json!({"error": format!("commit {at_commit} is out of range")})),
1791 )
1792 .into_response(),
1793 Err(e) => graph_err(e),
1794 };
1795 }
1796
1797 let g = state.db.read();
1799 match g.was_linked(&a, &b, &edge_type, at_commit) {
1800 Ok(linked) => json_ok(json!({
1801 "a": a, "b": b, "edge_type": edge_type,
1802 "at_commit": at_commit, "linked": linked,
1803 })),
1804 Err(GraphError::CommitOutOfRange { .. }) => (
1805 StatusCode::BAD_REQUEST,
1806 Json(json!({"error": format!("commit {at_commit} is out of range")})),
1807 )
1808 .into_response(),
1809 Err(e) => graph_err(e),
1810 }
1811}
1812
1813async fn remove_node_prop(
1815 State(state): State<AppState>,
1816 Extension(identity): Extension<AuthIdentity>,
1817 Path((key, field)): Path<(String, String)>,
1818) -> Response {
1819 let db = state.db.clone();
1820 if let AuthIdentity::Role(role_name) = &identity {
1821 let role = role_name.clone();
1822 return match blocking_write(move || {
1823 db.submit_batch_authz(role, vec![BatchOp::RemoveProp { key, field }])
1824 })
1825 .await
1826 {
1827 Ok(_) => json_ok(json!({"ok": true})),
1828 Err(resp) => resp,
1829 };
1830 }
1831 match blocking_write(move || db.submit_batch(vec![BatchOp::RemoveProp { key, field }])).await {
1832 Ok(_) => json_ok(json!({"ok": true})),
1833 Err(resp) => resp,
1834 }
1835}
1836
1837async fn backup(
1854 State(state): State<AppState>,
1855 Extension(identity): Extension<AuthIdentity>,
1856 Json(body): Json<Js>,
1857) -> Response {
1858 if let AuthIdentity::Role(_) = identity {
1859 return forbidden("role-bound token: /backup requires a full-access token");
1860 }
1861 let dest = match body.get("dest").and_then(Js::as_str) {
1862 Some(s) if !s.is_empty() => std::path::PathBuf::from(s),
1863 _ => return err_response("missing or empty \"dest\" field"),
1864 };
1865 let db = state.db.clone();
1866 let report: BackupReport = match tokio::task::spawn_blocking(move || {
1867 let g = db.read();
1870 g.backup_to(&dest)
1871 })
1872 .await
1873 {
1874 Ok(Ok(r)) => r,
1875 Ok(Err(e)) => return graph_err(e),
1876 Err(_) => return err_response("backup task panicked"),
1877 };
1878
1879 let body = match serde_json::to_value(BackupReportJson::from(&report)) {
1880 Ok(v) => v,
1881 Err(e) => return err_response(e.to_string()),
1882 };
1883
1884 if report.verified {
1885 json_ok(body)
1886 } else {
1887 (StatusCode::INTERNAL_SERVER_ERROR, Json(body)).into_response()
1888 }
1889}
1890
1891#[derive(serde::Serialize)]
1893struct BackupReportJson<'a> {
1894 files: &'a [String],
1895 bytes: u64,
1896 verified: bool,
1897}
1898
1899impl<'a> From<&'a BackupReport> for BackupReportJson<'a> {
1900 fn from(r: &'a BackupReport) -> Self {
1901 Self {
1902 files: &r.files,
1903 bytes: r.bytes,
1904 verified: r.verified,
1905 }
1906 }
1907}
1908
1909#[cfg(test)]
1910mod tests {
1911 use super::*;
1912 use crate::json::result_set_json;
1913 use core_api::{DegreeConfig, PageRankConfig, ResultSet, Value, WccConfig};
1914
1915 #[test]
1916 fn nan_float_cell_serializes_as_null() {
1917 let mut rs = ResultSet::new(vec!["n".into()]);
1918 rs.push_row(vec![Some(Value::Float(f64::NAN))]);
1919 let j = result_set_json(&rs);
1920 assert_eq!(j["rows"][0][0], Js::Null);
1921 }
1922
1923 #[test]
1926 fn pagerank_config_empty_body_uses_defaults() {
1927 let config: PageRankConfig = serde_json::from_str("{}").unwrap();
1928 let default = PageRankConfig::default();
1929 assert_eq!(config.damping, default.damping);
1930 assert_eq!(config.max_iters, default.max_iters);
1931 assert_eq!(config.tol, default.tol);
1932 assert_eq!(config.budget_ms, default.budget_ms);
1933 assert_eq!(config.edge_type, default.edge_type);
1934 }
1935
1936 #[test]
1938 fn wcc_config_empty_body_uses_defaults() {
1939 let config: WccConfig = serde_json::from_str("{}").unwrap();
1940 let default = WccConfig::default();
1941 assert_eq!(config.budget_ms, default.budget_ms);
1942 assert_eq!(config.edge_type, default.edge_type);
1943 }
1944
1945 #[test]
1947 fn degree_config_empty_body_uses_defaults() {
1948 let config: DegreeConfig = serde_json::from_str("{}").unwrap();
1949 let default = DegreeConfig::default();
1950 assert_eq!(config.budget_ms, default.budget_ms);
1951 assert_eq!(config.edge_type, default.edge_type);
1952 }
1953}