diff --git a/apps/api/src/cmd/start_server.rs b/apps/api/src/cmd/start_server.rs index e3629fd..e49f5a8 100644 --- a/apps/api/src/cmd/start_server.rs +++ b/apps/api/src/cmd/start_server.rs @@ -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 { diff --git a/apps/api/src/routes.rs b/apps/api/src/routes.rs index 85ca15a..41cc73b 100644 --- a/apps/api/src/routes.rs +++ b/apps/api/src/routes.rs @@ -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, - // TODO: remove dead_code allowances when fields are used #[allow(dead_code)] pub service: Arc, } @@ -44,6 +43,7 @@ pub struct AppService { pub settings: ServiceState, pub auth_state: AuthState, pub user: ServiceState, + pub server_state: ServiceState, } pub fn get_root_router(state: impl Into>) -> Router { diff --git a/apps/api/src/routes/api.rs b/apps/api/src/routes/api.rs index 8fbf5d0..3546a2b 100644 --- a/apps/api/src/routes/api.rs +++ b/apps/api/src/routes/api.rs @@ -13,7 +13,7 @@ use axum::{Router, response::IntoResponse, routing::any}; pub fn get_api_router(state: Arc) -> 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 diff --git a/apps/api/src/routes/api/health.rs b/apps/api/src/routes/api/health.rs index b6cc7bc..329c589 100644 --- a/apps/api/src/routes/api/health.rs +++ b/apps/api/src/routes/api/health.rs @@ -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) -> 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()), + })) } diff --git a/apps/api/src/routes/api/health/info.rs b/apps/api/src/routes/api/health/info.rs index 069d5b1..27ea792 100644 --- a/apps/api/src/routes/api/health/info.rs +++ b/apps/api/src/routes/api/health/info.rs @@ -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, /// List of error messages if unhealthy pub errors: Option>, + /// 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>, + State(app_state_with_health): State>, ) -> (StatusCode, Json) { #[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::::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()) diff --git a/apps/api/src/routes/api/health/state.rs b/apps/api/src/routes/api/health/state.rs index db2f703..a46fcdd 100644 --- a/apps/api/src/routes/api/health/state.rs +++ b/apps/api/src/routes/api/health/state.rs @@ -1,5 +1,14 @@ +use std::sync::Arc; + use chrono::{DateTime, Utc}; +use crate::routes::AppState; + +pub struct AppStateWithHealth { + pub app_state: Arc, + pub health_state: Arc, +} + pub struct HealthState { start_at: DateTime, } diff --git a/apps/api/src/services.rs b/apps/api/src/services.rs index f7da917..173b067 100644 --- a/apps/api/src/services.rs +++ b/apps/api/src/services.rs @@ -1,2 +1,3 @@ pub mod auth; +pub mod server_state; pub mod settings; diff --git a/apps/api/src/services/server_state.rs b/apps/api/src/services/server_state.rs new file mode 100644 index 0000000..75ca966 --- /dev/null +++ b/apps/api/src/services/server_state.rs @@ -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; +} + +pub struct ServerStateService { + connection: Arc, +} + +impl ServerStateService { + pub fn new(connection: Arc) -> Self { + Self { connection } + } +} + +#[async_trait::async_trait] +impl ServerStateStore for ServerStateService { + async fn is_server_initialized(&self) -> Result { + // 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) + } +} diff --git a/apps/api/swagger.json b/apps/api/swagger.json index 6bc6036..dc65ac2 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -135,7 +135,8 @@ "required": [ "status", "version", - "up_since" + "up_since", + "is_initialized" ], "properties": { "errors": { @@ -148,6 +149,10 @@ }, "description": "List of error messages if unhealthy" }, + "is_initialized": { + "type": "boolean", + "description": "Is initialized" + }, "status": { "type": "string", "description": "Health status: \"healthy\" or \"unhealthy\"" diff --git a/apps/frontend/app/generated/api-client/api-client.ts b/apps/frontend/app/generated/api-client/api-client.ts index aba754f..5ebf513 100644 --- a/apps/frontend/app/generated/api-client/api-client.ts +++ b/apps/frontend/app/generated/api-client/api-client.ts @@ -3,6 +3,7 @@ export namespace Schemas { export type AdminInitRequest = { password: string; setup_secret: string; username: string }; export type HealthInfo = { errors?: (Array | null) | undefined; + is_initialized: boolean; status: string; up_since: string; version: string;