Skip to main content

server/
http.rs

1//! Thin HTTP wrapper over [`SharedDb`]. Every endpoint is a lock, a public
2//! core-api call, then a response — no business logic.
3//!
4//! # Single-sink design
5//!
6//! The HTTP router is the designated broadcast producer. [`router`] installs
7//! one `broadcast::Sender` as the [`core_api::GraphDb`] event sink. MCP and
8//! CLI mutations on the same [`SharedDb`] fire into that same sink (one
9//! producer, many `/watch` subscribers). A second [`router`] call replaces
10//! the sink and terminates every existing subscriber with
11//! [`tokio::sync::broadcast::error::RecvError::Closed`].
12
13use crate::json::{
14    node_edges_json, node_info_json, params_from_json, parse_ingest_edges, result_set_json,
15    rule_def_from_json,
16};
17use crate::AppState;
18use arrow_bridge::to_ipc_bytes;
19use axum::extract::{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, AutoFk, DegreeConfig, Dir, GraphError, IngestOptions, NodeMask,
27    PageRankConfig, SharedDb, SuggestConfig, WccConfig, SUGGEST_DEFAULT_SEED,
28};
29use serde_json::{json, Value as Js};
30use std::collections::BTreeMap;
31use std::net::SocketAddr;
32use std::path::PathBuf;
33use tower_http::services::ServeDir;
34
35/// Build the HTTP router over `db`. Read endpoints take the read lock;
36/// `/ingest` takes the write lock. Guards are dropped before any `.await`.
37/// `GET /watch` upgrades to a WebSocket fed by the post-commit sink.
38///
39/// **Call at most once per [`SharedDb`].** A second call replaces the sink
40/// and terminates all existing `/watch` subscribers with
41/// [`tokio::sync::broadcast::error::RecvError::Closed`].
42///
43/// Installing the watch sink replaces any previously installed
44/// [`core_api::GraphDb::set_event_sink`]. The sink only
45/// `broadcast::Sender::send`s (non-blocking) and never re-enters `db`.
46///
47/// # Blocking
48///
49/// [`SharedDb`] uses a std [`std::sync::RwLock`]. Write handlers
50/// (`query_write`, `/ingest`, `create_rule`) run in
51/// `tokio::task::spawn_blocking` and drop the write guard before `.await`.
52/// Reads stay on the worker (neighborhood is µs). `suggest` and `algo`
53/// already use the blocking pool.
54pub fn router(db: SharedDb) -> Router {
55    router_with_auth(db, None)
56}
57
58/// [`router`] with an optional bearer/`?token=` requirement on every route
59/// except unauthenticated `GET /health`.
60pub fn router_with_auth(db: SharedDb, token: Option<String>) -> Router {
61    build_app(db, token, UiFallback::None, default_advertise_addr())
62}
63
64/// Same as [`router_with_auth`], then `ServeDir` as the fallback so API routes win.
65pub fn router_with_ui(
66    db: SharedDb,
67    ui_dir: impl AsRef<std::path::Path>,
68    token: Option<String>,
69) -> Router {
70    build_app(
71        db,
72        token,
73        UiFallback::Dir(ui_dir.as_ref().to_path_buf()),
74        default_advertise_addr(),
75    )
76}
77
78#[cfg(feature = "embed-ui")]
79static EMBEDDED_UI: include_dir::Dir<'_> =
80    include_dir::include_dir!("$CARGO_MANIFEST_DIR/../../ui/dist");
81
82/// [`router`] plus the `embed-ui` static tree as fallback.
83#[cfg(feature = "embed-ui")]
84pub fn router_with_embedded_ui(db: SharedDb) -> Router {
85    build_app(db, None, UiFallback::Embedded, default_advertise_addr())
86}
87
88#[cfg(feature = "embed-ui")]
89async fn embedded_fallback(uri: axum::http::Uri) -> Response {
90    let rel = if uri.path() == "/" || uri.path().is_empty() {
91        "index.html"
92    } else {
93        uri.path().trim_start_matches('/')
94    };
95    if rel.split('/').any(|seg| seg == "..") {
96        return StatusCode::NOT_FOUND.into_response();
97    }
98    match EMBEDDED_UI.get_file(rel) {
99        Some(file) => (
100            StatusCode::OK,
101            [(header::CONTENT_TYPE, embedded_ctype(rel))],
102            file.contents(),
103        )
104            .into_response(),
105        None => StatusCode::NOT_FOUND.into_response(),
106    }
107}
108
109#[cfg(feature = "embed-ui")]
110fn embedded_ctype(path: &str) -> &'static str {
111    if path.ends_with(".html") {
112        "text/html; charset=utf-8"
113    } else if path.ends_with(".js") {
114        "application/javascript; charset=utf-8"
115    } else if path.ends_with(".css") {
116        "text/css; charset=utf-8"
117    } else if path.ends_with(".woff2") {
118        "font/woff2"
119    } else if path.ends_with(".svg") {
120        "image/svg+xml"
121    } else if path.ends_with(".ico") {
122        "image/x-icon"
123    } else if path.ends_with(".txt") {
124        "text/plain; charset=utf-8"
125    } else {
126        "application/octet-stream"
127    }
128}
129
130/// Bind `addr` (port 0 is ephemeral) and serve.
131///
132/// Sends the resolved local address on `ready` once the listener is accepting.
133/// Does not hold a database lock.
134pub async fn serve(
135    db: SharedDb,
136    addr: SocketAddr,
137    ready: tokio::sync::oneshot::Sender<SocketAddr>,
138    token: Option<String>,
139) -> std::io::Result<()> {
140    serve_inner(db, addr, ready, UiFallback::None, token).await
141}
142
143/// [`serve`] plus a UI dist directory mounted behind the API routes.
144pub async fn serve_with_ui(
145    db: SharedDb,
146    addr: SocketAddr,
147    ready: tokio::sync::oneshot::Sender<SocketAddr>,
148    ui_dir: PathBuf,
149    token: Option<String>,
150) -> std::io::Result<()> {
151    serve_inner(db, addr, ready, UiFallback::Dir(ui_dir), token).await
152}
153
154/// [`serve`] plus the compiled-in UI (no-op fallback if `embed-ui` is off).
155#[cfg(feature = "embed-ui")]
156pub async fn serve_with_embedded_ui(
157    db: SharedDb,
158    addr: SocketAddr,
159    ready: tokio::sync::oneshot::Sender<SocketAddr>,
160    token: Option<String>,
161) -> std::io::Result<()> {
162    serve_inner(db, addr, ready, UiFallback::Embedded, token).await
163}
164
165enum UiFallback {
166    None,
167    Dir(PathBuf),
168    #[cfg(feature = "embed-ui")]
169    Embedded,
170}
171
172async fn serve_inner(
173    db: SharedDb,
174    addr: SocketAddr,
175    ready: tokio::sync::oneshot::Sender<SocketAddr>,
176    ui: UiFallback,
177    token: Option<String>,
178) -> std::io::Result<()> {
179    let listener = tokio::net::TcpListener::bind(addr).await?;
180    let local = listener.local_addr()?;
181    if ready.send(local).is_err() {
182        // Caller dropped the readiness receiver; still serve.
183        eprintln!("serve: readiness receiver dropped before bind notify");
184    }
185    let app = build_app(db, token, ui, local);
186    axum::serve(listener, app).await
187}
188
189fn default_advertise_addr() -> SocketAddr {
190    SocketAddr::from(([127, 0, 0, 1], 8080))
191}
192
193fn build_app(db: SharedDb, token: Option<String>, ui: UiFallback, addr: SocketAddr) -> Router {
194    debug_assert!(
195        !db.read().has_event_sink(),
196        "router() must be called at most once per SharedDb; a second call \
197         replaces the sink and terminates all existing /watch subscribers \
198         with RecvError::Closed"
199    );
200    let (tx, _) = tokio::sync::broadcast::channel(1024);
201    {
202        let tx = tx.clone();
203        db.write().set_event_sink(Box::new(move |ev| {
204            let _ = tx.send(ev);
205        }));
206    }
207    let state = AppState {
208        db,
209        watch: tx,
210        token,
211        addr,
212    };
213    let app = Router::new()
214        .route("/health", get(health))
215        .route("/query", post(query))
216        .route("/stats", get(stats))
217        .route("/ingest", post(ingest))
218        .route("/rules", post(create_rule))
219        .route("/suggest", get(suggest))
220        .route("/explain", get(explain))
221        .route("/node/{key}", get(node_info))
222        .route("/node/{key}/edges", get(node_edges))
223        .route("/node/{key}/neighborhood", get(neighborhood))
224        .route("/algo/pagerank", post(algo_pagerank))
225        .route("/algo/wcc", post(algo_wcc))
226        .route("/algo/degree", post(algo_degree))
227        .route("/watch", get(crate::ws::watch))
228        .route("/subscribe", get(crate::subscribe::subscribe))
229        .with_state(state.clone());
230    let app = match ui {
231        UiFallback::None => app,
232        UiFallback::Dir(dir) => app.fallback_service(ServeDir::new(dir)),
233        #[cfg(feature = "embed-ui")]
234        UiFallback::Embedded => app.fallback(embedded_fallback),
235    };
236    app.layer(middleware::from_fn_with_state(state, auth_middleware))
237}
238
239async fn health(State(state): State<AppState>) -> Response {
240    let (nodes, edges) = {
241        let g = state.db.read();
242        let s = g.stats();
243        (s.nodes_live, s.edges)
244    };
245    json_ok(json!({
246        "ok": true,
247        "nodes": nodes,
248        "edges": edges,
249        "addr": state.addr.to_string(),
250    }))
251}
252
253/// Run a GraphDb write on the blocking pool. The write guard lives only
254/// inside `f` and is dropped before this future awaits.
255async fn blocking_write<T, F>(f: F) -> std::result::Result<T, Response>
256where
257    T: Send + 'static,
258    F: FnOnce() -> core_api::Result<T> + Send + 'static,
259{
260    match tokio::task::spawn_blocking(f).await {
261        Ok(Ok(v)) => Ok(v),
262        Ok(Err(e)) => Err(graph_err(e)),
263        Err(_) => Err(err_response("write task panicked")),
264    }
265}
266
267const TOKEN_COOKIE: &str = "mushroomdb_token";
268
269async fn auth_middleware(State(state): State<AppState>, req: Request, next: Next) -> Response {
270    let Some(expected) = state.token.clone().filter(|s| !s.is_empty()) else {
271        return next.run(req).await;
272    };
273    if req.method() == Method::GET && req.uri().path() == "/health" {
274        return next.run(req).await;
275    }
276    if request_token(&req).as_deref() != Some(expected.as_str()) {
277        return unauthorized();
278    }
279    let set_cookie = presented_bearer_or_query(&req).as_deref() == Some(expected.as_str());
280    let mut res = next.run(req).await;
281    if set_cookie && is_html_response(&res) {
282        attach_token_cookie(&mut res, &expected);
283    }
284    res
285}
286
287fn request_token(req: &Request) -> Option<String> {
288    presented_bearer_or_query(req).or_else(|| presented_cookie(req))
289}
290
291fn presented_bearer_or_query(req: &Request) -> Option<String> {
292    if let Some(header) = req
293        .headers()
294        .get(header::AUTHORIZATION)
295        .and_then(|v| v.to_str().ok())
296    {
297        if let Some(value) = bearer_token(header) {
298            return Some(value.to_string());
299        }
300    }
301    query_param(req.uri().query().unwrap_or(""), "token")
302}
303
304fn presented_cookie(req: &Request) -> Option<String> {
305    let header = req.headers().get(header::COOKIE)?.to_str().ok()?;
306    cookie_named(header, TOKEN_COOKIE).map(str::to_string)
307}
308
309fn cookie_named<'a>(header: &'a str, name: &str) -> Option<&'a str> {
310    for part in header.split(';') {
311        let part = part.trim();
312        let Some((k, v)) = part.split_once('=') else {
313            continue;
314        };
315        if k.trim() == name {
316            return Some(v.trim());
317        }
318    }
319    None
320}
321
322fn is_html_response(res: &Response) -> bool {
323    res.headers()
324        .get(header::CONTENT_TYPE)
325        .and_then(|v| v.to_str().ok())
326        .is_some_and(|ct| {
327            ct.split(';')
328                .next()
329                .unwrap_or("")
330                .trim()
331                .eq_ignore_ascii_case("text/html")
332        })
333}
334
335fn attach_token_cookie(res: &mut Response, token: &str) {
336    let value = format!("{TOKEN_COOKIE}={token}; Path=/; SameSite=Lax; HttpOnly");
337    if let Ok(hv) = HeaderValue::from_str(&value) {
338        res.headers_mut().insert(header::SET_COOKIE, hv);
339    }
340}
341
342fn bearer_token(header: &str) -> Option<&str> {
343    let (scheme, value) = header.split_once(' ')?;
344    if scheme.eq_ignore_ascii_case("Bearer") {
345        Some(value.trim())
346    } else {
347        None
348    }
349}
350
351fn query_param(query: &str, key: &str) -> Option<String> {
352    for pair in query.split('&') {
353        if pair.is_empty() {
354            continue;
355        }
356        match pair.split_once('=') {
357            Some((k, v)) if k == key => return percent_decode_plus(v),
358            None if pair == key => return Some(String::new()),
359            _ => {}
360        }
361    }
362    None
363}
364
365/// `application/x-www-form-urlencoded`: `+` is space, `%HH` is a byte.
366fn percent_decode_plus(s: &str) -> Option<String> {
367    let bytes = s.as_bytes();
368    let mut out = Vec::with_capacity(bytes.len());
369    let mut i = 0;
370    while i < bytes.len() {
371        match bytes[i] {
372            b'+' => {
373                out.push(b' ');
374                i += 1;
375            }
376            b'%' => {
377                if i + 2 >= bytes.len() {
378                    return None;
379                }
380                let hi = from_hex(bytes[i + 1])?;
381                let lo = from_hex(bytes[i + 2])?;
382                out.push((hi << 4) | lo);
383                i += 3;
384            }
385            c => {
386                out.push(c);
387                i += 1;
388            }
389        }
390    }
391    String::from_utf8(out).ok()
392}
393
394fn from_hex(b: u8) -> Option<u8> {
395    match b {
396        b'0'..=b'9' => Some(b - b'0'),
397        b'a'..=b'f' => Some(b - b'a' + 10),
398        b'A'..=b'F' => Some(b - b'A' + 10),
399        _ => None,
400    }
401}
402
403fn unauthorized() -> Response {
404    (
405        StatusCode::UNAUTHORIZED,
406        Json(json!({"error": "unauthorized"})),
407    )
408        .into_response()
409}
410
411fn err_response(detail: impl Into<String>) -> Response {
412    (
413        StatusCode::BAD_REQUEST,
414        Json(json!({"error": detail.into()})),
415    )
416        .into_response()
417}
418
419fn graph_err(e: GraphError) -> Response {
420    let detail = match e {
421        GraphError::QueryError { detail } | GraphError::IngestError { detail } => detail,
422        other => other.to_string(),
423    };
424    err_response(detail)
425}
426
427fn key_not_found(key: String) -> Response {
428    (
429        StatusCode::NOT_FOUND,
430        Json(json!({"error": GraphError::KeyNotFound { key }.to_string()})),
431    )
432        .into_response()
433}
434
435fn json_ok(value: Js) -> Response {
436    (StatusCode::OK, Json(value)).into_response()
437}
438
439fn ingest_options(v: Option<&Js>) -> Result<IngestOptions, String> {
440    let Some(v) = v else {
441        return Ok(IngestOptions::default());
442    };
443    if v.is_null() {
444        return Ok(IngestOptions::default());
445    }
446    let obj = v
447        .as_object()
448        .ok_or_else(|| "options must be an object".to_string())?;
449    let mut opts = IngestOptions::default();
450    if let Some(kf) = obj.get("key_field") {
451        opts.key_field = kf
452            .as_str()
453            .ok_or_else(|| "options.key_field must be a string".to_string())?
454            .to_string();
455    }
456    if let Some(fk) = obj.get("auto_fk") {
457        if fk == &Js::Bool(false) || fk.as_str() == Some("off") {
458            opts.auto_fk = AutoFk::Off;
459        } else if let Some(m) = fk.as_object() {
460            let suf = m
461                .get("suffix")
462                .and_then(Js::as_str)
463                .ok_or_else(|| "options.auto_fk.suffix must be a string".to_string())?;
464            opts.auto_fk = AutoFk::Auto {
465                suffix: suf.to_string(),
466            };
467        } else {
468            return Err("options.auto_fk must be false, \"off\", or {suffix}".into());
469        }
470    }
471    Ok(opts)
472}
473
474async fn query(
475    State(state): State<AppState>,
476    Query(qs): Query<BTreeMap<String, String>>,
477    Json(body): Json<Js>,
478) -> Response {
479    let cypher = match body.get("cypher").and_then(Js::as_str) {
480        Some(s) => s.to_string(),
481        None => return err_response("missing cypher"),
482    };
483    let params = match params_from_json(body.get("params")) {
484        Ok(p) => p,
485        Err(e) => return err_response(e),
486    };
487    let format = qs.get("format").map(String::as_str).unwrap_or("");
488
489    // Optional node mask: when present, route to query_masked (read-only).
490    let mask_keys: Option<Vec<String>> = match body.get("mask") {
491        None | Some(Js::Null) => None,
492        Some(Js::Array(arr)) => {
493            let mut keys = Vec::with_capacity(arr.len());
494            for v in arr {
495                match v.as_str() {
496                    Some(s) => keys.push(s.to_string()),
497                    None => return err_response("mask must be an array of strings"),
498                }
499            }
500            Some(keys)
501        }
502        Some(_) => return err_response("mask must be an array of strings"),
503    };
504
505    // When a mask is provided, route to query_masked (rejects writes).
506    // Hold a single read guard for both from_keys and query_masked so the mask
507    // and the query execute on the same database snapshot.
508    if let Some(ref keys) = mask_keys {
509        let db = state.db.read();
510        let mask = NodeMask::from_keys(&*db, keys.iter().map(String::as_str));
511        return match db.query_masked(&cypher, &params, &mask) {
512            Ok(rs) => match format {
513                "" => match to_ipc_bytes(&rs) {
514                    Ok(bytes) => (
515                        StatusCode::OK,
516                        [(header::CONTENT_TYPE, "application/vnd.apache.arrow.stream")],
517                        bytes,
518                    )
519                        .into_response(),
520                    Err(e) => err_response(e),
521                },
522                "json" => json_ok(result_set_json(&rs)),
523                other => err_response(format!("unknown format: {other}")),
524            },
525            Err(GraphError::QueryError { detail })
526                if detail.contains("masked queries are read-only") =>
527            {
528                (StatusCode::BAD_REQUEST, Json(json!({"error": detail}))).into_response()
529            }
530            Err(e) => graph_err(e),
531        };
532    }
533
534    // Detect write statements at the token level to dispatch to the correct lock.
535    // Write statements (CREATE / MATCH…SET / MATCH…DELETE / MERGE) need the
536    // write lock so mutations flow through WAL + rule engine with fsync before
537    // the response is sent.  Read queries (MATCH … RETURN …) use the read lock.
538    let is_write = match is_write_query(&cypher) {
539        Ok(b) => b,
540        Err(e) => return err_response(e),
541    };
542
543    let rs = if is_write {
544        let db = state.db.clone();
545        match blocking_write(move || db.write().query_write(&cypher, &params)).await {
546            Ok(rs) => rs,
547            Err(resp) => return resp,
548        }
549    } else {
550        match state.db.read().query(&cypher, &params) {
551            Ok(rs) => rs,
552            Err(e) => return graph_err(e),
553        }
554    };
555
556    match format {
557        "" => match to_ipc_bytes(&rs) {
558            Ok(bytes) => (
559                StatusCode::OK,
560                [(header::CONTENT_TYPE, "application/vnd.apache.arrow.stream")],
561                bytes,
562            )
563                .into_response(),
564            Err(e) => err_response(e),
565        },
566        "json" => json_ok(result_set_json(&rs)),
567        other => err_response(format!("unknown format: {other}")),
568    }
569}
570
571async fn stats(State(state): State<AppState>) -> Response {
572    let snap = {
573        let g = state.db.read();
574        g.stats()
575    };
576    match serde_json::to_value(&snap) {
577        Ok(v) => json_ok(v),
578        Err(e) => err_response(e.to_string()),
579    }
580}
581
582async fn ingest(State(state): State<AppState>, Json(body): Json<Js>) -> Response {
583    let label = match body.get("label").and_then(Js::as_str) {
584        Some(s) => s.to_string(),
585        None => return err_response("missing label"),
586    };
587    let rows = match body.get("rows") {
588        Some(r) => r,
589        None => return err_response("missing rows"),
590    };
591    let mut converted = match json_to_rows(rows) {
592        Ok(c) => c,
593        Err(e) => return graph_err(e),
594    };
595    let opts = match ingest_options(body.get("options")) {
596        Ok(o) => o,
597        Err(e) => return err_response(e),
598    };
599    let taken = std::mem::take(&mut converted.rows);
600    let edges = match body.get("edges") {
601        None | Some(Js::Null) => Vec::new(),
602        Some(raw) => match parse_ingest_edges(raw) {
603            Ok(e) => e,
604            Err(e) => return err_response(e),
605        },
606    };
607    let db = state.db.clone();
608    let report =
609        match blocking_write(move || db.write().ingest_with_edges(&label, taken, &opts, &edges))
610            .await
611        {
612            Ok(r) => converted.into_report(r),
613            Err(resp) => return resp,
614        };
615    match serde_json::to_value(&report) {
616        Ok(v) => json_ok(v),
617        Err(e) => err_response(e.to_string()),
618    }
619}
620
621/// `GET /suggest` — profile the database and return rule suggestions.
622///
623/// # Locking and blocking strategy
624///
625/// `suggest_rules_with_config` is CPU-intensive and synchronous. Running it on a
626/// Tokio worker thread would starve the executor. This handler offloads the work to
627/// `tokio::task::spawn_blocking`, which uses the blocking thread-pool. The
628/// `std::sync::RwLock` read guard is acquired and held inside the blocking task —
629/// reads don't block other reads; writes wait for the guard to drop. The global
630/// budget (`SuggestConfig::global_budget_ms`, default 5 s) caps lock-hold time.
631async fn suggest(State(state): State<AppState>) -> Response {
632    let db = state.db.clone();
633    match tokio::task::spawn_blocking(move || {
634        let config = SuggestConfig::default();
635        db.read()
636            .suggest_rules_with_config(&config, SUGGEST_DEFAULT_SEED)
637    })
638    .await
639    {
640        Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
641        Err(_) => err_response("suggest task panicked"),
642    }
643}
644
645async fn create_rule(State(state): State<AppState>, Json(body): Json<Js>) -> Response {
646    let def = match rule_def_from_json(body) {
647        Ok(d) => d,
648        Err(e) => return err_response(e),
649    };
650    let name = def.name.clone();
651    let db = state.db.clone();
652    match blocking_write(move || db.write().create_rule(def)).await {
653        Ok(()) => json_ok(json!({"ok": true, "name": name})),
654        Err(resp) => resp,
655    }
656}
657
658async fn explain(
659    State(state): State<AppState>,
660    Query(qs): Query<BTreeMap<String, String>>,
661) -> Response {
662    let a = match qs.get("a") {
663        Some(s) if !s.is_empty() => s.clone(),
664        _ => return err_response("missing query param a"),
665    };
666    let b = match qs.get("b") {
667        Some(s) if !s.is_empty() => s.clone(),
668        _ => return err_response("missing query param b"),
669    };
670    let out = {
671        let g = state.db.read();
672        g.explain(&a, &b)
673    };
674    match out {
675        Ok(v) => match serde_json::to_value(&v) {
676            Ok(j) => json_ok(j),
677            Err(e) => err_response(e.to_string()),
678        },
679        Err(e) => graph_err(e),
680    }
681}
682
683async fn node_info(State(state): State<AppState>, Path(key): Path<String>) -> Response {
684    let info = {
685        let g = state.db.read();
686        g.node_info(&key)
687    };
688    match info {
689        Some(info) => json_ok(node_info_json(&info)),
690        None => key_not_found(key),
691    }
692}
693
694async fn node_edges(State(state): State<AppState>, Path(key): Path<String>) -> Response {
695    let out = {
696        let g = state.db.read();
697        g.node_edges(&key)
698    };
699    match out {
700        Ok(edges) => json_ok(node_edges_json(&edges)),
701        Err(GraphError::KeyNotFound { key }) => key_not_found(key),
702        Err(e) => graph_err(e),
703    }
704}
705
706async fn neighborhood(
707    State(state): State<AppState>,
708    Path(key): Path<String>,
709    Query(qs): Query<BTreeMap<String, String>>,
710) -> Response {
711    let depth = match qs.get("depth") {
712        None => 1u32,
713        Some(s) => match s.parse() {
714            Ok(d) => d,
715            Err(_) => return err_response("depth must be an integer"),
716        },
717    };
718    let dir = match qs.get("dir").map(String::as_str).unwrap_or("both") {
719        s if s.eq_ignore_ascii_case("out") => Dir::Out,
720        s if s.eq_ignore_ascii_case("in") => Dir::In,
721        s if s.eq_ignore_ascii_case("both") => Dir::Both,
722        other => return err_response(format!("unknown dir: {other}")),
723    };
724    let edge_type_names: Option<Vec<String>> = qs.get("edge_types").map(|s| {
725        s.split(',')
726            .map(str::trim)
727            .filter(|t| !t.is_empty())
728            .map(str::to_string)
729            .collect()
730    });
731    let etype_refs: Option<Vec<&str>> = edge_type_names
732        .as_ref()
733        .map(|v| v.iter().map(String::as_str).collect());
734    let rs = {
735        let g = state.db.read();
736        match g.node_ref(&key) {
737            Some(n) => Ok(n.neighborhood(depth, etype_refs.as_deref(), dir)),
738            None => Err(GraphError::KeyNotFound { key: key.clone() }),
739        }
740    };
741    match rs {
742        Ok(rs) => json_ok(result_set_json(&rs)),
743        Err(e) => graph_err(e),
744    }
745}
746
747/// `POST /algo/pagerank` — run PageRank over the unified topology.
748///
749/// # Locking and blocking strategy
750///
751/// PageRank is CPU-intensive and synchronous. This handler offloads the work
752/// to `tokio::task::spawn_blocking` (blocking thread-pool). The read guard is
753/// acquired and held inside the blocking task — reads don't block other reads.
754/// The `budget_ms` field in [`PageRankConfig`] caps lock-hold time.
755async fn algo_pagerank(
756    State(state): State<AppState>,
757    Json(body): Json<serde_json::Value>,
758) -> Response {
759    let config: PageRankConfig = match serde_json::from_value(body) {
760        Ok(c) => c,
761        Err(e) => return err_response(format!("invalid pagerank config: {e}")),
762    };
763    let db = state.db.clone();
764    match tokio::task::spawn_blocking(move || db.read().pagerank(&config)).await {
765        Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
766        Err(_) => err_response("pagerank task panicked"),
767    }
768}
769
770/// `POST /algo/wcc` — weakly-connected components over the unified topology.
771///
772/// Mirrors the `suggest` locking and blocking pattern exactly: spawn_blocking,
773/// read guard inside, `budget_ms` in config caps lock-hold time.
774async fn algo_wcc(State(state): State<AppState>, Json(body): Json<serde_json::Value>) -> Response {
775    let config: WccConfig = match serde_json::from_value(body) {
776        Ok(c) => c,
777        Err(e) => return err_response(format!("invalid wcc config: {e}")),
778    };
779    let db = state.db.clone();
780    match tokio::task::spawn_blocking(move || db.read().connected_components(&config)).await {
781        Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
782        Err(_) => err_response("wcc task panicked"),
783    }
784}
785
786/// `POST /algo/degree` — degree centrality over the unified topology.
787///
788/// Mirrors the `suggest` locking and blocking pattern exactly.
789async fn algo_degree(
790    State(state): State<AppState>,
791    Json(body): Json<serde_json::Value>,
792) -> Response {
793    let config: DegreeConfig = match serde_json::from_value(body) {
794        Ok(c) => c,
795        Err(e) => return err_response(format!("invalid degree config: {e}")),
796    };
797    let db = state.db.clone();
798    match tokio::task::spawn_blocking(move || db.read().degree_centrality(&config)).await {
799        Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
800        Err(_) => err_response("degree task panicked"),
801    }
802}
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807    use crate::json::result_set_json;
808    use core_api::{DegreeConfig, PageRankConfig, ResultSet, Value, WccConfig};
809
810    #[test]
811    fn nan_float_cell_serializes_as_null() {
812        let mut rs = ResultSet::new(vec!["n".into()]);
813        rs.push_row(vec![Some(Value::Float(f64::NAN))]);
814        let j = result_set_json(&rs);
815        assert_eq!(j["rows"][0][0], Js::Null);
816    }
817
818    /// Verify that `POST /algo/pagerank` accepts an empty JSON body `{}` and
819    /// applies server defaults (regression guard for `#[serde(default)]`).
820    #[test]
821    fn pagerank_config_empty_body_uses_defaults() {
822        let config: PageRankConfig = serde_json::from_str("{}").unwrap();
823        let default = PageRankConfig::default();
824        assert_eq!(config.damping, default.damping);
825        assert_eq!(config.max_iters, default.max_iters);
826        assert_eq!(config.tol, default.tol);
827        assert_eq!(config.budget_ms, default.budget_ms);
828        assert_eq!(config.edge_type, default.edge_type);
829    }
830
831    /// Same guard for `POST /algo/wcc`.
832    #[test]
833    fn wcc_config_empty_body_uses_defaults() {
834        let config: WccConfig = serde_json::from_str("{}").unwrap();
835        let default = WccConfig::default();
836        assert_eq!(config.budget_ms, default.budget_ms);
837        assert_eq!(config.edge_type, default.edge_type);
838    }
839
840    /// Same guard for `POST /algo/degree`.
841    #[test]
842    fn degree_config_empty_body_uses_defaults() {
843        let config: DegreeConfig = serde_json::from_str("{}").unwrap();
844        let default = DegreeConfig::default();
845        assert_eq!(config.budget_ms, default.budget_ms);
846        assert_eq!(config.edge_type, default.edge_type);
847    }
848}