use axum::{
Json, Router,
extract::{DefaultBodyLimit, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::{delete, get, patch, post, put},
};
use serde_json::json;
use sqlx::PgPool;
use tower_http::trace::TraceLayer;
use crate::{admin, audit, config::Config, department, ingest, login};
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub max_batch_days: usize,
pub secure_cookies: bool,
}
pub fn router_with(pool: PgPool, config: &Config) -> Router {
let api_v1 = Router::new()
.route("/days", post(ingest::upload_day))
.route("/days/batch", post(ingest::upload_batch))
.route("/auth/login", post(login::login))
.route("/auth/logout", post(login::logout))
.route("/auth/logout-everywhere", post(login::logout_everywhere))
.route("/auth/me", get(login::me))
.route("/auth/password", post(admin::change_own_password))
.route("/users", get(admin::list_users).post(admin::create_user))
.route("/users/{id}", patch(admin::update_user))
.route("/users/{id}/agents", get(admin::list_agents).post(admin::create_agent))
.route("/agents/{id}", delete(admin::revoke_agent))
.route("/departments", get(department::list).post(department::create))
.route("/departments/{id}", patch(department::update).delete(department::delete))
.route("/users/{id}/department", put(department::assign))
.route("/audit", get(audit::list));
Router::new()
.route("/health", get(health))
.nest("/api/v1", api_v1)
.with_state(AppState {
pool,
max_batch_days: config.max_batch_days,
secure_cookies: config.secure_cookies,
})
.layer(DefaultBodyLimit::max(config.max_body_bytes))
.layer(TraceLayer::new_for_http())
}
pub fn router(pool: PgPool) -> Router {
router_with(pool, &Config::defaults_for_database(String::new()))
}
async fn health(State(state): State<AppState>) -> Response {
match sqlx::query("SELECT 1").execute(&state.pool).await {
Ok(_) => (
StatusCode::OK,
Json(json!({
"status": "ok",
"version": env!("CARGO_PKG_VERSION"),
"database": "ok",
})),
)
.into_response(),
Err(error) => {
tracing::error!(%error, "health check: database unreachable");
(
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"status": "degraded",
"version": env!("CARGO_PKG_VERSION"),
"database": "unavailable",
})),
)
.into_response()
}
}
}
#[cfg(test)]
mod tests {
use axum::{body::Body, http::Request};
use http_body_util::BodyExt;
use sqlx::postgres::PgPoolOptions;
use tower::ServiceExt;
use super::*;
fn dead_pool() -> PgPool {
PgPoolOptions::new()
.acquire_timeout(std::time::Duration::from_secs(1))
.connect_lazy("postgres://nobody:nowhere@127.0.0.1:1/kasl")
.expect("lazy pool creation does not touch the network")
}
#[tokio::test]
async fn health_reports_degraded_without_a_database() {
let response = router(dead_pool()).oneshot(Request::get("/health").body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = response.into_body().collect().await.unwrap().to_bytes();
let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(body["status"], "degraded");
assert_eq!(body["database"], "unavailable");
assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
}
#[tokio::test]
async fn unknown_routes_return_404() {
let response = router(dead_pool()).oneshot(Request::get("/nope").body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}