Skip to main content

kasl_server/
app.rs

1use axum::{
2    Json, Router,
3    extract::State,
4    http::StatusCode,
5    response::{IntoResponse, Response},
6    routing::{get, post},
7};
8use serde_json::json;
9use sqlx::PgPool;
10use tower_http::trace::TraceLayer;
11
12use crate::ingest;
13
14#[derive(Clone)]
15pub struct AppState {
16    pub pool: PgPool,
17}
18
19pub fn router(pool: PgPool) -> Router {
20    // `/api/v1` from the very first endpoint: kasl agents update on their own
21    // schedule, so the path a working agent calls must keep meaning what it
22    // meant when that agent shipped (ADR 0001).
23    let api_v1 = Router::new().route("/days", post(ingest::upload_day));
24
25    Router::new()
26        .route("/health", get(health))
27        .nest("/api/v1", api_v1)
28        .with_state(AppState { pool })
29        .layer(TraceLayer::new_for_http())
30}
31
32/// Liveness + readiness in one place: the process answers, and the database
33/// round-trip tells whether the server can actually do its job.
34async fn health(State(state): State<AppState>) -> Response {
35    match sqlx::query("SELECT 1").execute(&state.pool).await {
36        Ok(_) => (
37            StatusCode::OK,
38            Json(json!({
39                "status": "ok",
40                "version": env!("CARGO_PKG_VERSION"),
41                "database": "ok",
42            })),
43        )
44            .into_response(),
45        Err(error) => {
46            tracing::error!(%error, "health check: database unreachable");
47            (
48                StatusCode::SERVICE_UNAVAILABLE,
49                Json(json!({
50                    "status": "degraded",
51                    "version": env!("CARGO_PKG_VERSION"),
52                    "database": "unavailable",
53                })),
54            )
55                .into_response()
56        }
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use axum::{body::Body, http::Request};
63    use http_body_util::BodyExt;
64    use sqlx::postgres::PgPoolOptions;
65    use tower::ServiceExt;
66
67    use super::*;
68
69    /// A pool pointing nowhere: `connect_lazy` never dials until a query runs,
70    /// so the router can be exercised without a live database.
71    fn dead_pool() -> PgPool {
72        PgPoolOptions::new()
73            // Keep the failure fast: the default acquire timeout is 30 s.
74            .acquire_timeout(std::time::Duration::from_secs(1))
75            .connect_lazy("postgres://nobody:nowhere@127.0.0.1:1/kasl")
76            .expect("lazy pool creation does not touch the network")
77    }
78
79    #[tokio::test]
80    async fn health_reports_degraded_without_a_database() {
81        let response = router(dead_pool()).oneshot(Request::get("/health").body(Body::empty()).unwrap()).await.unwrap();
82        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
83
84        let body = response.into_body().collect().await.unwrap().to_bytes();
85        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
86        assert_eq!(body["status"], "degraded");
87        assert_eq!(body["database"], "unavailable");
88        assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
89    }
90
91    #[tokio::test]
92    async fn unknown_routes_return_404() {
93        let response = router(dead_pool()).oneshot(Request::get("/nope").body(Body::empty()).unwrap()).await.unwrap();
94        assert_eq!(response.status(), StatusCode::NOT_FOUND);
95    }
96}