Skip to main content

dbrest_core/app/
admin.rs

1//! Admin server
2//!
3//! Provides health check, readiness, metrics, and config endpoints on a
4//! separate port for operational monitoring.
5//!
6//! # Endpoints
7//!
8//! | Path        | Method | Description                          |
9//! |-------------|--------|--------------------------------------|
10//! | `/live`     | GET    | Liveness probe (always 200)          |
11//! | `/ready`    | GET    | Readiness probe (200 if schema ready)|
12//! | `/metrics`  | GET    | Basic metrics (JSON)                 |
13//! | `/config`   | GET    | Current config (redacted secrets)    |
14
15use std::sync::atomic::Ordering;
16
17use axum::{
18    Router,
19    body::Body,
20    extract::State,
21    http::{StatusCode, header},
22    response::{IntoResponse, Response},
23    routing::get,
24};
25
26use super::state::AppState;
27
28/// Build a JSON response, returning 500 if the builder somehow fails.
29fn json_response(body: String) -> Response {
30    Response::builder()
31        .status(StatusCode::OK)
32        .header(header::CONTENT_TYPE, "application/json; charset=utf-8")
33        .body(Body::from(body))
34        .unwrap_or_else(|_| {
35            Response::builder()
36                .status(StatusCode::INTERNAL_SERVER_ERROR)
37                .body(Body::from("Internal Server Error"))
38                .expect("static 500 response must be valid")
39        })
40}
41
42/// Create the admin router.
43pub fn create_admin_router(state: AppState) -> Router {
44    Router::new()
45        .route("/live", get(liveness))
46        .route("/ready", get(readiness))
47        .route("/metrics", get(metrics))
48        .route("/config", get(config_handler))
49        .with_state(state)
50}
51
52/// Liveness probe — always returns 200 OK.
53async fn liveness() -> Response {
54    StatusCode::OK.into_response()
55}
56
57/// Readiness probe — returns 200 if schema cache is loaded, 503 otherwise.
58async fn readiness(State(state): State<AppState>) -> Response {
59    let cache_guard = state.schema_cache.load();
60    if cache_guard.is_some() {
61        StatusCode::OK.into_response()
62    } else {
63        StatusCode::SERVICE_UNAVAILABLE.into_response()
64    }
65}
66
67/// Current configuration (JSON, secrets redacted).
68async fn config_handler(State(state): State<AppState>) -> Response {
69    let config = state.config.load();
70    let body = redacted_config(&config);
71    let json = serde_json::to_string_pretty(&body).unwrap_or_else(|_| "{}".to_string());
72    json_response(json)
73}
74
75/// Serialize config to JSON with secrets redacted.
76pub fn redacted_config(config: &crate::config::AppConfig) -> serde_json::Value {
77    serde_json::json!({
78        "db_uri": "***",
79        "db_schemas": config.db_schemas,
80        "db_anon_role": config.db_anon_role,
81        "db_pool_size": config.db_pool_size,
82        "db_channel": config.db_channel,
83        "db_channel_enabled": config.db_channel_enabled,
84        "db_max_rows": config.db_max_rows,
85        "server_host": config.server_host,
86        "server_port": config.server_port,
87        "server_timing_enabled": config.server_timing_enabled,
88        "jwt_secret": if config.jwt_secret.is_some() { "***" } else { "" },
89        "jwt_secret_is_base64": config.jwt_secret_is_base64,
90        "log_level": config.log_level.as_str(),
91        "openapi_mode": config.openapi_mode.as_str(),
92    })
93}
94
95/// Basic metrics endpoint (JSON).
96async fn metrics(State(state): State<AppState>) -> Response {
97    let m = &state.metrics;
98    let body = serde_json::json!({
99        "requests_total": m.requests_total.load(Ordering::Relaxed),
100        "requests_success": m.requests_success.load(Ordering::Relaxed),
101        "requests_error": m.requests_error.load(Ordering::Relaxed),
102        "db_queries_total": m.db_queries_total.load(Ordering::Relaxed),
103        "schema_cache_reloads": m.schema_cache_reloads.load(Ordering::Relaxed),
104        "jwt_cache_hits": m.jwt_cache_hits.load(Ordering::Relaxed),
105        "jwt_cache_misses": m.jwt_cache_misses.load(Ordering::Relaxed),
106        "jwt_cache_entries": state.jwt_cache.entry_count(),
107        "pg_version": format!("{}.{}.{}", state.pg_version.major, state.pg_version.minor, state.pg_version.patch),
108    });
109
110    let json = serde_json::to_string_pretty(&body).unwrap_or_else(|_| "{}".to_string());
111    json_response(json)
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::config::AppConfig;
118
119    #[test]
120    fn redacted_config_hides_db_uri() {
121        let config = AppConfig::default();
122        let json = redacted_config(&config);
123        assert_eq!(json["db_uri"], "***");
124    }
125
126    #[test]
127    fn redacted_config_hides_jwt_secret_when_set() {
128        let mut config = AppConfig::default();
129        config.jwt_secret = Some("super-secret-key".to_string());
130        let json = redacted_config(&config);
131        assert_eq!(json["jwt_secret"], "***");
132    }
133
134    #[test]
135    fn redacted_config_empty_jwt_when_none() {
136        let config = AppConfig::default();
137        let json = redacted_config(&config);
138        assert_eq!(json["jwt_secret"], "");
139    }
140
141    #[test]
142    fn redacted_config_preserves_schemas() {
143        let mut config = AppConfig::default();
144        config.db_schemas = vec!["public".to_string(), "api".to_string()];
145        let json = redacted_config(&config);
146        let schemas = json["db_schemas"].as_array().unwrap();
147        assert_eq!(schemas.len(), 2);
148        assert_eq!(schemas[0], "public");
149        assert_eq!(schemas[1], "api");
150    }
151
152    #[test]
153    fn redacted_config_preserves_server_fields() {
154        let mut config = AppConfig::default();
155        config.server_host = "0.0.0.0".to_string();
156        config.server_port = 8080;
157        config.server_timing_enabled = true;
158        let json = redacted_config(&config);
159        assert_eq!(json["server_host"], "0.0.0.0");
160        assert_eq!(json["server_port"], 8080);
161        assert_eq!(json["server_timing_enabled"], true);
162    }
163
164    #[test]
165    fn redacted_config_preserves_db_pool_and_channel() {
166        let mut config = AppConfig::default();
167        config.db_pool_size = 20;
168        config.db_channel = "dbrst".to_string();
169        config.db_channel_enabled = true;
170        config.db_max_rows = Some(1000);
171        let json = redacted_config(&config);
172        assert_eq!(json["db_pool_size"], 20);
173        assert_eq!(json["db_channel"], "dbrst");
174        assert_eq!(json["db_channel_enabled"], true);
175        assert_eq!(json["db_max_rows"], 1000);
176    }
177}