1use axum::{
2 Json, Router,
3 extract::{DefaultBodyLimit, State},
4 http::StatusCode,
5 response::{IntoResponse, Response},
6 routing::{delete, get, patch, post, put},
7};
8use serde_json::json;
9use sqlx::PgPool;
10use tower_http::trace::TraceLayer;
11
12use crate::{admin, audit, config::Config, department, ingest, login};
13
14#[derive(Clone)]
15pub struct AppState {
16 pub pool: PgPool,
17 pub max_batch_days: usize,
19 pub secure_cookies: bool,
21}
22
23pub fn router_with(pool: PgPool, config: &Config) -> Router {
25 let api_v1 = Router::new()
29 .route("/days", post(ingest::upload_day))
30 .route("/days/batch", post(ingest::upload_batch))
31 .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 .route("/auth/password", post(admin::change_own_password))
38 .route("/users", get(admin::list_users).post(admin::create_user))
41 .route("/users/{id}", patch(admin::update_user))
42 .route("/users/{id}/agents", get(admin::list_agents).post(admin::create_agent))
43 .route("/agents/{id}", delete(admin::revoke_agent))
44 .route("/departments", get(department::list).post(department::create))
46 .route("/departments/{id}", patch(department::update).delete(department::delete))
47 .route("/users/{id}/department", put(department::assign))
48 .route("/audit", get(audit::list));
51
52 Router::new()
53 .route("/health", get(health))
54 .nest("/api/v1", api_v1)
55 .with_state(AppState {
56 pool,
57 max_batch_days: config.max_batch_days,
58 secure_cookies: config.secure_cookies,
59 })
60 .layer(DefaultBodyLimit::max(config.max_body_bytes))
63 .layer(TraceLayer::new_for_http())
64}
65
66pub fn router(pool: PgPool) -> Router {
69 router_with(pool, &Config::defaults_for_database(String::new()))
70}
71
72async fn health(State(state): State<AppState>) -> Response {
75 match sqlx::query("SELECT 1").execute(&state.pool).await {
76 Ok(_) => (
77 StatusCode::OK,
78 Json(json!({
79 "status": "ok",
80 "version": env!("CARGO_PKG_VERSION"),
81 "database": "ok",
82 })),
83 )
84 .into_response(),
85 Err(error) => {
86 tracing::error!(%error, "health check: database unreachable");
87 (
88 StatusCode::SERVICE_UNAVAILABLE,
89 Json(json!({
90 "status": "degraded",
91 "version": env!("CARGO_PKG_VERSION"),
92 "database": "unavailable",
93 })),
94 )
95 .into_response()
96 }
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use axum::{body::Body, http::Request};
103 use http_body_util::BodyExt;
104 use sqlx::postgres::PgPoolOptions;
105 use tower::ServiceExt;
106
107 use super::*;
108
109 fn dead_pool() -> PgPool {
112 PgPoolOptions::new()
113 .acquire_timeout(std::time::Duration::from_secs(1))
115 .connect_lazy("postgres://nobody:nowhere@127.0.0.1:1/kasl")
116 .expect("lazy pool creation does not touch the network")
117 }
118
119 #[tokio::test]
120 async fn health_reports_degraded_without_a_database() {
121 let response = router(dead_pool()).oneshot(Request::get("/health").body(Body::empty()).unwrap()).await.unwrap();
122 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
123
124 let body = response.into_body().collect().await.unwrap().to_bytes();
125 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
126 assert_eq!(body["status"], "degraded");
127 assert_eq!(body["database"], "unavailable");
128 assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
129 }
130
131 #[tokio::test]
132 async fn unknown_routes_return_404() {
133 let response = router(dead_pool()).oneshot(Request::get("/nope").body(Body::empty()).unwrap()).await.unwrap();
134 assert_eq!(response.status(), StatusCode::NOT_FOUND);
135 }
136}