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 "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}