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 pub max_batch_days: usize,
19}
20
21pub fn router_with(pool: PgPool, config: &Config) -> Router {
23 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 .layer(DefaultBodyLimit::max(config.max_body_bytes))
40 .layer(TraceLayer::new_for_http())
41}
42
43pub fn router(pool: PgPool) -> Router {
46 router_with(pool, &Config::defaults_for_database(String::new()))
47}
48
49async 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 fn dead_pool() -> PgPool {
89 PgPoolOptions::new()
90 .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}