1use std::sync::Arc;
18
19use axum::extract::{Path, State};
20use axum::http::header;
21use axum::http::StatusCode;
22use axum::response::{IntoResponse, Response};
23use axum::routing::{get, post};
24use axum::Json;
25use mongreldb_core::schema::{Schema, TypeId};
26use mongreldb_core::{Database, Value};
27use mongreldb_query::{ExternalTableModule, MongrelSession};
28use serde::Deserialize;
29use serde_json::json;
30
31mod kit;
32mod procedure;
33mod trigger;
34
35struct AppState {
36 db: Arc<Database>,
37 idem: kit::IdempotencyStore,
38 external_modules: Vec<Arc<dyn ExternalTableModule>>,
39 auth_token: Option<String>,
40}
41
42pub fn build_app(db: Arc<Database>) -> axum::Router {
43 build_app_with_config(db, std::iter::empty::<Arc<dyn ExternalTableModule>>(), None, None)
44}
45
46pub fn build_app_with_external_modules(
47 db: Arc<Database>,
48 external_modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
49) -> axum::Router {
50 build_app_with_config(db, external_modules, None, None)
51}
52
53pub fn build_app_with_config(
55 db: Arc<Database>,
56 external_modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
57 auth_token: Option<String>,
58 max_connections: Option<usize>,
59) -> axum::Router {
60 let state = Arc::new(AppState {
61 idem: kit::IdempotencyStore::new(db.root()),
62 db,
63 external_modules: external_modules.into_iter().collect(),
64 auth_token,
65 });
66 let router = axum::Router::new()
67 .route("/health", get(health))
68 .route("/tables", get(list_tables).post(create_table))
69 .route("/tables/{name}", axum::routing::delete(drop_table))
70 .route("/tables/{name}/put", post(put_row))
71 .route("/tables/{name}/count", get(count))
72 .route("/tables/{name}/commit", post(commit))
73 .route("/sql", post(sql))
74 .route("/txn", post(txn))
75 .route("/procedures", get(procedure::list).post(procedure::create))
76 .route(
77 "/procedures/{name}",
78 get(procedure::describe)
79 .put(procedure::replace)
80 .delete(procedure::drop_procedure),
81 )
82 .route("/procedures/{name}/call", post(procedure::call))
83 .route("/triggers", get(trigger::list).post(trigger::create))
84 .route(
85 "/triggers/{name}",
86 get(trigger::describe)
87 .put(trigger::replace)
88 .delete(trigger::drop_trigger),
89 )
90 .route("/kit/schema", get(kit::schema_all))
92 .route("/kit/schema/{table}", get(kit::schema_one))
93 .route("/kit/txn", post(kit::kit_txn))
94 .route("/kit/query", post(kit::kit_query))
95 .route("/kit/create_table", post(kit::kit_create_table))
96 .route("/kit/procedures/{name}/call", post(procedure::kit_call))
97 .route("/compact", post(compact_all))
98 .route("/tables/{name}/compact", post(compact_table))
99 .route("/wal/stream", get(wal_stream))
100 .route("/events", get(events_stream))
101 .with_state(state.clone());
102
103 let router = if state.auth_token.is_some() {
105 router.layer(axum::middleware::from_fn_with_state(
106 state.clone(),
107 auth_middleware,
108 ))
109 } else {
110 router
111 };
112
113 if let Some(max) = max_connections {
115 router.layer(tower::limit::ConcurrencyLimitLayer::new(max))
116 } else {
117 router
118 }
119}
120
121async fn auth_middleware(
125 axum::extract::State(state): axum::extract::State<Arc<AppState>>,
126 req: axum::extract::Request,
127 next: axum::middleware::Next,
128) -> Result<axum::response::Response, axum::http::StatusCode> {
129 let expected = state.auth_token.as_ref().unwrap();
130 let header = req
131 .headers()
132 .get("authorization")
133 .and_then(|v| v.to_str().ok())
134 .unwrap_or("");
135 let provided = header.strip_prefix("Bearer ").unwrap_or("");
136 if provided == expected {
137 Ok(next.run(req).await)
138 } else {
139 Err(axum::http::StatusCode::UNAUTHORIZED)
140 }
141}
142
143async fn wal_stream(
147 axum::extract::State(state): axum::extract::State<Arc<AppState>>,
148 axum::extract::Query(params): axum::extract::Query<WalStreamParams>,
149) -> Result<Response, StatusCode> {
150 let db_root = state.db.root().to_path_buf();
151 let since = params.since.unwrap_or(0);
152
153 let body = tokio::task::spawn_blocking(move || -> Result<String, String> {
155 let records = mongreldb_core::wal::SharedWal::replay(&db_root).map_err(|e| e.to_string())?;
156 let mut out = String::new();
157 for record in records.iter().filter(|r| r.seq.0 > since) {
158 if let Ok(json) = serde_json::to_string(record) {
159 out.push_str(&json);
160 out.push('\n');
161 }
162 }
163 Ok(out)
164 })
165 .await
166 .map_err(|_e| StatusCode::INTERNAL_SERVER_ERROR)?;
167
168 let body = body.map_err(|e| {
169 eprintln!("wal_stream error: {e}");
170 StatusCode::INTERNAL_SERVER_ERROR
171 })?;
172
173 Ok((
174 [
175 (header::CONTENT_TYPE, "application/x-ndjson".to_string()),
176 (header::CACHE_CONTROL, "no-cache".to_string()),
177 ],
178 body,
179 )
180 .into_response())
181}
182
183#[derive(serde::Deserialize)]
184struct WalStreamParams {
185 since: Option<u64>,
186}
187
188async fn events_stream(
192 axum::extract::State(state): axum::extract::State<Arc<AppState>>,
193) -> Result<Response, StatusCode> {
194 let mut rx = state.db.subscribe_changes();
195 let body = tokio::task::spawn_blocking(move || {
196 let mut out = String::new();
198 while let Ok(event) = rx.try_recv() {
199 if let Ok(json) = serde_json::to_string(&event) {
200 out.push_str(&json);
201 out.push('\n');
202 }
203 }
204 out
205 })
206 .await
207 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
208
209 Ok((
210 [
211 (header::CONTENT_TYPE, "application/x-ndjson".to_string()),
212 (header::CACHE_CONTROL, "no-cache".to_string()),
213 ],
214 body,
215 )
216 .into_response())
217}
218
219pub fn spawn_auto_compactor(db: Arc<Database>) {
224 std::thread::Builder::new()
225 .name("mongreldb-auto-compact".into())
226 .spawn(move || loop {
227 std::thread::sleep(std::time::Duration::from_secs(30));
228 for name in db.table_names() {
229 let Ok(handle) = db.table(&name) else {
230 continue;
231 };
232 let mut t = handle.lock();
233 let before = t.run_count();
234 match t.maybe_compact() {
235 Ok(true) => {
236 eprintln!(
237 "[auto-compact] {name}: {} runs -> {}",
238 before,
239 t.run_count()
240 );
241 }
242 Ok(false) => {}
243 Err(e) => {
244 eprintln!("[auto-compact] {name}: compaction failed: {e}");
245 }
246 }
247 }
248 })
249 .expect("spawn auto-compact thread");
250}
251
252async fn health() -> StatusCode {
253 StatusCode::OK
254}
255
256async fn compact_all(State(state): State<Arc<AppState>>) -> (StatusCode, Json<serde_json::Value>) {
258 match state.db.compact() {
259 Ok((compacted, skipped)) => (
260 StatusCode::OK,
261 Json(json!({
262 "status": "ok",
263 "compacted": compacted,
264 "skipped": skipped,
265 })),
266 ),
267 Err(e) => (
268 StatusCode::INTERNAL_SERVER_ERROR,
269 Json(json!({ "status": "error", "message": format!("{e}") })),
270 ),
271 }
272}
273
274async fn compact_table(
276 State(state): State<Arc<AppState>>,
277 Path(name): Path<String>,
278) -> (StatusCode, Json<serde_json::Value>) {
279 match state.db.compact_table(&name) {
280 Ok(true) => (
281 StatusCode::OK,
282 Json(json!({ "status": "compacted", "table": name })),
283 ),
284 Ok(false) => (
285 StatusCode::OK,
286 Json(json!({ "status": "skipped", "table": name, "reason": "fewer than 2 runs" })),
287 ),
288 Err(e) => (
289 StatusCode::INTERNAL_SERVER_ERROR,
290 Json(json!({ "status": "error", "table": name, "message": format!("{e}") })),
291 ),
292 }
293}
294
295#[derive(Deserialize)]
296struct CreateTableRequest {
297 name: String,
298 columns: Vec<ColumnDefJson>,
299}
300
301#[derive(Deserialize)]
302struct ColumnDefJson {
303 id: u16,
304 name: String,
305 ty: String,
306 primary_key: bool,
307 #[serde(default)]
308 nullable: bool,
309}
310
311async fn create_table(
312 State(state): State<Arc<AppState>>,
313 Json(req): Json<CreateTableRequest>,
314) -> Response {
315 let mut columns = Vec::new();
316 for c in &req.columns {
317 let ty = match c.ty.as_str() {
318 "int64" | "bigint" => TypeId::Int64,
319 "float64" | "double" => TypeId::Float64,
320 "bytes" | "varchar" | "text" => TypeId::Bytes,
321 "bool" => TypeId::Bool,
322 other => {
323 return (StatusCode::BAD_REQUEST, format!("unknown type: {other}")).into_response()
324 }
325 };
326 let mut flags = mongreldb_core::schema::ColumnFlags::empty();
327 if c.primary_key {
328 flags = flags.with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY);
329 }
330 if c.nullable {
331 flags = flags.with(mongreldb_core::schema::ColumnFlags::NULLABLE);
332 }
333 columns.push(mongreldb_core::schema::ColumnDef {
334 id: c.id,
335 name: c.name.clone(),
336 ty,
337 flags,
338 });
339 }
340 let schema = Schema {
341 schema_id: 0,
342 columns,
343 indexes: vec![],
344 colocation: vec![],
345 constraints: Default::default(),
346 clustered: false,
347 };
348 if let Err(msg) = validate_table_name(&req.name) {
349 return (StatusCode::BAD_REQUEST, msg).into_response();
350 }
351 match state.db.create_table(&req.name, schema) {
352 Ok(id) => Json(json!({ "table_id": id })).into_response(),
353 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
354 }
355}
356
357async fn list_tables(State(state): State<Arc<AppState>>) -> Json<Vec<String>> {
358 Json(state.db.table_names())
359}
360
361async fn drop_table(State(state): State<Arc<AppState>>, Path(name): Path<String>) -> Response {
362 match state.db.drop_table(&name) {
363 Ok(_) => StatusCode::OK.into_response(),
364 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
365 }
366}
367
368#[derive(Deserialize)]
369struct PutRequest {
370 row: Vec<serde_json::Value>,
371}
372
373pub(crate) fn json_to_value(v: &serde_json::Value, expected: TypeId) -> Value {
374 match (v, expected) {
375 (serde_json::Value::Number(n), TypeId::Float64) => {
376 n.as_f64().map(Value::Float64).unwrap_or(Value::Null)
377 }
378 (serde_json::Value::Number(n), TypeId::Int64) => {
379 n.as_i64().map(Value::Int64).unwrap_or(Value::Null)
380 }
381 (serde_json::Value::String(s), TypeId::Bytes) => Value::Bytes(s.as_bytes().to_vec()),
382 (serde_json::Value::Bool(b), TypeId::Bool) => Value::Bool(*b),
383 (serde_json::Value::Null, _) => Value::Null,
384 (serde_json::Value::Number(n), _) => {
386 if let Some(i) = n.as_i64() {
387 Value::Int64(i)
388 } else if let Some(f) = n.as_f64() {
389 Value::Float64(f)
390 } else {
391 Value::Null
392 }
393 }
394 (serde_json::Value::String(s), _) => Value::Bytes(s.as_bytes().to_vec()),
395 (serde_json::Value::Bool(b), _) => Value::Bool(*b),
396 _ => Value::Null,
397 }
398}
399
400fn parse_cells(
403 row: &[serde_json::Value],
404 schema: &mongreldb_core::schema::Schema,
405) -> Result<Vec<(u16, Value)>, String> {
406 if row.len() & 1 != 0 {
407 return Err("row must be an even-length array of [col_id, value] pairs".into());
408 }
409 let mut out = Vec::with_capacity(row.len() / 2);
410 for chunk in row.chunks(2) {
411 let col_id = chunk[0]
412 .as_u64()
413 .ok_or("column id must be a non-negative integer")? as u16;
414 let expected = schema
415 .columns
416 .iter()
417 .find(|c| c.id == col_id)
418 .map(|c| c.ty)
419 .ok_or_else(|| format!("unknown column id {col_id}"))?;
420 let val = json_to_value(&chunk[1], expected);
421 out.push((col_id, val));
422 }
423 Ok(out)
424}
425
426pub(crate) fn validate_table_name(name: &str) -> Result<(), String> {
428 if name.is_empty() {
429 return Err("table name must not be empty".into());
430 }
431 if name.contains('/') || name.contains('\\') || name.contains('\0') {
432 return Err("table name contains invalid characters".into());
433 }
434 Ok(())
435}
436
437async fn put_row(
438 State(state): State<Arc<AppState>>,
439 Path(name): Path<String>,
440 Json(req): Json<PutRequest>,
441) -> Response {
442 let handle = match state.db.table(&name) {
443 Ok(h) => h,
444 Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
445 };
446 let mut g = handle.lock();
447 let schema = g.schema().clone();
448 let row = match parse_cells(&req.row, &schema) {
449 Ok(r) => r,
450 Err(msg) => return (StatusCode::BAD_REQUEST, msg).into_response(),
451 };
452 match g.put(row) {
453 Ok(rid) => Json(json!({ "row_id": rid.0.to_string() })).into_response(),
454 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
455 }
456}
457
458async fn count(State(state): State<Arc<AppState>>, Path(name): Path<String>) -> Response {
459 let handle = match state.db.table(&name) {
460 Ok(h) => h,
461 Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
462 };
463 Json(json!({ "count": handle.lock().count() })).into_response()
464}
465
466async fn commit(State(state): State<Arc<AppState>>, Path(name): Path<String>) -> Response {
467 let handle = match state.db.table(&name) {
468 Ok(h) => h,
469 Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
470 };
471 let mut g = handle.lock();
472 match g.commit() {
473 Ok(epoch) => Json(json!({ "epoch": epoch.0 })).into_response(),
474 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
475 }
476}
477
478#[derive(Deserialize)]
479struct SqlRequest {
480 sql: String,
481}
482
483async fn sql(State(state): State<Arc<AppState>>, Json(req): Json<SqlRequest>) -> Response {
484 let session = match MongrelSession::open_with_external_modules(
485 Arc::clone(&state.db),
486 state.external_modules.iter().cloned(),
487 ) {
488 Ok(s) => s,
489 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
490 };
491 match session.run(&req.sql).await {
492 Ok(batches) => {
493 if batches.is_empty() {
494 return StatusCode::OK.into_response();
495 }
496 let schema = batches[0].schema();
497 let mut buf = Vec::new();
498 let mut writer =
499 arrow::ipc::writer::FileWriter::try_new(&mut buf, schema.as_ref()).unwrap();
500 for b in &batches {
501 let _ = writer.write(b);
502 }
503 let _ = writer.finish();
504 drop(writer);
505 (
506 [(header::CONTENT_TYPE, "application/vnd.apache.arrow.file")],
507 buf,
508 )
509 .into_response()
510 }
511 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
512 }
513}
514
515#[derive(Deserialize)]
516struct TxnOp {
517 table: String,
518 op: String,
519 cells: Option<Vec<serde_json::Value>>,
520 row_id: Option<u64>,
521}
522
523#[derive(Deserialize)]
524struct TxnRequest {
525 ops: Vec<TxnOp>,
526}
527
528async fn txn(State(state): State<Arc<AppState>>, Json(req): Json<TxnRequest>) -> Response {
529 let mut parsed: Vec<(String, TxnAction)> = Vec::with_capacity(req.ops.len());
533 for op in &req.ops {
534 match op.op.as_str() {
535 "put" => {
536 let cells_json = match op.cells.as_ref() {
537 Some(c) if !c.is_empty() => c,
538 _ => {
539 return (StatusCode::BAD_REQUEST, "put op requires non-empty cells")
540 .into_response()
541 }
542 };
543 let handle = match state.db.table(&op.table) {
544 Ok(h) => h,
545 Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
546 };
547 let schema = handle.lock().schema().clone();
548 let cells = match parse_cells(cells_json, &schema) {
549 Ok(c) => c,
550 Err(msg) => return (StatusCode::BAD_REQUEST, msg).into_response(),
551 };
552 parsed.push((op.table.clone(), TxnAction::Put(cells)));
553 }
554 "delete" => {
555 let rid = match op.row_id {
556 Some(r) => r,
557 None => {
558 return (StatusCode::BAD_REQUEST, "delete op requires row_id")
559 .into_response()
560 }
561 };
562 parsed.push((op.table.clone(), TxnAction::Delete(rid)));
563 }
564 other => {
565 return (StatusCode::BAD_REQUEST, format!("unknown op: {other}")).into_response()
566 }
567 }
568 }
569
570 let result = state.db.transaction(|t| {
571 for (table, action) in &parsed {
572 match action {
573 TxnAction::Put(cells) => {
574 t.put(table, cells.clone())?;
575 }
576 TxnAction::Delete(rid) => {
577 t.delete(table, mongreldb_core::RowId(*rid))?;
578 }
579 }
580 }
581 Ok(())
582 });
583 match result {
584 Ok(_) => Json(json!({ "status": "committed" })).into_response(),
585 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
586 }
587}
588
589enum TxnAction {
590 Put(Vec<(u16, Value)>),
591 Delete(u64),
592}
593
594#[cfg(test)]
595mod auth_tests {
596 use super::*;
597 use mongreldb_core::Database;
598 use tempfile::tempdir;
599
600 #[tokio::test]
601 async fn auth_rejects_missing_token() {
602 let dir = tempdir().unwrap();
603 let db = Arc::new(Database::create(dir.path()).unwrap());
604 let app = build_app_with_config(db, std::iter::empty(), Some("secret".into()), None);
605 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
606 let addr = listener.local_addr().unwrap();
607 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
608 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
610
611 let client = reqwest::Client::new();
612 let resp = client
613 .get(format!("http://{addr}/health"))
614 .send()
615 .await
616 .unwrap();
617 assert_eq!(resp.status(), 401);
618 }
619
620 #[tokio::test]
621 async fn auth_accepts_valid_token() {
622 let dir = tempdir().unwrap();
623 let db = Arc::new(Database::create(dir.path()).unwrap());
624 let app = build_app_with_config(db, std::iter::empty(), Some("secret".into()), None);
625 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
626 let addr = listener.local_addr().unwrap();
627 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
628 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
629
630 let client = reqwest::Client::new();
631 let resp = client
632 .get(format!("http://{addr}/health"))
633 .header("Authorization", "Bearer secret")
634 .send()
635 .await
636 .unwrap();
637 assert_eq!(resp.status(), 200);
638 }
639
640 #[tokio::test]
641 async fn no_auth_when_token_unset() {
642 let dir = tempdir().unwrap();
643 let db = Arc::new(Database::create(dir.path()).unwrap());
644 let app = build_app_with_config(db, std::iter::empty(), None, None);
645 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
646 let addr = listener.local_addr().unwrap();
647 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
648 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
649
650 let client = reqwest::Client::new();
651 let resp = client
652 .get(format!("http://{addr}/health"))
653 .send()
654 .await
655 .unwrap();
656 assert_eq!(resp.status(), 200);
657 }
658}
659
660#[cfg(test)]
661mod wal_stream_tests {
662 use super::*;
663 use mongreldb_core::Database;
664 use tempfile::tempdir;
665
666 #[tokio::test]
667 async fn wal_stream_returns_records_after_commit() {
668 let dir = tempdir().unwrap();
669 let db = Arc::new(Database::create(dir.path()).unwrap());
670 let table_schema = mongreldb_core::schema::Schema {
671 schema_id: 1,
672 columns: vec![mongreldb_core::schema::ColumnDef {
673 id: 1,
674 name: "id".into(),
675 ty: TypeId::Int64,
676 flags: mongreldb_core::schema::ColumnFlags::empty()
677 .with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY),
678 }],
679 indexes: vec![],
680 colocation: vec![],
681 constraints: Default::default(),
682 clustered: false,
683 };
684 db.create_table("items", table_schema).unwrap();
685 let handle = db.table("items").unwrap();
687 handle.lock().put(vec![(1, Value::Int64(1))]).unwrap();
688 handle.lock().flush().unwrap();
689
690 let app = build_app(db);
691 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
692 let addr = listener.local_addr().unwrap();
693 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
694 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
695
696 let resp = reqwest::get(format!("http://{addr}/wal/stream")).await.unwrap();
697 assert_eq!(resp.status(), 200);
698 let body = resp.text().await.unwrap();
699 assert!(!body.is_empty(), "wal_stream should return records");
701 assert!(body.contains("seq"), "response should contain seq field");
702 }
703}