use std::sync::Arc;
use canton_auth::TokenProvider;
use canton_core::telemetry::{self, TRANSPORT_JSON};
use canton_core::{Auth, Error, Result};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
#[derive(Clone)]
pub struct JsonClient {
base_url: String,
http: reqwest::Client,
auth: Auth,
tls: Option<canton_core::TlsConfig>,
retry: Option<canton_core::RetryConfig>,
max_decoding_message_size: usize,
timeout: std::time::Duration,
}
const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
impl std::fmt::Debug for JsonClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JsonClient")
.field("base_url", &canton_core::redact_url(&self.base_url))
.field("auth", &self.auth)
.field("tls", &self.tls)
.field("retry", &self.retry)
.field("max_decoding_message_size", &self.max_decoding_message_size)
.finish_non_exhaustive()
}
}
#[derive(Deserialize)]
struct VersionResponse {
version: String,
}
#[derive(Deserialize)]
struct LedgerEndResponse {
offset: i64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct JsonCommands {
command_id: String,
act_as: Vec<String>,
commands: Vec<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
user_id: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
read_as: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
workflow_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
synchronizer_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
submission_id: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
disclosed_contracts: Vec<Value>,
#[serde(skip_serializing_if = "Vec::is_empty")]
package_id_selection_preference: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
deduplication_period: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
min_ledger_time_abs: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
min_ledger_time_rel: Option<Value>,
}
impl JsonCommands {
#[must_use]
pub fn new(act_as: Vec<String>) -> Self {
Self {
command_id: format!("sdk-{}", uuid::Uuid::new_v4()),
act_as,
commands: Vec::new(),
user_id: None,
read_as: Vec::new(),
workflow_id: None,
synchronizer_id: None,
submission_id: None,
disclosed_contracts: Vec::new(),
package_id_selection_preference: Vec::new(),
deduplication_period: None,
min_ledger_time_abs: None,
min_ledger_time_rel: None,
}
}
#[must_use]
pub fn with_command_id(mut self, command_id: impl Into<String>) -> Self {
self.command_id = command_id.into();
self
}
#[must_use]
pub fn change_id(&self) -> crate::ChangeId {
crate::ChangeId::new(
self.user_id.clone().unwrap_or_default(),
self.act_as.clone(),
self.command_id.clone(),
)
}
#[must_use]
pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
self.user_id = Some(user_id.into());
self
}
#[must_use]
pub fn with_read_as(mut self, read_as: Vec<String>) -> Self {
self.read_as = read_as;
self
}
#[must_use]
pub fn with_workflow_id(mut self, workflow_id: impl Into<String>) -> Self {
self.workflow_id = Some(workflow_id.into());
self
}
#[must_use]
pub fn with_synchronizer_id(mut self, synchronizer_id: impl Into<String>) -> Self {
self.synchronizer_id = Some(synchronizer_id.into());
self
}
#[must_use]
pub fn add_create(mut self, template_id: impl Into<String>, create_arguments: Value) -> Self {
let mut create = serde_json::Map::new();
create.insert("templateId".to_string(), Value::String(template_id.into()));
create.insert("createArguments".to_string(), create_arguments);
let mut command = serde_json::Map::new();
command.insert("CreateCommand".to_string(), Value::Object(create));
self.commands.push(Value::Object(command));
self
}
#[must_use]
pub fn add_command(mut self, command: Value) -> Self {
self.commands.push(command);
self
}
#[must_use]
pub fn with_submission_id(mut self, submission_id: impl Into<String>) -> Self {
self.submission_id = Some(submission_id.into());
self
}
#[must_use]
pub fn add_disclosed_contract(mut self, contract: Value) -> Self {
self.disclosed_contracts.push(contract);
self
}
#[must_use]
pub fn with_package_id_selection_preference(mut self, package_ids: Vec<String>) -> Self {
self.package_id_selection_preference = package_ids;
self
}
#[must_use]
pub fn with_deduplication_period(mut self, period: Value) -> Self {
self.deduplication_period = Some(period);
self
}
#[must_use]
pub fn with_min_ledger_time_abs(mut self, time: Value) -> Self {
self.min_ledger_time_abs = Some(time);
self
}
#[must_use]
pub fn with_min_ledger_time_rel(mut self, duration: Value) -> Self {
self.min_ledger_time_rel = Some(duration);
self
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct JsonSubmitAndWaitResponse {
pub update_id: String,
pub completion_offset: i64,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct JsonSubmitResponse {
pub transaction: JsonTransaction,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct JsonTransaction {
pub update_id: String,
#[serde(default)]
pub command_id: String,
#[serde(default)]
pub workflow_id: String,
pub offset: i64,
#[serde(default)]
pub synchronizer_id: String,
#[serde(default)]
pub effective_at: String,
#[serde(default)]
pub record_time: String,
#[serde(default)]
pub events: Vec<Value>,
}
fn active_contracts_request(parties: &[String], active_at_offset: i64) -> Value {
crate::request::ActiveContractsRequest::new(parties.to_vec(), active_at_offset).json_body()
}
fn updates_request(parties: &[String], begin_exclusive: i64, end_inclusive: Option<i64>) -> Value {
let mut request = crate::request::UpdatesRequest::new(parties.to_vec(), begin_exclusive);
if let Some(end) = end_inclusive {
request = request.until(end);
}
request.json_body()
}
#[cfg(feature = "ws")]
fn completions_request(parties: &[String], begin_exclusive: i64) -> Value {
crate::request::CompletionsRequest::new(parties.to_vec(), begin_exclusive).json_body()
}
fn is_duplicate_submission(error: &Error) -> bool {
match error {
Error::Http { status, body } => *status == 409 || body.contains("DUPLICATE_COMMAND"),
_ => false,
}
}
fn with_trace_context(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
#[cfg(feature = "otel")]
{
let mut headers = reqwest::header::HeaderMap::new();
canton_core::telemetry::otel::inject_trace_context(&mut headers);
if !headers.is_empty() {
return request.headers(headers);
}
}
request
}
async fn read_json<T: for<'de> Deserialize<'de>>(
response: reqwest::Response,
path: &str,
) -> Result<T> {
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(Error::Http { status, body });
}
let body = response
.text()
.await
.map_err(|e| Error::Connection(format!("reading json body from {path} failed: {e}")))?;
serde_json::from_str::<T>(&body).map_err(Error::from)
}
fn upgrade_base_url_for_tls(base_url: &str) -> String {
if base_url
.get(..7)
.is_some_and(|s| s.eq_ignore_ascii_case("http://"))
{
format!("https://{}", &base_url[7..])
} else {
base_url.to_string()
}
}
impl JsonClient {
#[must_use]
pub fn new(base_url: impl Into<String>) -> Self {
let mut base_url = base_url.into();
while base_url.ends_with('/') {
base_url.pop();
}
Self {
base_url,
http: reqwest::Client::new(),
auth: Auth::None,
tls: None,
retry: None,
max_decoding_message_size: canton_core::DEFAULT_MAX_DECODING_MESSAGE_SIZE,
timeout: DEFAULT_TIMEOUT,
}
}
pub fn from_env() -> Result<Self> {
Self::for_role(None)
}
pub fn from_env_for(role: &str) -> Result<Self> {
Self::for_role(Some(role))
}
fn for_role(role: Option<&str>) -> Result<Self> {
use canton_core::localnet;
let base_url = localnet::json_endpoint(role).ok_or_else(|| {
let variable = match role {
None => "CANTON_JSON_LEDGER_API_URL".to_string(),
Some(role) => format!(
"CANTON_{}_JSON_LEDGER_API_URL",
role.to_uppercase().replace('-', "_")
),
};
Error::InvalidRequest(format!(
"no JSON ledger endpoint in the environment: set {variable}. \
A local network exports it with `canton-devkit localnet env <instance>`; \
run that through `eval` first."
))
})?;
let client = Self::new(base_url);
Ok(match localnet::token(role) {
Some(token) => client.with_token(token),
None => client,
})
}
#[must_use]
pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn with_max_decoding_message_size(mut self, bytes: usize) -> Self {
self.max_decoding_message_size = bytes;
self
}
#[must_use]
pub fn with_retry(mut self, retry: canton_core::RetryConfig) -> Self {
self.retry = Some(retry);
self
}
pub fn with_tls(mut self, tls: &canton_core::TlsConfig) -> Result<Self> {
let mut builder = reqwest::Client::builder();
if let Some(ca) = &tls.ca_certificate_pem {
let cert = reqwest::Certificate::from_pem(ca)
.map_err(|e| Error::InvalidRequest(format!("invalid CA certificate: {e}")))?;
builder = builder.add_root_certificate(cert);
}
if let Some((cert, key)) = &tls.client_identity_pem {
let mut pem = cert.clone();
pem.push(b'\n');
pem.extend_from_slice(key);
let identity = reqwest::Identity::from_pem(&pem)
.map_err(|e| Error::InvalidRequest(format!("invalid client identity: {e}")))?;
builder = builder.identity(identity);
}
self.http = builder
.build()
.map_err(|e| Error::InvalidRequest(format!("building the HTTPS client failed: {e}")))?;
self.base_url = upgrade_base_url_for_tls(&self.base_url);
self.tls = Some(tls.clone());
Ok(self)
}
#[must_use]
pub fn with_token(mut self, token: impl Into<String>) -> Self {
self.auth = Auth::Static(token.into());
self
}
#[must_use]
pub fn with_oidc(mut self, provider: TokenProvider) -> Self {
self.auth = Auth::Dynamic(Arc::new(provider));
self
}
#[cfg(feature = "ws")]
fn ws_transport(&self) -> crate::ws::WsTransport<'_> {
crate::ws::WsTransport {
base_url: &self.base_url,
auth: &self.auth,
tls: self.tls.as_ref(),
max_decoding_message_size: self.max_decoding_message_size,
timeout: self.timeout,
}
}
async fn get<T: for<'de> Deserialize<'de>>(&self, path: &str) -> Result<T> {
canton_core::retry::run_with_retry(self.retry.as_ref(), || async {
let mut request = self
.http
.get(format!("{}{path}", self.base_url))
.timeout(self.timeout);
if let Some(token) = self.auth.bearer().await? {
request = request.bearer_auth(token);
}
request = with_trace_context(request);
let response = request
.send()
.await
.map_err(|e| Error::Connection(format!("json request to {path} failed: {e}")))?;
read_json(response, path).await
})
.await
}
async fn post<B: Serialize, T: for<'de> Deserialize<'de>>(
&self,
path: &str,
body: &B,
) -> Result<T> {
canton_core::retry::run_with_retry(self.retry.as_ref(), || self.post_once(path, body)).await
}
async fn post_once<B: Serialize, T: for<'de> Deserialize<'de>>(
&self,
path: &str,
body: &B,
) -> Result<T> {
let mut request = self
.http
.post(format!("{}{path}", self.base_url))
.timeout(self.timeout)
.json(body);
if let Some(token) = self.auth.bearer().await? {
request = request.bearer_auth(token);
}
request = with_trace_context(request);
let response = request
.send()
.await
.map_err(|e| Error::Connection(format!("json request to {path} failed: {e}")))?;
read_json(response, path).await
}
pub async fn version(&self) -> Result<String> {
telemetry::instrument("version", TRANSPORT_JSON, async {
Ok(self.get::<VersionResponse>("/v2/version").await?.version)
})
.await
}
pub async fn ledger_end(&self) -> Result<i64> {
telemetry::instrument("ledger_end", TRANSPORT_JSON, async {
Ok(self
.get::<LedgerEndResponse>("/v2/state/ledger-end")
.await?
.offset)
})
.await
}
pub async fn submit_and_wait_for_transaction(
&self,
commands: &JsonCommands,
) -> Result<JsonSubmitResponse> {
telemetry::instrument("submit_and_wait_for_transaction", TRANSPORT_JSON, async {
let body = json!({ "commands": commands });
self.post("/v2/commands/submit-and-wait-for-transaction", &body)
.await
})
.await
}
pub async fn submit(&self, commands: &JsonCommands) -> Result<()> {
telemetry::instrument("submit", TRANSPORT_JSON, async {
self.post_submission("/v2/commands/async/submit", commands)
.await
})
.await
}
async fn post_submission<B: Serialize>(&self, path: &str, body: &B) -> Result<()> {
let attempt = std::sync::atomic::AtomicU32::new(0);
canton_core::retry::run_with_retry(self.retry.as_ref(), || async {
let retry = attempt.fetch_add(1, std::sync::atomic::Ordering::Relaxed) > 0;
match self.post_once::<B, serde_json::Value>(path, body).await {
Ok(_) => Ok(()),
Err(error) if retry && is_duplicate_submission(&error) => {
tracing::debug!(
"submission retry was de-duplicated; the earlier attempt is the one that landed"
);
Ok(())
}
Err(error) => Err(error),
}
})
.await
}
pub async fn submit_and_wait(
&self,
commands: &JsonCommands,
) -> Result<JsonSubmitAndWaitResponse> {
telemetry::instrument("submit_and_wait", TRANSPORT_JSON, async {
self.post("/v2/commands/submit-and-wait", commands).await
})
.await
}
pub async fn events_by_contract_id(
&self,
contract_id: impl Into<String>,
parties: Vec<String>,
) -> Result<Value> {
telemetry::instrument("events_by_contract_id", TRANSPORT_JSON, async {
let request = crate::request::ActiveContractsRequest::new(parties, 0);
let body = json!({
"contractId": contract_id.into(),
"eventFormat": request.json_body()["eventFormat"],
});
self.post("/v2/events/events-by-contract-id", &body).await
})
.await
}
#[must_use]
pub fn submission(&self, commands: JsonCommands) -> crate::submission::JsonSubmission {
crate::submission::JsonSubmission::new(self.clone(), commands)
}
pub async fn active_contracts(
&self,
parties: Vec<String>,
active_at_offset: i64,
limit: Option<i64>,
) -> Result<Vec<Value>> {
telemetry::instrument("active_contracts", TRANSPORT_JSON, async {
let body = active_contracts_request(&parties, active_at_offset);
let path = with_limit("/v2/state/active-contracts", limit);
self.post(&path, &body).await
})
.await
}
pub async fn active_contracts_with(
&self,
request: &crate::request::ActiveContractsRequest,
limit: Option<i64>,
) -> Result<Vec<Value>> {
telemetry::instrument("active_contracts", TRANSPORT_JSON, async {
let path = with_limit("/v2/state/active-contracts", limit);
self.post(&path, &request.json_body()).await
})
.await
}
pub async fn updates(
&self,
parties: Vec<String>,
begin_exclusive: i64,
end_inclusive: Option<i64>,
limit: Option<i64>,
) -> Result<Vec<Value>> {
telemetry::instrument("updates", TRANSPORT_JSON, async {
let body = updates_request(&parties, begin_exclusive, end_inclusive);
let path = with_limit("/v2/updates", limit);
self.post(&path, &body).await
})
.await
}
pub async fn updates_with(
&self,
request: &crate::request::UpdatesRequest,
limit: Option<i64>,
) -> Result<Vec<Value>> {
telemetry::instrument("updates", TRANSPORT_JSON, async {
let path = with_limit("/v2/updates", limit);
self.post(&path, &request.json_body()).await
})
.await
}
}
fn with_limit(path: &str, limit: Option<i64>) -> String {
match limit {
Some(limit) => format!("{path}?limit={limit}"),
None => path.to_string(),
}
}
#[cfg(feature = "ws")]
use futures_util::StreamExt as _;
#[cfg(feature = "ws")]
impl JsonClient {
#[allow(clippy::large_futures)] #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
pub async fn ws_updates(
&self,
parties: Vec<String>,
begin_exclusive: i64,
end_inclusive: Option<i64>,
) -> Result<impl futures_core::Stream<Item = Result<Value>> + Send + use<>> {
telemetry::instrument("ws_updates", TRANSPORT_JSON, async move {
let request = updates_request(&parties, begin_exclusive, end_inclusive);
let inner = crate::ws::subscribe(&self.ws_transport(), "/v2/updates", request).await?;
Ok(telemetry::instrument_stream(
"ws_updates",
TRANSPORT_JSON,
crate::ws::filter_checkpoints(inner),
))
})
.await
}
#[allow(clippy::large_futures)] #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
pub async fn ws_updates_with(
&self,
request: &crate::request::UpdatesRequest,
) -> Result<impl futures_core::Stream<Item = Result<Value>> + Send + use<>> {
telemetry::instrument("ws_updates", TRANSPORT_JSON, async move {
let inner =
crate::ws::subscribe(&self.ws_transport(), "/v2/updates", request.json_body())
.await?;
Ok(telemetry::instrument_stream(
"ws_updates",
TRANSPORT_JSON,
crate::ws::filter_checkpoints(inner),
))
})
.await
}
#[allow(clippy::large_futures)] #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
pub async fn ws_active_contracts(
&self,
parties: Vec<String>,
active_at_offset: i64,
) -> Result<impl futures_core::Stream<Item = Result<Value>> + Send + use<>> {
telemetry::instrument("ws_active_contracts", TRANSPORT_JSON, async move {
let request = active_contracts_request(&parties, active_at_offset);
let inner =
crate::ws::subscribe(&self.ws_transport(), "/v2/state/active-contracts", request)
.await?;
Ok(telemetry::instrument_stream(
"ws_active_contracts",
TRANSPORT_JSON,
inner,
))
})
.await
}
#[allow(clippy::large_futures)] #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
pub async fn ws_active_contracts_with(
&self,
request: &crate::request::ActiveContractsRequest,
) -> Result<impl futures_core::Stream<Item = Result<Value>> + Send + use<>> {
telemetry::instrument("ws_active_contracts", TRANSPORT_JSON, async move {
let inner = crate::ws::subscribe(
&self.ws_transport(),
"/v2/state/active-contracts",
request.json_body(),
)
.await?;
Ok(telemetry::instrument_stream(
"ws_active_contracts",
TRANSPORT_JSON,
inner,
))
})
.await
}
#[allow(clippy::large_futures)] #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
pub async fn ws_completions(
&self,
parties: Vec<String>,
begin_exclusive: i64,
) -> Result<impl futures_core::Stream<Item = Result<Value>> + Send + use<>> {
telemetry::instrument("ws_completions", TRANSPORT_JSON, async move {
let request = completions_request(&parties, begin_exclusive);
let inner = crate::ws::subscribe(
&self.ws_transport(),
"/v2/commands/command-completions",
request,
)
.await?;
Ok(telemetry::instrument_stream(
"ws_completions",
TRANSPORT_JSON,
crate::ws::filter_checkpoints(inner),
))
})
.await
}
#[allow(clippy::large_futures)] #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
pub async fn ws_completions_with(
&self,
request: &crate::request::CompletionsRequest,
) -> Result<impl futures_core::Stream<Item = Result<Value>> + Send + use<>> {
telemetry::instrument("ws_completions", TRANSPORT_JSON, async move {
let inner = crate::ws::subscribe(
&self.ws_transport(),
"/v2/commands/command-completions",
request.json_body(),
)
.await?;
Ok(telemetry::instrument_stream(
"ws_completions",
TRANSPORT_JSON,
crate::ws::filter_checkpoints(inner),
))
})
.await
}
#[cfg(feature = "ws")]
fn reconnect_policy(&self) -> (u32, std::time::Duration) {
match &self.retry {
Some(retry) => (retry.max_attempts, retry.initial_backoff),
None => (5, std::time::Duration::from_millis(250)),
}
}
#[cfg(feature = "ws")]
#[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
pub fn ws_active_contracts_resumable(
&self,
parties: Vec<String>,
active_at_offset: i64,
) -> impl futures_core::Stream<Item = Result<Value>> + Send + use<> {
let (max_reconnects, backoff_unit) = self.reconnect_policy();
let base_url = self.base_url.clone();
let auth = self.auth.clone();
let tls = self.tls.clone();
let max_decoding_message_size = self.max_decoding_message_size;
let timeout = self.timeout;
async_stream::stream! {
let mut token: Option<String> = None;
let mut reconnects = 0u32;
loop {
let mut request = active_contracts_request(&parties, active_at_offset);
if let Some(token) = &token {
request["streamContinuationToken"] = Value::String(token.clone());
}
let transport = crate::ws::WsTransport {
base_url: &base_url,
auth: &auth,
tls: tls.as_ref(),
max_decoding_message_size,
timeout,
};
let cause = match crate::ws::subscribe(&transport, "/v2/state/active-contracts", request).await {
Ok(inner) => {
let inner = telemetry::instrument_stream(
"ws_active_contracts",
TRANSPORT_JSON,
inner,
);
tokio::pin!(inner);
loop {
match inner.next().await {
Some(Ok(frame)) => {
if let Some(next) = frame
.get("streamContinuationToken")
.and_then(Value::as_str)
.filter(|next| !next.is_empty())
{
token = Some(next.to_string());
}
reconnects = 0;
yield Ok(frame);
}
Some(Err(err)) if err.is_retriable() => break err,
Some(Err(err)) => {
yield Err(err);
return;
}
None => return,
}
}
}
Err(err) if err.is_retriable() => err,
Err(err) => {
yield Err(err);
return;
}
};
reconnects += 1;
if reconnects > max_reconnects {
tracing::warn!(
max_reconnects,
"ws acs stream gave up resuming; reporting the failure that caused it"
);
yield Err(cause);
return;
}
tokio::time::sleep(backoff_unit * reconnects).await;
}
}
}
#[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
pub fn ws_updates_resumable(
&self,
parties: Vec<String>,
begin_exclusive: i64,
) -> impl futures_core::Stream<Item = Result<Value>> + Send + use<> {
let (max_reconnects, backoff_unit) = self.reconnect_policy();
let base_url = self.base_url.clone();
let auth = self.auth.clone();
let tls = self.tls.clone();
let max_decoding_message_size = self.max_decoding_message_size;
let timeout = self.timeout;
async_stream::stream! {
let mut offset = begin_exclusive;
let mut reconnects = 0u32;
loop {
let request = updates_request(&parties, offset, None);
let transport = crate::ws::WsTransport {
base_url: &base_url,
auth: &auth,
tls: tls.as_ref(),
max_decoding_message_size,
timeout,
};
let cause = match crate::ws::subscribe(&transport, "/v2/updates", request).await {
Ok(inner) => {
let inner =
telemetry::instrument_stream("ws_updates", TRANSPORT_JSON, inner);
tokio::pin!(inner);
loop {
match inner.next().await {
Some(Ok(frame)) => {
if let Some(o) = crate::ws::update_offset(&frame) {
offset = o;
}
reconnects = 0;
if !crate::ws::is_offset_checkpoint(&frame) {
yield Ok(frame);
}
}
Some(Err(err)) if err.is_retriable() => break Some(err),
Some(Err(err)) => {
yield Err(err);
return;
}
None => break None, }
}
}
Err(err) if err.is_retriable() => Some(err),
Err(err) => {
yield Err(err);
return;
}
};
reconnects += 1;
if reconnects > max_reconnects {
tracing::warn!(
max_reconnects,
offset,
"ws update stream gave up resuming; reporting the failure that caused it"
);
yield Err(cause.unwrap_or_else(|| Error::UnexpectedResponse(format!(
"ws update stream was closed and reopened {max_reconnects} times \
without delivering an update"
))));
return;
}
tokio::time::sleep(backoff_unit * reconnects).await;
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn updates_request_matches_grpc_and_includes_reassignments() {
let parties = vec!["alice::1".to_string()];
let body = updates_request(&parties, 10, Some(20));
assert_eq!(body["beginExclusive"], 10);
assert_eq!(body["endInclusive"], 20);
let fmt = &body["updateFormat"];
assert!(fmt["includeTransactions"].is_object(), "{body}");
assert!(
fmt["includeReassignments"].is_object(),
"reassignments must be requested, or the JSON lane drops them: {body}"
);
assert_eq!(
fmt["includeTransactions"]["transactionShape"],
"TRANSACTION_SHAPE_LEDGER_EFFECTS"
);
}
#[test]
fn commands_serialize_to_the_json_api_shape() {
let commands = JsonCommands::new(vec!["alice::1".to_string()])
.with_command_id("cmd-1")
.add_create("pkg:Mod:Ent", json!({ "owner": "alice::1" }));
let value = serde_json::to_value(&commands).unwrap();
assert_eq!(value["commandId"], "cmd-1");
assert_eq!(value["actAs"][0], "alice::1");
assert_eq!(
value["commands"][0]["CreateCommand"]["templateId"],
"pkg:Mod:Ent"
);
assert_eq!(
value["commands"][0]["CreateCommand"]["createArguments"]["owner"],
"alice::1"
);
assert!(value.get("userId").is_none());
assert!(value.get("readAs").is_none());
}
#[test]
fn all_command_options_serialize_to_camel_case() {
let commands = JsonCommands::new(vec!["alice::1".to_string()])
.with_command_id("cmd-1")
.with_user_id("user-1")
.with_read_as(vec!["bob::2".to_string()])
.with_workflow_id("wf-1")
.with_synchronizer_id("sync-1")
.with_submission_id("sub-1")
.add_disclosed_contract(json!({ "contractId": "c9", "createdEventBlob": "AQI=" }))
.with_package_id_selection_preference(vec!["pkg-9".to_string()])
.with_deduplication_period(
json!({ "DeduplicationDuration": { "value": { "duration": "30s" } } }),
)
.with_min_ledger_time_rel(json!("5s"))
.add_create("pkg:Mod:Ent", json!({ "owner": "alice::1" }))
.add_command(json!({ "ExerciseCommand": { "contractId": "c1" } }));
let value = serde_json::to_value(&commands).unwrap();
assert_eq!(value["userId"], "user-1");
assert_eq!(value["readAs"][0], "bob::2");
assert_eq!(value["workflowId"], "wf-1");
assert_eq!(value["synchronizerId"], "sync-1");
assert_eq!(value["submissionId"], "sub-1");
assert_eq!(value["disclosedContracts"][0]["contractId"], "c9");
assert_eq!(value["packageIdSelectionPreference"][0], "pkg-9");
assert_eq!(
value["deduplicationPeriod"]["DeduplicationDuration"]["value"]["duration"],
"30s"
);
assert_eq!(value["minLedgerTimeRel"], "5s");
assert!(value.get("minLedgerTimeAbs").is_none());
assert!(value["commands"][0]["CreateCommand"].is_object());
assert_eq!(value["commands"][1]["ExerciseCommand"]["contractId"], "c1");
}
#[test]
fn wildcard_event_format_filters_each_party() {
let format = &active_contracts_request(&["alice::1".to_string(), "bob::2".to_string()], 0)
["eventFormat"];
assert_eq!(format["verbose"], true);
assert!(format["filtersByParty"]["alice::1"]["cumulative"][0]["identifierFilter"]
["WildcardFilter"]
.is_object());
assert!(format["filtersByParty"]["bob::2"].is_object());
}
#[test]
fn with_limit_appends_only_when_set() {
assert_eq!(with_limit("/v2/updates", None), "/v2/updates");
assert_eq!(with_limit("/v2/updates", Some(5)), "/v2/updates?limit=5");
}
#[tokio::test]
async fn a_request_to_a_silent_participant_gives_up_instead_of_hanging() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let mut held = Vec::new();
while let Ok((socket, _)) = listener.accept().await {
held.push(socket);
}
});
let client = JsonClient::new(format!("http://{addr}"))
.with_timeout(std::time::Duration::from_millis(250));
let outcome =
tokio::time::timeout(std::time::Duration::from_secs(5), client.version()).await;
let Ok(result) = outcome else {
panic!("the request never returned — the per-request timeout is not being applied");
};
let error = result.unwrap_err();
assert!(
error.is_retriable(),
"a timeout is transient and should be retriable: {error}"
);
assert_eq!(
JsonClient::new("http://localhost:3975").timeout,
DEFAULT_TIMEOUT
);
}
#[test]
fn debug_does_not_print_credentials_carried_in_the_base_url() {
let secret = "s3cr3t-p@ssw0rd";
let client = JsonClient::new(format!("https://svc-account:{secret}@ledger.example:3975"))
.with_token("eyJhbGciOiJSUzI1NiJ9.PAYLOAD.SIG");
let rendered = format!("{client:?}");
assert!(
!rendered.contains(secret),
"leaked the password: {rendered}"
);
assert!(
!rendered.contains("svc-account"),
"leaked the user: {rendered}"
);
assert!(
!rendered.contains("PAYLOAD"),
"leaked the token: {rendered}"
);
assert!(
rendered.contains("ledger.example:3975"),
"should keep the host: {rendered}"
);
}
#[test]
fn the_ws_lane_starts_at_the_sdk_size_limit_not_tungstenites() {
let client = JsonClient::new("http://localhost:3975");
assert_eq!(
client.max_decoding_message_size,
canton_core::DEFAULT_MAX_DECODING_MESSAGE_SIZE,
);
assert!(client.max_decoding_message_size > 64 << 20);
let raised = client.with_max_decoding_message_size(256 << 20);
assert_eq!(raised.max_decoding_message_size, 256 << 20);
}
#[test]
fn command_id_defaults_to_a_generated_uuid() {
let commands = JsonCommands::new(vec!["alice::1".to_string()]);
let value = serde_json::to_value(&commands).unwrap();
let id = value["commandId"].as_str().unwrap();
assert!(id.starts_with("sdk-"), "got {id}");
assert!(id.len() > 10, "expected a uuid suffix, got {id}");
}
#[test]
fn with_tls_threads_a_ca_and_client_identity() {
let ck = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
let cert_pem = ck.cert.pem().into_bytes();
let key_pem = ck.key_pair.serialize_pem().into_bytes();
let tls = canton_core::TlsConfig::new()
.with_ca_certificate(cert_pem.clone())
.with_client_identity(cert_pem, key_pem);
assert!(
JsonClient::new("https://localhost:3975")
.with_token("t")
.with_tls(&tls)
.is_ok()
);
let bad = canton_core::TlsConfig::new()
.with_client_identity(b"not a pem".to_vec(), b"nor this".to_vec());
assert!(matches!(
JsonClient::new("https://localhost:3975").with_tls(&bad),
Err(Error::InvalidRequest(_))
));
}
#[test]
fn with_tls_upgrades_an_http_base_url_to_https() {
let client = JsonClient::new("http://localhost:3975")
.with_tls(&canton_core::TlsConfig::new())
.unwrap();
assert_eq!(client.base_url, "https://localhost:3975");
assert_eq!(
upgrade_base_url_for_tls("https://host:443"),
"https://host:443"
);
assert_eq!(
upgrade_base_url_for_tls("HTTP://host:80"),
"https://host:80"
);
assert_eq!(
JsonClient::new("http://localhost:3975").base_url,
"http://localhost:3975"
);
}
}