1use 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#[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#[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#[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#[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#[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#[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}