Skip to main content

mold_server/
routes_config.rs

1//! `/api/config` — the HTTP counterpart of the `mold config` CLI verbs.
2//!
3//! Shares the CLI's key registry, typed get/set, env-override detection,
4//! and DB-vs-TOML surface routing via `mold_core::config_keys`, and its
5//! DB persistence via `mold_db::config_sync`. Reads come from the server's
6//! in-memory `Config`; writes mutate it in place (so the running server
7//! reflects changes immediately) and persist to the owning surface.
8
9use axum::{
10    extract::{Path, State},
11    http::StatusCode,
12    Json,
13};
14use mold_core::config_keys as keys;
15use mold_core::{Config, ConfigEntry, ConfigListing, ConfigProfiles};
16use serde::Deserialize;
17
18use crate::routes::ApiError;
19use crate::state::AppState;
20
21/// 503 error code when the metadata DB is disabled (`MOLD_DB_DISABLE=1`).
22const CONFIG_UNAVAILABLE: &str = "CONFIG_UNAVAILABLE";
23/// 403 error code when an env var owns the key at runtime.
24const ENV_OVERRIDDEN: &str = "ENV_OVERRIDDEN";
25/// 404/422 error code for keys outside the registry.
26const UNKNOWN_CONFIG_KEY: &str = "UNKNOWN_CONFIG_KEY";
27/// 422 error code for reset attempts on config.toml-owned keys.
28const FILE_BACKED_KEY: &str = "FILE_BACKED_KEY";
29
30fn settings_db(state: &AppState) -> Result<&mold_db::MetadataDb, ApiError> {
31    state.metadata_db.as_ref().as_ref().ok_or_else(|| {
32        ApiError::with_code(
33            "config settings are unavailable because the metadata DB is disabled",
34            CONFIG_UNAVAILABLE,
35            StatusCode::SERVICE_UNAVAILABLE,
36        )
37    })
38}
39
40/// `(source, env_var)` for a key — `"env"` (with the variable name) when an
41/// environment variable overrides it at runtime, otherwise the owning
42/// storage surface (`"db"` / `"file"`). Matches the CLI's
43/// `surface_annotated_value` classification exactly.
44fn source_for(key: &str) -> (String, Option<String>) {
45    if let Some((var, _)) = keys::env_override_for(key) {
46        ("env".to_string(), Some(var.to_string()))
47    } else {
48        (keys::effective_surface(key).as_str().to_string(), None)
49    }
50}
51
52fn entry_for(key: &str, value: serde_json::Value) -> ConfigEntry {
53    let (source, env_var) = source_for(key);
54    ConfigEntry {
55        key: key.to_string(),
56        value,
57        source,
58        env_var,
59    }
60}
61
62fn unknown_key(err: impl std::fmt::Display, status: StatusCode) -> ApiError {
63    ApiError::with_code(err.to_string(), UNKNOWN_CONFIG_KEY, status)
64}
65
66// ── GET /api/config ──────────────────────────────────────────────────────────
67
68/// Every effective config row — static keys plus `models.<name>.<field>`
69/// rows for configured models — exactly like `mold config list --json`.
70#[utoipa::path(
71    get,
72    path = "/api/config",
73    tag = "config",
74    responses(
75        (status = 200, description = "Effective config rows with sources", body = mold_core::ConfigListing),
76    )
77)]
78pub async fn list_config(State(state): State<AppState>) -> Json<ConfigListing> {
79    let cfg = state.config.read().await;
80    let mut entries = Vec::new();
81    for info in keys::ALL_KEYS {
82        if let Ok(val) = keys::get_static_value(&cfg, info.key) {
83            entries.push(entry_for(info.key, val.to_json()));
84        }
85    }
86    for model_name in cfg.models.keys() {
87        for (field, _) in keys::MODEL_FIELDS {
88            let full_key = format!("models.{model_name}.{field}");
89            if let Ok(val) = keys::get_model_value(&cfg, &full_key) {
90                entries.push(entry_for(&full_key, val.to_json()));
91            }
92        }
93    }
94    let profile = state
95        .metadata_db
96        .as_ref()
97        .as_ref()
98        .map(mold_db::resolve_active_profile);
99    Json(ConfigListing { profile, entries })
100}
101
102// ── GET /api/config/:key ─────────────────────────────────────────────────────
103
104/// One effective config row (mirrors `mold config get` + `mold config where`).
105#[utoipa::path(
106    get,
107    path = "/api/config/{key}",
108    tag = "config",
109    params(("key" = String, Path, description = "Config key (e.g. expand.enabled, models.flux-dev:q4.lora)")),
110    responses(
111        (status = 200, description = "Config row", body = mold_core::ConfigEntry),
112        (status = 404, description = "Unknown config key"),
113    )
114)]
115pub async fn get_config_key(
116    State(state): State<AppState>,
117    Path(key): Path<String>,
118) -> Result<Json<ConfigEntry>, ApiError> {
119    let cfg = state.config.read().await;
120    let value = keys::get_value(&cfg, &key)
121        .map_err(|e| unknown_key(e, StatusCode::NOT_FOUND))?
122        .to_json();
123    Ok(Json(entry_for(&key, value)))
124}
125
126// ── PUT /api/config/:key ─────────────────────────────────────────────────────
127
128#[derive(Debug, Deserialize, utoipa::ToSchema)]
129pub struct ConfigSetRequest {
130    /// New value — string, number, bool, or null (null clears optional keys).
131    #[schema(value_type = Object)]
132    pub value: serde_json::Value,
133}
134
135/// Coerce the JSON body value into the raw string form the shared
136/// `set_value` parser expects (the CLI receives values as strings).
137fn raw_value(value: &serde_json::Value) -> Result<String, ApiError> {
138    match value {
139        serde_json::Value::Null => Ok(String::new()),
140        serde_json::Value::Bool(b) => Ok(b.to_string()),
141        serde_json::Value::Number(n) => Ok(n.to_string()),
142        serde_json::Value::String(s) => Ok(s.clone()),
143        _ => Err(ApiError::validation(
144            "value must be a scalar (string, number, bool, or null)",
145        )),
146    }
147}
148
149/// Set a config key, routed by surface exactly like `mold config set`:
150/// DB-backed keys land in the settings DB for the active profile, file
151/// keys are written back to config.toml.
152#[utoipa::path(
153    put,
154    path = "/api/config/{key}",
155    tag = "config",
156    params(("key" = String, Path, description = "Config key")),
157    request_body = ConfigSetRequest,
158    responses(
159        (status = 200, description = "Updated config row", body = mold_core::ConfigEntry),
160        (status = 403, description = "Key is overridden by an environment variable"),
161        (status = 422, description = "Unknown key or invalid value"),
162        (status = 503, description = "Metadata DB disabled (DB-backed key)"),
163    )
164)]
165pub async fn put_config_key(
166    State(state): State<AppState>,
167    Path(key): Path<String>,
168    Json(req): Json<ConfigSetRequest>,
169) -> Result<Json<ConfigEntry>, ApiError> {
170    if !keys::is_known_key(&key) {
171        return Err(unknown_key(
172            keys::unknown_key_error(&key),
173            StatusCode::UNPROCESSABLE_ENTITY,
174        ));
175    }
176    if let Some((var, _)) = keys::env_override_for(&key) {
177        return Err(ApiError::with_code(
178            format!("'{key}' is set by {var} in the environment — unset it to edit"),
179            ENV_OVERRIDDEN,
180            StatusCode::FORBIDDEN,
181        ));
182    }
183    let raw = raw_value(&req.value)?;
184
185    // Route by surface BEFORE mutating anything so a DB-backed write with
186    // the DB disabled fails without leaving the in-memory config changed.
187    let surface = keys::effective_surface(&key);
188    if surface == keys::Surface::Db {
189        settings_db(&state)?;
190    }
191
192    let mut cfg = state.config.write().await;
193    keys::set_value(&mut cfg, &key, &raw).map_err(|e| ApiError::validation(e.to_string()))?;
194
195    match surface {
196        keys::Surface::Db => {
197            let db = settings_db(&state)?;
198            let persisted = if key.starts_with("models.") {
199                mold_db::config_sync::persist_model_field(db, &cfg, &key)
200            } else {
201                mold_db::config_sync::persist_config_key(db, &cfg, &key)
202            };
203            persisted
204                .map_err(|e| ApiError::internal(format!("failed to persist '{key}': {e:#}")))?;
205        }
206        keys::Surface::File => {
207            // Bootstrap-only writer, mirroring the CLI — DB-owned fields
208            // that the hydrate hook mixed in must not leak back into TOML.
209            cfg.save_bootstrap_only().map_err(|e| {
210                ApiError::internal(format!("failed to write config.toml for '{key}': {e:#}"))
211            })?;
212        }
213    }
214
215    let value = keys::get_value(&cfg, &key)
216        .map(|v| v.to_json())
217        .unwrap_or(serde_json::Value::Null);
218    Ok(Json(ConfigEntry {
219        key,
220        value,
221        source: surface.as_str().to_string(),
222        env_var: None,
223    }))
224}
225
226// ── DELETE /api/config/:key ──────────────────────────────────────────────────
227
228/// Reset a DB-backed key (mirrors `mold config reset`): drops the settings
229/// or model_prefs row for the active profile so the next read falls back
230/// to config.toml / env / the compiled default. The response reports the
231/// post-reset fallback value. File-backed keys are rejected — edit them
232/// via `PUT /api/config/:key` instead.
233#[utoipa::path(
234    delete,
235    path = "/api/config/{key}",
236    tag = "config",
237    params(("key" = String, Path, description = "Config key")),
238    responses(
239        (status = 200, description = "Key reset; body carries the fallback value", body = mold_core::ConfigEntry),
240        (status = 422, description = "Unknown key or file-backed key"),
241        (status = 503, description = "Metadata DB disabled"),
242    )
243)]
244pub async fn delete_config_key(
245    State(state): State<AppState>,
246    Path(key): Path<String>,
247) -> Result<Json<ConfigEntry>, ApiError> {
248    if !keys::is_known_key(&key) {
249        return Err(unknown_key(
250            keys::unknown_key_error(&key),
251            StatusCode::UNPROCESSABLE_ENTITY,
252        ));
253    }
254    if keys::effective_surface(&key) != keys::Surface::Db {
255        return Err(ApiError::with_code(
256            format!("'{key}' is stored in config.toml — edit it via PUT /api/config/{key}"),
257            FILE_BACKED_KEY,
258            StatusCode::UNPROCESSABLE_ENTITY,
259        ));
260    }
261    let db = settings_db(&state)?;
262    let profile = mold_db::resolve_active_profile(db);
263    let dropped = if key.starts_with("models.") {
264        mold_db::config_sync::reset_model_field(db, &profile, &key)
265    } else {
266        mold_db::config_sync::reset_global_key(db, &profile, &key)
267    };
268    dropped.map_err(|e| ApiError::internal(format!("failed to reset '{key}': {e:#}")))?;
269
270    // Reload from the surviving surfaces (TOML + compiled defaults; the
271    // process-wide DB hydrate hook, when installed, no longer sees the
272    // dropped row) to learn what the key falls back to, and mirror that
273    // into the running server's config.
274    let fresh = Config::load_or_default();
275    let fallback = keys::get_value(&fresh, &key).ok();
276    {
277        let mut cfg = state.config.write().await;
278        let raw = fallback
279            .as_ref()
280            .map(|v| v.raw())
281            .unwrap_or_else(|| "none".to_string());
282        // Best-effort: optional keys clear on "", per-model keys clear on
283        // "none"; required keys keep their current runtime value if the
284        // fallback raw form doesn't parse.
285        let _ = keys::set_value(&mut cfg, &key, &raw);
286    }
287
288    let (source, env_var) = match keys::env_override_for(&key) {
289        Some((var, _)) => ("env".to_string(), Some(var.to_string())),
290        None => ("default".to_string(), None),
291    };
292    Ok(Json(ConfigEntry {
293        key,
294        value: fallback
295            .map(|v| v.to_json())
296            .unwrap_or(serde_json::Value::Null),
297        source,
298        env_var,
299    }))
300}
301
302// ── GET /api/config/profiles ─────────────────────────────────────────────────
303
304/// List settings profiles and the active one. The active profile resolves
305/// `MOLD_PROFILE` (env) → the stored `profile.active` row → `"default"`.
306#[utoipa::path(
307    get,
308    path = "/api/config/profiles",
309    tag = "config",
310    responses(
311        (status = 200, description = "Profiles and the active profile", body = mold_core::ConfigProfiles),
312        (status = 503, description = "Metadata DB disabled"),
313    )
314)]
315pub async fn list_config_profiles(
316    State(state): State<AppState>,
317) -> Result<Json<ConfigProfiles>, ApiError> {
318    let db = settings_db(&state)?;
319    Ok(Json(profiles_snapshot(db)?))
320}
321
322fn profiles_snapshot(db: &mold_db::MetadataDb) -> Result<ConfigProfiles, ApiError> {
323    let active = mold_db::resolve_active_profile(db);
324    let mut profiles = mold_db::settings::list_profiles(db)
325        .map_err(|e| ApiError::internal(format!("failed to list profiles: {e:#}")))?;
326    if !profiles.iter().any(|p| p == &active) {
327        profiles.push(active.clone());
328        profiles.sort();
329    }
330    Ok(ConfigProfiles { active, profiles })
331}
332
333// ── PUT /api/config/profile ──────────────────────────────────────────────────
334
335#[derive(Debug, Deserialize, utoipa::ToSchema)]
336pub struct ProfileSetRequest {
337    /// Profile name to activate (created implicitly on first write).
338    pub name: String,
339}
340
341/// Switch the active settings profile by writing the `profile.active`
342/// meta-row (the same mechanism the CLI's `--profile` resolution reads).
343/// A `MOLD_PROFILE` environment variable still wins at runtime — the
344/// response reports the profile that is actually active.
345#[utoipa::path(
346    put,
347    path = "/api/config/profile",
348    tag = "config",
349    request_body = ProfileSetRequest,
350    responses(
351        (status = 200, description = "Active profile after the switch", body = mold_core::ConfigProfiles),
352        (status = 422, description = "Invalid profile name"),
353        (status = 503, description = "Metadata DB disabled"),
354    )
355)]
356pub async fn put_config_profile(
357    State(state): State<AppState>,
358    Json(req): Json<ProfileSetRequest>,
359) -> Result<Json<ConfigProfiles>, ApiError> {
360    let name = req.name.trim();
361    if name.is_empty() {
362        return Err(ApiError::validation("profile name cannot be empty"));
363    }
364    let db = settings_db(&state)?;
365    mold_db::settings::set_active_profile(db, name)
366        .map_err(|e| ApiError::internal(format!("failed to switch profile: {e:#}")))?;
367    Ok(Json(profiles_snapshot(db)?))
368}