use std::sync::Arc;
use canton_auth::TokenProvider;
use canton_core::telemetry::{self, TRANSPORT_JSON};
use canton_core::{Auth, Error, Result};
use canton_proto::com::daml::ledger::api::v2::admin;
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(Deserialize)]
struct PackagesResponse {
#[serde(rename = "packageIds", default)]
package_ids: Vec<String>,
}
#[derive(Deserialize)]
struct PackageStatusResponse {
#[serde(rename = "packageStatus")]
package_status: String,
}
#[derive(Deserialize)]
struct ParticipantIdResponse {
#[serde(rename = "participantId")]
participant_id: String,
}
#[derive(Deserialize, Serialize)]
struct PartyDetailsWire {
party: String,
#[serde(
rename = "isLocal",
default,
skip_serializing_if = "std::ops::Not::not"
)]
is_local: bool,
#[serde(
rename = "localMetadata",
default,
skip_serializing_if = "Option::is_none"
)]
local_metadata: Option<ObjectMetaWire>,
#[serde(
rename = "identityProviderId",
default,
skip_serializing_if = "String::is_empty"
)]
identity_provider_id: String,
}
#[derive(Deserialize, Serialize, Default)]
struct ObjectMetaWire {
#[serde(
rename = "resourceVersion",
default,
skip_serializing_if = "String::is_empty"
)]
resource_version: String,
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
annotations: std::collections::HashMap<String, String>,
}
impl From<PartyDetailsWire> for admin::PartyDetails {
fn from(wire: PartyDetailsWire) -> Self {
admin::PartyDetails {
party: wire.party,
is_local: wire.is_local,
local_metadata: wire.local_metadata.map(|meta| admin::ObjectMeta {
resource_version: meta.resource_version,
annotations: meta.annotations,
}),
identity_provider_id: wire.identity_provider_id,
}
}
}
impl From<&admin::PartyDetails> for PartyDetailsWire {
fn from(details: &admin::PartyDetails) -> Self {
PartyDetailsWire {
party: details.party.clone(),
is_local: details.is_local,
local_metadata: details.local_metadata.as_ref().map(|meta| ObjectMetaWire {
resource_version: meta.resource_version.clone(),
annotations: meta.annotations.clone(),
}),
identity_provider_id: details.identity_provider_id.clone(),
}
}
}
#[derive(Deserialize)]
struct ListKnownPartiesResponse {
#[serde(rename = "partyDetails", default)]
party_details: Vec<PartyDetailsWire>,
#[serde(rename = "nextPageToken", default)]
next_page_token: String,
}
#[derive(Deserialize)]
struct PartiesResponse {
#[serde(rename = "partyDetails", default)]
party_details: Vec<PartyDetailsWire>,
}
#[derive(Deserialize)]
struct PartyDetailsResponse {
#[serde(rename = "partyDetails")]
party_details: PartyDetailsWire,
}
#[derive(Serialize)]
struct AllocatePartyWire<'a> {
#[serde(rename = "partyIdHint", skip_serializing_if = "str::is_empty")]
party_id_hint: &'a str,
#[serde(rename = "localMetadata", skip_serializing_if = "Option::is_none")]
local_metadata: Option<ObjectMetaWire>,
#[serde(rename = "identityProviderId", skip_serializing_if = "str::is_empty")]
identity_provider_id: &'a str,
#[serde(rename = "synchronizerId", skip_serializing_if = "str::is_empty")]
synchronizer_id: &'a str,
#[serde(rename = "userId", skip_serializing_if = "str::is_empty")]
user_id: &'a str,
}
#[derive(Serialize)]
struct UpdatePartyDetailsWire {
#[serde(rename = "partyDetails")]
party_details: PartyDetailsWire,
#[serde(rename = "updateMask")]
update_mask: FieldMaskWire,
}
#[derive(Serialize)]
struct FieldMaskWire {
paths: Vec<String>,
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct AllocateParty {
party_id_hint: String,
annotations: std::collections::HashMap<String, String>,
identity_provider_id: String,
synchronizer_id: String,
user_id: String,
}
impl AllocateParty {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_hint(mut self, party_id_hint: impl Into<String>) -> Self {
self.party_id_hint = party_id_hint.into();
self
}
#[must_use]
pub fn with_annotation(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.annotations.insert(key.into(), value.into());
self
}
#[must_use]
pub fn with_identity_provider_id(mut self, identity_provider_id: impl Into<String>) -> Self {
self.identity_provider_id = identity_provider_id.into();
self
}
#[must_use]
pub fn with_synchronizer_id(mut self, synchronizer_id: impl Into<String>) -> Self {
self.synchronizer_id = synchronizer_id.into();
self
}
#[must_use]
pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
self.user_id = user_id.into();
self
}
fn wire(&self) -> AllocatePartyWire<'_> {
AllocatePartyWire {
party_id_hint: &self.party_id_hint,
local_metadata: (!self.annotations.is_empty()).then(|| ObjectMetaWire {
resource_version: String::new(),
annotations: self.annotations.clone(),
}),
identity_provider_id: &self.identity_provider_id,
synchronizer_id: &self.synchronizer_id,
user_id: &self.user_id,
}
}
}
#[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 {
error
.error_info()
.is_some_and(|info| info.reason == "DUPLICATE_COMMAND")
|| error.code() == Some(tonic::Code::AlreadyExists)
}
fn http_client_builder() -> reqwest::ClientBuilder {
reqwest::Client::builder().redirect(reqwest::redirect::Policy::none())
}
fn encode(value: &str) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(char::from(byte));
}
_ => {
let _ = write!(out, "%{byte:02X}");
}
}
}
out
}
fn segment(kind: &str, value: &str) -> Result<String> {
if value.is_empty() || value.chars().any(char::is_whitespace) {
return Err(Error::InvalidRequest(format!(
"{kind} {value:?} cannot form a path segment"
)));
}
Ok(encode(value))
}
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
}
const ERROR_BODY_CAP: usize = 64 * 1024;
async fn read_json<T: for<'de> Deserialize<'de>>(
response: reqwest::Response,
path: &str,
cap: usize,
) -> Result<T> {
if !response.status().is_success() {
let status = response.status().as_u16();
let body = read_body(response, ERROR_BODY_CAP, path)
.await
.unwrap_or_else(|e| format!("<body not read: {e}>"));
return Err(Error::http_at(status, body, path));
}
let body = read_body(response, cap, path).await?;
serde_json::from_str::<T>(&body).map_err(Error::from)
}
async fn read_body(mut response: reqwest::Response, cap: usize, path: &str) -> Result<String> {
if let Some(declared) = response
.content_length()
.filter(|declared| usize::try_from(*declared).map_or(true, |n| n > cap))
{
{
return Err(Error::UnexpectedResponse(format!(
"{path} answered with a {declared}-byte body, above the {cap}-byte limit \
(raise it with `with_max_decoding_message_size` if it is expected)"
)));
}
}
let mut buffer: Vec<u8> = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|e| Error::Connection(format!("reading json body from {path} failed: {e}")))?
{
if buffer.len() + chunk.len() > cap {
return Err(Error::UnexpectedResponse(format!(
"{path} answered with a body above the {cap}-byte limit \
(raise it with `with_max_decoding_message_size` if it is expected)"
)));
}
buffer.extend_from_slice(&chunk);
}
String::from_utf8(buffer).map_err(|e| {
Error::UnexpectedResponse(format!(
"{path} answered with a body that is not UTF-8: {e}"
))
})
}
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: http_client_builder()
.build()
.unwrap_or_else(|_| 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 = http_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| transport_error(path, &e))?;
read_json(response, path, self.max_decoding_message_size).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| transport_error(path, &e))?;
read_json(response, path, self.max_decoding_message_size).await
}
async fn patch_once<B: Serialize, T: for<'de> Deserialize<'de>>(
&self,
path: &str,
body: &B,
) -> Result<T> {
let mut request = self
.http
.patch(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| transport_error(path, &e))?;
read_json(response, path, self.max_decoding_message_size).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 list_packages(&self) -> Result<Vec<String>> {
telemetry::instrument("list_packages", TRANSPORT_JSON, async {
Ok(self
.get::<PackagesResponse>("/v2/packages")
.await?
.package_ids)
})
.await
}
pub async fn package_status(
&self,
package_id: &str,
) -> Result<canton_proto::com::daml::ledger::api::v2::PackageStatus> {
use canton_proto::com::daml::ledger::api::v2::PackageStatus;
if package_id.is_empty()
|| package_id
.chars()
.any(|c| matches!(c, '/' | '?' | '#' | '%') || c.is_whitespace())
{
return Err(Error::InvalidRequest(format!(
"package id {package_id:?} cannot form a path segment"
)));
}
let path = format!("/v2/packages/{package_id}/status");
telemetry::instrument("package_status", TRANSPORT_JSON, async {
let status = self
.get::<PackageStatusResponse>(&path)
.await?
.package_status;
PackageStatus::from_str_name(&status).ok_or_else(|| {
Error::UnexpectedResponse(format!(
"package status {status:?} is not one this build knows"
))
})
})
.await
}
pub async fn participant_id(&self) -> Result<String> {
telemetry::instrument("participant_id", TRANSPORT_JSON, async {
Ok(self
.get::<ParticipantIdResponse>("/v2/parties/participant-id")
.await?
.participant_id)
})
.await
}
pub async fn list_known_parties_page(
&self,
page_size: i32,
page_token: Option<String>,
) -> Result<(Vec<admin::PartyDetails>, Option<String>)> {
use std::fmt::Write as _;
let mut path = String::from("/v2/parties");
let mut separator = '?';
if page_size > 0 {
let _ = write!(path, "{separator}pageSize={page_size}");
separator = '&';
}
if let Some(token) = page_token.as_deref().filter(|t| !t.is_empty()) {
let _ = write!(path, "{separator}pageToken={}", encode(token));
}
telemetry::instrument("list_known_parties_page", TRANSPORT_JSON, async {
let response = self.get::<ListKnownPartiesResponse>(&path).await?;
let next = (!response.next_page_token.is_empty()).then_some(response.next_page_token);
Ok((
response.party_details.into_iter().map(Into::into).collect(),
next,
))
})
.await
}
pub async fn list_known_parties(&self) -> Result<Vec<admin::PartyDetails>> {
telemetry::instrument("list_known_parties", TRANSPORT_JSON, async {
let mut all = Vec::new();
let mut page_token: Option<String> = None;
let mut seen = std::collections::HashSet::new();
for page in 1.. {
let (parties, next) = self.list_known_parties_page(0, page_token.clone()).await?;
all.extend(parties);
let Some(next) = next else {
break;
};
if !seen.insert(next.clone()) {
return Err(Error::UnexpectedResponse(format!(
"the participant repeated a page token after {} parties; \
the list is incomplete and cannot be continued",
all.len()
)));
}
if page >= canton_core::MAX_LIST_PAGES {
return Err(Error::UnexpectedResponse(format!(
"the party listing did not end after {} pages and {} parties; \
the list is incomplete and cannot be continued",
canton_core::MAX_LIST_PAGES,
all.len()
)));
}
page_token = Some(next);
}
Ok(all)
})
.await
}
pub async fn get_parties(&self, parties: Vec<String>) -> Result<Vec<admin::PartyDetails>> {
use std::fmt::Write as _;
let Some((first, rest)) = parties.split_first() else {
return Err(Error::InvalidRequest(
"get_parties needs at least one party".to_string(),
));
};
let mut path = format!("/v2/parties/{}", segment("party", first)?);
for (i, party) in rest.iter().enumerate() {
let separator = if i == 0 { '?' } else { '&' };
let _ = write!(path, "{separator}parties={}", segment("party", party)?);
}
telemetry::instrument("get_parties", TRANSPORT_JSON, async {
Ok(self
.get::<PartiesResponse>(&path)
.await?
.party_details
.into_iter()
.map(Into::into)
.collect())
})
.await
}
pub async fn allocate_party(&self, party_id_hint: Option<&str>) -> Result<admin::PartyDetails> {
let mut request = AllocateParty::new();
if let Some(hint) = party_id_hint {
request = request.with_hint(hint);
}
self.allocate_party_with(&request).await
}
pub async fn allocate_party_with(
&self,
request: &AllocateParty,
) -> Result<admin::PartyDetails> {
telemetry::instrument("allocate_party", TRANSPORT_JSON, async {
Ok(self
.post_once::<_, PartyDetailsResponse>("/v2/parties", &request.wire())
.await?
.party_details
.into())
})
.await
}
pub async fn update_party_details(
&self,
details: &admin::PartyDetails,
update_paths: &[&str],
) -> Result<admin::PartyDetails> {
if update_paths.is_empty() {
return Err(Error::InvalidRequest(
"update_party_details needs at least one update path".to_string(),
));
}
let path = format!("/v2/parties/{}", segment("party", &details.party)?);
let body = UpdatePartyDetailsWire {
party_details: details.into(),
update_mask: FieldMaskWire {
paths: update_paths.iter().map(|p| (*p).to_string()).collect(),
},
};
telemetry::instrument("update_party_details", TRANSPORT_JSON, async {
Ok(self
.patch_once::<_, PartyDetailsResponse>(&path, &body)
.await?
.party_details
.into())
})
.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;
}
}
}
}
fn transport_error(path: &str, e: &reqwest::Error) -> Error {
let detail = canton_core::chain(e);
if e.is_timeout() {
return Error::Timeout;
}
if e.is_builder() || e.is_redirect() {
return Error::InvalidRequest(format!("the request to {path} could not be made: {detail}"));
}
if e.is_decode() {
return Error::UnexpectedResponse(format!(
"{path} returned a body this cannot read: {detail}"
));
}
if detail.to_ascii_lowercase().contains("certificate") {
return Error::InvalidRequest(format!(
"the certificate presented for {path} could not be verified: {detail}"
));
}
Error::Connection(format!("json request to {path} failed: {detail}"))
}
#[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"
);
}
}