lmrc_http_common/
health.rs

1//! Health check utilities
2//!
3//! This module provides standardized health check functionality for HTTP services.
4//!
5//! ## Example
6//!
7//! ```rust
8//! use lmrc_http_common::health::{HealthStatus, Status, CheckResult};
9//! use std::collections::HashMap;
10//!
11//! async fn check_app_health() -> HealthStatus {
12//!     let mut checks = HashMap::new();
13//!
14//!     checks.insert(
15//!         "cache".to_string(),
16//!         CheckResult {
17//!             status: Status::Healthy,
18//!             message: Some("Redis connected".to_string()),
19//!             duration_ms: 5,
20//!         }
21//!     );
22//!
23//!     HealthStatus {
24//!         status: Status::Healthy,
25//!         version: env!("CARGO_PKG_VERSION").to_string(),
26//!         checks,
27//!     }
28//! }
29//! ```
30
31use async_trait::async_trait;
32use axum::{http::StatusCode, response::IntoResponse, Json};
33use serde::{Deserialize, Serialize};
34use std::collections::HashMap;
35use std::sync::Arc;
36use std::time::Instant;
37
38/// Overall health status
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "lowercase")]
41pub enum Status {
42    /// All checks passed
43    Healthy,
44    /// Some checks failed but service is still operational
45    Degraded,
46    /// Critical checks failed, service is not operational
47    Unhealthy,
48}
49
50impl Status {
51    /// Get the HTTP status code for this health status
52    pub fn status_code(&self) -> StatusCode {
53        match self {
54            Status::Healthy => StatusCode::OK,
55            Status::Degraded => StatusCode::OK, // Still return 200 for degraded
56            Status::Unhealthy => StatusCode::SERVICE_UNAVAILABLE,
57        }
58    }
59}
60
61/// Result of a single health check
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct CheckResult {
64    /// Status of this check
65    pub status: Status,
66    /// Optional message with details
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub message: Option<String>,
69    /// Time taken to perform the check in milliseconds
70    pub duration_ms: u64,
71}
72
73impl CheckResult {
74    /// Create a healthy check result
75    pub fn healthy() -> Self {
76        Self {
77            status: Status::Healthy,
78            message: None,
79            duration_ms: 0,
80        }
81    }
82
83    /// Create a healthy check result with a message
84    pub fn healthy_with_message(message: impl Into<String>) -> Self {
85        Self {
86            status: Status::Healthy,
87            message: Some(message.into()),
88            duration_ms: 0,
89        }
90    }
91
92    /// Create an unhealthy check result
93    pub fn unhealthy(message: impl Into<String>) -> Self {
94        Self {
95            status: Status::Unhealthy,
96            message: Some(message.into()),
97            duration_ms: 0,
98        }
99    }
100
101    /// Create a degraded check result
102    pub fn degraded(message: impl Into<String>) -> Self {
103        Self {
104            status: Status::Degraded,
105            message: Some(message.into()),
106            duration_ms: 0,
107        }
108    }
109
110    /// Set the duration in milliseconds
111    pub fn with_duration(mut self, duration_ms: u64) -> Self {
112        self.duration_ms = duration_ms;
113        self
114    }
115}
116
117/// Complete health status response
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct HealthStatus {
120    /// Overall status
121    pub status: Status,
122    /// Application version
123    pub version: String,
124    /// Individual check results
125    pub checks: HashMap<String, CheckResult>,
126}
127
128impl HealthStatus {
129    /// Create a new health status
130    pub fn new(version: impl Into<String>) -> Self {
131        Self {
132            status: Status::Healthy,
133            version: version.into(),
134            checks: HashMap::new(),
135        }
136    }
137
138    /// Add a check result
139    pub fn with_check(mut self, name: impl Into<String>, result: CheckResult) -> Self {
140        self.checks.insert(name.into(), result);
141        self.update_overall_status();
142        self
143    }
144
145    /// Calculate and update the overall status based on individual checks
146    fn update_overall_status(&mut self) {
147        if self.checks.is_empty() {
148            self.status = Status::Healthy;
149            return;
150        }
151
152        let has_unhealthy = self.checks.values().any(|c| c.status == Status::Unhealthy);
153        let has_degraded = self.checks.values().any(|c| c.status == Status::Degraded);
154
155        self.status = if has_unhealthy {
156            Status::Unhealthy
157        } else if has_degraded {
158            Status::Degraded
159        } else {
160            Status::Healthy
161        };
162    }
163
164    /// Get the HTTP status code for this health status
165    pub fn status_code(&self) -> StatusCode {
166        self.status.status_code()
167    }
168}
169
170impl IntoResponse for HealthStatus {
171    fn into_response(self) -> axum::response::Response {
172        let status_code = self.status_code();
173        (status_code, Json(self)).into_response()
174    }
175}
176
177/// Trait for implementing health checks
178#[async_trait]
179pub trait HealthCheck: Send + Sync {
180    /// Perform the health check
181    async fn check(&self) -> CheckResult;
182
183    /// Get the name of this health check
184    fn name(&self) -> &str;
185}
186
187/// Health check aggregator
188pub struct HealthChecker {
189    checks: Vec<Arc<dyn HealthCheck>>,
190    version: String,
191}
192
193impl HealthChecker {
194    /// Create a new health checker
195    pub fn new(version: impl Into<String>) -> Self {
196        Self {
197            checks: Vec::new(),
198            version: version.into(),
199        }
200    }
201
202    /// Add a health check
203    pub fn add_check(mut self, check: Arc<dyn HealthCheck>) -> Self {
204        self.checks.push(check);
205        self
206    }
207
208    /// Run all health checks and return the overall status
209    pub async fn check_health(&self) -> HealthStatus {
210        let mut status = HealthStatus::new(&self.version);
211
212        for check in &self.checks {
213            let start = Instant::now();
214            let mut result = check.check().await;
215            result.duration_ms = start.elapsed().as_millis() as u64;
216
217            status = status.with_check(check.name(), result);
218        }
219
220        status
221    }
222}
223
224/// Simple always-healthy check (for testing or minimal setups)
225pub struct AlwaysHealthyCheck {
226    name: String,
227}
228
229impl AlwaysHealthyCheck {
230    pub fn new(name: impl Into<String>) -> Self {
231        Self { name: name.into() }
232    }
233}
234
235#[async_trait]
236impl HealthCheck for AlwaysHealthyCheck {
237    async fn check(&self) -> CheckResult {
238        CheckResult::healthy()
239    }
240
241    fn name(&self) -> &str {
242        &self.name
243    }
244}
245
246/// Axum handler for health checks
247///
248/// ## Example
249///
250/// ```rust
251/// use axum::{Router, routing::get};
252/// use lmrc_http_common::health::{health_handler, HealthChecker, AlwaysHealthyCheck};
253/// use std::sync::Arc;
254///
255/// # async fn example() {
256/// let checker = Arc::new(
257///     HealthChecker::new(env!("CARGO_PKG_VERSION"))
258///         .add_check(Arc::new(AlwaysHealthyCheck::new("system")))
259/// );
260///
261/// let app: Router<Arc<HealthChecker>> = Router::new()
262///     .route("/health", get(health_handler))
263///     .with_state(checker);
264/// # }
265/// ```
266pub async fn health_handler(
267    axum::extract::State(checker): axum::extract::State<Arc<HealthChecker>>,
268) -> impl IntoResponse {
269    checker.check_health().await
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_status_code() {
278        assert_eq!(Status::Healthy.status_code(), StatusCode::OK);
279        assert_eq!(Status::Degraded.status_code(), StatusCode::OK);
280        assert_eq!(
281            Status::Unhealthy.status_code(),
282            StatusCode::SERVICE_UNAVAILABLE
283        );
284    }
285
286    #[test]
287    fn test_check_result_builders() {
288        let healthy = CheckResult::healthy();
289        assert_eq!(healthy.status, Status::Healthy);
290        assert!(healthy.message.is_none());
291
292        let unhealthy = CheckResult::unhealthy("Database down");
293        assert_eq!(unhealthy.status, Status::Unhealthy);
294        assert_eq!(unhealthy.message, Some("Database down".to_string()));
295
296        let degraded = CheckResult::degraded("Cache unavailable");
297        assert_eq!(degraded.status, Status::Degraded);
298    }
299
300    #[test]
301    fn test_health_status_overall() {
302        let mut status = HealthStatus::new("1.0.0");
303
304        // All healthy
305        status = status
306            .with_check("db", CheckResult::healthy())
307            .with_check("cache", CheckResult::healthy());
308        assert_eq!(status.status, Status::Healthy);
309
310        // One degraded
311        let mut status = HealthStatus::new("1.0.0");
312        status = status
313            .with_check("db", CheckResult::healthy())
314            .with_check("cache", CheckResult::degraded("Slow response"));
315        assert_eq!(status.status, Status::Degraded);
316
317        // One unhealthy
318        let mut status = HealthStatus::new("1.0.0");
319        status = status
320            .with_check("db", CheckResult::unhealthy("Connection refused"))
321            .with_check("cache", CheckResult::healthy());
322        assert_eq!(status.status, Status::Unhealthy);
323    }
324
325    #[tokio::test]
326    async fn test_health_checker() {
327        let checker = HealthChecker::new("1.0.0")
328            .add_check(Arc::new(AlwaysHealthyCheck::new("test")));
329
330        let health = checker.check_health().await;
331        assert_eq!(health.status, Status::Healthy);
332        assert_eq!(health.version, "1.0.0");
333        assert!(health.checks.contains_key("test"));
334    }
335
336    #[tokio::test]
337    async fn test_always_healthy_check() {
338        let check = AlwaysHealthyCheck::new("test");
339        assert_eq!(check.name(), "test");
340
341        let result = check.check().await;
342        assert_eq!(result.status, Status::Healthy);
343    }
344}