lmrc-http-common 0.3.16

Common HTTP utilities and patterns for LMRC Stack applications
Documentation
//! Health check utilities
//!
//! This module provides standardized health check functionality for HTTP services.
//!
//! ## Example
//!
//! ```rust
//! use lmrc_http_common::health::{HealthStatus, Status, CheckResult};
//! use std::collections::HashMap;
//!
//! async fn check_app_health() -> HealthStatus {
//!     let mut checks = HashMap::new();
//!
//!     checks.insert(
//!         "cache".to_string(),
//!         CheckResult {
//!             status: Status::Healthy,
//!             message: Some("Redis connected".to_string()),
//!             duration_ms: 5,
//!         }
//!     );
//!
//!     HealthStatus {
//!         status: Status::Healthy,
//!         version: env!("CARGO_PKG_VERSION").to_string(),
//!         checks,
//!     }
//! }
//! ```

use async_trait::async_trait;
use axum::{http::StatusCode, response::IntoResponse, Json};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;

/// Overall health status
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
    /// All checks passed
    Healthy,
    /// Some checks failed but service is still operational
    Degraded,
    /// Critical checks failed, service is not operational
    Unhealthy,
}

impl Status {
    /// Get the HTTP status code for this health status
    pub fn status_code(&self) -> StatusCode {
        match self {
            Status::Healthy => StatusCode::OK,
            Status::Degraded => StatusCode::OK, // Still return 200 for degraded
            Status::Unhealthy => StatusCode::SERVICE_UNAVAILABLE,
        }
    }
}

/// Result of a single health check
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckResult {
    /// Status of this check
    pub status: Status,
    /// Optional message with details
    #[serde(skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
    /// Time taken to perform the check in milliseconds
    pub duration_ms: u64,
}

impl CheckResult {
    /// Create a healthy check result
    pub fn healthy() -> Self {
        Self {
            status: Status::Healthy,
            message: None,
            duration_ms: 0,
        }
    }

    /// Create a healthy check result with a message
    pub fn healthy_with_message(message: impl Into<String>) -> Self {
        Self {
            status: Status::Healthy,
            message: Some(message.into()),
            duration_ms: 0,
        }
    }

    /// Create an unhealthy check result
    pub fn unhealthy(message: impl Into<String>) -> Self {
        Self {
            status: Status::Unhealthy,
            message: Some(message.into()),
            duration_ms: 0,
        }
    }

    /// Create a degraded check result
    pub fn degraded(message: impl Into<String>) -> Self {
        Self {
            status: Status::Degraded,
            message: Some(message.into()),
            duration_ms: 0,
        }
    }

    /// Set the duration in milliseconds
    pub fn with_duration(mut self, duration_ms: u64) -> Self {
        self.duration_ms = duration_ms;
        self
    }
}

/// Complete health status response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthStatus {
    /// Overall status
    pub status: Status,
    /// Application version
    pub version: String,
    /// Individual check results
    pub checks: HashMap<String, CheckResult>,
}

impl HealthStatus {
    /// Create a new health status
    pub fn new(version: impl Into<String>) -> Self {
        Self {
            status: Status::Healthy,
            version: version.into(),
            checks: HashMap::new(),
        }
    }

    /// Add a check result
    pub fn with_check(mut self, name: impl Into<String>, result: CheckResult) -> Self {
        self.checks.insert(name.into(), result);
        self.update_overall_status();
        self
    }

    /// Calculate and update the overall status based on individual checks
    fn update_overall_status(&mut self) {
        if self.checks.is_empty() {
            self.status = Status::Healthy;
            return;
        }

        let has_unhealthy = self.checks.values().any(|c| c.status == Status::Unhealthy);
        let has_degraded = self.checks.values().any(|c| c.status == Status::Degraded);

        self.status = if has_unhealthy {
            Status::Unhealthy
        } else if has_degraded {
            Status::Degraded
        } else {
            Status::Healthy
        };
    }

    /// Get the HTTP status code for this health status
    pub fn status_code(&self) -> StatusCode {
        self.status.status_code()
    }
}

impl IntoResponse for HealthStatus {
    fn into_response(self) -> axum::response::Response {
        let status_code = self.status_code();
        (status_code, Json(self)).into_response()
    }
}

/// Trait for implementing health checks
#[async_trait]
pub trait HealthCheck: Send + Sync {
    /// Perform the health check
    async fn check(&self) -> CheckResult;

    /// Get the name of this health check
    fn name(&self) -> &str;
}

/// Health check aggregator
pub struct HealthChecker {
    checks: Vec<Arc<dyn HealthCheck>>,
    version: String,
}

impl HealthChecker {
    /// Create a new health checker
    pub fn new(version: impl Into<String>) -> Self {
        Self {
            checks: Vec::new(),
            version: version.into(),
        }
    }

    /// Add a health check
    pub fn add_check(mut self, check: Arc<dyn HealthCheck>) -> Self {
        self.checks.push(check);
        self
    }

    /// Run all health checks and return the overall status
    pub async fn check_health(&self) -> HealthStatus {
        let mut status = HealthStatus::new(&self.version);

        for check in &self.checks {
            let start = Instant::now();
            let mut result = check.check().await;
            result.duration_ms = start.elapsed().as_millis() as u64;

            status = status.with_check(check.name(), result);
        }

        status
    }
}

/// Simple always-healthy check (for testing or minimal setups)
pub struct AlwaysHealthyCheck {
    name: String,
}

impl AlwaysHealthyCheck {
    pub fn new(name: impl Into<String>) -> Self {
        Self { name: name.into() }
    }
}

#[async_trait]
impl HealthCheck for AlwaysHealthyCheck {
    async fn check(&self) -> CheckResult {
        CheckResult::healthy()
    }

    fn name(&self) -> &str {
        &self.name
    }
}

/// Axum handler for health checks
///
/// ## Example
///
/// ```rust
/// use axum::{Router, routing::get};
/// use lmrc_http_common::health::{health_handler, HealthChecker, AlwaysHealthyCheck};
/// use std::sync::Arc;
///
/// # async fn example() {
/// let checker = Arc::new(
///     HealthChecker::new(env!("CARGO_PKG_VERSION"))
///         .add_check(Arc::new(AlwaysHealthyCheck::new("system")))
/// );
///
/// let app: Router<Arc<HealthChecker>> = Router::new()
///     .route("/health", get(health_handler))
///     .with_state(checker);
/// # }
/// ```
pub async fn health_handler(
    axum::extract::State(checker): axum::extract::State<Arc<HealthChecker>>,
) -> impl IntoResponse {
    checker.check_health().await
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_status_code() {
        assert_eq!(Status::Healthy.status_code(), StatusCode::OK);
        assert_eq!(Status::Degraded.status_code(), StatusCode::OK);
        assert_eq!(
            Status::Unhealthy.status_code(),
            StatusCode::SERVICE_UNAVAILABLE
        );
    }

    #[test]
    fn test_check_result_builders() {
        let healthy = CheckResult::healthy();
        assert_eq!(healthy.status, Status::Healthy);
        assert!(healthy.message.is_none());

        let unhealthy = CheckResult::unhealthy("Database down");
        assert_eq!(unhealthy.status, Status::Unhealthy);
        assert_eq!(unhealthy.message, Some("Database down".to_string()));

        let degraded = CheckResult::degraded("Cache unavailable");
        assert_eq!(degraded.status, Status::Degraded);
    }

    #[test]
    fn test_health_status_overall() {
        let mut status = HealthStatus::new("1.0.0");

        // All healthy
        status = status
            .with_check("db", CheckResult::healthy())
            .with_check("cache", CheckResult::healthy());
        assert_eq!(status.status, Status::Healthy);

        // One degraded
        let mut status = HealthStatus::new("1.0.0");
        status = status
            .with_check("db", CheckResult::healthy())
            .with_check("cache", CheckResult::degraded("Slow response"));
        assert_eq!(status.status, Status::Degraded);

        // One unhealthy
        let mut status = HealthStatus::new("1.0.0");
        status = status
            .with_check("db", CheckResult::unhealthy("Connection refused"))
            .with_check("cache", CheckResult::healthy());
        assert_eq!(status.status, Status::Unhealthy);
    }

    #[tokio::test]
    async fn test_health_checker() {
        let checker = HealthChecker::new("1.0.0")
            .add_check(Arc::new(AlwaysHealthyCheck::new("test")));

        let health = checker.check_health().await;
        assert_eq!(health.status, Status::Healthy);
        assert_eq!(health.version, "1.0.0");
        assert!(health.checks.contains_key("test"));
    }

    #[tokio::test]
    async fn test_always_healthy_check() {
        let check = AlwaysHealthyCheck::new("test");
        assert_eq!(check.name(), "test");

        let result = check.check().await;
        assert_eq!(result.status, Status::Healthy);
    }
}