diff --git a/packages/core/Cargo.lock b/packages/core/Cargo.lock index c133cd8..243ad3b 100644 --- a/packages/core/Cargo.lock +++ b/packages/core/Cargo.lock @@ -2362,6 +2362,7 @@ name = "stellar-explain-core" version = "0.0.1" dependencies = [ "axum", + "bytes", "dotenvy", "governor", "httpmock", diff --git a/packages/core/Cargo.toml b/packages/core/Cargo.toml index 43ad1f9..2da1c9f 100644 --- a/packages/core/Cargo.toml +++ b/packages/core/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] axum = "0.7" +bytes = "1" tokio = { version = "1", features = ["full"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } diff --git a/packages/core/README.md b/packages/core/README.md index 22e31ca..c1d0701 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -92,6 +92,24 @@ Expected response: ok ``` +### GET /tx/:hash + +Returns a human-readable explanation of a Stellar transaction. + +```bash +curl http://localhost:4000/tx/ +``` + +### GET /tx/:hash/raw + +Returns the raw, unprocessed JSON response from Horizon for the given transaction hash. Useful for developers and power users who want direct access to the full Horizon data. + +```bash +curl http://localhost:4000/tx//raw +``` + +Applies the same error handling as `/tx/:hash` — 400 for invalid hashes, 404 for not found, 502 for upstream failures. + --- ## 🧪 Testing diff --git a/packages/core/src/main.rs b/packages/core/src/main.rs index e7d9de1..f41e386 100644 --- a/packages/core/src/main.rs +++ b/packages/core/src/main.rs @@ -121,6 +121,7 @@ async fn main() { let app = Router::new() .route("/health", get(health)) .route("/tx/:hash", get(routes::tx::get_tx_explanation)) + .route("/tx/:hash/raw", get(routes::tx::get_tx_raw)) // OpenAPI JSON .route( diff --git a/packages/core/src/routes/mod.rs b/packages/core/src/routes/mod.rs index f239de9..4df2bfc 100644 --- a/packages/core/src/routes/mod.rs +++ b/packages/core/src/routes/mod.rs @@ -4,7 +4,8 @@ use utoipa::OpenApi; #[openapi( paths( health::health, - tx::get_tx_explanation + tx::get_tx_explanation, + tx::get_tx_raw ), components( schemas( diff --git a/packages/core/src/routes/tx.rs b/packages/core/src/routes/tx.rs index c95c32f..b8bf5a7 100644 --- a/packages/core/src/routes/tx.rs +++ b/packages/core/src/routes/tx.rs @@ -1,5 +1,8 @@ use axum::{ + body::Body, extract::{Extension, Path, State}, + http::{header, StatusCode}, + response::Response, Json, }; use serde::Serialize; @@ -166,4 +169,83 @@ pub async fn get_tx_explanation( fn is_valid_transaction_hash(hash: &str) -> bool { hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) +} + +#[utoipa::path( + get, + path = "/tx/{hash}/raw", + params( + ("hash" = String, Path, description = "Transaction hash") + ), + responses( + (status = 200, description = "Raw Horizon transaction JSON"), + (status = 400, description = "Invalid transaction hash"), + (status = 404, description = "Transaction not found"), + (status = 502, description = "Upstream error from Horizon") + ) +)] +pub async fn get_tx_raw( + Path(hash): Path, + State(horizon_client): State>, + Extension(request_id): Extension, +) -> Result { + let span = info_span!( + "tx_raw_request", + request_id = %request_id, + hash = %hash + ); + let _span_guard = span.enter(); + let request_started_at = Instant::now(); + + info!( + request_id = %request_id, + hash = %hash, + "incoming_request" + ); + + if !is_valid_transaction_hash(&hash) { + let app_error = AppError::BadRequest( + "Invalid transaction hash format. Expected 64-character hexadecimal hash." + .to_string(), + ); + info!( + request_id = %request_id, + hash = %hash, + status = app_error.status_code().as_u16(), + total_duration_ms = request_started_at.elapsed().as_millis() as u64, + error = ?app_error, + "request_completed" + ); + return Err(app_error); + } + + let bytes = match horizon_client.fetch_transaction_raw(&hash).await { + Ok(bytes) => bytes, + Err(err) => { + let app_error: AppError = err.into(); + error!( + request_id = %request_id, + hash = %hash, + total_duration_ms = request_started_at.elapsed().as_millis() as u64, + status = app_error.status_code().as_u16(), + error = ?app_error, + "horizon_transaction_raw_fetch_failed" + ); + return Err(app_error); + } + }; + + info!( + request_id = %request_id, + hash = %hash, + total_duration_ms = request_started_at.elapsed().as_millis() as u64, + status = 200u16, + "request_completed" + ); + + Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(bytes)) + .expect("infallible response build")) } \ No newline at end of file diff --git a/packages/core/src/services/horizon.rs b/packages/core/src/services/horizon.rs index af5d413..d2ca4d2 100644 --- a/packages/core/src/services/horizon.rs +++ b/packages/core/src/services/horizon.rs @@ -119,6 +119,30 @@ impl HorizonClient { Some(FeeStats::new(base_fee, min_fee, max_fee, mode_fee, p90_fee)) } + /// Fetch the raw Horizon JSON bytes for a transaction without deserializing. + pub async fn fetch_transaction_raw( + &self, + hash: &str, + ) -> Result { + let url = format!("{}/transactions/{}", self.base_url, hash); + + let res = self + .client + .get(url) + .send() + .await + .map_err(|_| HorizonError::NetworkError)?; + + match res.status().as_u16() { + 200 => res + .bytes() + .await + .map_err(|_| HorizonError::InvalidResponse), + 404 => Err(HorizonError::TransactionNotFound), + _ => Err(HorizonError::InvalidResponse), + } + } + /// Check whether Horizon is reachable by hitting the root endpoint. pub async fn is_reachable(&self) -> bool { let url = format!("{}/", self.base_url); diff --git a/packages/core/src/services/horizon_test.rs b/packages/core/src/services/horizon_test.rs index 920f97a..684a54f 100644 --- a/packages/core/src/services/horizon_test.rs +++ b/packages/core/src/services/horizon_test.rs @@ -176,6 +176,55 @@ mod tests { matches!(err, crate::errors::HorizonError::AccountNotFound); } + #[tokio::test] + async fn fetch_transaction_raw_proxies_horizon_response() { + let server = MockServer::start(); + let raw_body = + r#"{"hash":"abc123","successful":true,"fee_charged":"100","ledger":12345}"#; + + server.mock(|when, then| { + when.method(GET).path("/transactions/abc123"); + then.status(200) + .header("content-type", "application/json") + .body(raw_body); + }); + + let client = HorizonClient::new(server.base_url()); + let bytes = client.fetch_transaction_raw("abc123").await.unwrap(); + + assert_eq!(bytes.as_ref(), raw_body.as_bytes()); + } + + #[tokio::test] + async fn fetch_transaction_raw_not_found() { + let server = MockServer::start(); + + server.mock(|when, then| { + when.method(GET).path("/transactions/missing"); + then.status(404); + }); + + let client = HorizonClient::new(server.base_url()); + let err = client.fetch_transaction_raw("missing").await.unwrap_err(); + + matches!(err, crate::errors::HorizonError::TransactionNotFound); + } + + #[tokio::test] + async fn fetch_transaction_raw_upstream_error() { + let server = MockServer::start(); + + server.mock(|when, then| { + when.method(GET).path("/transactions/bad"); + then.status(500); + }); + + let client = HorizonClient::new(server.base_url()); + let err = client.fetch_transaction_raw("bad").await.unwrap_err(); + + matches!(err, crate::errors::HorizonError::InvalidResponse); + } + #[tokio::test] async fn fetch_stellar_toml_org_name_with_cache() { let server = MockServer::start(); diff --git a/packages/core/src/services/mod.rs b/packages/core/src/services/mod.rs index ad4dad5..27d7228 100644 --- a/packages/core/src/services/mod.rs +++ b/packages/core/src/services/mod.rs @@ -2,3 +2,6 @@ pub mod explain; pub mod horizon; pub mod labels; pub mod transaction_cache; + +#[cfg(test)] +mod horizon_test;