diff --git a/crates/api/src/health.rs b/crates/api/src/health.rs new file mode 100644 index 0000000..109af78 --- /dev/null +++ b/crates/api/src/health.rs @@ -0,0 +1,62 @@ +use axum::{Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::get}; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::AppState; + +/// Liveness payload: the process is running. +#[derive(Serialize, ToSchema)] +pub(crate) struct Live { + /// Always `"ok"` when the process serves requests. + pub status: &'static str, +} + +/// Readiness payload: dependencies were checked. +#[derive(Serialize, ToSchema)] +pub(crate) struct Ready { + /// `"ok"` when ready, `"degraded"` otherwise. + pub status: &'static str, + /// Whether the database responded to a ping. + pub database: bool, +} + +/// Liveness probe — no dependencies checked. +#[utoipa::path(get, path = "/health/live", responses((status = 200, body = Live)))] +pub(crate) async fn live() -> Json { + Json(Live { status: "ok" }) +} + +/// Readiness probe — confirms the database answers. +#[utoipa::path( + get, + path = "/health/ready", + responses( + (status = 200, body = Ready, description = "Ready"), + (status = 503, body = Ready, description = "A dependency is unavailable") + ) +)] +pub(crate) async fn ready(State(state): State) -> impl IntoResponse { + match state.db.ping().await { + Ok(()) => ( + StatusCode::OK, + Json(Ready { + status: "ok", + database: true, + }), + ), + Err(_) => ( + StatusCode::SERVICE_UNAVAILABLE, + Json(Ready { + status: "degraded", + database: false, + }), + ), + } +} + +/// Health routes, parameterized over [`AppState`]. +pub(crate) fn routes() -> Router { + Router::new() + .route("/health/live", get(live)) + .route("/health/ready", get(ready)) +} diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 374cc41..10ce5c6 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -1 +1,24 @@ //! HTTP API: router, handlers, and OpenAPI document. + +mod health; +mod openapi; + +use axum::Router; +use db::Db; + +/// Shared application state passed to handlers. +#[derive(Clone)] +pub struct AppState { + /// Database handle for this organization. + pub db: Db, + /// User-facing product name (from config). Never hardcoded. + pub app_name: String, +} + +/// Build the application router from shared state. +pub fn build_app(state: AppState) -> Router { + Router::new() + .merge(health::routes()) + .merge(openapi::routes()) + .with_state(state) +} diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs new file mode 100644 index 0000000..6ef33fc --- /dev/null +++ b/crates/api/src/openapi.rs @@ -0,0 +1,25 @@ +use axum::{Json, Router, extract::State, routing::get}; +use utoipa::OpenApi; + +use crate::{AppState, health}; + +#[derive(OpenApi)] +#[openapi( + paths(health::live, health::ready), + components(schemas(health::Live, health::Ready)), + info(title = "Collection Management System", version = "0.0.0") +)] +struct ApiDoc; + +/// Serve the OpenAPI document, overriding the title from runtime config so the +/// product name is never hardcoded. +async fn openapi_json(State(state): State) -> Json { + let mut doc = ApiDoc::openapi(); + doc.info.title = state.app_name.clone(); + Json(doc) +} + +/// OpenAPI routes, parameterized over [`AppState`]. +pub(crate) fn routes() -> Router { + Router::new().route("/api-docs/openapi.json", get(openapi_json)) +} diff --git a/crates/api/tests/health.rs b/crates/api/tests/health.rs new file mode 100644 index 0000000..7109dc3 --- /dev/null +++ b/crates/api/tests/health.rs @@ -0,0 +1,70 @@ +use api::{AppState, build_app}; +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use sqlx::PgPool; +use tower::ServiceExt; // for `oneshot` + +fn state(pool: PgPool, app_name: &str) -> AppState { + AppState { + db: db::Db::from_pool(pool), + app_name: app_name.to_string(), + } +} + +#[sqlx::test] +async fn live_returns_ok(pool: PgPool) { + let app = build_app(state(pool, "Test")); + let resp = app + .oneshot( + Request::builder() + .uri("/health/live") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(json["status"], "ok"); +} + +#[sqlx::test] +async fn ready_reports_database_true(pool: PgPool) { + let app = build_app(state(pool, "Test")); + let resp = app + .oneshot( + Request::builder() + .uri("/health/ready") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(json["database"], true); +} + +#[sqlx::test] +async fn openapi_doc_uses_configured_title(pool: PgPool) { + let app = build_app(state(pool, "My Museum CMS")); + let resp = app + .oneshot( + Request::builder() + .uri("/api-docs/openapi.json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(json["info"]["title"], "My Museum CMS"); +}