mongreldb-server 0.34.0

HTTP daemon for MongrelDB — serves SQL, native queries, and typed Kit API over HTTP for multi-process access.
Documentation
use std::sync::Arc;

use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use mongreldb_core::{StoredTrigger, TriggerDefinition};
use serde::Deserialize;
use serde_json::{json, Value as Jval};

use crate::AppState;

#[derive(Deserialize)]
pub struct TriggerRequest {
    trigger: StoredTrigger,
    #[serde(default)]
    idempotency_key: Option<String>,
}

pub async fn list(State(state): State<Arc<AppState>>) -> Response {
    Json(json!({ "triggers": state.db.triggers() })).into_response()
}

pub async fn describe(State(state): State<Arc<AppState>>, Path(name): Path<String>) -> Response {
    match state.db.trigger(&name) {
        Some(trigger) => Json(json!({ "trigger": trigger })).into_response(),
        None => error(
            StatusCode::NOT_FOUND,
            "TRIGGER_NOT_FOUND",
            "trigger not found",
        ),
    }
}

pub async fn create(
    headers: HeaderMap,
    State(state): State<Arc<AppState>>,
    Json(req): Json<TriggerRequest>,
) -> Response {
    let key = request_idempotency_key(&headers, req.idempotency_key.as_deref());
    idempotent_json(&state, "trigger:create", key, || {
        normalized(req.trigger)
            .and_then(|trigger| state.db.create_trigger(trigger))
            .map(|trigger| json!({ "status": "ok", "trigger": trigger }))
            .map_err(|e| {
                Box::new(error(
                    StatusCode::BAD_REQUEST,
                    "TRIGGER_VALIDATION",
                    &e.to_string(),
                ))
            })
    })
}

pub async fn replace(
    headers: HeaderMap,
    State(state): State<Arc<AppState>>,
    Path(name): Path<String>,
    Json(req): Json<TriggerRequest>,
) -> Response {
    let mut trigger = req.trigger;
    trigger.name = name;
    let key = request_idempotency_key(&headers, req.idempotency_key.as_deref());
    idempotent_json(&state, "trigger:replace", key, || {
        normalized(trigger)
            .and_then(|trigger| state.db.create_or_replace_trigger(trigger))
            .map(|trigger| json!({ "status": "ok", "trigger": trigger }))
            .map_err(|e| {
                Box::new(error(
                    StatusCode::BAD_REQUEST,
                    "TRIGGER_VALIDATION",
                    &e.to_string(),
                ))
            })
    })
}

pub async fn drop_trigger(
    headers: HeaderMap,
    State(state): State<Arc<AppState>>,
    Path(name): Path<String>,
) -> Response {
    let key = request_idempotency_key(&headers, None);
    idempotent_json(&state, "trigger:drop", key, || {
        state
            .db
            .drop_trigger(&name)
            .map(|()| json!({ "status": "ok" }))
            .map_err(|e| {
                Box::new(error(
                    StatusCode::NOT_FOUND,
                    "TRIGGER_NOT_FOUND",
                    &e.to_string(),
                ))
            })
    })
}

fn normalized(trigger: StoredTrigger) -> mongreldb_core::Result<StoredTrigger> {
    StoredTrigger::new(
        trigger.name,
        TriggerDefinition {
            target: trigger.target,
            timing: trigger.timing,
            event: trigger.event,
            update_of: trigger.update_of,
            target_columns: trigger.target_columns,
            when: trigger.when,
            program: trigger.program,
        },
        0,
    )
}

fn error(status: StatusCode, code: &str, message: &str) -> Response {
    (
        status,
        Json(json!({
            "status": "aborted",
            "error": {
                "code": code,
                "message": message
            }
        })),
    )
        .into_response()
}

fn request_idempotency_key(headers: &HeaderMap, body_key: Option<&str>) -> Option<String> {
    body_key
        .filter(|key| !key.is_empty())
        .map(ToOwned::to_owned)
        .or_else(|| {
            headers
                .get("idempotency-key")
                .or_else(|| headers.get("Idempotency-Key"))
                .and_then(|value| value.to_str().ok())
                .filter(|key| !key.is_empty())
                .map(ToOwned::to_owned)
        })
}

fn idempotent_json(
    state: &Arc<AppState>,
    scope: &str,
    key: Option<String>,
    f: impl FnOnce() -> Result<Jval, Box<Response>>,
) -> Response {
    let Some(key) = key else {
        return match f() {
            Ok(value) => Json(value).into_response(),
            Err(response) => *response,
        };
    };
    let scoped_key = format!("{scope}:{key}");
    if let Some(cached) = state.idem.get_json(&scoped_key) {
        return Json(cached).into_response();
    }
    let lock = state.idem.key_lock(&scoped_key);
    let _guard = lock.lock().unwrap();
    if let Some(cached) = state.idem.get_json(&scoped_key) {
        return Json(cached).into_response();
    }
    match f() {
        Ok(value) => {
            state.idem.store_json(scoped_key, value.clone());
            Json(value).into_response()
        }
        Err(response) => *response,
    }
}