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
325 changes: 321 additions & 4 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ prometheus = "0.13"
lru = "0.12"
parking_lot = "0.12"
lazy_static = "1"
synapse-sdk = { path = "sdks/rust" }

[dev-dependencies]
mockito = "1"
Expand Down
8 changes: 8 additions & 0 deletions cli/synapse-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ path = "src/cli_main.rs"
name = "synapse_cli"
path = "src/lib.rs"

[[bin]]
name = "mock-server"
path = "src/bin/mock-server.rs"

[lib]
name = "synapse_cli"
path = "src/lib.rs"

[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive", "env"] }
Expand Down
71 changes: 64 additions & 7 deletions cli/synapse-cli/src/bin/mock-server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ const SAMPLE_REPORT_ID: &str = "3f1d8c31-5f1d-4fb8-93e0-112233445566";
const SAMPLE_LOCK_TOKEN: &str = "4e4e9e47-7e0f-4f2f-8d63-323c61279209";

fn main() -> std::io::Result<()> {
let addr = std::env::var("MOCK_SERVER_ADDR")
.unwrap_or_else(|_| "127.0.0.1:4010".to_string());
let scenario = std::env::var("MOCK_SERVER_SCENARIO")
.unwrap_or_else(|_| "happy".to_string());

let listener = TcpListener::bind(&addr)?;
eprintln!("Mock Synapse API listening on http://{addr} (scenario={scenario})");
let addr = std::env::var("MOCK_SERVER_ADDR").unwrap_or_else(|_| ADDRESS.to_string());
let scenario = std::env::var("MOCK_SERVER_SCENARIO").unwrap_or_else(|_| "happy".to_string());
let listener = TcpListener::bind(&addr)?;
Expand Down Expand Up @@ -52,13 +59,34 @@ fn route(request_line: &str, scenario: &str) -> String {
match (method, path) {
// ── Reconciliation ────────────────────────────────────────────────────
("POST", "/admin/reconciliation/run") => {
let body = if scenario == "edge" {
format!(
r#"{{
"message": "Reconciliation completed successfully",
"report": {{
"id": "{SAMPLE_REPORT_ID}",
"generated_at": "2026-06-27T06:10:12Z",
"period_start": "2026-06-26T06:10:12Z",
"period_end": "2026-06-27T06:10:12Z",
"total_db_transactions": 0,
"total_chain_payments": 0,
"missing_on_chain_count": 0,
"orphaned_payments_count": 0,
"amount_mismatches_count": 0,
"has_discrepancies": false
}}
}}"#
)
} else {
format!(
r#"{{
if scenario == "edge" {
json_response(200, &run_body(false, 0, 0))
} else {
r#"{
"message": "Reconciliation completed successfully",
"report": {
"id": "3f1d8c31-5f1d-4fb8-93e0-112233445566",
"report": {{
"id": "{SAMPLE_REPORT_ID}",
"generated_at": "2026-06-27T06:10:12Z",
"period_start": "2026-06-26T06:10:12Z",
"period_end": "2026-06-27T06:10:12Z",
Expand All @@ -68,9 +96,23 @@ fn route(request_line: &str, scenario: &str) -> String {
"orphaned_payments_count": 0,
"amount_mismatches_count": 1,
"has_discrepancies": true
}
}"#
}}
}}"#
)
};
json_response(200, &body)
}

("GET", path) if path.starts_with("/admin/reconciliation/reports?") => {
let query = path.split_once('?').map(|(_, q)| q).unwrap_or_default();
let params = parse_query(query);
let limit = params
.get("limit")
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(20);
let offset = params
.get("offset")
.and_then(|v| v.parse::<i32>().ok())

json_response(200, body)
json_response(200, &run_body(true, 12, 11))
Expand Down Expand Up @@ -126,7 +168,6 @@ fn route(request_line: &str, scenario: &str) -> String {
),
)
};

json_response(200, &body)
}
("GET", path) if path.starts_with("/events/watch") => {
Expand Down Expand Up @@ -241,6 +282,22 @@ fn report_detail(report_id: &str, has_discrepancies: bool, db: i32, chain: i32)
"orphaned_payments": [],
"amount_mismatches": []
}}"#
)
};
json_response(200, &body)
}

("POST", "/graphql") => {
// Consume request body (read remaining headers + body) so the client
// does not get a broken-pipe error. For tests we just serve a fixed
// happy-path response regardless of query content.
json_response(
200,
r#"{"data":{"transactions":[{"id":"550e8400-e29b-41d4-a716-446655440000","status":"pending"}]}}"#,
)
}

_ => json_response(404, r#"{"error":"Not found"}"#),
)
}

Expand Down Expand Up @@ -284,8 +341,8 @@ fn json_response(status: u16, body: &str) -> String {
_ => "OK",
};
format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}",
len = body.len(),
)
}

Expand Down
127 changes: 127 additions & 0 deletions cli/synapse-cli/src/client.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
use anyhow::Result;
use reqwest::Client;
use serde_json::Value;
use anyhow::{bail, Context, Result};

pub struct SynapseCliClient {
Expand All @@ -14,6 +17,9 @@ impl SynapseCliClient {
}

pub async fn get_json<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
let url = format!("{}{}", self.base_url, path);
let response = self.client.get(&url).send().await?;
response.json().await.map_err(|e| anyhow::anyhow!(e))
self.send(self.client.get(self.url(path))).await
}

Expand All @@ -27,6 +33,90 @@ impl SynapseCliClient {
}

pub async fn get_bytes(&self, path: &str, query_params: &[(&str, &str)]) -> Result<Vec<u8>> {
let url = format!("{}{}", self.base_url, path);
let mut req = self.client.get(&url);

for (key, value) in query_params {
req = req.query(&[(key, value)]);
}

let response = req.send().await?;
response
.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| anyhow::anyhow!(e))
}

/// POST a JSON body to `path` and deserialize the response as `T`.
///
/// Returns an error for non-2xx HTTP status codes. On success the raw
/// response body is deserialized — callers are responsible for inspecting
/// the returned value for application-level GraphQL errors.
pub async fn post_json<T: serde::de::DeserializeOwned>(
&self,
path: &str,
body: &Value,
) -> Result<T> {
let url = format!("{}{}", self.base_url, path);
let response = self.client.post(&url).json(body).send().await?;

let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
anyhow::bail!("HTTP {}: {}", status.as_u16(), text);
}

response.json::<T>().await.map_err(|e| anyhow::anyhow!(e))
}
}

/// Generic API client used by the health and stats command modules.
/// Sends requests with an `X-API-Key` header and surfaces non-2xx responses
/// as errors.
pub struct ApiClient {
base_url: String,
api_key: String,
client: Client,
}

impl ApiClient {
pub fn new(base_url: &str, api_key: &str) -> Self {
Self {
base_url: base_url.trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
client: Client::new(),
}
}

pub async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> anyhow::Result<T> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.client
.get(&url)
.header("X-API-Key", &self.api_key)
.send()
.await?;

let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("HTTP {}: {}", status.as_u16(), body);
}

resp.json::<T>().await.map_err(|e| anyhow::anyhow!(e))
}

pub async fn get_with_query<T: serde::de::DeserializeOwned>(
&self,
path: &str,
query_params: &[(&str, &str)],
) -> anyhow::Result<T> {
let url = format!("{}{}", self.base_url, path);
let mut req = self
.client
.get(&url)
.header("X-API-Key", &self.api_key);
let response = self
.client
.get(self.url(path))
Expand All @@ -47,13 +137,50 @@ impl SynapseCliClient {
);
}

let resp = req.send().await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("HTTP {}: {}", status.as_u16(), body);
}

resp.json::<T>().await.map_err(|e| anyhow::anyhow!(e))
}
}

/// Thin client used by older command modules that need per-request API-key
/// injection and typed error variants.
#[derive(Debug)]
pub enum ClientError {
NotFound(String),
Http { status: u16, body: String },
Network(String),
}

impl std::fmt::Display for ClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ClientError::NotFound(msg) => write!(f, "Not found: {}", msg),
ClientError::Http { status, body } => write!(f, "HTTP {}: {}", status, body),
ClientError::Network(msg) => write!(f, "Network error: {}", msg),
}
Ok(bytes.to_vec())
}

fn url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}

/// Fetch a transaction by ID. Returns `NotFound` for 404, `Http` for other
/// non-success statuses.
pub async fn get_transaction(&self, id: &str) -> Result<Value, ClientError> {
let url = format!("{}/transactions/{}", self.base_url, id);
let client = reqwest::Client::new();

let resp = client
.get(&url)
.header("X-API-Key", &self.api_key)
.send()
async fn send<T: serde::de::DeserializeOwned>(
&self,
request: reqwest::RequestBuilder,
Expand Down
41 changes: 41 additions & 0 deletions cli/synapse-cli/src/commands/graphql.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
use clap::{Args, Subcommand};

/// Top-level argument group for the `graphql` subcommand.
#[derive(Args)]
pub struct GraphqlCmd {
#[command(subcommand)]
pub command: GraphqlSubcommand,
}

/// Subcommands available under `synapse graphql`.
#[derive(Subcommand)]
pub enum GraphqlSubcommand {
/// Send a raw GraphQL query to `POST /graphql` and print the response.
///
/// Exit codes:
/// 0 – success
/// 1 – GraphQL application error (HTTP 200 with `errors` array) or network/HTTP error
///
/// Output formats:
/// table – human-readable key/value output (default)
/// json – pretty-printed JSON response body
#[command(
about = "Send a raw GraphQL query and print the response",
long_about = "Send a raw GraphQL query to POST /graphql and print the result.\n\n\
Exit codes:\n \
0 - Success\n \
1 - GraphQL application error or network/HTTP failure\n\n\
Output formats:\n \
table - Human-readable output (default)\n \
json - Pretty-printed JSON"
)]
Query {
/// The GraphQL query string (e.g. \"{ transactions { id status } }\")
#[arg(long)]
query: String,

/// Output format: 'table' (default) or 'json'
#[arg(long, default_value = "table")]
format: String,
},
}
4 changes: 4 additions & 0 deletions cli/synapse-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
pub mod graphql;
pub mod events;
pub mod health;
pub mod settlements;
pub mod stats;
pub mod transactions;

pub use settlements::SettlementsCmd;
pub use transactions::TransactionsCmd;

pub use events::EventsCmd;
pub use settlements::SettlementsCmd;
pub use transactions::TransactionsCmd;
Expand Down
Loading
Loading