use std::collections::{HashMap, HashSet};
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
use axum::{
extract::{Path, State},
http::{HeaderMap, HeaderValue, StatusCode},
response::{Html, IntoResponse, Json, Response},
routing::{delete, get, post},
Router,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value as JsonValue};
use tokio::sync::Mutex;
use crate::types::{ulid_decode, ulid_encode, EdgeId, NodeId};
use super::{
auth::{verify_password, ServerConfig},
protocol::{row_to_json, value_to_json},
registry::GraphRegistry,
};
#[derive(Clone)]
struct AppState {
registry: Arc<GraphRegistry>,
config: Arc<ServerConfig>,
tokens: Arc<Mutex<HashSet<String>>>,
}
fn gen_token() -> String {
static CTR: AtomicU64 = AtomicU64::new(0);
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
let c = CTR.fetch_add(1, Ordering::Relaxed);
format!("{:016x}{:016x}", t, c.wrapping_add(t >> 3))
}
async fn is_auth(headers: &HeaderMap, state: &AppState) -> bool {
if !state.config.server.auth_required {
return true;
}
let Some(v) = headers.get("authorization").and_then(|h| h.to_str().ok()) else {
return false;
};
let Some(tok) = v.strip_prefix("Bearer ") else { return false };
state.tokens.lock().await.contains(tok)
}
macro_rules! auth {
($h:expr, $s:expr) => {
if !is_auth($h, $s).await {
return StatusCode::UNAUTHORIZED.into_response();
}
};
}
pub async fn serve(
addr: SocketAddr,
registry: Arc<GraphRegistry>,
config: Arc<ServerConfig>,
) -> std::io::Result<()> {
let state = AppState {
registry,
config,
tokens: Arc::new(Mutex::new(HashSet::new())),
};
let app = Router::new()
.route("/", get(serve_html))
.route("/assets/minigdb_logo.webp", get(serve_logo))
.route("/api/info", get(api_info))
.route("/api/auth", post(api_auth))
.route("/api/graphs", get(api_graphs).post(api_create_graph))
.route("/api/graphs/:name", delete(api_drop_graph))
.route("/api/query", post(api_query))
.route("/api/viz", post(api_viz))
.route("/api/upload/nodes", post(api_upload_nodes))
.route("/api/upload/edges", post(api_upload_edges))
.with_state(state);
let listener = tokio::net::TcpListener::bind(addr).await?;
eprintln!("minigdb GUI → http://{addr}");
axum::serve(listener, app)
.await
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
}
static HTML: &str = include_str!("static/gui.html");
static LOGO: &[u8] = include_bytes!("static/assets/minigdb_logo.webp");
async fn serve_html() -> Html<&'static str> {
Html(HTML)
}
async fn serve_logo() -> Response {
let mut res = Response::new(axum::body::Body::from(LOGO));
res.headers_mut().insert(
axum::http::header::CONTENT_TYPE,
HeaderValue::from_static("image/webp"),
);
res
}
async fn api_info(State(s): State<AppState>) -> impl IntoResponse {
Json(json!({ "auth_required": s.config.server.auth_required }))
}
#[derive(Deserialize)]
struct AuthBody {
user: String,
password: String,
}
async fn api_auth(State(s): State<AppState>, Json(body): Json<AuthBody>) -> impl IntoResponse {
if !s.config.server.auth_required {
return Json(json!({ "token": "" })).into_response();
}
match s.config.find_user(&body.user) {
Some(u) if verify_password(&body.password, &u.password_hash) => {
let tok = gen_token();
s.tokens.lock().await.insert(tok.clone());
Json(json!({ "token": tok })).into_response()
}
_ => (StatusCode::UNAUTHORIZED, Json(json!({ "error": "invalid credentials" }))).into_response(),
}
}
async fn api_graphs(headers: HeaderMap, State(s): State<AppState>) -> impl IntoResponse {
auth!(&headers, &s);
let graphs = s.registry.list().await;
Json(json!({ "graphs": graphs })).into_response()
}
#[derive(Deserialize)]
struct CreateBody {
name: String,
}
async fn api_create_graph(
headers: HeaderMap,
State(s): State<AppState>,
Json(body): Json<CreateBody>,
) -> impl IntoResponse {
auth!(&headers, &s);
match s.registry.create(&body.name).await {
Ok(()) => Json(json!({})).into_response(),
Err(e) => (StatusCode::BAD_REQUEST, Json(json!({ "error": e.to_string() }))).into_response(),
}
}
async fn api_drop_graph(
headers: HeaderMap,
State(s): State<AppState>,
Path(name): Path<String>,
) -> impl IntoResponse {
auth!(&headers, &s);
if name.starts_with('_') {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": format!("cannot drop system graph '{name}'") }))).into_response();
}
match s.registry.drop_graph(&name).await {
Ok(()) => Json(json!({})).into_response(),
Err(e) => (StatusCode::BAD_REQUEST, Json(json!({ "error": e.to_string() }))).into_response(),
}
}
#[derive(Deserialize)]
struct QueryBody {
graph: Option<String>,
query: String,
}
async fn api_query(
headers: HeaderMap,
State(s): State<AppState>,
Json(body): Json<QueryBody>,
) -> impl IntoResponse {
auth!(&headers, &s);
let graph_name = body.graph.as_deref().unwrap_or("default");
let arc = match s.registry.get_or_open(graph_name).await {
Ok(a) => a,
Err(e) => return Json(json!({ "error": e.to_string() })).into_response(),
};
let start = Instant::now();
let mut guard = arc.lock().await;
let (graph, txn_id) = &mut *guard;
let bare = body.query.trim().trim_end_matches(';').trim().to_ascii_uppercase();
let result: Result<Vec<HashMap<String, JsonValue>>, _> = match bare.as_str() {
"BEGIN" => graph.begin_transaction().map(|_| vec![]),
"COMMIT" => graph.commit_transaction().map(|_| vec![]),
"ROLLBACK" => graph.rollback_transaction().map(|_| vec![]),
_ => crate::query_capturing(&body.query, graph, txn_id)
.map(|(rows, _)| rows.iter().map(row_to_json).collect()),
};
match result {
Ok(json_rows) => Json(json!({
"rows": json_rows,
"elapsed_ms": start.elapsed().as_secs_f64() * 1000.0
}))
.into_response(),
Err(e) => Json(json!({ "error": e.to_string() })).into_response(),
}
}
#[derive(Serialize)]
struct VizNode {
id: String,
labels: Vec<String>,
properties: HashMap<String, JsonValue>,
}
#[derive(Serialize)]
struct VizEdge {
id: String,
source: String,
target: String,
label: String,
properties: HashMap<String, JsonValue>,
}
async fn api_viz(
headers: HeaderMap,
State(s): State<AppState>,
Json(body): Json<QueryBody>,
) -> impl IntoResponse {
auth!(&headers, &s);
let graph_name = body.graph.as_deref().unwrap_or("default");
let arc = match s.registry.get_or_open(graph_name).await {
Ok(a) => a,
Err(e) => return Json(json!({ "error": e.to_string() })).into_response(),
};
let start = Instant::now();
let mut guard = arc.lock().await;
let (graph, txn_id) = &mut *guard;
let rows = match crate::query_capturing(&body.query, graph, txn_id) {
Ok((rows, _)) => rows,
Err(e) => return Json(json!({ "error": e.to_string() })).into_response(),
};
let mut nodes: HashMap<String, VizNode> = HashMap::new();
let mut edges: HashMap<String, VizEdge> = HashMap::new();
for row in &rows {
for val in row.values() {
if let crate::Value::String(s) = val {
enrich(s, graph, &mut nodes, &mut edges);
}
}
}
let json_rows: Vec<_> = rows.iter().map(row_to_json).collect();
Json(json!({
"rows": json_rows,
"nodes": nodes.into_values().collect::<Vec<_>>(),
"edges": edges.into_values().collect::<Vec<_>>(),
"elapsed_ms": start.elapsed().as_secs_f64() * 1000.0,
}))
.into_response()
}
fn enrich(
s: &str,
graph: &crate::Graph,
nodes: &mut HashMap<String, VizNode>,
edges: &mut HashMap<String, VizEdge>,
) {
if nodes.contains_key(s) || edges.contains_key(s) {
return;
}
let Ok(raw) = ulid_decode(s) else { return };
if let Some(node) = graph.get_node(NodeId(raw)) {
let properties = node
.properties
.iter()
.map(|(k, v)| (k.clone(), value_to_json(v)))
.collect();
nodes.insert(
s.to_string(),
VizNode { id: s.to_string(), labels: node.labels.clone(), properties },
);
} else if let Some(edge) = graph.get_edge(EdgeId(raw)) {
let properties = edge
.properties
.iter()
.map(|(k, v)| (k.clone(), value_to_json(v)))
.collect();
let src = ulid_encode(edge.from_node.0);
let tgt = ulid_encode(edge.to_node.0);
edges.insert(
s.to_string(),
VizEdge {
id: s.to_string(),
source: src.clone(),
target: tgt.clone(),
label: edge.label.clone(),
properties,
},
);
enrich(&src, graph, nodes, edges);
enrich(&tgt, graph, nodes, edges);
}
}
#[derive(Deserialize)]
struct UploadNodesBody {
graph: Option<String>,
csv: String,
label: Option<String>,
}
async fn api_upload_nodes(
headers: HeaderMap,
State(s): State<AppState>,
Json(body): Json<UploadNodesBody>,
) -> impl IntoResponse {
auth!(&headers, &s);
let graph_name = body.graph.as_deref().unwrap_or("default");
let arc = match s.registry.get_or_open(graph_name).await {
Ok(a) => a,
Err(e) => return Json(json!({ "error": e.to_string() })).into_response(),
};
let mut guard = arc.lock().await;
let (graph, _txn_id) = &mut *guard;
match crate::csv_import::load_nodes_csv(body.csv.as_bytes(), graph, body.label.as_deref()) {
Ok(result) => {
let id_map_json = crate::csv_import::id_map_to_strings(&result.id_map);
Json(json!({
"inserted": result.inserted,
"id_map": id_map_json,
})).into_response()
}
Err(e) => Json(json!({ "error": e.to_string() })).into_response(),
}
}
#[derive(Deserialize)]
struct UploadEdgesBody {
graph: Option<String>,
csv: String,
label: Option<String>,
id_map: Option<HashMap<String, String>>,
}
async fn api_upload_edges(
headers: HeaderMap,
State(s): State<AppState>,
Json(body): Json<UploadEdgesBody>,
) -> impl IntoResponse {
auth!(&headers, &s);
let graph_name = body.graph.as_deref().unwrap_or("default");
let arc = match s.registry.get_or_open(graph_name).await {
Ok(a) => a,
Err(e) => return Json(json!({ "error": e.to_string() })).into_response(),
};
let mut guard = arc.lock().await;
let (graph, _txn_id) = &mut *guard;
let id_map = body.id_map
.as_ref()
.map(|m| crate::csv_import::id_map_from_strings(m))
.unwrap_or_default();
match crate::csv_import::load_edges_csv(body.csv.as_bytes(), graph, &id_map, body.label.as_deref()) {
Ok(result) => Json(json!({
"inserted": result.inserted,
"skipped": result.skipped,
})).into_response(),
Err(e) => Json(json!({ "error": e.to_string() })).into_response(),
}
}