From f8210315cbaf91531b3714e132b4a5b27d8109d6 Mon Sep 17 00:00:00 2001 From: shakourllahfashola-dev Date: Sat, 27 Jun 2026 14:16:26 +0000 Subject: [PATCH] feat: add GET /health/rpc endpoint with Soroban RPC and Horizon checks --- backend/src/main.rs | 8 +- backend/src/routes/health.rs | 165 +++++++++++++++++++++++++++++++++++ backend/src/routes/mod.rs | 1 + backend/src/routes/pay.rs | 2 + backend/src/soroban.rs | 47 +++++++++- backend/src/types.rs | 19 ++++ 6 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 backend/src/routes/health.rs diff --git a/backend/src/main.rs b/backend/src/main.rs index a2ebcd9..c00b585 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -5,8 +5,7 @@ mod types; use axum::{routing::{get, post}, Router}; use std::sync::Arc; -use routes::invoices::get_invoice; -use routes::pay::pay_invoice; +use routes::{health::get_rpc_health, invoices::get_invoice, pay::pay_invoice}; use soroban::SorobanClient; #[tokio::main] @@ -15,10 +14,13 @@ async fn main() { .unwrap_or_else(|_| "http://localhost:8000/soroban/rpc".to_string()); let contract_id = std::env::var("INVOICE_CONTRACT_ID") .unwrap_or_else(|_| "CONTRACT_ID_PLACEHOLDER".to_string()); + let horizon_url = std::env::var("HORIZON_API_URL") + .unwrap_or_else(|_| "https://horizon.stellar.org".to_string()); - let client = Arc::new(SorobanClient::new(rpc_url, contract_id)); + let client = Arc::new(SorobanClient::new(rpc_url, contract_id, horizon_url)); let app = Router::new() + .route("/health/rpc", get(get_rpc_health)) .route("/invoices/:id", get(get_invoice)) .route("/invoices/:id/pay", post(pay_invoice)) .with_state(client); diff --git a/backend/src/routes/health.rs b/backend/src/routes/health.rs new file mode 100644 index 0000000..16a8f4b --- /dev/null +++ b/backend/src/routes/health.rs @@ -0,0 +1,165 @@ +use axum::{ + extract::State, + http::StatusCode, + response::IntoResponse, + Json, +}; +use std::{collections::BTreeMap, sync::Arc}; + +use crate::{ + soroban::SorobanClient, + types::{DependencyHealth, HealthStatus, RpcHealthResponse}, +}; + +pub async fn get_rpc_health( + State(client): State>, +) -> impl IntoResponse { + let soroban_rpc = client.check_rpc_health().await; + let horizon = client.check_horizon_health().await; + + let soroban_health = match soroban_rpc { + Ok(()) => DependencyHealth { + status: HealthStatus::Healthy, + detail: Some("Soroban RPC responded to getLatestLedger".to_string()), + }, + Err(err) => DependencyHealth { + status: HealthStatus::Degraded, + detail: Some(err.to_string()), + }, + }; + + let horizon_health = match horizon { + Ok(()) => DependencyHealth { + status: HealthStatus::Healthy, + detail: Some("Horizon health endpoint responded".to_string()), + }, + Err(err) => DependencyHealth { + status: HealthStatus::Degraded, + detail: Some(err.to_string()), + }, + }; + + let mut dependencies = BTreeMap::new(); + dependencies.insert("soroban_rpc".to_string(), soroban_health); + dependencies.insert("horizon".to_string(), horizon_health); + + let overall_status = if dependencies.values().all(|dep| dep.status == HealthStatus::Healthy) { + HealthStatus::Healthy + } else { + HealthStatus::Degraded + }; + + let status_code = match overall_status { + HealthStatus::Healthy => StatusCode::OK, + HealthStatus::Degraded => StatusCode::SERVICE_UNAVAILABLE, + }; + + let response = RpcHealthResponse { + status: overall_status, + dependencies, + }; + + (status_code, Json(response)).into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::soroban::SorobanClient; + use axum::{ + body::Body, + http::{Request, StatusCode}, + routing::{get, post}, + Router, + }; + use serde_json::json; + use std::{net::SocketAddr, sync::Arc}; + use tokio::net::TcpListener; + + async fn spawn_test_server(healthy: bool) -> SocketAddr { + let app = Router::new() + .route( + "/soroban/rpc", + post(move || async move { + if healthy { + axum::Json(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { "sequence": 42 } + })) + } else { + StatusCode::INTERNAL_SERVER_ERROR + } + }), + ) + .route( + "/health", + get(move || async move { + if healthy { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + } + }), + ) + .route("/health/rpc", get(get_rpc_health)); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + addr + } + + #[tokio::test] + async fn returns_200_when_all_dependencies_are_healthy() { + let addr = spawn_test_server(true).await; + let client = Arc::new(SorobanClient::new( + format!("http://{addr}/soroban/rpc"), + "contract".to_string(), + format!("http://{addr}"), + )); + + let app = Router::new() + .route("/health/rpc", get(get_rpc_health)) + .with_state(client); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let health_addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let response = reqwest::get(format!("http://{health_addr}/health/rpc")) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn returns_503_when_any_dependency_is_degraded() { + let addr = spawn_test_server(false).await; + let client = Arc::new(SorobanClient::new( + format!("http://{addr}/soroban/rpc"), + "contract".to_string(), + format!("http://{addr}"), + )); + + let app = Router::new() + .route("/health/rpc", get(get_rpc_health)) + .with_state(client); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let health_addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let response = reqwest::get(format!("http://{health_addr}/health/rpc")) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + } +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 02a6e8b..4d5a2c8 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -1,2 +1,3 @@ +pub mod health; pub mod invoices; pub mod pay; diff --git a/backend/src/routes/pay.rs b/backend/src/routes/pay.rs index 1ecc9e0..8e1ad6d 100644 --- a/backend/src/routes/pay.rs +++ b/backend/src/routes/pay.rs @@ -65,6 +65,7 @@ mod tests { let client = SorobanClient::new( "http://127.0.0.1:19999/soroban/rpc".to_string(), "CONTRACT_ID".to_string(), + "https://horizon.stellar.org".to_string(), ); let app = make_app(client); let server = TestServer::new(app).unwrap(); @@ -79,6 +80,7 @@ mod tests { let client = SorobanClient::new( "http://127.0.0.1:19999/soroban/rpc".to_string(), "CONTRACT_ID".to_string(), + "https://horizon.stellar.org".to_string(), ); let app = make_app(client); let server = TestServer::new(app).unwrap(); diff --git a/backend/src/soroban.rs b/backend/src/soroban.rs index c164e17..813effe 100644 --- a/backend/src/soroban.rs +++ b/backend/src/soroban.rs @@ -1,6 +1,7 @@ use anyhow::{anyhow, Result}; use reqwest::Client; use serde_json::{json, Value}; +use std::time::Duration; use crate::types::{InvoiceResponse, InvoiceStatus, PayResponse, RpcRequest, RpcResponse}; @@ -10,15 +11,20 @@ const CONTRACT_UNAUTHORIZED: u32 = 1; pub struct SorobanClient { pub rpc_url: String, pub contract_id: String, + pub horizon_url: String, http: Client, } impl SorobanClient { - pub fn new(rpc_url: String, contract_id: String) -> Self { + pub fn new(rpc_url: String, contract_id: String, horizon_url: String) -> Self { Self { rpc_url, contract_id, - http: Client::new(), + horizon_url, + http: Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .expect("reqwest client should be created"), } } @@ -51,6 +57,43 @@ impl SorobanClient { parse_invoice_result(&result, invoice_id) } + pub async fn check_rpc_health(&self) -> Result<()> { + let req = RpcRequest { + jsonrpc: "2.0", + id: 3, + method: "getLatestLedger", + params: json!([]), + }; + + let resp: RpcResponse = self + .http + .post(&self.rpc_url) + .json(&req) + .send() + .await? + .json() + .await?; + + if let Some(err) = resp.error { + return Err(rpc_error_to_anyhow(&err)); + } + + resp.result + .ok_or_else(|| anyhow!("Empty RPC result")) + .map(|_| ()) + } + + pub async fn check_horizon_health(&self) -> Result<()> { + let health_url = format!("{}/health", self.horizon_url.trim_end_matches('/')); + let response = self.http.get(&health_url).send().await?; + + if !response.status().is_success() { + return Err(anyhow!("Horizon health check failed with status {}", response.status())); + } + + Ok(()) + } + /// Submit a signed mark_paid transaction to Soroban. /// Returns the updated invoice status and transaction hash. /// diff --git a/backend/src/types.rs b/backend/src/types.rs index b1cf746..5fab186 100644 --- a/backend/src/types.rs +++ b/backend/src/types.rs @@ -61,6 +61,25 @@ pub struct ErrorResponse { pub code: Option, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum HealthStatus { + Healthy, + Degraded, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DependencyHealth { + pub status: HealthStatus, + pub detail: Option, +} + +#[derive(Debug, Serialize)] +pub struct RpcHealthResponse { + pub status: HealthStatus, + pub dependencies: std::collections::BTreeMap, +} + #[derive(Debug, Serialize)] pub struct RpcRequest { pub jsonrpc: &'static str,