Skip to main content

mdql_web/
server.rs

1//! MDQL browser UI — axum server with embedded SPA.
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::{Arc, Mutex};
6
7use axum::extract::{Path as AxumPath, State};
8use axum::http::{header, StatusCode, Uri};
9use axum::response::{IntoResponse, Json};
10use axum::routing::{get, post};
11use axum::Router;
12use rust_embed::Embed;
13use serde::{Deserialize, Serialize};
14use tower_http::cors::CorsLayer;
15
16use mdql_core::executor::{self, QueryResult};
17use mdql_core::loader;
18use mdql_core::model::{Row, Value};
19use mdql_core::projector::format_results;
20use mdql_core::schema::Schema;
21
22#[derive(Embed)]
23#[folder = "static/"]
24struct StaticFiles;
25
26#[derive(Clone)]
27struct AppState {
28    db_path: PathBuf,
29    tables: Arc<Mutex<HashMap<String, (Schema, Vec<Row>)>>>,
30    fk_errors: Arc<Mutex<Vec<mdql_core::errors::ValidationError>>>,
31}
32
33#[derive(Serialize)]
34struct TableInfo {
35    name: String,
36    row_count: usize,
37}
38
39#[derive(Serialize)]
40struct TablesResponse {
41    tables: Vec<TableInfo>,
42}
43
44#[derive(Serialize)]
45struct TableDetailResponse {
46    table: String,
47    primary_key: String,
48    row_count: usize,
49    frontmatter: HashMap<String, FieldInfo>,
50    sections: HashMap<String, SectionInfo>,
51}
52
53#[derive(Serialize)]
54struct FieldInfo {
55    #[serde(rename = "type")]
56    field_type: String,
57    required: bool,
58    enum_values: Option<Vec<String>>,
59}
60
61#[derive(Serialize)]
62struct SectionInfo {
63    content_type: String,
64    required: bool,
65}
66
67#[derive(Deserialize)]
68struct QueryRequest {
69    sql: String,
70    #[serde(default = "default_format")]
71    format: String,
72}
73
74fn default_format() -> String {
75    "table".into()
76}
77
78#[derive(Serialize)]
79struct QueryResponse {
80    columns: Option<Vec<String>>,
81    rows: Option<Vec<HashMap<String, serde_json::Value>>>,
82    output: Option<String>,
83    error: Option<String>,
84    row_count: Option<usize>,
85}
86
87pub async fn run_server(db_path: PathBuf, port: u16) {
88    // Load the database
89    let tables = match load_all_tables(&db_path) {
90        Ok(t) => t,
91        Err(e) => {
92            eprintln!("Failed to load database: {}", e);
93            std::process::exit(1);
94        }
95    };
96
97    let fk_errors: Arc<Mutex<Vec<mdql_core::errors::ValidationError>>> =
98        Arc::new(Mutex::new(Vec::new()));
99
100    let state = AppState {
101        db_path: db_path.clone(),
102        tables: Arc::new(Mutex::new(tables)),
103        fk_errors: fk_errors.clone(),
104    };
105
106    // Start filesystem watcher for FK validation
107    {
108        let tables_clone = state.tables.clone();
109        let fk_errors_clone = fk_errors.clone();
110        let db_path_clone = db_path.clone();
111        tokio::task::spawn_blocking(move || {
112            let watcher = match mdql_core::watcher::FkWatcher::start(db_path_clone.clone()) {
113                Ok(w) => w,
114                Err(e) => {
115                    eprintln!("Warning: could not start FK watcher: {}", e);
116                    return;
117                }
118            };
119            loop {
120                if let Some(errors) = watcher.poll() {
121                    *fk_errors_clone.lock().unwrap() = errors;
122                    // Also reload tables on file change
123                    if let Ok(new_tables) = load_all_tables(&db_path_clone) {
124                        *tables_clone.lock().unwrap() = new_tables;
125                    }
126                }
127                std::thread::sleep(std::time::Duration::from_millis(200));
128            }
129        });
130    }
131
132    let app = Router::new()
133        .route("/api/tables", get(list_tables))
134        .route("/api/tables/{name}", get(table_detail))
135        .route("/api/query", post(execute_query))
136        .route("/api/reload", post(reload_tables))
137        .route("/api/fk-errors", get(get_fk_errors))
138        .fallback(static_handler)
139        .layer(CorsLayer::permissive())
140        .with_state(state);
141
142    let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port))
143        .await
144        .expect("Failed to bind");
145
146    println!("MDQL client running at http://localhost:{}", port);
147
148    axum::serve(listener, app).await.expect("Server failed");
149}
150
151fn load_all_tables(db_path: &std::path::Path) -> Result<HashMap<String, (Schema, Vec<Row>)>, String> {
152    // Try as database first
153    if let Ok((_config, tables, _errors)) = loader::load_database(db_path) {
154        return Ok(tables);
155    }
156
157    // Try as single table
158    match loader::load_table(db_path) {
159        Ok((schema, rows, _errors)) => {
160            let mut map = HashMap::new();
161            map.insert(schema.table.clone(), (schema, rows));
162            Ok(map)
163        }
164        Err(e) => Err(format!("Failed to load: {}", e)),
165    }
166}
167
168async fn list_tables(State(state): State<AppState>) -> Json<TablesResponse> {
169    let tables = state.tables.lock().unwrap();
170    let mut infos: Vec<TableInfo> = tables
171        .iter()
172        .map(|(name, (_schema, rows))| TableInfo {
173            name: name.clone(),
174            row_count: rows.len(),
175        })
176        .collect();
177    infos.sort_by(|a, b| a.name.cmp(&b.name));
178    Json(TablesResponse { tables: infos })
179}
180
181async fn table_detail(
182    State(state): State<AppState>,
183    AxumPath(name): AxumPath<String>,
184) -> Result<Json<TableDetailResponse>, StatusCode> {
185    let tables = state.tables.lock().unwrap();
186    let (schema, rows) = tables.get(&name).ok_or(StatusCode::NOT_FOUND)?;
187
188    let frontmatter: HashMap<String, FieldInfo> = schema
189        .frontmatter
190        .iter()
191        .map(|(k, v)| {
192            (
193                k.clone(),
194                FieldInfo {
195                    field_type: format!("{:?}", v.field_type),
196                    required: v.required,
197                    enum_values: v.enum_values.clone(),
198                },
199            )
200        })
201        .collect();
202
203    let sections: HashMap<String, SectionInfo> = schema
204        .sections
205        .iter()
206        .map(|(k, v)| {
207            (
208                k.clone(),
209                SectionInfo {
210                    content_type: v.content_type.clone(),
211                    required: v.required,
212                },
213            )
214        })
215        .collect();
216
217    Ok(Json(TableDetailResponse {
218        table: schema.table.clone(),
219        primary_key: schema.primary_key.clone(),
220        row_count: rows.len(),
221        frontmatter,
222        sections,
223    }))
224}
225
226async fn execute_query(
227    State(state): State<AppState>,
228    Json(req): Json<QueryRequest>,
229) -> Json<QueryResponse> {
230    let result = executor::execute(&state.db_path, &req.sql);
231
232    match result {
233        Ok((QueryResult::Rows { rows, columns }, _warnings)) => {
234            if req.format == "json" || req.format == "csv" {
235                let output = format_results(&rows, Some(&columns), &req.format, 80);
236                Json(QueryResponse {
237                    columns: None,
238                    rows: None,
239                    output: Some(output),
240                    error: None,
241                    row_count: Some(rows.len()),
242                })
243            } else {
244                let json_rows: Vec<HashMap<String, serde_json::Value>> = rows
245                    .iter()
246                    .map(|row| {
247                        columns
248                            .iter()
249                            .map(|col| {
250                                let val = row.get(col).unwrap_or(&Value::Null);
251                                (col.clone(), value_to_json(val))
252                            })
253                            .collect()
254                    })
255                    .collect();
256
257                Json(QueryResponse {
258                    columns: Some(columns),
259                    rows: Some(json_rows.clone()),
260                    output: None,
261                    error: None,
262                    row_count: Some(json_rows.len()),
263                })
264            }
265        }
266        Ok((QueryResult::Message(msg), _warnings)) => {
267            // Reload tables after write
268            if let Ok(new_tables) = load_all_tables(&state.db_path) {
269                let mut tables = state.tables.lock().unwrap();
270                *tables = new_tables;
271            }
272            Json(QueryResponse {
273                columns: None,
274                rows: None,
275                output: Some(msg),
276                error: None,
277                row_count: None,
278            })
279        }
280        Err(e) => Json(QueryResponse {
281            columns: None,
282            rows: None,
283            output: None,
284            error: Some(e.to_string()),
285            row_count: None,
286        }),
287    }
288}
289
290async fn get_fk_errors(State(state): State<AppState>) -> Json<serde_json::Value> {
291    let errors = state.fk_errors.lock().unwrap();
292    let error_list: Vec<serde_json::Value> = errors
293        .iter()
294        .map(|e| {
295            serde_json::json!({
296                "file": e.file_path,
297                "field": e.field,
298                "message": e.message,
299            })
300        })
301        .collect();
302    Json(serde_json::json!({ "errors": error_list }))
303}
304
305async fn reload_tables(State(state): State<AppState>) -> Json<serde_json::Value> {
306    match load_all_tables(&state.db_path) {
307        Ok(new_tables) => {
308            let mut tables = state.tables.lock().unwrap();
309            *tables = new_tables;
310            Json(serde_json::json!({ "status": "ok" }))
311        }
312        Err(e) => Json(serde_json::json!({ "status": "error", "message": e })),
313    }
314}
315
316fn value_to_json(val: &Value) -> serde_json::Value {
317    match val {
318        Value::Null => serde_json::Value::Null,
319        Value::String(s) => serde_json::Value::String(s.clone()),
320        Value::Int(n) => serde_json::json!(n),
321        Value::Float(f) => serde_json::json!(f),
322        Value::Bool(b) => serde_json::json!(b),
323        Value::Date(d) => serde_json::Value::String(d.format("%Y-%m-%d").to_string()),
324        Value::DateTime(dt) => serde_json::Value::String(dt.format("%Y-%m-%dT%H:%M:%S").to_string()),
325        Value::List(items) => serde_json::json!(items),
326        Value::Dict(map) => {
327            let obj: serde_json::Map<String, serde_json::Value> = map.iter()
328                .map(|(k, v)| (k.clone(), value_to_json(v)))
329                .collect();
330            serde_json::Value::Object(obj)
331        }
332    }
333}
334
335async fn static_handler(uri: Uri) -> impl IntoResponse {
336    let path = uri.path().trim_start_matches('/');
337    let path = if path.is_empty() { "index.html" } else { path };
338
339    match StaticFiles::get(path) {
340        Some(content) => {
341            let mime = mime_guess::from_path(path).first_or_octet_stream();
342            (
343                StatusCode::OK,
344                [(header::CONTENT_TYPE, mime.as_ref().to_string())],
345                content.data.into_owned(),
346            )
347                .into_response()
348        }
349        None => {
350            // SPA fallback: serve index.html for unknown routes
351            match StaticFiles::get("index.html") {
352                Some(content) => (
353                    StatusCode::OK,
354                    [(header::CONTENT_TYPE, "text/html".to_string())],
355                    content.data.into_owned(),
356                )
357                    .into_response(),
358                None => (StatusCode::NOT_FOUND, "Not found").into_response(),
359            }
360        }
361    }
362}
363