use crate::infrastructure::{DependencyReport, Readiness};
use actix_web::{HttpResponse, Responder, web};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
pub(crate) const HEALTH_PATH: &str = "/health";
pub(crate) const READY_PATH: &str = "/ready";
pub(crate) const PROBE_PATHS: [&str; 2] = [HEALTH_PATH, READY_PATH];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum DependencyState {
Up,
Down,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ReadinessState {
Ready,
NotReady,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct HealthResponse {
pub status: String,
}
impl HealthResponse {
#[must_use]
pub fn alive() -> Self {
Self {
status: "alive".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct DependencyStatus {
pub name: String,
pub status: DependencyState,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl DependencyStatus {
#[must_use]
pub fn is_up(&self) -> bool {
self.status == DependencyState::Up
}
}
impl From<&DependencyReport> for DependencyStatus {
fn from(report: &DependencyReport) -> Self {
Self {
name: report.name.to_string(),
status: if report.is_up() {
DependencyState::Up
} else {
DependencyState::Down
},
reason: report.failure.map(|failure| failure.as_str().to_string()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ReadinessResponse {
pub status: ReadinessState,
pub dependencies: Vec<DependencyStatus>,
}
impl ReadinessResponse {
#[must_use]
pub fn new(reports: &[DependencyReport]) -> Self {
let dependencies: Vec<DependencyStatus> =
reports.iter().map(DependencyStatus::from).collect();
let ready = dependencies.iter().all(DependencyStatus::is_up);
Self {
status: if ready {
ReadinessState::Ready
} else {
ReadinessState::NotReady
},
dependencies,
}
}
#[must_use]
pub fn is_ready(&self) -> bool {
self.status == ReadinessState::Ready
}
}
#[utoipa::path(
get,
path = "/health",
tag = "Operations",
responses(
(status = 200, description = "The process is alive", body = HealthResponse)
)
)]
pub(crate) async fn health() -> impl Responder {
HttpResponse::Ok().json(HealthResponse::alive())
}
#[utoipa::path(
get,
path = "/ready",
tag = "Operations",
responses(
(status = 200, description = "Every dependency answered", body = ReadinessResponse),
(
status = 503,
description = "At least one dependency did not answer; the body names it",
body = ReadinessResponse
)
)
)]
pub(crate) async fn ready(readiness: web::Data<Readiness>) -> impl Responder {
let body = ReadinessResponse::new(&readiness.evaluate().await);
if body.is_ready() {
HttpResponse::Ok().json(body)
} else {
HttpResponse::ServiceUnavailable().json(body)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::infrastructure::DependencyProbe;
use actix_web::{App, http::StatusCode, test, web};
use async_trait::async_trait;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
struct Switch {
name: &'static str,
up: AtomicBool,
}
impl Switch {
fn new(name: &'static str, up: bool) -> Self {
Self {
name,
up: AtomicBool::new(up),
}
}
}
#[async_trait]
impl DependencyProbe for Switch {
fn name(&self) -> &'static str {
self.name
}
async fn check(&self) -> Result<(), String> {
if self.up.load(Ordering::SeqCst) {
Ok(())
} else {
Err("connection refused".to_string())
}
}
}
#[actix_web::test]
async fn test_health_answers_while_a_dependency_is_down() {
let app = test::init_service(
App::new()
.app_data(web::Data::new(Readiness::new(vec![Arc::new(Switch::new(
"redis", false,
))])))
.route(HEALTH_PATH, web::get().to(health))
.route(READY_PATH, web::get().to(ready)),
)
.await;
let alive =
test::call_service(&app, test::TestRequest::get().uri(HEALTH_PATH).to_request()).await;
assert_eq!(alive.status(), StatusCode::OK);
let not_ready =
test::call_service(&app, test::TestRequest::get().uri(READY_PATH).to_request()).await;
assert_eq!(
not_ready.status(),
StatusCode::SERVICE_UNAVAILABLE,
"the same instance must be alive and not ready at once"
);
}
#[actix_web::test]
async fn test_health_reports_alive() {
let app = test::init_service(App::new().route(HEALTH_PATH, web::get().to(health))).await;
let body: HealthResponse = test::call_and_read_body_json(
&app,
test::TestRequest::get().uri(HEALTH_PATH).to_request(),
)
.await;
assert_eq!(body.status, "alive");
}
#[actix_web::test]
async fn test_ready_reports_every_dependency_when_all_answer() {
let app = test::init_service(
App::new()
.app_data(web::Data::new(Readiness::new(vec![
Arc::new(Switch::new("redis", true)),
Arc::new(Switch::new("mongodb", true)),
Arc::new(Switch::new("clickhouse", true)),
])))
.route(READY_PATH, web::get().to(ready)),
)
.await;
let response =
test::call_service(&app, test::TestRequest::get().uri(READY_PATH).to_request()).await;
assert_eq!(response.status(), StatusCode::OK);
let body: ReadinessResponse = test::read_body_json(response).await;
assert_eq!(body.status, ReadinessState::Ready);
assert_eq!(
body.dependencies
.iter()
.map(|dependency| dependency.name.as_str())
.collect::<Vec<_>>(),
vec!["redis", "mongodb", "clickhouse"],
"the order is the one the probes were registered in"
);
assert!(body.dependencies.iter().all(|d| d.reason.is_none()));
}
#[actix_web::test]
async fn test_ready_names_the_failing_dependency() {
let app = test::init_service(
App::new()
.app_data(web::Data::new(Readiness::new(vec![
Arc::new(Switch::new("redis", false)),
Arc::new(Switch::new("mongodb", true)),
])))
.route(READY_PATH, web::get().to(ready)),
)
.await;
let response =
test::call_service(&app, test::TestRequest::get().uri(READY_PATH).to_request()).await;
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body: ReadinessResponse = test::read_body_json(response).await;
assert_eq!(body.status, ReadinessState::NotReady);
match body
.dependencies
.iter()
.find(|dependency| dependency.name == "redis")
{
Some(redis) => {
assert_eq!(redis.status, DependencyState::Down);
assert_eq!(redis.reason.as_deref(), Some("unreachable"));
}
None => panic!("the failing dependency must be in the body: {body:?}"),
}
match body
.dependencies
.iter()
.find(|dependency| dependency.name == "mongodb")
{
Some(mongodb) => assert!(mongodb.is_up(), "a healthy dependency is still reported"),
None => panic!("every dependency must be in the body: {body:?}"),
}
}
#[actix_web::test]
async fn test_ready_recovers_without_a_restart() {
let switch = Arc::new(Switch::new("redis", false));
let app = test::init_service(
App::new()
.app_data(web::Data::new(Readiness::new(vec![switch.clone()])))
.route(READY_PATH, web::get().to(ready)),
)
.await;
let down =
test::call_service(&app, test::TestRequest::get().uri(READY_PATH).to_request()).await;
assert_eq!(down.status(), StatusCode::SERVICE_UNAVAILABLE);
switch.up.store(true, Ordering::SeqCst);
let up =
test::call_service(&app, test::TestRequest::get().uri(READY_PATH).to_request()).await;
assert_eq!(
up.status(),
StatusCode::OK,
"the same process must report ready once the dependency answers"
);
}
#[actix_web::test]
async fn test_no_probes_is_ready() {
let body = ReadinessResponse::new(&[]);
assert!(body.is_ready());
assert_eq!(body.status, ReadinessState::Ready);
assert!(body.dependencies.is_empty());
}
#[actix_web::test]
async fn test_the_excluded_paths_are_the_probe_routes() {
assert_eq!(PROBE_PATHS, [HEALTH_PATH, READY_PATH]);
assert_eq!(HEALTH_PATH, "/health");
assert_eq!(READY_PATH, "/ready");
}
}