Skip to main content

bhtune_server/routes/
draft.rs

1//! `GET`/`PUT /api/runs/draft` for the app-wide New Tune form draft.
2//!
3//! This is deliberately separate from `tune_runs.request_json`: a draft is mutable, may be
4//! incomplete while someone is editing it, and is not historical run data. Notes are omitted
5//! from the DTO so transient operator context is never persisted as a form preference.
6
7use axum::routing::get;
8use axum::{Json, Router};
9use bhtune_core::{ControllerDirection, ControllerType, ProcessType, ResponseLevel, TagOverrides};
10use bhtune_db::models::{SettingRow, TuneDriver};
11use chrono::Utc;
12use serde::{Deserialize, Serialize};
13use utoipa::ToSchema;
14
15use crate::error::{ApiError, ErrorBody};
16use crate::state::AppState;
17
18const NEW_RUN_DRAFT_KEY: &str = "new_run_draft";
19
20/// The source selector state used only by the mutable New Tune draft.
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
22#[serde(rename_all = "snake_case")]
23pub enum DraftTagSource {
24    Template,
25    Custom,
26}
27
28/// The source selector state used only by the mutable New Tune draft.
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
30#[serde(rename_all = "snake_case")]
31pub enum DraftValueSource {
32    Tag,
33    Custom,
34    Fixed,
35}
36
37/// Per-tag source choices in the New Tune mapping editor.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
39pub struct DraftTagSources {
40    pub process_variable: DraftTagSource,
41    pub manipulated_variable: DraftTagSource,
42    pub setpoint_variable: DraftTagSource,
43    pub controller_mode: DraftTagSource,
44    pub mode_attribute: DraftTagSource,
45    pub proportional_constant: DraftTagSource,
46    pub integral_constant: DraftTagSource,
47    pub derivative_constant: DraftTagSource,
48}
49
50/// Per-direction/range source choices in the New Tune mapping editor.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
52pub struct DraftValueSources {
53    pub direction: DraftValueSource,
54    pub pv_range_high: DraftValueSource,
55    pub pv_range_low: DraftValueSource,
56    pub mv_range_high: DraftValueSource,
57    pub mv_range_low: DraftValueSource,
58}
59
60/// The editable state of the New Tune form.
61///
62/// Fields are optional because the form is allowed to be incomplete while it is being edited.
63/// The frontend sends the complete shape on each save, using `null` for a cleared numeric or
64/// enum field. Notes are intentionally absent: they describe one run, not a reusable draft.
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
66pub struct NewRunDraft {
67    pub driver: Option<TuneDriver>,
68    pub template: Option<String>,
69    pub tagname: Option<String>,
70    pub server: Option<String>,
71    pub bridge_host: Option<String>,
72    pub process_type: Option<ProcessType>,
73    pub controller_type: Option<ControllerType>,
74    pub relay_amp: Option<f32>,
75    pub cycles_skip: Option<u32>,
76    pub cycles_count: Option<u32>,
77    pub noise_protection_secs: Option<u32>,
78    pub mrft_delay: Option<u32>,
79    pub poll_interval_ms: Option<u64>,
80    pub timeout_secs: Option<u64>,
81    pub op_timeout_secs: Option<u64>,
82    pub restore_timeout_secs: Option<u64>,
83    pub direction: Option<ControllerDirection>,
84    pub tag_overrides: Option<TagOverrides>,
85    /// Driver context for the saved direction/range values, alongside their source metadata.
86    pub source_driver: Option<TuneDriver>,
87    /// Simulator-specific direction/range values. These are draft-only and never go into a
88    /// run request when the OPC DA driver is active.
89    pub source_direction: Option<ControllerDirection>,
90    pub pv_range_high: Option<f32>,
91    pub pv_range_low: Option<f32>,
92    pub mv_range_high: Option<f32>,
93    pub mv_range_low: Option<f32>,
94    pub source_pv_range_high: Option<f32>,
95    pub source_pv_range_low: Option<f32>,
96    pub source_mv_range_high: Option<f32>,
97    pub source_mv_range_low: Option<f32>,
98    /// Draft-only source selectors for tag names and direction/range values.
99    pub tag_sources: Option<DraftTagSources>,
100    pub value_sources: Option<DraftValueSources>,
101    pub sim_gain: Option<f32>,
102    pub sim_tau: Option<f32>,
103    pub sim_dead_time: Option<f32>,
104    pub sim_noise: Option<f32>,
105    pub sim_seed: Option<u64>,
106    pub sim_initial_pv: Option<f32>,
107    pub sim_initial_mv: Option<f32>,
108    pub write_pid: Option<ResponseLevel>,
109    pub yes: Option<bool>,
110}
111
112fn invalid_stored_draft(error: serde_json::Error) -> ApiError {
113    ApiError::Internal(anyhow::anyhow!(
114        "saved New Tune draft has an invalid shape: {error}"
115    ))
116}
117
118/// Returns the saved New Tune draft, or `null` when no draft has been saved yet.
119#[utoipa::path(
120    get,
121    path = "/api/runs/draft",
122    tag = "runs",
123    responses(
124        (status = 200, description = "The saved New Tune draft, or null when none exists.", body = Option<NewRunDraft>),
125        (status = 500, description = "The stored draft is malformed or the database failed.", body = ErrorBody),
126    ),
127)]
128pub(crate) async fn get_draft(
129    axum::extract::State(state): axum::extract::State<AppState>,
130) -> Result<Json<Option<NewRunDraft>>, ApiError> {
131    let Some(setting) = SettingRow::get(&state.pool, NEW_RUN_DRAFT_KEY).await? else {
132        return Ok(Json(None));
133    };
134    let draft = serde_json::from_value(setting.value).map_err(invalid_stored_draft)?;
135    Ok(Json(Some(draft)))
136}
137
138/// Replaces the saved New Tune draft and returns the stored value.
139#[utoipa::path(
140    put,
141    path = "/api/runs/draft",
142    tag = "runs",
143    request_body = NewRunDraft,
144    responses(
145        (status = 200, description = "The draft was saved.", body = NewRunDraft),
146        (status = 500, description = "The database failed or the draft could not be stored.", body = ErrorBody),
147    ),
148)]
149pub(crate) async fn put_draft(
150    axum::extract::State(state): axum::extract::State<AppState>,
151    Json(draft): Json<NewRunDraft>,
152) -> Result<Json<NewRunDraft>, ApiError> {
153    let value = encode_draft(&draft, |value| serde_json::to_value(value))?;
154    let stored = SettingRow::upsert(&state.pool, NEW_RUN_DRAFT_KEY, &value, Utc::now()).await?;
155    let persisted = serde_json::from_value(stored.value).map_err(invalid_stored_draft)?;
156    Ok(Json(persisted))
157}
158
159fn encode_draft<E>(
160    draft: &NewRunDraft,
161    encode: impl FnOnce(&NewRunDraft) -> Result<serde_json::Value, E>,
162) -> Result<serde_json::Value, ApiError>
163where
164    E: std::fmt::Display,
165{
166    encode(draft)
167        .map_err(|error| ApiError::Internal(anyhow::anyhow!("failed to encode draft: {error}")))
168}
169
170pub fn router() -> Router<AppState> {
171    Router::new().route("/api/runs/draft", get(get_draft).put(put_draft))
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use axum::body::{Body, to_bytes};
178    use axum::http::{Request, StatusCode};
179    use bhtune_core::ControllerDirection;
180    use bhtune_db::models::SettingRow;
181    use serde_json::json;
182    use tower::ServiceExt;
183
184    #[test]
185    fn draft_encoding_failure_is_an_internal_error() {
186        let error = encode_draft(
187            &NewRunDraft {
188                driver: None,
189                template: None,
190                tagname: None,
191                server: None,
192                bridge_host: None,
193                process_type: None,
194                controller_type: None,
195                relay_amp: None,
196                cycles_skip: None,
197                cycles_count: None,
198                noise_protection_secs: None,
199                mrft_delay: None,
200                poll_interval_ms: None,
201                timeout_secs: None,
202                op_timeout_secs: None,
203                restore_timeout_secs: None,
204                direction: None,
205                tag_overrides: None,
206                source_driver: None,
207                source_direction: None,
208                pv_range_high: None,
209                pv_range_low: None,
210                mv_range_high: None,
211                mv_range_low: None,
212                source_pv_range_high: None,
213                source_pv_range_low: None,
214                source_mv_range_high: None,
215                source_mv_range_low: None,
216                tag_sources: None,
217                value_sources: None,
218                sim_gain: None,
219                sim_tau: None,
220                sim_dead_time: None,
221                sim_noise: None,
222                sim_seed: None,
223                sim_initial_pv: None,
224                sim_initial_mv: None,
225                write_pid: None,
226                yes: None,
227            },
228            |_| Err::<serde_json::Value, _>("injected encoding failure"),
229        )
230        .unwrap_err();
231        assert!(
232            matches!(error, ApiError::Internal(message) if message.to_string().contains("encoding failure"))
233        );
234    }
235
236    #[tokio::test]
237    async fn missing_draft_is_null() {
238        let app = crate::build_router(crate::test_support::in_memory_state().await);
239        let response = app
240            .oneshot(
241                Request::get("/api/runs/draft")
242                    .body(Body::empty())
243                    .expect("request"),
244            )
245            .await
246            .expect("response");
247
248        assert_eq!(response.status(), StatusCode::OK);
249        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
250        assert_eq!(
251            serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
252            json!(null)
253        );
254    }
255
256    #[tokio::test]
257    async fn put_replaces_draft_and_never_persists_notes() {
258        let state = crate::test_support::in_memory_state().await;
259        let pool = state.pool.clone();
260        let app = crate::build_router(state);
261        let request_body = json!({
262            "driver": "opcda",
263            "template": "Yokogawa CentumVP",
264            "bridge_host": "localhost:7600",
265            "notes": "transient operator context"
266        });
267
268        let response = app
269            .clone()
270            .oneshot(
271                Request::put("/api/runs/draft")
272                    .header("content-type", "application/json")
273                    .body(Body::from(request_body.to_string()))
274                    .expect("request"),
275            )
276            .await
277            .expect("response");
278        assert_eq!(response.status(), StatusCode::OK);
279
280        let response = app
281            .oneshot(
282                Request::get("/api/runs/draft")
283                    .body(Body::empty())
284                    .expect("request"),
285            )
286            .await
287            .expect("response");
288        assert_eq!(response.status(), StatusCode::OK);
289        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
290        let saved: serde_json::Value = serde_json::from_slice(&body).unwrap();
291        assert_eq!(saved["driver"], "opcda");
292        assert_eq!(saved["bridge_host"], "localhost:7600");
293        assert!(saved.get("notes").is_none());
294
295        let raw = SettingRow::get(&pool, NEW_RUN_DRAFT_KEY)
296            .await
297            .unwrap()
298            .unwrap();
299        assert!(raw.value.get("notes").is_none());
300    }
301
302    #[tokio::test]
303    async fn malformed_saved_draft_is_an_explicit_server_error() {
304        let state = crate::test_support::in_memory_state().await;
305        SettingRow::upsert(
306            &state.pool,
307            NEW_RUN_DRAFT_KEY,
308            &json!({"driver": 42}),
309            Utc::now(),
310        )
311        .await
312        .unwrap();
313        let app = crate::build_router(state);
314
315        let response = app
316            .oneshot(
317                Request::get("/api/runs/draft")
318                    .body(Body::empty())
319                    .expect("request"),
320            )
321            .await
322            .expect("response");
323
324        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
325        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
326        assert_eq!(
327            serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
328            json!({"error": "internal server error"})
329        );
330    }
331
332    #[tokio::test]
333    async fn draft_round_trips_source_metadata_and_omits_notes() {
334        let state = crate::test_support::in_memory_state().await;
335        let app = crate::build_router(state.clone());
336        let request_body = json!({
337            "driver": "opcda",
338            "template": "Yokogawa CentumVP",
339            "tagname": "Loop3.PV",
340            "server": "Sim.Server",
341            "bridge_host": "localhost:7600",
342            "process_type": "flow",
343            "controller_type": "pi",
344            "relay_amp": 5.0,
345            "cycles_skip": 1,
346            "cycles_count": 3,
347            "noise_protection_secs": 0,
348            "mrft_delay": 0,
349            "poll_interval_ms": 800,
350            "timeout_secs": 3600,
351            "op_timeout_secs": 30,
352            "restore_timeout_secs": 30,
353            "allow_uncertain_quality": true,
354            "direction": "reverse",
355            "tag_overrides": {
356                "pv": "Loop3.PV",
357                "mv": "Loop3.MV"
358            },
359            "source_driver": "simulator",
360            "source_direction": "direct",
361            "pv_range_high": 100.0,
362            "pv_range_low": 0.0,
363            "mv_range_high": 100.0,
364            "mv_range_low": 0.0,
365            "source_pv_range_high": 200.0,
366            "source_pv_range_low": 10.0,
367            "source_mv_range_high": 75.0,
368            "source_mv_range_low": 5.0,
369            "sim_gain": 1.0,
370            "sim_tau": 2.0,
371            "sim_dead_time": 5.0,
372            "sim_noise": 0.0,
373            "sim_seed": 123,
374            "sim_initial_pv": 50.0,
375            "sim_initial_mv": 50.0,
376            "write_pid": "moderate",
377            "yes": true,
378            "notes": "transient operator context"
379        });
380
381        let response = app
382            .clone()
383            .oneshot(
384                Request::put("/api/runs/draft")
385                    .header("content-type", "application/json")
386                    .body(Body::from(request_body.to_string()))
387                    .expect("request"),
388            )
389            .await
390            .expect("response");
391        assert_eq!(response.status(), StatusCode::OK);
392
393        let response = app
394            .oneshot(
395                Request::get("/api/runs/draft")
396                    .body(Body::empty())
397                    .expect("request"),
398            )
399            .await
400            .expect("response");
401        assert_eq!(response.status(), StatusCode::OK);
402        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
403        let saved: NewRunDraft = serde_json::from_slice(&body).unwrap();
404        assert_eq!(saved.source_driver, Some(TuneDriver::Simulator));
405        assert_eq!(saved.source_direction, Some(ControllerDirection::Direct));
406        assert_eq!(saved.source_pv_range_high, Some(200.0));
407        assert_eq!(saved.source_pv_range_low, Some(10.0));
408        assert_eq!(saved.source_mv_range_high, Some(75.0));
409        assert_eq!(saved.source_mv_range_low, Some(5.0));
410        assert!(
411            serde_json::from_slice::<serde_json::Value>(&body)
412                .unwrap()
413                .get("notes")
414                .is_none()
415        );
416    }
417}