An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

feat(relay): implement GET /xrpc/_health endpoint (MM-73)

Adds the first real XRPC route establishing the pattern for all subsequent
endpoint additions. Returns {"version":"0.1.0","db":"ok"} on 200 or
{"version":"0.1.0","db":"error"} on 503 when the SQLite pool is unreachable.

- Add routes/ module with one file per endpoint (routes/health.rs)
- Register /xrpc/_health before the catch-all /xrpc/:method route
- Promote test_state() to pub(crate) so per-endpoint test modules can share it
- Remove dead_code suppression on AppState.db now that a handler uses it

authored by malpercio.dev and committed by

Tangled a83e7a27 2ed775c6

+163 -26
+1
Cargo.lock
··· 1260 1260 "axum", 1261 1261 "clap", 1262 1262 "common", 1263 + "serde", 1263 1264 "serde_json", 1264 1265 "sqlx", 1265 1266 "tempfile",
+1
crates/relay/Cargo.toml
··· 20 20 tracing-subscriber = { workspace = true } 21 21 tokio = { workspace = true } 22 22 tower-http = { workspace = true } 23 + serde = { workspace = true } 23 24 sqlx = { workspace = true } 24 25 25 26 [dev-dependencies]
+31 -26
crates/relay/src/app.rs
··· 4 4 use common::{ApiError, Config, ErrorCode}; 5 5 use tower_http::{cors::CorsLayer, trace::TraceLayer}; 6 6 7 + use crate::routes::health::health; 8 + 7 9 /// Shared application state cloned into every request handler via Axum's `State` extractor. 8 - /// Fields are marked as dead_code until XRPC endpoint handlers are implemented and read them. 9 10 #[derive(Clone)] 10 11 pub struct AppState { 11 12 #[allow(dead_code)] 12 13 pub config: Arc<Config>, 13 - #[allow(dead_code)] 14 14 pub db: sqlx::SqlitePool, 15 15 } 16 16 ··· 20 20 /// listener — callers can use `tower::ServiceExt::oneshot` to drive requests in tests. 21 21 pub fn app(state: AppState) -> Router { 22 22 Router::new() 23 + .route("/xrpc/_health", get(health)) 23 24 .route("/xrpc/:method", get(xrpc_handler).post(xrpc_handler)) 24 25 .layer(CorsLayer::permissive()) 25 26 .layer(TraceLayer::new_for_http()) ··· 37 38 ) 38 39 } 39 40 41 + /// Build a minimal `AppState` backed by an in-memory SQLite database. 42 + /// Available to all test modules in this crate via `crate::app::test_state()`. 43 + #[cfg(test)] 44 + pub(crate) async fn test_state() -> AppState { 45 + use common::{BlobsConfig, IrohConfig, OAuthConfig}; 46 + use std::path::PathBuf; 47 + 48 + let pool = crate::db::open_pool("sqlite::memory:") 49 + .await 50 + .expect("failed to open test pool"); 51 + crate::db::run_migrations(&pool) 52 + .await 53 + .expect("failed to run test migrations"); 54 + AppState { 55 + config: Arc::new(Config { 56 + bind_address: "127.0.0.1".to_string(), 57 + port: 8080, 58 + data_dir: PathBuf::from("/tmp"), 59 + database_url: "sqlite::memory:".to_string(), 60 + public_url: "https://test.example.com".to_string(), 61 + blobs: BlobsConfig::default(), 62 + oauth: OAuthConfig::default(), 63 + iroh: IrohConfig::default(), 64 + }), 65 + db: pool, 66 + } 67 + } 68 + 40 69 #[cfg(test)] 41 70 mod tests { 42 71 use super::*; ··· 44 73 body::Body, 45 74 http::{Request, StatusCode}, 46 75 }; 47 - use common::{BlobsConfig, IrohConfig, OAuthConfig}; 48 - use std::path::PathBuf; 49 76 use tower::ServiceExt; 50 - 51 - async fn test_state() -> AppState { 52 - let pool = crate::db::open_pool("sqlite::memory:") 53 - .await 54 - .expect("failed to open test pool"); 55 - crate::db::run_migrations(&pool) 56 - .await 57 - .expect("failed to run test migrations"); 58 - AppState { 59 - config: Arc::new(Config { 60 - bind_address: "127.0.0.1".to_string(), 61 - port: 8080, 62 - data_dir: PathBuf::from("/tmp"), 63 - database_url: "sqlite::memory:".to_string(), 64 - public_url: "https://test.example.com".to_string(), 65 - blobs: BlobsConfig::default(), 66 - oauth: OAuthConfig::default(), 67 - iroh: IrohConfig::default(), 68 - }), 69 - db: pool, 70 - } 71 - } 72 77 73 78 #[tokio::test] 74 79 async fn xrpc_get_unknown_method_returns_501() {
+1
crates/relay/src/main.rs
··· 4 4 5 5 mod app; 6 6 mod db; 7 + mod routes; 7 8 8 9 /// Convert a config database_url (which may be a plain filesystem path or a sqlx URL) to a valid sqlx URL. 9 10 ///
+128
crates/relay/src/routes/health.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: DB health via SELECT 1 4 + // Processes: none (response shape is trivial — no pure core to extract) 5 + // Returns: JSON response with version and db status 6 + 7 + use axum::{extract::State, http::StatusCode, response::{IntoResponse, Json}}; 8 + use serde::Serialize; 9 + 10 + use crate::app::AppState; 11 + 12 + #[derive(Serialize)] 13 + struct HealthResponse { 14 + version: &'static str, 15 + db: &'static str, 16 + } 17 + 18 + pub async fn health(State(state): State<AppState>) -> impl IntoResponse { 19 + let version = env!("CARGO_PKG_VERSION"); 20 + match sqlx::query("SELECT 1").execute(&state.db).await { 21 + Ok(_) => ( 22 + StatusCode::OK, 23 + Json(HealthResponse { version, db: "ok" }), 24 + ), 25 + Err(_) => ( 26 + StatusCode::SERVICE_UNAVAILABLE, 27 + Json(HealthResponse { 28 + version, 29 + db: "error", 30 + }), 31 + ), 32 + } 33 + } 34 + 35 + #[cfg(test)] 36 + mod tests { 37 + use axum::{ 38 + body::Body, 39 + http::{Request, StatusCode}, 40 + }; 41 + use tower::ServiceExt; 42 + 43 + use crate::app::{app, test_state}; 44 + 45 + #[tokio::test] 46 + async fn health_returns_200_with_db_ok() { 47 + let response = app(test_state().await) 48 + .oneshot( 49 + Request::builder() 50 + .uri("/xrpc/_health") 51 + .body(Body::empty()) 52 + .unwrap(), 53 + ) 54 + .await 55 + .unwrap(); 56 + 57 + assert_eq!(response.status(), StatusCode::OK); 58 + 59 + let body = axum::body::to_bytes(response.into_body(), 4096) 60 + .await 61 + .unwrap(); 62 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 63 + assert_eq!(json["db"], "ok"); 64 + } 65 + 66 + #[tokio::test] 67 + async fn health_version_is_cargo_pkg_version() { 68 + let response = app(test_state().await) 69 + .oneshot( 70 + Request::builder() 71 + .uri("/xrpc/_health") 72 + .body(Body::empty()) 73 + .unwrap(), 74 + ) 75 + .await 76 + .unwrap(); 77 + 78 + let body = axum::body::to_bytes(response.into_body(), 4096) 79 + .await 80 + .unwrap(); 81 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 82 + assert_eq!(json["version"], env!("CARGO_PKG_VERSION")); 83 + } 84 + 85 + #[tokio::test] 86 + async fn health_response_has_json_content_type() { 87 + let response = app(test_state().await) 88 + .oneshot( 89 + Request::builder() 90 + .uri("/xrpc/_health") 91 + .body(Body::empty()) 92 + .unwrap(), 93 + ) 94 + .await 95 + .unwrap(); 96 + 97 + assert_eq!( 98 + response.headers().get("content-type").unwrap(), 99 + "application/json" 100 + ); 101 + } 102 + 103 + #[tokio::test] 104 + async fn health_db_error_returns_503_with_db_error() { 105 + let state = test_state().await; 106 + // Closing the pool causes the next acquire() to fail, simulating DB unavailability. 107 + state.db.close().await; 108 + 109 + let response = app(state) 110 + .oneshot( 111 + Request::builder() 112 + .uri("/xrpc/_health") 113 + .body(Body::empty()) 114 + .unwrap(), 115 + ) 116 + .await 117 + .unwrap(); 118 + 119 + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); 120 + 121 + let body = axum::body::to_bytes(response.into_body(), 4096) 122 + .await 123 + .unwrap(); 124 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 125 + assert_eq!(json["db"], "error"); 126 + assert_eq!(json["version"], env!("CARGO_PKG_VERSION")); 127 + } 128 + }
+1
crates/relay/src/routes/mod.rs
··· 1 + pub mod health;