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
2 changes: 1 addition & 1 deletion .licenseignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
# Generated protobuf files from Google
crates/iota-grpc-types/src/proto_generated/google.*
crates/iota-grpc-types/src/proto/generated/google.*
11 changes: 11 additions & 0 deletions Cargo.lock

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

6 changes: 5 additions & 1 deletion crates/iota-grpc-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@ workspace = true
[dependencies]
# external dependencies
anyhow.workspace = true
base64.workspace = true
futures.workspace = true
http.workspace = true
iota-sdk2.workspace = true
serde_json.workspace = true
tonic.workspace = true
tap.workspace = true
tonic = { workspace = true, features = ["tls-ring"] }

# internal dependencies
iota-grpc-types.workspace = true
Expand Down
134 changes: 134 additions & 0 deletions crates/iota-grpc-client/src/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright (c) Mysten Labs, Inc.
// Modifications Copyright (c) 2025 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

use std::{
sync::{Arc, OnceLock},
time::Duration,
};

use iota_grpc_types::v0::ledger_service::ledger_service_client::LedgerServiceClient;
use tap::Pipe;
use tonic::{codec::CompressionEncoding, transport::channel::ClientTlsConfig};

use crate::interceptors::HeadersInterceptor;

type Result<T, E = tonic::Status> = std::result::Result<T, E>;
type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;

type InterceptedChannel =
tonic::service::interceptor::InterceptedService<tonic::transport::Channel, HeadersInterceptor>;

/// gRPC client factory for IOTA gRPC operations.
#[derive(Clone)]
pub struct Client {
/// Target URI of the gRPC server
uri: http::Uri,
/// Shared gRPC channel for all service clients
channel: tonic::transport::Channel,
/// Headers interceptor for adding custom headers to requests
headers: HeadersInterceptor,
/// Maximum decoding message size for responses
max_decoding_message_size: Option<usize>,

/// Cached ledger client (singleton)
ledger_client: Arc<OnceLock<LedgerServiceClient<InterceptedChannel>>>,
}

impl Client {
/// Connect to a gRPC server and create a new Client instance.
#[allow(clippy::result_large_err)]
pub async fn connect<T>(uri: T) -> Result<Self>
where
T: TryInto<http::Uri>,
T::Error: Into<BoxError>,
{
let uri = uri
.try_into()
.map_err(Into::into)
.map_err(tonic::Status::from_error)?;

let mut endpoint = tonic::transport::Endpoint::from(uri.clone());
if uri.scheme() == Some(&http::uri::Scheme::HTTPS) {
endpoint = endpoint
.tls_config(ClientTlsConfig::new().with_enabled_roots())
.map_err(Into::into)
.map_err(tonic::Status::from_error)?;
}

let channel = endpoint
.connect_timeout(Duration::from_secs(5))
.http2_keep_alive_interval(Duration::from_secs(5))
.connect_lazy();

Ok(Self {
uri,
channel,
headers: Default::default(),
max_decoding_message_size: None,
ledger_client: Arc::new(OnceLock::new()),
})
}

pub fn uri(&self) -> &http::Uri {
&self.uri
}

/// Get a reference to the underlying channel.
///
/// This can be useful for creating additional service clients that aren't
/// yet integrated into Client.
pub fn channel(&self) -> &tonic::transport::Channel {
&self.channel
}

pub fn headers(&self) -> &HeadersInterceptor {
&self.headers
}

pub fn max_decoding_message_size(&self) -> Option<usize> {
self.max_decoding_message_size
}

pub fn with_headers(mut self, headers: HeadersInterceptor) -> Self {
self.headers = headers;
self
}

pub fn with_max_decoding_message_size(mut self, limit: usize) -> Self {
self.max_decoding_message_size = Some(limit);
self
}

// ========================================
// Service Client Factories
// ========================================

/// Get a ledger service client.
///
/// Returns `Some(LedgerClient)` if the server supports ledger-related
/// operations, `None` otherwise. The client is created only once and
/// cached for subsequent calls.
pub fn ledger_service_client(&self) -> Option<LedgerServiceClient<InterceptedChannel>> {
// For now, always return Some since ledger service is always available
// In the future, this could check server capabilities first
Some(
self.ledger_client
.get_or_init(|| {
LedgerServiceClient::with_interceptor(
self.channel.clone(),
self.headers.clone(),
)
.accept_compressed(CompressionEncoding::Zstd)
.pipe(|client| {
if let Some(limit) = self.max_decoding_message_size {
client.max_decoding_message_size(limit)
} else {
client
}
})
})
.clone(),
)
}
}
90 changes: 90 additions & 0 deletions crates/iota-grpc-client/src/interceptors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright (c) Mysten Labs, Inc.
// Modifications Copyright (c) 2025 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

/// Interceptor used to add additional headers to a Request
#[derive(Clone, Debug, Default)]
pub struct HeadersInterceptor {
headers: tonic::metadata::MetadataMap,
}

impl HeadersInterceptor {
/// Create a new, empty `HeadersInterceptor`.
pub fn new() -> Self {
Self::default()
}

/// Return reference to the internal `MetadataMap`.
pub fn headers(&self) -> &tonic::metadata::MetadataMap {
&self.headers
}

/// Get mutable access to the internal `MetadataMap` for modification.
pub fn headers_mut(&mut self) -> &mut tonic::metadata::MetadataMap {
&mut self.headers
}

/// Enable HTTP basic authentication with a username and optional password.
pub fn basic_auth<U, P>(&mut self, username: U, password: Option<P>)
where
U: std::fmt::Display,
P: std::fmt::Display,
{
use std::io::Write;

use base64::{prelude::BASE64_STANDARD, write::EncoderWriter};

let mut buf = b"Basic ".to_vec();
{
let mut encoder = EncoderWriter::new(&mut buf, &BASE64_STANDARD);
let _ = write!(encoder, "{username}:");
if let Some(password) = password {
let _ = write!(encoder, "{password}");
}
}
let mut header = tonic::metadata::MetadataValue::try_from(buf)
.expect("base64 is always valid HeaderValue");
header.set_sensitive(true);

self.headers
.insert(http::header::AUTHORIZATION.as_str(), header);
}

/// Enable HTTP bearer authentication.
pub fn bearer_auth<T>(&mut self, token: T)
where
T: std::fmt::Display,
{
let header_value = format!("Bearer {token}");
let mut header = tonic::metadata::MetadataValue::try_from(header_value)
.expect("token is always valid HeaderValue");
header.set_sensitive(true);

self.headers
.insert(http::header::AUTHORIZATION.as_str(), header);
}
}

impl tonic::service::Interceptor for &HeadersInterceptor {
fn call(
&mut self,
mut request: tonic::Request<()>,
) -> std::result::Result<tonic::Request<()>, tonic::Status> {
if !self.headers.is_empty() {
request
.metadata_mut()
.as_mut()
.extend(self.headers.clone().into_headers());
}
Ok(request)
}
}

impl tonic::service::Interceptor for HeadersInterceptor {
fn call(
&mut self,
request: tonic::Request<()>,
) -> std::result::Result<tonic::Request<()>, tonic::Status> {
(&*self).call(request)
}
}
23 changes: 0 additions & 23 deletions crates/iota-grpc-client/src/ledger.rs

This file was deleted.

11 changes: 7 additions & 4 deletions crates/iota-grpc-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@

//! gRPC client for IOTA node operations.
mod ledger;
mod node_client;
mod client;
pub use client::Client;

pub use ledger::LedgerClient;
pub use node_client::NodeClient;
mod response_ext;
pub use response_ext::ResponseExt;

mod interceptors;
pub use interceptors::HeadersInterceptor;
55 changes: 0 additions & 55 deletions crates/iota-grpc-client/src/node_client.rs

This file was deleted.

Loading
Loading