Skip to main content

bestool_alertd/http_server/endpoints/
validate.rs

1use axum::{Json, http::StatusCode, response::IntoResponse};
2
3use crate::{
4	alert::{AlertDefinition, TicketSource},
5	http_server::types::{ValidationInfo, ValidationResponse},
6};
7
8pub async fn handle_validate(body: String) -> impl IntoResponse {
9	// Try to parse as YAML with serde_path_to_error for better error messages
10	let deserializer = serde_yaml::Deserializer::from_str(&body);
11	let alert: AlertDefinition = match serde_path_to_error::deserialize(deserializer) {
12		Ok(alert) => alert,
13		Err(err) => {
14			// Parse error - return detailed error information
15			let path = err.path().to_string();
16			let inner = err.into_inner();
17			let error_msg = format!("{}", inner);
18
19			// The inner error is already a serde_yaml::Error, extract location if available
20			// Note: serde_yaml::Error doesn't expose location() in all cases
21			let response = ValidationResponse {
22				valid: false,
23				error: Some(format!("Parse error at '{}': {}", path, error_msg)),
24				error_location: None, // Location info is included in the error message
25				info: None,
26			};
27
28			return (StatusCode::OK, Json(response)).into_response();
29		}
30	};
31
32	// Validate templates BEFORE normalizing (normalization clears send targets)
33	if let Err(err) = validate_templates(&alert) {
34		let response = ValidationResponse {
35			valid: false,
36			error: Some(format!("Template validation error: {:#}", err)),
37			error_location: None,
38			info: None,
39		};
40
41		return (StatusCode::OK, Json(response)).into_response();
42	}
43
44	// Try to normalize the alert (this validates send targets and other fields)
45	let external_targets = std::collections::HashMap::new();
46	match alert.normalise(&external_targets) {
47		Ok((alert, resolved_targets)) => {
48			let source_type = match &alert.source {
49				TicketSource::Sql { .. } => "sql",
50				TicketSource::Shell { .. } => "shell",
51				TicketSource::Event { .. } => "event",
52				TicketSource::None => "none",
53			}
54			.to_string();
55
56			let response = ValidationResponse {
57				valid: true,
58				error: None,
59				error_location: None,
60				info: Some(ValidationInfo {
61					enabled: alert.enabled,
62					interval: alert.interval.clone(),
63					source_type,
64					targets: resolved_targets.len(),
65				}),
66			};
67
68			(StatusCode::OK, Json(response)).into_response()
69		}
70		Err(err) => {
71			// Normalization error (e.g., invalid interval, missing targets)
72			let response = ValidationResponse {
73				valid: false,
74				error: Some(format!("Validation error: {:#}", err)),
75				error_location: None,
76				info: None,
77			};
78
79			(StatusCode::OK, Json(response)).into_response()
80		}
81	}
82}
83
84fn validate_templates(alert: &AlertDefinition) -> miette::Result<()> {
85	use crate::templates;
86	use miette::Context as _;
87
88	// Validate each send target's templates by compiling them
89	// We only compile, not render, because we don't know the actual data structure
90	// that will be available at runtime (e.g., SQL column names, shell output format)
91	// Compilation catches syntax errors, which is the main goal
92	for (idx, target) in alert.send.iter().enumerate() {
93		// Load and compile templates for this target
94		// This will catch syntax errors like mismatched tags, invalid filters, etc.
95		templates::load_templates(target.subject(), target.template())
96			.wrap_err_with(|| format!("validating templates for send target #{}", idx + 1))?;
97	}
98
99	Ok(())
100}
101
102#[cfg(test)]
103mod tests {
104	use axum::{http::StatusCode, response::IntoResponse};
105
106	use super::*;
107
108	#[tokio::test]
109	async fn test_validate_valid_sql_alert() {
110		let yaml = r#"
111sql: "SELECT 1"
112send:
113  - id: test
114    subject: Test
115    template: Test
116"#;
117
118		let response = handle_validate(yaml.to_string()).await.into_response();
119
120		assert_eq!(response.status(), StatusCode::OK);
121		let body = axum::body::to_bytes(response.into_body(), usize::MAX)
122			.await
123			.unwrap();
124		let validation: ValidationResponse = serde_json::from_slice(&body).unwrap();
125
126		assert!(validation.valid);
127		assert!(validation.info.is_some());
128	}
129
130	#[tokio::test]
131	async fn test_validate_valid_shell_alert() {
132		let yaml = r#"
133shell: uptime
134run: uptime
135send:
136  - id: test
137    subject: Test
138    template: Test
139"#;
140
141		let response = handle_validate(yaml.to_string()).await.into_response();
142
143		assert_eq!(response.status(), StatusCode::OK);
144		let body = axum::body::to_bytes(response.into_body(), usize::MAX)
145			.await
146			.unwrap();
147		let validation: ValidationResponse = serde_json::from_slice(&body).unwrap();
148
149		assert!(validation.valid);
150		assert!(validation.info.is_some());
151	}
152
153	#[tokio::test]
154	async fn test_validate_event_alert() {
155		let yaml = r#"
156event: source-error
157send:
158  - id: test
159    subject: Test
160    template: Test
161"#;
162
163		let response = handle_validate(yaml.to_string()).await.into_response();
164
165		assert_eq!(response.status(), StatusCode::OK);
166		let body = axum::body::to_bytes(response.into_body(), usize::MAX)
167			.await
168			.unwrap();
169		let validation: ValidationResponse = serde_json::from_slice(&body).unwrap();
170
171		assert!(validation.valid);
172		assert!(validation.info.is_some());
173	}
174
175	#[tokio::test]
176	async fn test_validate_invalid_yaml() {
177		let yaml = "this is: not: valid: yaml:";
178
179		let response = handle_validate(yaml.to_string()).await.into_response();
180
181		assert_eq!(response.status(), StatusCode::OK);
182		let body = axum::body::to_bytes(response.into_body(), usize::MAX)
183			.await
184			.unwrap();
185		let validation: ValidationResponse = serde_json::from_slice(&body).unwrap();
186
187		assert!(!validation.valid);
188		assert!(validation.error.is_some());
189	}
190
191	#[tokio::test]
192	async fn test_validate_template_syntax_error() {
193		let yaml = r#"
194sql: "SELECT 1"
195send:
196  - id: test
197    subject: Test
198    template: "{{ unclosed tag"
199"#;
200
201		let response = handle_validate(yaml.to_string()).await.into_response();
202
203		assert_eq!(response.status(), StatusCode::OK);
204		let body = axum::body::to_bytes(response.into_body(), usize::MAX)
205			.await
206			.unwrap();
207		let validation: ValidationResponse = serde_json::from_slice(&body).unwrap();
208
209		assert!(!validation.valid);
210		assert!(validation.error.is_some());
211		assert!(validation.error.unwrap().contains("Template"));
212	}
213
214	#[tokio::test]
215	async fn test_validate_template_mismatched_tags() {
216		let yaml = r#"
217sql: "SELECT 1"
218send:
219  - id: test
220    subject: Test
221    template: "{% if foo %}bar"
222"#;
223
224		let response = handle_validate(yaml.to_string()).await.into_response();
225
226		assert_eq!(response.status(), StatusCode::OK);
227		let body = axum::body::to_bytes(response.into_body(), usize::MAX)
228			.await
229			.unwrap();
230		let validation: ValidationResponse = serde_json::from_slice(&body).unwrap();
231
232		assert!(!validation.valid);
233		assert!(validation.error.is_some());
234	}
235
236	#[tokio::test]
237	async fn test_validate_multiple_targets() {
238		let yaml = r#"
239sql: "SELECT 1"
240send:
241  - id: test1
242    subject: Test 1
243    template: Test 1
244  - id: test2
245    subject: Test 2
246    template: Test 2
247"#;
248
249		let response = handle_validate(yaml.to_string()).await.into_response();
250
251		assert_eq!(response.status(), StatusCode::OK);
252		let body = axum::body::to_bytes(response.into_body(), usize::MAX)
253			.await
254			.unwrap();
255		let validation: ValidationResponse = serde_json::from_slice(&body).unwrap();
256
257		assert!(validation.valid);
258		let info = validation.info.as_ref().unwrap();
259		// Should have 0 targets because we don't provide external targets
260		assert_eq!(info.targets, 0);
261	}
262}