Skip to main content

kasl_server/
app.rs

1use axum::{
2    Json, Router,
3    extract::{DefaultBodyLimit, 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::{config::Config, ingest};
13
14#[derive(Clone)]
15pub struct AppState {
16    pub pool: PgPool,
17    /// Days one batch may carry; enforced by the batch handler.
18    pub max_batch_days: usize,
19}
20
21/// Builds the router with the operator's limits applied.
22pub fn router_with(pool: PgPool, config: &Config) -> Router {
23    // `/api/v1` from the very first endpoint: kasl agents update on their own
24    // schedule, so the path a working agent calls must keep meaning what it
25    // meant when that agent shipped (ADR 0001).
26    let api_v1 = Router::new()
27        .route("/days", post(ingest::upload_day))
28        .route("/days/batch", post(ingest::upload_batch));
29
30    Router::new()
31        .route("/health", get(health))
32        .nest("/api/v1", api_v1)
33        .with_state(AppState {
34            pool,
35            max_batch_days: config.max_batch_days,
36        })
37        // A body larger than this is refused before it is buffered: backfilling
38        // a year and attacking the server look identical up to the size.
39        .layer(DefaultBodyLimit::max(config.max_body_bytes))
40        .layer(TraceLayer::new_for_http())
41}
42
43/// The router with default limits - what the tests and `/health` callers want
44/// when the limits are not what is under test.
45pub fn router(pool: PgPool) -> Router {
46    router_with(pool, &Config::defaults_for_database(String::new()))
47}
48
49/// Liveness + readiness in one place: the process answers, and the database
50/// round-trip tells whether the server can actually do its job.
51async fn health(State(state): State<AppState>) -> Response {
52    match sqlx::query("SELECT 1").execute(&state.pool).await {
53        Ok(_) => (
54            StatusCode::OK,
55            Json(json!({
56                "status": "ok",
57                "version": env!("CARGO_PKG_VERSION"),
58                "database": "ok",
59            })),
60        )
61            .into_response(),
62        Err(error) => {
63            tracing::error!(%error, "health check: database unreachable");
64            (
65                StatusCode::SERVICE_UNAVAILABLE,
66                Json(json!({
67                    "status": "degraded",
68                    "version": env!("CARGO_PKG_VERSION"),
69                    "database": "unavailable",
70                })),
71            )
72                .into_response()
73        }
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use axum::{body::Body, http::Request};
80    use http_body_util::BodyExt;
81    use sqlx::postgres::PgPoolOptions;
82    use tower::ServiceExt;
83
84    use super::*;
85
86    /// A pool pointing nowhere: `connect_lazy` never dials until a query runs,
87    /// so the router can be exercised without a live database.
88    fn dead_pool() -> PgPool {
89        PgPoolOptions::new()
90            // Keep the failure fast: the default acquire timeout is 30 s.
91            .acquire_timeout(std::time::Duration::from_secs(1))
92            .connect_lazy("postgres://nobody:nowhere@127.0.0.1:1/kasl")
93            .expect("lazy pool creation does not touch the network")
94    }
95
96    #[tokio::test]
97    async fn health_reports_degraded_without_a_database() {
98        let response = router(dead_pool()).oneshot(Request::get("/health").body(Body::empty()).unwrap()).await.unwrap();
99        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
100
101        let body = response.into_body().collect().await.unwrap().to_bytes();
102        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
103        assert_eq!(body["status"], "degraded");
104        assert_eq!(body["database"], "unavailable");
105        assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
106    }
107
108    #[tokio::test]
109    async fn unknown_routes_return_404() {
110        let response = router(dead_pool()).oneshot(Request::get("/nope").body(Body::empty()).unwrap()).await.unwrap();
111        assert_eq!(response.status(), StatusCode::NOT_FOUND);
112    }
113}