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