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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

93 changes: 93 additions & 0 deletions cmd/crates/soroban-test/tests/it/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,99 @@ fn set_and_remove_network() {
});
}

// When `--output json*` is requested, a failing command must render a
// structured `{ "error": { "message": … } }` envelope on stdout and exit
// non-zero, so machine consumers can parse the failure and still detect it via
// the exit code. An unreachable RPC is a representative, network-free failure.
#[test]
fn json_output_renders_structured_errors_and_exits_non_zero() {
let sandbox = TestEnv::default();

for format in ["json", "json-formatted"] {
let stdout = sandbox
.new_assert_cmd("network")
.arg("health")
.args([
"--rpc-url=http://127.0.0.1:1",
"--network-passphrase",
LOCAL_NETWORK_PASSPHRASE,
&format!("--output={format}"),
])
.assert()
.failure()
.stdout_as_str();

let value: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("invalid JSON ({format}): {e}\n{stdout}"));

assert!(
value
.get("error")
.and_then(|error| error.get("message"))
.and_then(serde_json::Value::as_str)
.is_some(),
"expected an `error.message` string ({format}), got: {stdout}"
);
}
}

// The JSON error envelope is scoped to commands migrated to the `Output`
// system. A command that is *not* migrated (here `ledger latest`) must keep its
// previous failure behavior even when invoked with `--output json`: no envelope
// on stdout, the human `error:` line on stderr, and a non-zero exit. This guards
// against silently changing the failure contract of unmigrated commands.
#[test]
fn non_migrated_command_keeps_human_error_line_in_json_mode() {
let sandbox = TestEnv::default();

sandbox
.new_assert_cmd("ledger")
.arg("latest")
.args([
"--rpc-url=http://127.0.0.1:1",
"--network-passphrase",
LOCAL_NETWORK_PASSPHRASE,
"--output=json",
])
.assert()
.failure()
.stdout("")
.stderr(predicate::str::contains("error:"));
}

// `network settings` defaults its `--output` to json, so a failure with no
// explicit flag must still render the structured JSON envelope on stdout (and
// exit non-zero). This is resolved by reading the parsed command's own output
// value rather than sniffing raw args, which cannot see per-command defaults.
#[test]
fn migrated_command_default_json_renders_structured_error() {
let sandbox = TestEnv::default();

let stdout = sandbox
.new_assert_cmd("network")
.arg("settings")
.args([
"--rpc-url=http://127.0.0.1:1",
"--network-passphrase",
LOCAL_NETWORK_PASSPHRASE,
])
.assert()
.failure()
.stdout_as_str();

let value: serde_json::Value =
serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("invalid JSON: {e}\n{stdout}"));

assert!(
value
.get("error")
.and_then(|error| error.get("message"))
.and_then(serde_json::Value::as_str)
.is_some(),
"expected an `error.message` string, got: {stdout}"
);
}

fn add_network(sandbox: &TestEnv, name: &str) {
sandbox
.new_assert_cmd("network")
Expand Down
36 changes: 35 additions & 1 deletion cmd/crates/soroban-test/tests/it/rpc_provider.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,41 @@
use httpmock::{prelude::*, Mock};
use serde_json::json;
use soroban_rpc::{GetEventsResponse, GetNetworkResponse};
use soroban_test::{TestEnv, LOCAL_NETWORK_PASSPHRASE};
use soroban_test::{AssertExt, TestEnv, LOCAL_NETWORK_PASSPHRASE};

// When the RPC responds with a JSON-RPC error body, `--output json` must surface
// the structured `{ code, message }` (not just the `{ message }` fallback). This
// exercises the `ErrorObjectOwned` downcast end-to-end, guarding the
// `jsonrpsee-types` version pin: if jsonrpsee drifts to two versions in the tree
// the downcast would silently degrade to the fallback and `error.code` would be
// absent, failing this test.
#[test]
fn json_error_envelope_includes_structured_rpc_code() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(POST).path("/");
then.status(200).json_body(json!({
"jsonrpc": "2.0",
"id": 0,
"error": { "code": -32603, "message": "DB is empty" }
}));
});

let sandbox = TestEnv::with_rpc_provider(&server.url(""), vec![]);
let stdout = sandbox
.new_assert_cmd("network")
.arg("health")
.arg("--output=json")
.assert()
.failure()
.stdout_as_str();

mock.assert();

let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(value["error"]["code"], json!(-32603));
assert_eq!(value["error"]["message"], json!("DB is empty"));
}

#[tokio::test]
async fn test_use_rpc_provider_with_auth_header() {
Expand Down
3 changes: 3 additions & 0 deletions cmd/soroban-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ serde_json = { workspace = true }
serde-aux = { workspace = true }
hex = { workspace = true }
num-bigint = "0.4"
# Pinned to match the version pulled in by soroban-rpc (jsonrpsee-core) so that
# downcasting RPC errors to `ErrorObjectOwned` resolves to the same type.
jsonrpsee-types = "0.26"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7.11", features = ["io", "io-util", "compat"] }
termcolor = { workspace = true }
Expand Down
30 changes: 30 additions & 0 deletions cmd/soroban-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ pub async fn main() {
println!("{help}");
std::process::exit(1);
}
// When a command migrated to the `Output` system requested JSON, render
// the error as JSON on stdout so machine consumers get parseable output
// instead of the human line. The process still exits non-zero: this is a
// failure, the JSON body is just a parseable representation of it.
if let Some(format) = json_error_format(&root.cmd) {
let output = crate::output::Output::new(format, root.global_args.quiet);
let _ = output.json_value(&crate::output::error_json(&e));
std::process::exit(1);
}
printer.errorln(format!("error: {e}"));
std::process::exit(1);
}
Expand All @@ -115,6 +124,27 @@ fn set_env_from_config() {
set_env_value_from_config("STELLAR_INCLUSION_FEE", config.defaults.inclusion_fee);
}

// Resolve the effective JSON output `Format` for the top-level error handler
// from the *parsed* command. Only commands migrated to the `Output` system
// participate: for those, we read their own `--output` field (honoring the
// per-command default), so a JSON failure renders as a structured envelope on
// stdout. Non-migrated commands return `None` and keep the human error line —
// this avoids silently changing their failure contract (and, for streaming
// commands like `events`, avoids splicing an error object into their stdout).
fn json_error_format(cmd: &commands::Cmd) -> Option<crate::output::Format> {
use crate::commands::network;
use crate::output::Format;

let format: Format = match cmd {
commands::Cmd::Network(network::Cmd::Health(cmd)) => cmd.output.into(),
commands::Cmd::Network(network::Cmd::Info(cmd)) => cmd.output.into(),
commands::Cmd::Network(network::Cmd::Settings(cmd)) => cmd.output.into(),
_ => return None,
};

matches!(format, Format::Json | Format::JsonFormatted).then_some(format)
}

fn config_dir_from_raw_args() -> Option<PathBuf> {
use std::ffi::OsStr;
let mut iter = std::env::args_os().peekable();
Expand Down
56 changes: 35 additions & 21 deletions cmd/soroban-cli/src/commands/network/health.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::commands::global;
use crate::config::network;
use crate::{config, print};
use crate::config::{self, network};
use crate::output::{Format, Output};

#[derive(thiserror::Error, Debug)]
pub enum Error {
Expand All @@ -9,6 +9,8 @@ pub enum Error {
#[error(transparent)]
Network(#[from] network::Error),
#[error(transparent)]
Rpc(#[from] crate::rpc::Error),
#[error(transparent)]
Serde(#[from] serde_json::Error),
}

Expand All @@ -23,6 +25,16 @@ pub enum OutputFormat {
JsonFormatted,
}

impl From<OutputFormat> for Format {
fn from(value: OutputFormat) -> Self {
match value {
OutputFormat::Text => Format::Readable,
OutputFormat::Json => Format::Json,
OutputFormat::JsonFormatted => Format::JsonFormatted,
}
}
}

#[derive(Debug, clap::Parser, Clone)]
#[group(skip)]
pub struct Cmd {
Expand All @@ -35,27 +47,29 @@ pub struct Cmd {

impl Cmd {
pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
let print = print::Print::new(global_args.quiet);
let result = self.config.get_network()?.rpc_client()?.get_health().await;

match result {
Ok(resp) => match self.output {
OutputFormat::Text => {
if resp.status.eq_ignore_ascii_case("healthy") {
print.checkln("Healthy");
} else {
print.warnln(format!("Status: {}", resp.status));
}
print.infoln(format!("Latest ledger: {}", resp.latest_ledger));
}
OutputFormat::Json => println!("{}", serde_json::to_string(&resp)?),
OutputFormat::JsonFormatted => println!("{}", serde_json::to_string_pretty(&resp)?),
},
let output = Output::new(self.output.into(), global_args.quiet);

// On failure, surface "Unhealthy" for humans then propagate the error so
// the process exits non-zero and the top-level handler renders it (as the
// structured JSON envelope in JSON mode). A reachable-but-unhealthy node
// instead returns `Ok` with a status, handled below.
let resp = match self.config.get_network()?.rpc_client()?.get_health().await {
Ok(resp) => resp,
Err(err) => {
print.errorln("Unhealthy");
print.errorln(format!("failed to fetch network health: {err}"));
output.readable(|print| print.errorln("Unhealthy"));
return Err(err.into());
}
}
};

output.readable(|print| {
if resp.status.eq_ignore_ascii_case("healthy") {
print.checkln("Healthy");
} else {
print.warnln(format!("Status: {}", resp.status));
}
print.infoln(format!("Latest ledger: {}", resp.latest_ledger));
});
output.json_value(&resp)?;

Ok(())
}
Expand Down
32 changes: 14 additions & 18 deletions cmd/soroban-cli/src/commands/network/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use sha2::{Digest, Sha256};

use crate::commands::global;
use crate::config::network;
use crate::output::{Format, Output};
use crate::utils::url::redact_url;
use crate::{config, print, rpc};

Expand All @@ -28,6 +29,16 @@ pub enum OutputFormat {
JsonFormatted,
}

impl From<OutputFormat> for Format {
fn from(value: OutputFormat) -> Self {
match value {
OutputFormat::Text => Format::Readable,
OutputFormat::Json => Format::Json,
OutputFormat::JsonFormatted => Format::JsonFormatted,
}
}
}

#[derive(Debug, clap::Parser, Clone)]
#[group(skip)]
pub struct Cmd {
Expand Down Expand Up @@ -66,23 +77,11 @@ impl Info {
print.infoln(format!("Friendbot Url: {friendbot_url}"));
}
}

fn print_json(&self) -> Result<(), serde_json::Error> {
let json = serde_json::to_string(&self)?;
println!("{json}");
Ok(())
}

fn print_json_formatted(&self) -> Result<(), serde_json::Error> {
let json = serde_json::to_string_pretty(&self)?;
println!("{json}");
Ok(())
}
}

impl Cmd {
pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
let print = print::Print::new(global_args.quiet);
let output = Output::new(self.output.into(), global_args.quiet);
let rpc_client = self.config.get_network()?.rpc_client()?;
let network_result = rpc_client.get_network().await?;
let version_result = rpc_client.get_version_info().await?;
Expand All @@ -98,11 +97,8 @@ impl Cmd {
passphrase: network_result.passphrase,
};

match self.output {
OutputFormat::Text => info.print_text(&print),
OutputFormat::Json => info.print_json()?,
OutputFormat::JsonFormatted => info.print_json_formatted()?,
}
output.readable(|print| info.print_text(print));
output.json_value(&info)?;

Ok(())
}
Expand Down
Loading
Loading