1use 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
35pub fn router(db: SharedDb) -> Router {
55 router_with_auth(db, None)
56}
57
58pub fn router_with_auth(db: SharedDb, token: Option<String>) -> Router {
61 build_app(db, token, UiFallback::None, default_advertise_addr())
62}
63
64pub 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#[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
130pub 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
143pub 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#[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 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
253async 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
365fn 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 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 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, ¶ms, &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 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, ¶ms)).await {
546 Ok(rs) => rs,
547 Err(resp) => return resp,
548 }
549 } else {
550 match state.db.read().query(&cypher, ¶ms) {
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
621async 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
747async 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
770async 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
786async 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 #[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 #[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 #[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}