Skip to main content

mlua_swarm_server/
enhance_settings.rs

1//! `EnhanceSetting` HTTP CRUD (= K-V store entry + `BPStore` commit
2//! orchestration).
3//!
4//! - `POST   /v1/enhance-settings`      — create (body = `EnhanceSettingInput`, includes data)
5//! - `GET    /v1/enhance-settings/:id`  — read (= `EnhanceSetting` = Ref form)
6//! - `PUT    /v1/enhance-settings/:id`  — update (body = `EnhanceSettingInput`)
7//! - `DELETE /v1/enhance-settings/:id`  — delete (= K-V only; `BPStore` history remains)
8//! - `GET    /v1/enhance-settings`      — list ids
9//!
10//! The POST/PUT input form is [`EnhanceSettingInput`] (= Blueprint embedded
11//! with data). Inside the server, `into_ref()` splits it into (`Blueprint`,
12//! `EnhanceSetting` Ref form); the BP is committed via `BPStore.write_new`
13//! first, then `setting_store.put` (= BP first, setting second, so a failed
14//! BP commit does not leave an orphan setting). Response / read return
15//! `EnhanceSetting` (= `BlueprintId` Ref + `ttl_secs` + meta).
16//!
17//! Current scope:
18//! - Consecutive PUTs of the same content produce duplicate commits in BP
19//!   history (= idempotency is a carry).
20
21use axum::{
22    extract::{Path, State},
23    http::StatusCode,
24    routing::get,
25    Json, Router,
26};
27use mlua_swarm::blueprint::store::{blueprint_version, BlueprintStore, CommitMetadata};
28use mlua_swarm::blueprint::Blueprint;
29use mlua_swarm::enhance::{EnhanceSetting, EnhanceSettingInput};
30use mlua_swarm::store::enhance_setting::{
31    EnhanceSettingId, EnhanceSettingStore, EnhanceSettingStoreError,
32};
33use std::sync::Arc;
34
35/// Router state for the `/v1/enhance-settings*` handlers.
36#[derive(Clone)]
37pub struct EnhanceSettingsState {
38    /// K-V backend for `EnhanceSetting` Ref-form records.
39    pub setting_store: Arc<dyn EnhanceSettingStore>,
40    /// Blueprint store the embedded Blueprint is committed to before the K-V write.
41    pub bp_store: Arc<dyn BlueprintStore>,
42}
43
44/// Builds the `/v1/enhance-settings*` router. See the module doc for the
45/// commit-then-K-V-write ordering and the POST/PUT input shape.
46pub fn build_enhance_settings_router(
47    setting_store: Arc<dyn EnhanceSettingStore>,
48    bp_store: Arc<dyn BlueprintStore>,
49) -> Router {
50    let state = EnhanceSettingsState {
51        setting_store,
52        bp_store,
53    };
54    Router::new()
55        .route(
56            "/v1/enhance-settings",
57            get(list_settings).post(post_setting),
58        )
59        .route(
60            "/v1/enhance-settings/:id",
61            get(get_setting).put(put_setting).delete(delete_setting),
62        )
63        .with_state(state)
64}
65
66fn now_ms() -> i64 {
67    std::time::SystemTime::now()
68        .duration_since(std::time::UNIX_EPOCH)
69        .map(|d| d.as_millis() as i64)
70        .unwrap_or(0)
71}
72
73/// Commits `setting.blueprint` to the `BPStore`. Idempotent (= if a head with
74/// the same `ContentHash` already exists, skip). If the BP commit fails,
75/// early-returns without calling `setting_store.put`.
76async fn commit_blueprint(
77    bp_store: &Arc<dyn BlueprintStore>,
78    blueprint: &Blueprint,
79    rationale: String,
80) -> Result<(), (StatusCode, String)> {
81    let bp_id = blueprint.id.clone();
82    let v = blueprint_version(blueprint).map_err(|e| {
83        (
84            StatusCode::INTERNAL_SERVER_ERROR,
85            format!("bp version: {e}"),
86        )
87    })?;
88
89    // Idempotency: skip when head's version already matches (prevents duplicate commits of the same content).
90    if let Ok(traced) = bp_store.read_head(&bp_id).await {
91        if traced.trace.version == v {
92            return Ok(());
93        }
94    }
95
96    let mut meta = CommitMetadata::seed(bp_id.clone(), v, now_ms());
97    meta.rationale = rationale;
98    bp_store
99        .write_new(&bp_id, blueprint, &[], meta)
100        .await
101        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("bp commit: {e}")))?;
102    Ok(())
103}
104
105async fn list_settings(
106    State(state): State<EnhanceSettingsState>,
107) -> Result<Json<Vec<String>>, (StatusCode, String)> {
108    let ids = state
109        .setting_store
110        .list()
111        .await
112        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
113    Ok(Json(ids.into_iter().map(|id| id.0).collect()))
114}
115
116async fn post_setting(
117    State(state): State<EnhanceSettingsState>,
118    Json(input): Json<EnhanceSettingInput>,
119) -> Result<(StatusCode, Json<EnhanceSetting>), (StatusCode, String)> {
120    let (blueprint, setting) = input.into_ref();
121    let rationale = format!(
122        "enhance-setting POST id={} blueprint_id={}",
123        setting.id, setting.blueprint_id
124    );
125    commit_blueprint(&state.bp_store, &blueprint, rationale).await?;
126    state
127        .setting_store
128        .put(&EnhanceSettingId::new(setting.id.clone()), setting.clone())
129        .await
130        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
131    Ok((StatusCode::CREATED, Json(setting)))
132}
133
134async fn get_setting(
135    State(state): State<EnhanceSettingsState>,
136    Path(id): Path<String>,
137) -> Result<Json<EnhanceSetting>, (StatusCode, String)> {
138    let setting = state
139        .setting_store
140        .get(&EnhanceSettingId::new(id))
141        .await
142        .map_err(|e| match e {
143            EnhanceSettingStoreError::NotFound(id) => (
144                StatusCode::NOT_FOUND,
145                format!("enhance setting not found: {id}"),
146            ),
147            EnhanceSettingStoreError::Other(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
148        })?;
149    Ok(Json(setting))
150}
151
152async fn put_setting(
153    State(state): State<EnhanceSettingsState>,
154    Path(id): Path<String>,
155    Json(input): Json<EnhanceSettingInput>,
156) -> Result<Json<EnhanceSetting>, (StatusCode, String)> {
157    if input.id != id {
158        return Err((
159            StatusCode::BAD_REQUEST,
160            format!("path id {id:?} != body id {:?}", input.id),
161        ));
162    }
163    let (blueprint, setting) = input.into_ref();
164    let rationale = format!(
165        "enhance-setting PUT id={} blueprint_id={}",
166        setting.id, setting.blueprint_id
167    );
168    commit_blueprint(&state.bp_store, &blueprint, rationale).await?;
169    state
170        .setting_store
171        .put(&EnhanceSettingId::new(id), setting.clone())
172        .await
173        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
174    Ok(Json(setting))
175}
176
177async fn delete_setting(
178    State(state): State<EnhanceSettingsState>,
179    Path(id): Path<String>,
180) -> Result<StatusCode, (StatusCode, String)> {
181    state
182        .setting_store
183        .delete(&EnhanceSettingId::new(id))
184        .await
185        .map_err(|e| match e {
186            EnhanceSettingStoreError::NotFound(id) => (
187                StatusCode::NOT_FOUND,
188                format!("enhance setting not found: {id}"),
189            ),
190            EnhanceSettingStoreError::Other(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
191        })?;
192    Ok(StatusCode::NO_CONTENT)
193}