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 the `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 the `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            "pid_rounding": { "kind": "significant_digits", "digits": 3 },
250            "process_variable_suffix": ".PV",
251            "manipulated_variable_suffix": ".MV",
252            "setpoint_variable_suffix": ".SV",
253            "controller_direction_suffix": "",
254            "controller_mode_suffix": "",
255            "mode_attribute_suffix": "",
256            "upper_pv_range_suffix": ".PVHR",
257            "lower_pv_range_suffix": ".PVLR",
258            "upper_mv_range_suffix": ".MVHR",
259            "lower_mv_range_suffix": ".MVLR",
260            "proportional_constant_suffix": ".KP",
261            "integral_constant_suffix": ".TI",
262            "derivative_constant_suffix": ".TD",
263            "mode_manual_value": "1",
264            "mode_auto_value": "0",
265            "mode_attribute_program_value": null,
266            "controller_action_direct_value": "0",
267        })
268    }
269
270    #[tokio::test]
271    async fn list_returns_the_four_seeded_builtin_templates() {
272        let app = router().with_state(crate::test_support::in_memory_state().await);
273        let response = app
274            .oneshot(Request::get("/api/templates").body(Body::empty()).unwrap())
275            .await
276            .unwrap();
277        assert_eq!(response.status(), StatusCode::OK);
278        let body = body_json(response).await;
279        assert_eq!(body.as_array().unwrap().len(), 4);
280    }
281
282    #[tokio::test]
283    async fn get_by_name_returns_the_matching_template() {
284        let app = router().with_state(crate::test_support::in_memory_state().await);
285        let response = app
286            .oneshot(
287                Request::get("/api/templates/Yokogawa%20CentumVP")
288                    .body(Body::empty())
289                    .unwrap(),
290            )
291            .await
292            .unwrap();
293        assert_eq!(response.status(), StatusCode::OK);
294        let body = body_json(response).await;
295        assert_eq!(body["name"], "Yokogawa CentumVP");
296        assert_eq!(body["origin"], "builtin");
297    }
298
299    #[tokio::test]
300    async fn get_by_name_404s_for_an_unknown_name() {
301        let app = router().with_state(crate::test_support::in_memory_state().await);
302        let response = app
303            .oneshot(
304                Request::get("/api/templates/does-not-exist")
305                    .body(Body::empty())
306                    .unwrap(),
307            )
308            .await
309            .unwrap();
310        assert_eq!(response.status(), StatusCode::NOT_FOUND);
311    }
312
313    #[tokio::test]
314    async fn template_routes_propagate_database_failures_as_500() {
315        let state = crate::test_support::in_memory_state().await;
316        let app = router().with_state(state.clone());
317        state.pool.close().await;
318
319        let get_response = app
320            .clone()
321            .oneshot(
322                Request::get("/api/templates/Yokogawa%20CentumVP")
323                    .body(Body::empty())
324                    .unwrap(),
325            )
326            .await
327            .unwrap();
328        assert_eq!(get_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
329
330        let body = minimal_valid_template("Database Failure");
331        let create_response = app
332            .clone()
333            .oneshot(
334                Request::post("/api/templates")
335                    .header("content-type", "application/json")
336                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
337                    .unwrap(),
338            )
339            .await
340            .unwrap();
341        assert_eq!(create_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
342
343        let update_response = app
344            .clone()
345            .oneshot(
346                Request::put("/api/templates/Database%20Failure")
347                    .header("content-type", "application/json")
348                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
349                    .unwrap(),
350            )
351            .await
352            .unwrap();
353        assert_eq!(update_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
354
355        let delete_response = app
356            .oneshot(
357                Request::delete("/api/templates/Yokogawa%20CentumVP")
358                    .body(Body::empty())
359                    .unwrap(),
360            )
361            .await
362            .unwrap();
363        assert_eq!(delete_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
364    }
365
366    #[tokio::test]
367    async fn create_then_get_round_trips_a_new_user_template() {
368        let app = router().with_state(crate::test_support::in_memory_state().await);
369        let body = minimal_valid_template("My Custom PLC");
370        let response = app
371            .clone()
372            .oneshot(
373                Request::post("/api/templates")
374                    .header("content-type", "application/json")
375                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
376                    .unwrap(),
377            )
378            .await
379            .unwrap();
380        assert_eq!(response.status(), StatusCode::CREATED);
381        let created = body_json(response).await;
382        assert_eq!(created["origin"], "user");
383        assert_eq!(created["name"], "My Custom PLC");
384
385        let response = app
386            .oneshot(
387                Request::get("/api/templates/My%20Custom%20PLC")
388                    .body(Body::empty())
389                    .unwrap(),
390            )
391            .await
392            .unwrap();
393        assert_eq!(response.status(), StatusCode::OK);
394    }
395
396    #[tokio::test]
397    async fn create_rejects_an_invalid_template_with_400() {
398        let app = router().with_state(crate::test_support::in_memory_state().await);
399        let mut body = minimal_valid_template("");
400        body["name"] = serde_json::json!("");
401        let response = app
402            .oneshot(
403                Request::post("/api/templates")
404                    .header("content-type", "application/json")
405                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
406                    .unwrap(),
407            )
408            .await
409            .unwrap();
410        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
411    }
412
413    #[tokio::test]
414    async fn create_rejects_a_duplicate_name_with_409() {
415        let app = router().with_state(crate::test_support::in_memory_state().await);
416        let body = minimal_valid_template("Yokogawa CentumVP");
417        let response = app
418            .oneshot(
419                Request::post("/api/templates")
420                    .header("content-type", "application/json")
421                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
422                    .unwrap(),
423            )
424            .await
425            .unwrap();
426        assert_eq!(response.status(), StatusCode::CONFLICT);
427    }
428
429    #[tokio::test]
430    async fn update_edits_a_user_template_and_returns_200() {
431        let app = router().with_state(crate::test_support::in_memory_state().await);
432        let created = minimal_valid_template("Editable Template");
433        let response = app
434            .clone()
435            .oneshot(
436                Request::post("/api/templates")
437                    .header("content-type", "application/json")
438                    .body(Body::from(serde_json::to_vec(&created).unwrap()))
439                    .unwrap(),
440            )
441            .await
442            .unwrap();
443        assert_eq!(response.status(), StatusCode::CREATED);
444
445        let mut updated = created;
446        updated["process_variable_suffix"] = serde_json::json!(".PVNEW");
447        let response = app
448            .clone()
449            .oneshot(
450                Request::put("/api/templates/Editable%20Template")
451                    .header("content-type", "application/json")
452                    .body(Body::from(serde_json::to_vec(&updated).unwrap()))
453                    .unwrap(),
454            )
455            .await
456            .unwrap();
457        assert_eq!(response.status(), StatusCode::OK);
458        let body = body_json(response).await;
459        assert_eq!(body["process_variable_suffix"], ".PVNEW");
460
461        let response = app
462            .oneshot(
463                Request::get("/api/templates/Editable%20Template")
464                    .body(Body::empty())
465                    .unwrap(),
466            )
467            .await
468            .unwrap();
469        let body = body_json(response).await;
470        assert_eq!(body["process_variable_suffix"], ".PVNEW");
471    }
472
473    #[tokio::test]
474    async fn update_404s_for_an_unknown_name() {
475        let app = router().with_state(crate::test_support::in_memory_state().await);
476        let body = minimal_valid_template("does-not-exist");
477        let response = app
478            .oneshot(
479                Request::put("/api/templates/does-not-exist")
480                    .header("content-type", "application/json")
481                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
482                    .unwrap(),
483            )
484            .await
485            .unwrap();
486        assert_eq!(response.status(), StatusCode::NOT_FOUND);
487    }
488
489    #[tokio::test]
490    async fn update_rejects_a_body_name_mismatch_with_400() {
491        let app = router().with_state(crate::test_support::in_memory_state().await);
492        let created = minimal_valid_template("Mismatch Template");
493        app.clone()
494            .oneshot(
495                Request::post("/api/templates")
496                    .header("content-type", "application/json")
497                    .body(Body::from(serde_json::to_vec(&created).unwrap()))
498                    .unwrap(),
499            )
500            .await
501            .unwrap();
502
503        let mut renamed = created;
504        renamed["name"] = serde_json::json!("A Different Name");
505        let response = app
506            .oneshot(
507                Request::put("/api/templates/Mismatch%20Template")
508                    .header("content-type", "application/json")
509                    .body(Body::from(serde_json::to_vec(&renamed).unwrap()))
510                    .unwrap(),
511            )
512            .await
513            .unwrap();
514        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
515    }
516
517    #[tokio::test]
518    async fn update_rejects_editing_a_builtin_template_with_409() {
519        let app = router().with_state(crate::test_support::in_memory_state().await);
520        let body = minimal_valid_template("Yokogawa CentumVP");
521        let response = app
522            .oneshot(
523                Request::put("/api/templates/Yokogawa%20CentumVP")
524                    .header("content-type", "application/json")
525                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
526                    .unwrap(),
527            )
528            .await
529            .unwrap();
530        assert_eq!(response.status(), StatusCode::CONFLICT);
531    }
532
533    #[tokio::test]
534    async fn delete_removes_a_user_template() {
535        let app = router().with_state(crate::test_support::in_memory_state().await);
536        let body = minimal_valid_template("Deletable Template");
537        let response = app
538            .clone()
539            .oneshot(
540                Request::post("/api/templates")
541                    .header("content-type", "application/json")
542                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
543                    .unwrap(),
544            )
545            .await
546            .unwrap();
547        assert_eq!(response.status(), StatusCode::CREATED);
548
549        let response = app
550            .clone()
551            .oneshot(
552                Request::delete("/api/templates/Deletable%20Template")
553                    .body(Body::empty())
554                    .unwrap(),
555            )
556            .await
557            .unwrap();
558        assert_eq!(response.status(), StatusCode::NO_CONTENT);
559
560        let response = app
561            .oneshot(
562                Request::get("/api/templates/Deletable%20Template")
563                    .body(Body::empty())
564                    .unwrap(),
565            )
566            .await
567            .unwrap();
568        assert_eq!(response.status(), StatusCode::NOT_FOUND);
569    }
570
571    #[tokio::test]
572    async fn delete_404s_for_an_unknown_name() {
573        let app = router().with_state(crate::test_support::in_memory_state().await);
574        let response = app
575            .oneshot(
576                Request::delete("/api/templates/does-not-exist")
577                    .body(Body::empty())
578                    .unwrap(),
579            )
580            .await
581            .unwrap();
582        assert_eq!(response.status(), StatusCode::NOT_FOUND);
583    }
584}