use axum::{
extract::State,
http::{header, StatusCode},
response::{IntoResponse, Response},
Json,
};
use serde::{Deserialize, Serialize};
use crate::db::pool::health_check as db_health_check;
use crate::state::AppState;
#[derive(Debug, Serialize, Deserialize)]
pub struct HealthCheckResponse {
pub status: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ApiHealthResponse {
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub database: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nats: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub uptime_seconds: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PoolStatusResponse {
pub pool_min: u32,
pub pool_max: u32,
pub pool_size: u32,
pub pool_available: u32,
pub requests_waiting: u32,
pub utilization: f64,
pub slots_available: u32,
pub status: String,
}
pub async fn health_check() -> Json<HealthCheckResponse> {
Json(HealthCheckResponse {
status: "ok".to_string(),
})
}
pub async fn api_health(State(state): State<AppState>) -> (StatusCode, Json<ApiHealthResponse>) {
let db_healthy = db_health_check(&state.db).await;
let nats_status = if state.has_nats() {
Some("connected".to_string())
} else {
Some("not_configured".to_string())
};
let overall_status = if db_healthy {
"ok".to_string()
} else {
"unhealthy".to_string()
};
let status_code = if db_healthy {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
};
let response = ApiHealthResponse {
status: overall_status,
database: Some(if db_healthy {
"connected".to_string()
} else {
"disconnected".to_string()
}),
nats: nats_status,
uptime_seconds: Some(state.uptime_seconds()),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
};
(status_code, Json(response))
}
pub async fn pool_status(State(state): State<AppState>) -> Json<PoolStatusResponse> {
let pool_size = u32::try_from(state.db.size()).unwrap_or(u32::MAX);
let pool_available = u32::try_from(state.db.num_idle()).unwrap_or(u32::MAX);
let pool_max = pool_size.max(pool_available);
let pool_min = 0;
let active = pool_size.saturating_sub(pool_available);
let utilization = if pool_max > 0 {
(active as f64 / pool_max as f64).clamp(0.0, 1.0)
} else {
0.0
};
Json(PoolStatusResponse {
pool_min,
pool_max,
pool_size,
pool_available,
requests_waiting: 0,
utilization,
slots_available: pool_available,
status: "ok".to_string(),
})
}
pub async fn metrics() -> Response {
match crate::metrics::gather_text() {
Ok(text) => (
StatusCode::OK,
[(
header::CONTENT_TYPE,
"text/plain; version=0.0.4; charset=utf-8",
)],
text,
)
.into_response(),
Err(e) => {
tracing::warn!(error = %e, "Failed to gather Prometheus metrics");
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to render metrics: {e}"),
)
.into_response()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_health_check() {
let response = health_check().await;
assert_eq!(response.status, "ok");
}
#[tokio::test]
async fn test_metrics_endpoint_returns_ok() {
crate::metrics::record_event_ingest("test.metrics_endpoint", "ok", 0.001);
let response = metrics().await;
assert_eq!(response.status(), StatusCode::OK);
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
content_type.contains("text/plain"),
"expected text/plain, got: {content_type}"
);
}
}