Skip to main content

bhtune_server/routes/
templates.rs

1//! Template CRUD routes: `GET /api/templates`, `GET /api/templates/{name}`,
2//! `POST /api/templates`, `PUT /api/templates/{name}`, `DELETE /api/templates/{name}`.
3//!
4//! Mirrors `bhtune-cli`'s `commands::template` behavior exactly (see `import_one` there):
5//! validate, then check for a name collision, then insert. HTTP-created templates are
6//! always [`TemplateOrigin::User`] -- the same origin the CLI's single-template `import`
7//! path assigns, as opposed to the auto-loaded `Builtin`/`Catalog` origins, which only ever
8//! come from the embedded catalog or a user catalog file, never from this endpoint. `PUT`
9//! is likewise restricted to `User`-origin rows -- see `update_template`'s doc comment.
10
11use axum::extract::{Path, State};
12use axum::http::StatusCode;
13use axum::routing::get;
14use axum::{Json, Router};
15use bhtune_core::DcsTemplate;
16use bhtune_db::models::{DcsTemplateRow, TemplateOrigin};
17use chrono::{DateTime, Utc};
18use serde::Serialize;
19use utoipa::ToSchema;
20
21use crate::error::{ApiError, ErrorBody};
22use crate::state::AppState;
23
24/// The HTTP-facing shape of a stored template: the caller-supplied [`DcsTemplate`] fields
25/// flattened alongside the database-assigned `id`/`origin`/timestamps. Per this workspace's
26/// established DTO-decoupling convention (see `bhtune-cli`'s `commands::history` module doc
27/// comments), [`DcsTemplateRow`] itself deliberately does not derive `Serialize` -- every
28/// JSON-facing consumer builds its own projection rather than the DB row shape leaking
29/// straight onto the wire.
30#[derive(Debug, Serialize, ToSchema)]
31pub struct TemplateResponse {
32    pub id: i64,
33    pub origin: TemplateOrigin,
34    #[serde(flatten)]
35    pub template: DcsTemplate,
36    pub created_at: DateTime<Utc>,
37    pub updated_at: DateTime<Utc>,
38}
39
40impl From<DcsTemplateRow> for TemplateResponse {
41    fn from(row: DcsTemplateRow) -> Self {
42        TemplateResponse {
43            id: row.id,
44            origin: row.origin,
45            template: row.template,
46            created_at: row.created_at,
47            updated_at: row.updated_at,
48        }
49    }
50}
51
52/// List every stored template.
53///
54/// `GET /api/templates` -- every stored template (built-in, catalog, and user-created
55/// alike), ordered by name, for a template picker.
56#[utoipa::path(
57    get,
58    path = "/api/templates",
59    tag = "templates",
60    responses(
61        (status = 200, description = "Every stored template, ordered by name.", body = Vec<TemplateResponse>),
62    ),
63)]
64pub(crate) async fn list_templates(
65    State(state): State<AppState>,
66) -> Result<Json<Vec<TemplateResponse>>, ApiError> {
67    let rows = DcsTemplateRow::list(&state.pool).await?;
68    Ok(Json(rows.into_iter().map(TemplateResponse::from).collect()))
69}
70
71/// Fetch one template by name.
72///
73/// `GET /api/templates/{name}` -- 404 if no template has that name.
74#[utoipa::path(
75    get,
76    path = "/api/templates/{name}",
77    tag = "templates",
78    params(
79        ("name" = String, Path, description = "Template name"),
80    ),
81    responses(
82        (status = 200, body = TemplateResponse),
83        (status = 404, description = "No template with that name.", body = ErrorBody),
84    ),
85)]
86pub(crate) async fn get_template(
87    State(state): State<AppState>,
88    Path(name): Path<String>,
89) -> Result<Json<TemplateResponse>, ApiError> {
90    let row = DcsTemplateRow::get_by_name(&state.pool, &name)
91        .await?
92        .ok_or_else(|| ApiError::NotFound(format!("no template named '{name}'")))?;
93    Ok(Json(row.into()))
94}
95
96/// Create a new, user-owned template.
97///
98/// `POST /api/templates` -- 400 if [`DcsTemplate::validate`] rejects the body, 409 if a
99/// template with the same name already exists.
100#[utoipa::path(
101    post,
102    path = "/api/templates",
103    tag = "templates",
104    request_body = DcsTemplate,
105    responses(
106        (status = 201, description = "Template created.", body = TemplateResponse),
107        (status = 400, description = "The template failed validation.", body = ErrorBody),
108        (status = 409, description = "A template with this name already exists.", body = ErrorBody),
109    ),
110)]
111pub(crate) async fn create_template(
112    State(state): State<AppState>,
113    Json(template): Json<DcsTemplate>,
114) -> Result<(StatusCode, Json<TemplateResponse>), ApiError> {
115    template
116        .validate()
117        .map_err(|err| ApiError::BadRequest(err.to_string()))?;
118    if DcsTemplateRow::get_by_name(&state.pool, &template.name)
119        .await?
120        .is_some()
121    {
122        return Err(ApiError::Conflict(format!(
123            "a template named '{}' already exists",
124            template.name
125        )));
126    }
127    let row =
128        DcsTemplateRow::insert(&state.pool, &template, TemplateOrigin::User, Utc::now()).await?;
129    Ok((StatusCode::CREATED, Json(row.into())))
130}
131
132/// Update an existing, user-owned template.
133///
134/// `PUT /api/templates/{name}` -- 404 if no template has that name, 400 if the body fails
135/// [`DcsTemplate::validate`] or its `name` doesn't match the path (renaming isn't supported
136/// here -- delete and recreate instead, the same restriction
137/// [`bhtune_db::models::DcsTemplateRow::update`] itself has), 409 if the template's origin
138/// isn't [`TemplateOrigin::User`]: `builtin`/`catalog` rows are re-upserted from their
139/// source file on every startup (see `bhtune_db::seed`), so an HTTP edit to one would just
140/// be silently discarded on the next restart -- the same reasoning that keeps `bhtune
141/// template import` from touching them either.
142#[utoipa::path(
143    put,
144    path = "/api/templates/{name}",
145    tag = "templates",
146    params(
147        ("name" = String, Path, description = "Template name"),
148    ),
149    request_body = DcsTemplate,
150    responses(
151        (status = 200, description = "Template updated.", body = TemplateResponse),
152        (status = 400, description = "The template failed validation, or its name doesn't match the path.", body = ErrorBody),
153        (status = 404, description = "No template with that name.", body = ErrorBody),
154        (status = 409, description = "The template isn't user-owned and can't be edited over HTTP.", body = ErrorBody),
155    ),
156)]
157pub(crate) async fn update_template(
158    State(state): State<AppState>,
159    Path(name): Path<String>,
160    Json(template): Json<DcsTemplate>,
161) -> Result<Json<TemplateResponse>, ApiError> {
162    template
163        .validate()
164        .map_err(|err| ApiError::BadRequest(err.to_string()))?;
165    if template.name != name {
166        return Err(ApiError::BadRequest(format!(
167            "the template name in the request body ('{}') must match the path ('{name}'); \
168             renaming isn't supported here -- delete and recreate instead",
169            template.name
170        )));
171    }
172    let existing = DcsTemplateRow::get_by_name(&state.pool, &name)
173        .await?
174        .ok_or_else(|| ApiError::NotFound(format!("no template named '{name}'")))?;
175    match existing.origin {
176        TemplateOrigin::User => {}
177        TemplateOrigin::Builtin | TemplateOrigin::Catalog => {
178            return Err(ApiError::Conflict(format!(
179                "template '{name}' is re-seeded from its source file on every startup; \
180                 only user-created templates can be edited over HTTP"
181            )));
182        }
183    }
184    let row = DcsTemplateRow::update(&state.pool, existing.id, &template, Utc::now()).await?;
185    Ok(Json(row.into()))
186}
187
188/// Delete a template by name.
189///
190/// `DELETE /api/templates/{name}` -- 404 if no template has that name, 409 if it is still
191/// referenced by a saved loop (`bhtune_db::DbError::TemplateInUse`, mapped by
192/// `From<DbError> for ApiError`).
193#[utoipa::path(
194    delete,
195    path = "/api/templates/{name}",
196    tag = "templates",
197    params(
198        ("name" = String, Path, description = "Template name"),
199    ),
200    responses(
201        (status = 204, description = "Template deleted."),
202        (status = 404, description = "No template with that name.", body = ErrorBody),
203        (status = 409, description = "The template is still referenced by one or more saved loops.", body = ErrorBody),
204    ),
205)]
206pub(crate) async fn delete_template(
207    State(state): State<AppState>,
208    Path(name): Path<String>,
209) -> Result<StatusCode, ApiError> {
210    let row = DcsTemplateRow::get_by_name(&state.pool, &name)
211        .await?
212        .ok_or_else(|| ApiError::NotFound(format!("no template named '{name}'")))?;
213    DcsTemplateRow::delete(&state.pool, row.id).await?;
214    Ok(StatusCode::NO_CONTENT)
215}
216
217pub fn router() -> Router<AppState> {
218    Router::new()
219        .route("/api/templates", get(list_templates).post(create_template))
220        .route(
221            "/api/templates/{name}",
222            get(get_template)
223                .put(update_template)
224                .delete(delete_template),
225        )
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use axum::body::{Body, to_bytes};
232    use axum::http::{Request, StatusCode};
233    use tower::ServiceExt;
234
235    async fn body_json(response: axum::response::Response) -> serde_json::Value {
236        let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
237        serde_json::from_slice(&bytes).unwrap()
238    }
239
240    fn minimal_valid_template(name: &str) -> serde_json::Value {
241        serde_json::json!({
242            "name": name,
243            "revert_mode": true,
244            "proportional_type": "gain",
245            "integral_type": "reset_time",
246            "integral_unit": "minutes",
247            "derivative_type": "derivative_time",
248            "derivative_unit": "minutes",
249            "process_variable_suffix": ".PV",
250            "manipulated_variable_suffix": ".MV",
251            "setpoint_variable_suffix": ".SV",
252            "controller_direction_suffix": "",
253            "controller_mode_suffix": "",
254            "mode_attribute_suffix": "",
255            "upper_pv_range_suffix": ".PVHR",
256            "lower_pv_range_suffix": ".PVLR",
257            "upper_mv_range_suffix": ".MVHR",
258            "lower_mv_range_suffix": ".MVLR",
259            "proportional_constant_suffix": ".KP",
260            "integral_constant_suffix": ".TI",
261            "derivative_constant_suffix": ".TD",
262            "mode_manual_value": "1",
263            "mode_auto_value": "0",
264            "mode_attribute_program_value": null,
265            "controller_action_direct_value": "0",
266        })
267    }
268
269    #[tokio::test]
270    async fn list_returns_the_four_seeded_builtin_templates() {
271        let app = router().with_state(crate::test_support::in_memory_state().await);
272        let response = app
273            .oneshot(Request::get("/api/templates").body(Body::empty()).unwrap())
274            .await
275            .unwrap();
276        assert_eq!(response.status(), StatusCode::OK);
277        let body = body_json(response).await;
278        assert_eq!(body.as_array().unwrap().len(), 4);
279    }
280
281    #[tokio::test]
282    async fn get_by_name_returns_the_matching_template() {
283        let app = router().with_state(crate::test_support::in_memory_state().await);
284        let response = app
285            .oneshot(
286                Request::get("/api/templates/Yokogawa%20CentumVP")
287                    .body(Body::empty())
288                    .unwrap(),
289            )
290            .await
291            .unwrap();
292        assert_eq!(response.status(), StatusCode::OK);
293        let body = body_json(response).await;
294        assert_eq!(body["name"], "Yokogawa CentumVP");
295        assert_eq!(body["origin"], "builtin");
296    }
297
298    #[tokio::test]
299    async fn get_by_name_404s_for_an_unknown_name() {
300        let app = router().with_state(crate::test_support::in_memory_state().await);
301        let response = app
302            .oneshot(
303                Request::get("/api/templates/does-not-exist")
304                    .body(Body::empty())
305                    .unwrap(),
306            )
307            .await
308            .unwrap();
309        assert_eq!(response.status(), StatusCode::NOT_FOUND);
310    }
311
312    #[tokio::test]
313    async fn template_routes_propagate_database_failures_as_500() {
314        let state = crate::test_support::in_memory_state().await;
315        let app = router().with_state(state.clone());
316        state.pool.close().await;
317
318        let get_response = app
319            .clone()
320            .oneshot(
321                Request::get("/api/templates/Yokogawa%20CentumVP")
322                    .body(Body::empty())
323                    .unwrap(),
324            )
325            .await
326            .unwrap();
327        assert_eq!(get_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
328
329        let body = minimal_valid_template("Database Failure");
330        let create_response = app
331            .clone()
332            .oneshot(
333                Request::post("/api/templates")
334                    .header("content-type", "application/json")
335                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
336                    .unwrap(),
337            )
338            .await
339            .unwrap();
340        assert_eq!(create_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
341
342        let update_response = app
343            .clone()
344            .oneshot(
345                Request::put("/api/templates/Database%20Failure")
346                    .header("content-type", "application/json")
347                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
348                    .unwrap(),
349            )
350            .await
351            .unwrap();
352        assert_eq!(update_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
353
354        let delete_response = app
355            .oneshot(
356                Request::delete("/api/templates/Yokogawa%20CentumVP")
357                    .body(Body::empty())
358                    .unwrap(),
359            )
360            .await
361            .unwrap();
362        assert_eq!(delete_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
363    }
364
365    #[tokio::test]
366    async fn create_then_get_round_trips_a_new_user_template() {
367        let app = router().with_state(crate::test_support::in_memory_state().await);
368        let body = minimal_valid_template("My Custom PLC");
369        let response = app
370            .clone()
371            .oneshot(
372                Request::post("/api/templates")
373                    .header("content-type", "application/json")
374                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
375                    .unwrap(),
376            )
377            .await
378            .unwrap();
379        assert_eq!(response.status(), StatusCode::CREATED);
380        let created = body_json(response).await;
381        assert_eq!(created["origin"], "user");
382        assert_eq!(created["name"], "My Custom PLC");
383
384        let response = app
385            .oneshot(
386                Request::get("/api/templates/My%20Custom%20PLC")
387                    .body(Body::empty())
388                    .unwrap(),
389            )
390            .await
391            .unwrap();
392        assert_eq!(response.status(), StatusCode::OK);
393    }
394
395    #[tokio::test]
396    async fn create_rejects_an_invalid_template_with_400() {
397        let app = router().with_state(crate::test_support::in_memory_state().await);
398        let mut body = minimal_valid_template("");
399        body["name"] = serde_json::json!("");
400        let response = app
401            .oneshot(
402                Request::post("/api/templates")
403                    .header("content-type", "application/json")
404                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
405                    .unwrap(),
406            )
407            .await
408            .unwrap();
409        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
410    }
411
412    #[tokio::test]
413    async fn create_rejects_a_duplicate_name_with_409() {
414        let app = router().with_state(crate::test_support::in_memory_state().await);
415        let body = minimal_valid_template("Yokogawa CentumVP");
416        let response = app
417            .oneshot(
418                Request::post("/api/templates")
419                    .header("content-type", "application/json")
420                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
421                    .unwrap(),
422            )
423            .await
424            .unwrap();
425        assert_eq!(response.status(), StatusCode::CONFLICT);
426    }
427
428    #[tokio::test]
429    async fn update_edits_a_user_template_and_returns_200() {
430        let app = router().with_state(crate::test_support::in_memory_state().await);
431        let created = minimal_valid_template("Editable Template");
432        let response = app
433            .clone()
434            .oneshot(
435                Request::post("/api/templates")
436                    .header("content-type", "application/json")
437                    .body(Body::from(serde_json::to_vec(&created).unwrap()))
438                    .unwrap(),
439            )
440            .await
441            .unwrap();
442        assert_eq!(response.status(), StatusCode::CREATED);
443
444        let mut updated = created;
445        updated["process_variable_suffix"] = serde_json::json!(".PVNEW");
446        let response = app
447            .clone()
448            .oneshot(
449                Request::put("/api/templates/Editable%20Template")
450                    .header("content-type", "application/json")
451                    .body(Body::from(serde_json::to_vec(&updated).unwrap()))
452                    .unwrap(),
453            )
454            .await
455            .unwrap();
456        assert_eq!(response.status(), StatusCode::OK);
457        let body = body_json(response).await;
458        assert_eq!(body["process_variable_suffix"], ".PVNEW");
459
460        let response = app
461            .oneshot(
462                Request::get("/api/templates/Editable%20Template")
463                    .body(Body::empty())
464                    .unwrap(),
465            )
466            .await
467            .unwrap();
468        let body = body_json(response).await;
469        assert_eq!(body["process_variable_suffix"], ".PVNEW");
470    }
471
472    #[tokio::test]
473    async fn update_404s_for_an_unknown_name() {
474        let app = router().with_state(crate::test_support::in_memory_state().await);
475        let body = minimal_valid_template("does-not-exist");
476        let response = app
477            .oneshot(
478                Request::put("/api/templates/does-not-exist")
479                    .header("content-type", "application/json")
480                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
481                    .unwrap(),
482            )
483            .await
484            .unwrap();
485        assert_eq!(response.status(), StatusCode::NOT_FOUND);
486    }
487
488    #[tokio::test]
489    async fn update_rejects_a_body_name_mismatch_with_400() {
490        let app = router().with_state(crate::test_support::in_memory_state().await);
491        let created = minimal_valid_template("Mismatch Template");
492        app.clone()
493            .oneshot(
494                Request::post("/api/templates")
495                    .header("content-type", "application/json")
496                    .body(Body::from(serde_json::to_vec(&created).unwrap()))
497                    .unwrap(),
498            )
499            .await
500            .unwrap();
501
502        let mut renamed = created;
503        renamed["name"] = serde_json::json!("A Different Name");
504        let response = app
505            .oneshot(
506                Request::put("/api/templates/Mismatch%20Template")
507                    .header("content-type", "application/json")
508                    .body(Body::from(serde_json::to_vec(&renamed).unwrap()))
509                    .unwrap(),
510            )
511            .await
512            .unwrap();
513        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
514    }
515
516    #[tokio::test]
517    async fn update_rejects_editing_a_builtin_template_with_409() {
518        let app = router().with_state(crate::test_support::in_memory_state().await);
519        let body = minimal_valid_template("Yokogawa CentumVP");
520        let response = app
521            .oneshot(
522                Request::put("/api/templates/Yokogawa%20CentumVP")
523                    .header("content-type", "application/json")
524                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
525                    .unwrap(),
526            )
527            .await
528            .unwrap();
529        assert_eq!(response.status(), StatusCode::CONFLICT);
530    }
531
532    #[tokio::test]
533    async fn delete_removes_a_user_template() {
534        let app = router().with_state(crate::test_support::in_memory_state().await);
535        let body = minimal_valid_template("Deletable Template");
536        let response = app
537            .clone()
538            .oneshot(
539                Request::post("/api/templates")
540                    .header("content-type", "application/json")
541                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
542                    .unwrap(),
543            )
544            .await
545            .unwrap();
546        assert_eq!(response.status(), StatusCode::CREATED);
547
548        let response = app
549            .clone()
550            .oneshot(
551                Request::delete("/api/templates/Deletable%20Template")
552                    .body(Body::empty())
553                    .unwrap(),
554            )
555            .await
556            .unwrap();
557        assert_eq!(response.status(), StatusCode::NO_CONTENT);
558
559        let response = app
560            .oneshot(
561                Request::get("/api/templates/Deletable%20Template")
562                    .body(Body::empty())
563                    .unwrap(),
564            )
565            .await
566            .unwrap();
567        assert_eq!(response.status(), StatusCode::NOT_FOUND);
568    }
569
570    #[tokio::test]
571    async fn delete_404s_for_an_unknown_name() {
572        let app = router().with_state(crate::test_support::in_memory_state().await);
573        let response = app
574            .oneshot(
575                Request::delete("/api/templates/does-not-exist")
576                    .body(Body::empty())
577                    .unwrap(),
578            )
579            .await
580            .unwrap();
581        assert_eq!(response.status(), StatusCode::NOT_FOUND);
582    }
583}