//! HTTP health probing: issues the request, applies the configured JSON //! expectations, and classifies the response into a `HealthStatus`. use std::time::Instant; use tracing::instrument; use crate::config::{HealthConfig, HealthExpectation}; use crate::types::{HealthDetails, HealthSnapshot, HealthStatus}; /// Maximum response body size we'll read into memory (10 MB). const MAX_RESPONSE_BYTES: u64 = 10 * 1024 * 1024; #[instrument(skip_all)] pub async fn check_health( target_name: &str, config: &HealthConfig, expect: Option<&HealthExpectation>, ) -> HealthSnapshot { let checked_at = chrono::Utc::now().to_rfc3339(); // reqwest 0.13's builder can genuinely fail (platform trust-store load), and // `Client::new()` panics on that same failure, so return an Unreachable result // instead of a fallback client that discards the timeout or panics. let client = match crate::tls::https_client_builder() .timeout(std::time::Duration::from_secs(config.timeout_secs)) .build() { Ok(c) => c, Err(e) => { return HealthSnapshot { id: None, target: target_name.to_string(), status: HealthStatus::Unreachable, checked_at, response_time_ms: 0, details: None, error: Some(format!("client build: {e}")), }; } }; let start = Instant::now(); match client.get(&config.url).send().await { Ok(response) => { let response_time_ms = start.elapsed().as_millis() as i64; let status_code = response.status().as_u16(); // Reject responses that declare a content-length exceeding our limit. if let Some(len) = response.content_length() && len > MAX_RESPONSE_BYTES { return HealthSnapshot { id: None, target: target_name.to_string(), status: HealthStatus::Degraded, checked_at, response_time_ms, details: None, error: Some(format!( "Response body too large: {len} bytes (limit: {MAX_RESPONSE_BYTES} bytes)" )), }; } // Read body with size cap (handles chunked/streaming responses without content-length). let body_result = match response.bytes().await { Ok(bytes) => { if bytes.len() as u64 > MAX_RESPONSE_BYTES { Err(format!( "Response body too large: {} bytes (limit: {MAX_RESPONSE_BYTES} bytes)", bytes.len() )) } else { String::from_utf8(bytes.to_vec()) .map_err(|e| format!("Response body not valid UTF-8: {e}")) } } Err(e) => Err(format!("Failed to read response body: {e}")), }; match body_result { Ok(body) => { let json: Option = serde_json::from_str(&body).ok(); let (mut status, details, mut error) = if let Some(ref json) = json { let (s, d) = classify_json_response(status_code, json); (s, Some(d), None) } else { ( classify_non_json(status_code), None, Some("Failed to parse response as JSON".to_string()), ) }; // Apply expectation validation if let Some(exp) = expect { let failures = validate_expectations(exp, status_code, &body, json.as_ref()); if !failures.is_empty() { status = HealthStatus::Degraded; error = Some(failures.join("; ")); } else if json.is_none() { // Non-JSON response but all expectations passed, treat as operational status = HealthStatus::Operational; error = None; } } HealthSnapshot { id: None, target: target_name.to_string(), status, checked_at, response_time_ms, details, error, } } Err(e) => HealthSnapshot { id: None, target: target_name.to_string(), status: HealthStatus::Degraded, checked_at, response_time_ms, details: None, error: Some(e), }, } } Err(e) => { let response_time_ms = start.elapsed().as_millis() as i64; HealthSnapshot { id: None, target: target_name.to_string(), status: HealthStatus::Unreachable, checked_at, response_time_ms, details: None, error: Some(format!("{e}")), } } } } /// Walk a dot-separated path through nested JSON objects. pub fn resolve_json_path<'a>( value: &'a serde_json::Value, path: &str, ) -> Option<&'a serde_json::Value> { let mut current = value; for key in path.split('.') { current = current.get(key)?; } Some(current) } /// Validate response against expectations. Returns a list of failure descriptions. pub fn validate_expectations( expect: &HealthExpectation, status_code: u16, body: &str, json: Option<&serde_json::Value>, ) -> Vec { let mut failures = Vec::new(); if let Some(expected_code) = expect.status_code && status_code != expected_code { failures.push(format!( "expected status {expected_code}, got {status_code}" )); } if let Some(ref substring) = expect.body_contains && !body.contains(substring.as_str()) { failures.push(format!("body missing expected substring \"{substring}\"")); } if !expect.json_fields.is_empty() { if let Some(json) = json { for (path, expected_value) in &expect.json_fields { match resolve_json_path(json, path) { Some(actual) => { let actual_str: std::borrow::Cow<'_, str> = match actual { serde_json::Value::String(s) => std::borrow::Cow::Borrowed(s), other => std::borrow::Cow::Owned(other.to_string()), }; if *actual_str != *expected_value { failures.push(format!("json field \"{path}\": expected \"{expected_value}\", got \"{actual_str}\"")); } } None => { failures.push(format!("json field \"{path}\" not found")); } } } } else { failures.push("expected JSON response for field validation, got non-JSON".to_string()); } } failures } /// Classify a JSON health response into status + details. pub fn classify_json_response( status_code: u16, json: &serde_json::Value, ) -> (HealthStatus, HealthDetails) { let api_status = json .get("status") .and_then(|s| s.as_str()) .unwrap_or("unknown"); let status = match api_status { "operational" => HealthStatus::Operational, "degraded" => HealthStatus::Degraded, _ if (200..300).contains(&status_code) => HealthStatus::Degraded, _ => HealthStatus::Error, }; let details = HealthDetails { version: json .get("version") .and_then(|v| v.as_str()) .map(String::from), git_sha: json .get("git_sha") .and_then(|v| v.as_str()) .map(String::from), uptime: json .get("uptime") .and_then(|v| v.as_str()) .map(String::from), checks: json.get("checks").cloned(), monitoring: json.get("monitoring").cloned(), }; (status, details) } /// Classify a response that couldn't be parsed as JSON. pub fn classify_non_json(status_code: u16) -> HealthStatus { if (200..300).contains(&status_code) { HealthStatus::Degraded } else { HealthStatus::Error } } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; #[test] fn classify_operational() { let json = serde_json::json!({ "status": "operational", "version": "2.1.0", "uptime": "3d 12h", }); let (status, details) = classify_json_response(200, &json); assert_eq!(status, HealthStatus::Operational); assert_eq!(details.version.as_deref(), Some("2.1.0")); assert_eq!(details.uptime.as_deref(), Some("3d 12h")); } #[test] fn classify_degraded_explicit() { let json = serde_json::json!({ "status": "degraded" }); let (status, _) = classify_json_response(200, &json); assert_eq!(status, HealthStatus::Degraded); } #[test] fn classify_unknown_status_with_success_code() { let json = serde_json::json!({ "status": "starting_up" }); let (status, _) = classify_json_response(200, &json); assert_eq!(status, HealthStatus::Degraded); } #[test] fn classify_unknown_status_with_error_code() { let json = serde_json::json!({ "status": "starting_up" }); let (status, _) = classify_json_response(503, &json); assert_eq!(status, HealthStatus::Error); } #[test] fn classify_missing_status_field() { let json = serde_json::json!({ "version": "1.0.0" }); let (status, details) = classify_json_response(200, &json); assert_eq!(status, HealthStatus::Degraded); // "unknown" falls through assert_eq!(details.version.as_deref(), Some("1.0.0")); } #[test] fn classify_extracts_checks_and_monitoring() { let json = serde_json::json!({ "status": "operational", "checks": { "db": "ok", "redis": "ok" }, "monitoring": { "external": true }, }); let (_, details) = classify_json_response(200, &json); assert!(details.checks.is_some()); assert!(details.monitoring.is_some()); } #[test] fn classify_non_json_success() { assert_eq!(classify_non_json(200), HealthStatus::Degraded); assert_eq!(classify_non_json(204), HealthStatus::Degraded); } #[test] fn classify_non_json_error() { assert_eq!(classify_non_json(500), HealthStatus::Error); assert_eq!(classify_non_json(404), HealthStatus::Error); } // resolve_json_path #[test] fn resolve_json_path_top_level() { let json = serde_json::json!({"status": "operational"}); let val = resolve_json_path(&json, "status").unwrap(); assert_eq!(val, "operational"); } #[test] fn resolve_json_path_nested() { let json = serde_json::json!({"checks": {"db": "ok", "redis": "warn"}}); let val = resolve_json_path(&json, "checks.db").unwrap(); assert_eq!(val, "ok"); } #[test] fn resolve_json_path_deeply_nested() { let json = serde_json::json!({"a": {"b": {"c": 42}}}); let val = resolve_json_path(&json, "a.b.c").unwrap(); assert_eq!(val, 42); } #[test] fn resolve_json_path_missing() { let json = serde_json::json!({"status": "operational"}); assert!(resolve_json_path(&json, "missing").is_none()); } #[test] fn resolve_json_path_partial_missing() { let json = serde_json::json!({"checks": {"db": "ok"}}); assert!(resolve_json_path(&json, "checks.redis").is_none()); } // validate_expectations #[test] fn validate_status_code_match() { let expect = HealthExpectation { status_code: Some(200), ..Default::default() }; let failures = validate_expectations(&expect, 200, "", None); assert!(failures.is_empty()); } #[test] fn validate_status_code_mismatch() { let expect = HealthExpectation { status_code: Some(200), ..Default::default() }; let failures = validate_expectations(&expect, 503, "", None); assert_eq!(failures.len(), 1); assert!(failures[0].contains("expected status 200")); assert!(failures[0].contains("got 503")); } #[test] fn validate_body_contains_match() { let expect = HealthExpectation { body_contains: Some("operational".to_string()), ..Default::default() }; let failures = validate_expectations(&expect, 200, r#"{"status":"operational"}"#, None); assert!(failures.is_empty()); } #[test] fn validate_body_contains_mismatch() { let expect = HealthExpectation { body_contains: Some("operational".to_string()), ..Default::default() }; let failures = validate_expectations(&expect, 200, r#"{"status":"error"}"#, None); assert_eq!(failures.len(), 1); assert!(failures[0].contains("body missing")); } #[test] fn validate_json_fields_match() { let mut fields = HashMap::new(); fields.insert("status".to_string(), "operational".to_string()); fields.insert("checks.db".to_string(), "ok".to_string()); let expect = HealthExpectation { json_fields: fields, ..Default::default() }; let json = serde_json::json!({"status": "operational", "checks": {"db": "ok"}}); let failures = validate_expectations(&expect, 200, "", Some(&json)); assert!(failures.is_empty()); } #[test] fn validate_json_fields_mismatch() { let mut fields = HashMap::new(); fields.insert("status".to_string(), "operational".to_string()); let expect = HealthExpectation { json_fields: fields, ..Default::default() }; let json = serde_json::json!({"status": "degraded"}); let failures = validate_expectations(&expect, 200, "", Some(&json)); assert_eq!(failures.len(), 1); assert!(failures[0].contains("expected \"operational\"")); assert!(failures[0].contains("got \"degraded\"")); } #[test] fn validate_json_field_missing() { let mut fields = HashMap::new(); fields.insert("checks.redis".to_string(), "ok".to_string()); let expect = HealthExpectation { json_fields: fields, ..Default::default() }; let json = serde_json::json!({"checks": {"db": "ok"}}); let failures = validate_expectations(&expect, 200, "", Some(&json)); assert_eq!(failures.len(), 1); assert!(failures[0].contains("not found")); } #[test] fn validate_json_fields_on_non_json() { let mut fields = HashMap::new(); fields.insert("status".to_string(), "ok".to_string()); let expect = HealthExpectation { json_fields: fields, ..Default::default() }; let failures = validate_expectations(&expect, 200, "not json", None); assert_eq!(failures.len(), 1); assert!(failures[0].contains("non-JSON")); } #[test] fn validate_mixed_failures() { let mut fields = HashMap::new(); fields.insert("status".to_string(), "operational".to_string()); let expect = HealthExpectation { status_code: Some(200), body_contains: Some("healthy".to_string()), json_fields: fields, }; let json = serde_json::json!({"status": "degraded"}); let failures = validate_expectations(&expect, 503, r#"{"status":"degraded"}"#, Some(&json)); assert_eq!(failures.len(), 3); // status code + body + json field } #[test] fn validate_empty_expectations_always_pass() { let expect = HealthExpectation::default(); let failures = validate_expectations(&expect, 500, "garbage", None); assert!(failures.is_empty()); } // classify_non_json: 200..300 range boundaries #[test] fn classify_non_json_status_boundaries() { // Pins the `(200..300).contains(&status_code)` range. assert_eq!( classify_non_json(199), HealthStatus::Error, "199 is below 2xx" ); assert_eq!( classify_non_json(200), HealthStatus::Degraded, "200 is start of 2xx" ); assert_eq!( classify_non_json(299), HealthStatus::Degraded, "299 is end of 2xx" ); assert_eq!( classify_non_json(300), HealthStatus::Error, "300 is start of 3xx" ); } #[test] fn classify_json_unknown_status_3xx_is_error() { // Pins the `_ if (200..300).contains(&status_code)` guard in // classify_json_response: status_code 300 with unknown api_status // must fall through to Error, not Degraded. let json = serde_json::json!({ "status": "starting_up" }); assert_eq!(classify_json_response(300, &json).0, HealthStatus::Error); assert_eq!(classify_json_response(199, &json).0, HealthStatus::Error); } // resolve_json_path edge cases #[test] fn resolve_json_path_empty_path_segment_is_none() { // path "a..b" splits to ["a", "", "b"]; `.get("")` returns None. let json = serde_json::json!({"a": {"b": 1}}); assert!(resolve_json_path(&json, "a..b").is_none()); } #[test] fn resolve_json_path_through_non_object_is_none() { // Trying to descend into a string value should return None. let json = serde_json::json!({"name": "hello"}); assert!(resolve_json_path(&json, "name.length").is_none()); } }