feat: enhance health check with application state and initialization status

This commit is contained in:
GW_MC
2025-12-19 10:24:47 +08:00
parent 66b29b96ee
commit 8111aaf672
10 changed files with 122 additions and 10 deletions

View File

@@ -17,6 +17,7 @@ use crate::{
authentication::{AuthenticationServiceImpl, strategies::password::PasswordStrategy},
user::UserServiceImpl,
},
server_state::ServerStateService,
settings::SettingsService,
},
tasks,
@@ -145,6 +146,7 @@ fn get_app_state(
AppState {
database_connection: db_connection.clone(),
service: Arc::new(AppService {
server_state: Arc::new(ServerStateService::new(db_connection.clone())),
settings: Arc::new(SettingsService::new(db_connection.clone())),
auth_state: routes::AuthState {
strategy: routes::AuthStrategy {

View File

@@ -15,16 +15,15 @@ use crate::{
authentication::{AuthenticationService, strategies::password::PasswordStrategy},
user::UserService,
},
server_state::ServerStateStore,
settings::SettingsStore,
},
};
#[derive(Clone)]
pub struct AppState {
// TODO: remove dead_code allowances when fields are used
#[allow(dead_code)]
pub database_connection: Arc<DatabaseConnection>,
// TODO: remove dead_code allowances when fields are used
#[allow(dead_code)]
pub service: Arc<AppService>,
}
@@ -44,6 +43,7 @@ pub struct AppService {
pub settings: ServiceState<dyn SettingsStore>,
pub auth_state: AuthState,
pub user: ServiceState<dyn UserService>,
pub server_state: ServiceState<dyn ServerStateStore>,
}
pub fn get_root_router(state: impl Into<Arc<AppState>>) -> Router {

View File

@@ -13,7 +13,7 @@ use axum::{Router, response::IntoResponse, routing::any};
pub fn get_api_router(state: Arc<AppState>) -> Router {
Router::new()
.nest("/health", health::get_health_router())
.nest("/health", health::get_health_router(state.clone()))
.merge(auth::get_basic_auth_router(state.clone()))
.merge(restricted::get_restricted_router(state.clone()))
// explicit fallback for unmatched API routes

View File

@@ -5,8 +5,13 @@ use std::sync::Arc;
use axum::{Router, routing::get};
pub fn get_health_router() -> Router {
use crate::routes::{AppState, api::health::state::AppStateWithHealth};
pub fn get_health_router(app_state: Arc<AppState>) -> Router {
Router::new()
.route("/info", get(info::get_health_info))
.with_state(Arc::new(state::HealthState::default()))
.with_state(Arc::new(AppStateWithHealth {
app_state: app_state.clone(),
health_state: Arc::new(state::HealthState::default()),
}))
}

View File

@@ -3,8 +3,9 @@ use std::sync::Arc;
use axum::{Json, extract::State, http::StatusCode};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tracing::error;
use crate::routes::api::{health::state::HealthState, openapi::tag::HEALTH_TAG};
use crate::routes::api::{health::state::AppStateWithHealth, openapi::tag::HEALTH_TAG};
const STATUS_HEALTHY: &str = "healthy";
const STATUS_UNHEALTHY: &str = "unhealthy";
@@ -20,6 +21,8 @@ pub struct HealthInfo {
pub up_since: DateTime<Utc>,
/// List of error messages if unhealthy
pub errors: Option<Vec<String>>,
/// Is initialized
pub is_initialized: bool,
}
/// Health check endpoint
@@ -35,12 +38,23 @@ pub struct HealthInfo {
tag = HEALTH_TAG,
)]
pub async fn get_health_info(
State(state): State<Arc<HealthState>>,
State(app_state_with_health): State<Arc<AppStateWithHealth>>,
) -> (StatusCode, Json<HealthInfo>) {
#[allow(unused_mut)]
let mut errors = vec![];
let is_healthy = errors.is_empty();
let health_state = &app_state_with_health.health_state;
let app_state = &app_state_with_health.app_state;
let is_initialized = match app_state.service.server_state.is_server_initialized().await {
Ok(initialized) => initialized,
Err(err) => {
errors.push("Failed to determine if server is initialized".to_string());
error!("Error checking server initialization status: {}", err);
false
}
};
(
if is_healthy {
@@ -55,14 +69,29 @@ pub async fn get_health_info(
STATUS_UNHEALTHY.into()
},
version: env!("CARGO_PKG_VERSION").into(),
up_since: *state.get_start_at(),
up_since: *health_state.get_start_at(),
errors: if is_healthy { None } else { Some(errors) },
is_initialized,
}),
)
}
#[cfg(test)]
mod test {
use crate::{
routes::{AppState, api::health::state::HealthState},
services::{
auth::{
authentication::{
AuthenticationServiceImpl, strategies::password::PasswordStrategy,
},
user::UserServiceImpl,
},
server_state::ServerStateService,
settings::SettingsService,
},
};
use super::*;
use axum::body::to_bytes;
use axum::{
@@ -70,14 +99,38 @@ mod test {
body::Body,
http::{Request, StatusCode},
};
use sea_orm::MockDatabase;
use tower::ServiceExt;
#[tokio::test]
async fn test_get_health_info() {
let health_state = Arc::new(HealthState::default());
let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite)
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
.into_connection();
let db = Arc::new(db);
let app_state = Arc::new(AppState {
database_connection: db.clone(),
service: Arc::new(crate::routes::AppService {
settings: Arc::new(SettingsService::new(db.clone())),
auth_state: crate::routes::AuthState {
strategy: crate::routes::AuthStrategy {
password: Arc::new(PasswordStrategy::new(db.clone())),
},
authentication: Arc::new(AuthenticationServiceImpl::new(None)),
},
user: Arc::new(UserServiceImpl::new(db.clone())),
server_state: Arc::new(ServerStateService::new(db.clone())),
}),
});
let app = Router::new()
.route("/info", axum::routing::get(get_health_info))
.with_state(health_state);
.with_state(Arc::new(AppStateWithHealth {
app_state: app_state.clone(),
health_state: health_state.clone(),
}));
let response = app
.oneshot(Request::builder().uri("/info").body(Body::empty()).unwrap())

View File

@@ -1,5 +1,14 @@
use std::sync::Arc;
use chrono::{DateTime, Utc};
use crate::routes::AppState;
pub struct AppStateWithHealth {
pub app_state: Arc<AppState>,
pub health_state: Arc<HealthState>,
}
pub struct HealthState {
start_at: DateTime<Utc>,
}

View File

@@ -1,2 +1,3 @@
pub mod auth;
pub mod server_state;
pub mod settings;

View File

@@ -0,0 +1,36 @@
use std::sync::Arc;
use sea_orm::{DatabaseConnection, prelude::*};
use crate::errors::service_error::ServiceError;
#[async_trait::async_trait]
pub trait ServerStateStore: Send + Sync {
async fn is_server_initialized(&self) -> Result<bool, ServiceError>;
}
pub struct ServerStateService {
connection: Arc<DatabaseConnection>,
}
impl ServerStateService {
pub fn new(connection: Arc<DatabaseConnection>) -> Self {
Self { connection }
}
}
#[async_trait::async_trait]
impl ServerStateStore for ServerStateService {
async fn is_server_initialized(&self) -> Result<bool, ServiceError> {
// For example, check if any admin user exists to determine if the server is initialized
let admin_exists = database::generated::entities::user::Entity::find()
.filter(database::generated::entities::user::Column::IsAdmin.eq(true))
.filter(database::generated::entities::user::Column::IsActive.eq(true))
.one(&*self.connection)
.await
.map_err(ServiceError::from)?
.is_some();
Ok(admin_exists)
}
}