Add health check routes and state management #6
@@ -1,7 +1,10 @@
|
|||||||
|
mod health;
|
||||||
|
|
||||||
use axum::{Router, response::IntoResponse, routing::any};
|
use axum::{Router, response::IntoResponse, routing::any};
|
||||||
|
|
||||||
pub fn get_api_router() -> Router {
|
pub fn get_api_router() -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
.nest("/health", health::get_health_router())
|
||||||
// explicit fallback for unmatched API routes
|
// explicit fallback for unmatched API routes
|
||||||
.route("/{*wildcard}", any(api_fallback_handler))
|
.route("/{*wildcard}", any(api_fallback_handler))
|
||||||
}
|
}
|
||||||
|
|||||||
12
apps/api/src/routes/api/health.rs
Normal file
12
apps/api/src/routes/api/health.rs
Normal file
@@ -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()))
|
||||||
|
}
|
||||||
108
apps/api/src/routes/api/health/info.rs
Normal file
108
apps/api/src/routes/api/health/info.rs
Normal file
@@ -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<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_health_info(
|
||||||
|
State(state): State<Arc<HealthState>>,
|
||||||
|
) -> (StatusCode, Json<HealthInfo>) {
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/api/src/routes/api/health/state.rs
Normal file
23
apps/api/src/routes/api/health/state.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user