From bbc6977e73eb5872c63bb4775560f461008c68c3 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:05:09 +0800 Subject: [PATCH 1/3] Add health check routes and state management --- apps/api/src/routes/api.rs | 3 + apps/api/src/routes/api/health.rs | 12 +++ apps/api/src/routes/api/health/info.rs | 108 ++++++++++++++++++++++++ apps/api/src/routes/api/health/state.rs | 23 +++++ 4 files changed, 146 insertions(+) create mode 100644 apps/api/src/routes/api/health.rs create mode 100644 apps/api/src/routes/api/health/info.rs create mode 100644 apps/api/src/routes/api/health/state.rs diff --git a/apps/api/src/routes/api.rs b/apps/api/src/routes/api.rs index 2924fe9..7f648a2 100644 --- a/apps/api/src/routes/api.rs +++ b/apps/api/src/routes/api.rs @@ -1,7 +1,10 @@ +mod health; + use axum::{Router, response::IntoResponse, routing::any}; pub fn get_api_router() -> Router { Router::new() + .nest("/health", health::get_health_router()) // explicit fallback for unmatched API routes .route("/{*wildcard}", any(api_fallback_handler)) } diff --git a/apps/api/src/routes/api/health.rs b/apps/api/src/routes/api/health.rs new file mode 100644 index 0000000..98f6b15 --- /dev/null +++ b/apps/api/src/routes/api/health.rs @@ -0,0 +1,12 @@ +mod info; +mod state; + +use std::sync::Arc; + +use axum::{Router, routing::get}; + +pub fn get_health_router() -> Router { + Router::new() + .route("/info", get(info::get_health_info)) + .with_state(Arc::new(state::HealthState::default())) +} diff --git a/apps/api/src/routes/api/health/info.rs b/apps/api/src/routes/api/health/info.rs new file mode 100644 index 0000000..6f38ac2 --- /dev/null +++ b/apps/api/src/routes/api/health/info.rs @@ -0,0 +1,108 @@ +use std::sync::Arc; + +use axum::{Json, extract::State, http::StatusCode}; +use chrono::{Duration, Utc}; +use serde::{Deserialize, Serialize}; +use tokio::time::Instant; + +use crate::routes::api::health::state::HealthState; + +const STATUS_HEALTHY: &str = "healthy"; +const STATUS_UNHEALTHY: &str = "unhealthy"; + +#[derive(Serialize, Deserialize)] +pub struct HealthInfo { + pub status: String, + pub version: String, + // timestamp in milliseconds when the service started + pub up_since: String, + pub errors: Option>, +} + +pub async fn get_health_info( + State(state): State>, +) -> (StatusCode, Json) { + let mut errors = vec![]; + let up_since = if *state.get_start_time() > Instant::now() { + errors.push("Invalid up time".into()); + // Fall back to reporting 'now' as the up_since timestamp when invalid. + Utc::now() + } else { + let elapsed = state.get_start_time().elapsed(); + Utc::now() + - Duration::from_std(elapsed).unwrap_or_else(|_| { + errors.push("Invalid up time".into()); + Duration::zero() + }) + }; + + ( + StatusCode::OK, + Json(HealthInfo { + status: if errors.is_empty() { + STATUS_HEALTHY.into() + } else { + STATUS_UNHEALTHY.into() + }, + version: env!("CARGO_PKG_VERSION").into(), + up_since: up_since.to_rfc3339(), + errors: if errors.is_empty() { + None + } else { + Some(errors) + }, + }), + ) +} + +#[cfg(test)] +mod test { + use super::*; + use axum::body::to_bytes; + use axum::{ + Router, + body::Body, + http::{Request, StatusCode}, + }; + use tower::ServiceExt; + + #[tokio::test] + async fn test_get_health_info() { + let health_state = Arc::new(HealthState::default()); + let app = Router::new() + .route("/info", axum::routing::get(get_health_info)) + .with_state(health_state); + + let response = app + .oneshot(Request::builder().uri("/info").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); // Set limit to 1 MB + let health_info: HealthInfo = serde_json::from_slice(&body).unwrap(); + assert_eq!(health_info.status, STATUS_HEALTHY); + assert_eq!(health_info.version, env!("CARGO_PKG_VERSION")); + assert!(health_info.errors.is_none()); + } + + #[tokio::test] + async fn test_invalid_up_time() { + let health_state = Arc::new(HealthState::new( + tokio::time::Instant::now() + std::time::Duration::from_secs(60000), + )); + let app = Router::new() + .route("/info", axum::routing::get(get_health_info)) + .with_state(health_state); + let response = app + .oneshot(Request::builder().uri("/info").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); // Set limit to 1 MB + let health_info: HealthInfo = serde_json::from_slice(&body).unwrap(); + assert_eq!(health_info.status, STATUS_UNHEALTHY); + assert_eq!(health_info.version, env!("CARGO_PKG_VERSION")); + assert!(health_info.errors.as_ref().is_some_and(|e| !e.is_empty())); + } +} diff --git a/apps/api/src/routes/api/health/state.rs b/apps/api/src/routes/api/health/state.rs new file mode 100644 index 0000000..ef81a99 --- /dev/null +++ b/apps/api/src/routes/api/health/state.rs @@ -0,0 +1,23 @@ +use tokio::time::Instant; + +pub struct HealthState { + start_time: Instant, +} + +impl Default for HealthState { + fn default() -> Self { + Self { + start_time: Instant::now(), + } + } +} + +impl HealthState { + pub fn new(start_time: Instant) -> Self { + Self { start_time } + } + + pub fn get_start_time(&self) -> &Instant { + &self.start_time + } +} -- 2.49.1 From 23c6bc4fd04fa4d8f10c94a8f63ac3e9589918c8 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:20:56 +0800 Subject: [PATCH 2/3] Add #[allow(dead_code)] annotation to HealthState::new for test usage --- apps/api/src/routes/api/health/state.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/src/routes/api/health/state.rs b/apps/api/src/routes/api/health/state.rs index ef81a99..2d63c45 100644 --- a/apps/api/src/routes/api/health/state.rs +++ b/apps/api/src/routes/api/health/state.rs @@ -13,6 +13,7 @@ impl Default for HealthState { } impl HealthState { + #[allow(dead_code)] // used in tests pub fn new(start_time: Instant) -> Self { Self { start_time } } -- 2.49.1 From 5210c64c5d259b56098284f18aedd7ae7203c840 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:08:21 +0800 Subject: [PATCH 3/3] Simplify health endpoint --- apps/api/src/routes/api/health/info.rs | 58 ++++++------------------- apps/api/src/routes/api/health/state.rs | 15 +++---- 2 files changed, 19 insertions(+), 54 deletions(-) diff --git a/apps/api/src/routes/api/health/info.rs b/apps/api/src/routes/api/health/info.rs index 6f38ac2..0e09d93 100644 --- a/apps/api/src/routes/api/health/info.rs +++ b/apps/api/src/routes/api/health/info.rs @@ -1,9 +1,8 @@ use std::sync::Arc; use axum::{Json, extract::State, http::StatusCode}; -use chrono::{Duration, Utc}; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use tokio::time::Instant; use crate::routes::api::health::state::HealthState; @@ -14,43 +13,34 @@ const STATUS_UNHEALTHY: &str = "unhealthy"; pub struct HealthInfo { pub status: String, pub version: String, - // timestamp in milliseconds when the service started - pub up_since: String, + // RFC 3339 formatted timestamp + pub up_since: DateTime, pub errors: Option>, } pub async fn get_health_info( State(state): State>, ) -> (StatusCode, Json) { + #[allow(unused_mut)] let mut errors = vec![]; - let up_since = if *state.get_start_time() > Instant::now() { - errors.push("Invalid up time".into()); - // Fall back to reporting 'now' as the up_since timestamp when invalid. - Utc::now() - } else { - let elapsed = state.get_start_time().elapsed(); - Utc::now() - - Duration::from_std(elapsed).unwrap_or_else(|_| { - errors.push("Invalid up time".into()); - Duration::zero() - }) - }; + + let is_healthy = errors.is_empty(); ( - StatusCode::OK, + if is_healthy { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }, Json(HealthInfo { - status: if errors.is_empty() { + status: if is_healthy { STATUS_HEALTHY.into() } else { STATUS_UNHEALTHY.into() }, version: env!("CARGO_PKG_VERSION").into(), - up_since: up_since.to_rfc3339(), - errors: if errors.is_empty() { - None - } else { - Some(errors) - }, + up_since: *state.get_start_at(), + errors: if is_healthy { None } else { Some(errors) }, }), ) } @@ -85,24 +75,4 @@ mod test { assert_eq!(health_info.version, env!("CARGO_PKG_VERSION")); assert!(health_info.errors.is_none()); } - - #[tokio::test] - async fn test_invalid_up_time() { - let health_state = Arc::new(HealthState::new( - tokio::time::Instant::now() + std::time::Duration::from_secs(60000), - )); - let app = Router::new() - .route("/info", axum::routing::get(get_health_info)) - .with_state(health_state); - let response = app - .oneshot(Request::builder().uri("/info").body(Body::empty()).unwrap()) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); // Set limit to 1 MB - let health_info: HealthInfo = serde_json::from_slice(&body).unwrap(); - assert_eq!(health_info.status, STATUS_UNHEALTHY); - assert_eq!(health_info.version, env!("CARGO_PKG_VERSION")); - assert!(health_info.errors.as_ref().is_some_and(|e| !e.is_empty())); - } } diff --git a/apps/api/src/routes/api/health/state.rs b/apps/api/src/routes/api/health/state.rs index 2d63c45..db2f703 100644 --- a/apps/api/src/routes/api/health/state.rs +++ b/apps/api/src/routes/api/health/state.rs @@ -1,24 +1,19 @@ -use tokio::time::Instant; +use chrono::{DateTime, Utc}; pub struct HealthState { - start_time: Instant, + start_at: DateTime, } impl Default for HealthState { fn default() -> Self { Self { - start_time: Instant::now(), + start_at: Utc::now(), } } } impl HealthState { - #[allow(dead_code)] // used in tests - pub fn new(start_time: Instant) -> Self { - Self { start_time } - } - - pub fn get_start_time(&self) -> &Instant { - &self.start_time + pub fn get_start_at(&self) -> &DateTime { + &self.start_at } } -- 2.49.1