acton_htmx/health/
mod.rs

1//! Health check endpoints and handlers
2//!
3//! Provides comprehensive health checks for application monitoring, including:
4//! - Liveness probe: Is the application running?
5//! - Readiness probe: Is the application ready to serve traffic?
6//! - Database connection health
7//! - Redis connection health (if enabled)
8//! - Background job system health
9//!
10//! # Example
11//!
12//! ```rust,no_run
13//! use axum::{Router, routing::get};
14//! use acton_htmx::health::{health_check, liveness, readiness};
15//!
16//! let app = Router::new()
17//!     .route("/health", get(health_check))
18//!     .route("/health/live", get(liveness))
19//!     .route("/health/ready", get(readiness));
20//! ```
21
22use axum::{
23    http::StatusCode,
24    response::{IntoResponse, Response},
25    Json,
26};
27use serde::{Deserialize, Serialize};
28use std::collections::HashMap;
29use std::time::SystemTime;
30
31/// Health check status
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum HealthStatus {
35    /// Service is healthy and ready
36    Healthy,
37    /// Service is degraded but operational
38    Degraded,
39    /// Service is unhealthy
40    Unhealthy,
41}
42
43/// Individual component health
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ComponentHealth {
46    /// Component status
47    pub status: HealthStatus,
48    /// Optional message with details
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub message: Option<String>,
51    /// Response time in milliseconds
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub response_time_ms: Option<u64>,
54}
55
56impl ComponentHealth {
57    /// Create a healthy component
58    #[must_use]
59    pub const fn healthy() -> Self {
60        Self {
61            status: HealthStatus::Healthy,
62            message: None,
63            response_time_ms: None,
64        }
65    }
66
67    /// Create a healthy component with message
68    #[must_use]
69    pub fn healthy_with_message(message: impl Into<String>) -> Self {
70        Self {
71            status: HealthStatus::Healthy,
72            message: Some(message.into()),
73            response_time_ms: None,
74        }
75    }
76
77    /// Create a degraded component
78    #[must_use]
79    pub fn degraded(message: impl Into<String>) -> Self {
80        Self {
81            status: HealthStatus::Degraded,
82            message: Some(message.into()),
83            response_time_ms: None,
84        }
85    }
86
87    /// Create an unhealthy component
88    #[must_use]
89    pub fn unhealthy(message: impl Into<String>) -> Self {
90        Self {
91            status: HealthStatus::Unhealthy,
92            message: Some(message.into()),
93            response_time_ms: None,
94        }
95    }
96
97    /// Add response time
98    #[must_use]
99    pub const fn with_response_time(mut self, ms: u64) -> Self {
100        self.response_time_ms = Some(ms);
101        self
102    }
103}
104
105/// Overall health check response
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct HealthCheckResponse {
108    /// Overall status
109    pub status: HealthStatus,
110    /// Application version
111    pub version: String,
112    /// Timestamp of health check (Unix epoch)
113    pub timestamp: u64,
114    /// Individual component healths
115    pub components: HashMap<String, ComponentHealth>,
116}
117
118impl HealthCheckResponse {
119    /// Create new health check response
120    #[must_use]
121    pub fn new(version: impl Into<String>) -> Self {
122        Self {
123            status: HealthStatus::Healthy,
124            version: version.into(),
125            timestamp: SystemTime::now()
126                .duration_since(SystemTime::UNIX_EPOCH)
127                .map_or(0, |d| d.as_secs()),
128            components: HashMap::new(),
129        }
130    }
131
132    /// Add component health
133    pub fn add_component(&mut self, name: impl Into<String>, health: ComponentHealth) {
134        self.components.insert(name.into(), health);
135        self.recalculate_status();
136    }
137
138    /// Recalculate overall status based on components
139    fn recalculate_status(&mut self) {
140        if self.components.values().any(|c| c.status == HealthStatus::Unhealthy) {
141            self.status = HealthStatus::Unhealthy;
142        } else if self.components.values().any(|c| c.status == HealthStatus::Degraded) {
143            self.status = HealthStatus::Degraded;
144        } else {
145            self.status = HealthStatus::Healthy;
146        }
147    }
148
149    /// Get HTTP status code based on health
150    #[must_use]
151    pub const fn status_code(&self) -> StatusCode {
152        match self.status {
153            HealthStatus::Healthy | HealthStatus::Degraded => StatusCode::OK, // Still operational
154            HealthStatus::Unhealthy => StatusCode::SERVICE_UNAVAILABLE,
155        }
156    }
157}
158
159impl IntoResponse for HealthCheckResponse {
160    fn into_response(self) -> Response {
161        let status = self.status_code();
162        (status, Json(self)).into_response()
163    }
164}
165
166/// Liveness probe handler
167///
168/// Returns 200 OK if the application is running.
169/// This is a simple check that the process is alive.
170///
171/// # Example
172///
173/// ```rust,no_run
174/// use axum::{Router, routing::get};
175/// use acton_htmx::health::liveness;
176///
177/// let app = Router::new()
178///     .route("/health/live", get(liveness));
179/// ```
180#[allow(clippy::unused_async)]
181pub async fn liveness() -> impl IntoResponse {
182    (StatusCode::OK, "OK")
183}
184
185/// Readiness probe handler
186///
187/// Returns 200 OK if the application is ready to serve traffic.
188/// This is a simple default implementation that always returns ready.
189/// Override with custom readiness checks in your application.
190///
191/// # Example
192///
193/// ```rust,no_run
194/// use axum::{Router, routing::get};
195/// use acton_htmx::health::readiness;
196///
197/// let app = Router::new()
198///     .route("/health/ready", get(readiness));
199/// ```
200#[allow(clippy::unused_async)]
201pub async fn readiness() -> impl IntoResponse {
202    let mut response = HealthCheckResponse::new(env!("CARGO_PKG_VERSION"));
203    response.add_component("application", ComponentHealth::healthy());
204    response
205}
206
207/// Comprehensive health check handler
208///
209/// Returns detailed health information about all components.
210/// This is the default implementation that checks application health only.
211/// Override with custom health checks in your application.
212///
213/// # Example
214///
215/// ```rust,no_run
216/// use axum::{Router, routing::get};
217/// use acton_htmx::health::health_check;
218///
219/// let app = Router::new()
220///     .route("/health", get(health_check));
221/// ```
222#[allow(clippy::unused_async)]
223pub async fn health_check() -> impl IntoResponse {
224    let mut response = HealthCheckResponse::new(env!("CARGO_PKG_VERSION"));
225    response.add_component("application", ComponentHealth::healthy());
226    response
227}
228
229/// Create a custom health check with state
230///
231/// # Example
232///
233/// ```rust,no_run
234/// use acton_htmx::health::{health_check_with_state, ComponentHealth, HealthCheckResponse};
235/// use acton_htmx::state::ActonHtmxState;
236/// use axum::extract::State;
237///
238/// async fn custom_health(State(state): State<ActonHtmxState>) -> HealthCheckResponse {
239///     health_check_with_state(&state).await
240/// }
241/// ```
242#[allow(clippy::unused_async)]
243pub async fn health_check_with_state<S: Send + Sync>(_state: &S) -> HealthCheckResponse {
244    let mut response = HealthCheckResponse::new(env!("CARGO_PKG_VERSION"));
245    response.add_component("application", ComponentHealth::healthy());
246
247    // Add more component checks here as needed
248    // Example: database, redis, job queue, etc.
249
250    response
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn test_component_health_healthy() {
259        let health = ComponentHealth::healthy();
260        assert_eq!(health.status, HealthStatus::Healthy);
261        assert!(health.message.is_none());
262        assert!(health.response_time_ms.is_none());
263    }
264
265    #[test]
266    fn test_component_health_with_message() {
267        let health = ComponentHealth::healthy_with_message("All good");
268        assert_eq!(health.status, HealthStatus::Healthy);
269        assert_eq!(health.message, Some("All good".to_string()));
270    }
271
272    #[test]
273    fn test_component_health_degraded() {
274        let health = ComponentHealth::degraded("High latency");
275        assert_eq!(health.status, HealthStatus::Degraded);
276        assert_eq!(health.message, Some("High latency".to_string()));
277    }
278
279    #[test]
280    fn test_component_health_unhealthy() {
281        let health = ComponentHealth::unhealthy("Connection failed");
282        assert_eq!(health.status, HealthStatus::Unhealthy);
283        assert_eq!(health.message, Some("Connection failed".to_string()));
284    }
285
286    #[test]
287    fn test_component_health_with_response_time() {
288        let health = ComponentHealth::healthy().with_response_time(150);
289        assert_eq!(health.response_time_ms, Some(150));
290    }
291
292    #[test]
293    fn test_health_check_response_new() {
294        let response = HealthCheckResponse::new("1.0.0");
295        assert_eq!(response.status, HealthStatus::Healthy);
296        assert_eq!(response.version, "1.0.0");
297        assert!(response.components.is_empty());
298    }
299
300    #[test]
301    fn test_health_check_response_add_component() {
302        let mut response = HealthCheckResponse::new("1.0.0");
303        response.add_component("database", ComponentHealth::healthy());
304        assert_eq!(response.components.len(), 1);
305        assert_eq!(response.status, HealthStatus::Healthy);
306    }
307
308    #[test]
309    fn test_health_check_response_degraded_status() {
310        let mut response = HealthCheckResponse::new("1.0.0");
311        response.add_component("app", ComponentHealth::healthy());
312        response.add_component("cache", ComponentHealth::degraded("High latency"));
313        assert_eq!(response.status, HealthStatus::Degraded);
314        assert_eq!(response.status_code(), StatusCode::OK);
315    }
316
317    #[test]
318    fn test_health_check_response_unhealthy_status() {
319        let mut response = HealthCheckResponse::new("1.0.0");
320        response.add_component("app", ComponentHealth::healthy());
321        response.add_component("database", ComponentHealth::unhealthy("Connection failed"));
322        assert_eq!(response.status, HealthStatus::Unhealthy);
323        assert_eq!(response.status_code(), StatusCode::SERVICE_UNAVAILABLE);
324    }
325
326    #[tokio::test]
327    async fn test_liveness_handler() {
328        let response = liveness().await.into_response();
329        assert_eq!(response.status(), StatusCode::OK);
330    }
331
332    #[tokio::test]
333    async fn test_readiness_handler() {
334        let response = readiness().await.into_response();
335        assert_eq!(response.status(), StatusCode::OK);
336    }
337
338    #[tokio::test]
339    async fn test_health_check_handler() {
340        let response = health_check().await.into_response();
341        assert_eq!(response.status(), StatusCode::OK);
342    }
343}