Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions backend/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@ mod types;
use axum::{routing::{get, post}, Router};
use std::sync::Arc;

use routes::cancel::cancel_invoice;
use routes::invoices::get_invoice;
use routes::pay::pay_invoice;
use routes::refund::refund_invoice;
use routes::{health::get_rpc_health, invoices::get_invoice, pay::pay_invoice};
use soroban::SorobanClient;

#[tokio::main]
Expand All @@ -17,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))
.route("/invoices/:id/cancel", post(cancel_invoice))
Expand Down
165 changes: 165 additions & 0 deletions backend/src/routes/health.rs
Original file line number Diff line number Diff line change
@@ -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<Arc<SorobanClient>>,
) -> 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);
}
}
2 changes: 1 addition & 1 deletion backend/src/routes/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
pub mod cancel;
pub mod health;
pub mod invoices;
pub mod pay;
pub mod refund;
2 changes: 2 additions & 0 deletions backend/src/routes/pay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down
47 changes: 45 additions & 2 deletions backend/src/soroban.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use anyhow::{anyhow, Result};
use reqwest::Client;
use serde_json::{json, Value};
use std::time::Duration;

use crate::types::{
CancelResponse, InvoiceResponse, InvoiceStatus, PayResponse, RefundResponse, RpcRequest,
Expand All @@ -14,15 +15,20 @@ const CONTRACT_NOT_PAID: u32 = 10;
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"),
}
}

Expand Down Expand Up @@ -55,6 +61,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.
///
Expand Down
19 changes: 19 additions & 0 deletions backend/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,25 @@ pub struct ErrorResponse {
pub code: Option<u32>,
}

#[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<String>,
}

#[derive(Debug, Serialize)]
pub struct RpcHealthResponse {
pub status: HealthStatus,
pub dependencies: std::collections::BTreeMap<String, DependencyHealth>,
}

#[derive(Debug, Serialize)]
pub struct RpcRequest {
pub jsonrpc: &'static str,
Expand Down