miden_node_proto/clients/
mod.rs

1//! gRPC client builder utilities for Miden node.
2//!
3//! This module provides a unified type-safe [`Builder`] for creating various gRPC clients with
4//! explicit configuration decisions for TLS, timeout, and metadata.
5//!
6//! # Examples
7//!
8//! ```rust,no_run
9//! use miden_node_proto::clients::{Builder, WantsTls, StoreNtxBuilderClient, StoreNtxBuilder};
10//!
11//! # async fn example() -> anyhow::Result<()> {
12//! // Create a store client with OTEL and TLS
13//! let client: StoreNtxBuilderClient = Builder::new("https://store.example.com")?
14//!     .with_tls()?                 // or `.without_tls()`
15//!     .without_timeout()           // or `.with_timeout(Duration::from_secs(10))`
16//!     .without_metadata_version()  // or `.with_metadata_version("1.0".into())`
17//!     .without_metadata_genesis()  // or `.with_metadata_genesis(genesis)`
18//!     .connect::<StoreNtxBuilder>()
19//!     .await?;
20//! # Ok(())
21//! # }
22//! ```
23
24use std::collections::HashMap;
25use std::fmt::Write;
26use std::marker::PhantomData;
27use std::time::Duration;
28
29use anyhow::{Context, Result};
30use miden_node_utils::tracing::grpc::OtelInterceptor;
31use tonic::metadata::AsciiMetadataValue;
32use tonic::service::Interceptor;
33use tonic::service::interceptor::InterceptedService;
34use tonic::transport::{Channel, ClientTlsConfig, Endpoint};
35use tonic::{Request, Status};
36use url::Url;
37
38use crate::generated;
39
40// METADATA INTERCEPTOR
41// ================================================================================================
42
43/// Interceptor designed to inject required metadata into all RPC requests.
44#[derive(Default, Clone)]
45pub struct MetadataInterceptor {
46    metadata: HashMap<&'static str, AsciiMetadataValue>,
47}
48
49impl MetadataInterceptor {
50    /// Adds or overwrites HTTP ACCEPT metadata to the interceptor.
51    ///
52    /// Provided version string must be ASCII.
53    pub fn with_accept_metadata(
54        mut self,
55        version: &str,
56        genesis: Option<&str>,
57    ) -> Result<Self, anyhow::Error> {
58        let mut accept_value = format!("application/vnd.miden; version={version}");
59        if let Some(genesis) = genesis {
60            write!(accept_value, "; genesis={genesis}")?;
61        }
62        self.metadata.insert("accept", AsciiMetadataValue::try_from(accept_value)?);
63        Ok(self)
64    }
65}
66// COMBINED INTERCEPTOR (OTEL + METADATA)
67// ================================================================================================
68
69#[derive(Clone)]
70pub struct OtelAndMetadataInterceptor {
71    otel: OtelInterceptor,
72    metadata: MetadataInterceptor,
73}
74
75impl OtelAndMetadataInterceptor {
76    pub fn new(otel: OtelInterceptor, metadata: MetadataInterceptor) -> Self {
77        Self { otel, metadata }
78    }
79}
80
81impl Interceptor for OtelAndMetadataInterceptor {
82    fn call(&mut self, request: Request<()>) -> Result<Request<()>, Status> {
83        // Apply OTEL first so tracing context propagates, then attach metadata headers
84        let req = self.otel.call(request)?;
85        self.metadata.call(req)
86    }
87}
88
89impl Interceptor for MetadataInterceptor {
90    fn call(&mut self, request: Request<()>) -> Result<Request<()>, Status> {
91        let mut request = request;
92        for (key, value) in &self.metadata {
93            request.metadata_mut().insert(*key, value.clone());
94        }
95        Ok(request)
96    }
97}
98
99// TYPE ALIASES FOR INSTRUMENTED CLIENTS
100// ================================================================================================
101
102pub type RpcClient =
103    generated::rpc::api_client::ApiClient<InterceptedService<Channel, OtelAndMetadataInterceptor>>;
104pub type BlockProducerClient =
105    generated::block_producer::api_client::ApiClient<InterceptedService<Channel, OtelInterceptor>>;
106pub type StoreNtxBuilderClient = generated::ntx_builder_store::ntx_builder_client::NtxBuilderClient<
107    InterceptedService<Channel, OtelInterceptor>,
108>;
109pub type StoreBlockProducerClient =
110    generated::block_producer_store::block_producer_client::BlockProducerClient<
111        InterceptedService<Channel, OtelInterceptor>,
112    >;
113pub type StoreRpcClient =
114    generated::rpc_store::rpc_client::RpcClient<InterceptedService<Channel, OtelInterceptor>>;
115
116pub type RemoteProverProxyStatusClient =
117    generated::remote_prover::proxy_status_api_client::ProxyStatusApiClient<
118        InterceptedService<Channel, OtelInterceptor>,
119    >;
120
121pub type RemoteProverClient =
122    generated::remote_prover::api_client::ApiClient<InterceptedService<Channel, OtelInterceptor>>;
123
124// GRPC CLIENT BUILDER TRAIT
125// ================================================================================================
126
127/// Configuration for gRPC clients.
128///
129/// This struct contains the configuration for gRPC clients, including the metadata version and
130/// genesis commitment.
131pub struct ClientConfig {
132    pub metadata_version: Option<String>,
133    pub metadata_genesis: Option<String>,
134}
135
136/// Trait for building gRPC clients from a common [`Builder`] configuration.
137///
138/// This trait provides a standardized way to create different gRPC clients with consistent
139/// configuration options like TLS, OTEL interceptors, and connection types.
140pub trait GrpcClientBuilder {
141    type Service;
142
143    fn with_interceptor(channel: Channel, config: &ClientConfig) -> Self::Service;
144}
145
146// CLIENT BUILDER MARKERS
147// ================================================================================================
148
149#[derive(Copy, Clone, Debug)]
150pub struct Rpc;
151
152#[derive(Copy, Clone, Debug)]
153pub struct BlockProducer;
154
155#[derive(Copy, Clone, Debug)]
156pub struct StoreNtxBuilder;
157
158#[derive(Copy, Clone, Debug)]
159pub struct StoreBlockProducer;
160
161#[derive(Copy, Clone, Debug)]
162pub struct StoreRpc;
163
164#[derive(Copy, Clone, Debug)]
165pub struct RemoteProverProxy;
166
167// CLIENT BUILDER IMPLEMENTATIONS
168// ================================================================================================
169
170impl GrpcClientBuilder for Rpc {
171    type Service = RpcClient;
172
173    fn with_interceptor(channel: Channel, config: &ClientConfig) -> Self::Service {
174        // Include Accept header only if version was explicitly provided; still combine with OTEL.
175        let mut metadata = MetadataInterceptor::default();
176        if let Some(version) = config.metadata_version.as_deref() {
177            metadata = metadata
178                .with_accept_metadata(version, config.metadata_genesis.as_deref())
179                .expect("Failed to create metadata interceptor");
180        }
181        let combined = OtelAndMetadataInterceptor::new(OtelInterceptor, metadata);
182        generated::rpc::api_client::ApiClient::with_interceptor(channel, combined)
183    }
184}
185
186impl GrpcClientBuilder for BlockProducer {
187    type Service = BlockProducerClient;
188
189    fn with_interceptor(channel: Channel, _config: &ClientConfig) -> Self::Service {
190        generated::block_producer::api_client::ApiClient::with_interceptor(channel, OtelInterceptor)
191    }
192}
193
194impl GrpcClientBuilder for StoreNtxBuilder {
195    type Service = StoreNtxBuilderClient;
196
197    fn with_interceptor(channel: Channel, _config: &ClientConfig) -> Self::Service {
198        generated::ntx_builder_store::ntx_builder_client::NtxBuilderClient::with_interceptor(
199            channel,
200            OtelInterceptor,
201        )
202    }
203}
204
205impl GrpcClientBuilder for StoreBlockProducer {
206    type Service = StoreBlockProducerClient;
207
208    fn with_interceptor(channel: Channel, _config: &ClientConfig) -> Self::Service {
209        generated::block_producer_store::block_producer_client::BlockProducerClient::with_interceptor(
210            channel,
211            OtelInterceptor,
212        )
213    }
214}
215
216impl GrpcClientBuilder for StoreRpc {
217    type Service = StoreRpcClient;
218
219    fn with_interceptor(channel: Channel, _config: &ClientConfig) -> Self::Service {
220        generated::rpc_store::rpc_client::RpcClient::with_interceptor(channel, OtelInterceptor)
221    }
222}
223
224impl GrpcClientBuilder for RemoteProverProxy {
225    type Service = RemoteProverProxyStatusClient;
226
227    fn with_interceptor(channel: Channel, _config: &ClientConfig) -> Self::Service {
228        generated::remote_prover::proxy_status_api_client::ProxyStatusApiClient::with_interceptor(
229            channel,
230            OtelInterceptor,
231        )
232    }
233}
234
235impl GrpcClientBuilder for RemoteProverClient {
236    type Service = RemoteProverClient;
237
238    fn with_interceptor(channel: Channel, _config: &ClientConfig) -> Self::Service {
239        generated::remote_prover::api_client::ApiClient::with_interceptor(channel, OtelInterceptor)
240    }
241}
242
243// STRICT TYPE-SAFE BUILDER (NO DEFAULTS)
244// ================================================================================================
245
246/// A type-safe builder that forces the caller to make an explicit decision for each
247/// configuration item (TLS, timeout, metadata version, metadata genesis) before connecting.
248///
249/// This builder replaces the previous defaulted builder. Callers must explicitly choose TLS,
250/// timeout, and metadata options before connecting.
251///
252/// Usage example:
253///
254/// ```rust,no_run
255/// use miden_node_proto::clients::{Builder, WantsTls, Rpc, RpcClient};
256/// use std::time::Duration;
257///
258/// # async fn example() -> anyhow::Result<()> {
259/// let client: RpcClient = Builder::new("https://rpc.example.com:8080")?
260///     .with_tls()?                        // or `.without_tls()`
261///     .with_timeout(Duration::from_secs(5)) // or `.without_timeout()`
262///     .with_metadata_version("1.0".into()) // or `.without_metadata_version()`
263///     .without_metadata_genesis()           // or `.with_metadata_genesis(genesis)`
264///     .connect::<Rpc>()
265///     .await?;
266/// # Ok(())
267/// # }
268/// ```
269#[derive(Clone, Debug)]
270pub struct Builder<State> {
271    endpoint: Endpoint,
272    metadata_version: Option<String>,
273    metadata_genesis: Option<String>,
274    _state: PhantomData<State>,
275}
276
277#[derive(Copy, Clone, Debug)]
278pub struct WantsTls;
279#[derive(Copy, Clone, Debug)]
280pub struct WantsTimeout;
281#[derive(Copy, Clone, Debug)]
282pub struct WantsVersion;
283#[derive(Copy, Clone, Debug)]
284pub struct WantsGenesis;
285#[derive(Copy, Clone, Debug)]
286pub struct WantsConnection;
287
288impl<State> Builder<State> {
289    /// Convenience function to cast the state type and carry internal configuration forward.
290    fn next_state<Next>(self) -> Builder<Next> {
291        Builder {
292            endpoint: self.endpoint,
293            metadata_version: self.metadata_version,
294            metadata_genesis: self.metadata_genesis,
295            _state: PhantomData::<Next>,
296        }
297    }
298}
299
300impl Builder<WantsTls> {
301    /// Create a new strict builder from a gRPC endpoint URL such as
302    /// `http://localhost:8080` or `https://api.example.com:443`.
303    pub fn new(url: Url) -> Builder<WantsTls> {
304        let endpoint = Endpoint::from_shared(String::from(url))
305            .expect("Url type always results in valid endpoint");
306
307        Builder {
308            endpoint,
309            metadata_version: None,
310            metadata_genesis: None,
311            _state: PhantomData,
312        }
313    }
314
315    /// Explicitly disable TLS.
316    pub fn without_tls(self) -> Builder<WantsTimeout> {
317        self.next_state()
318    }
319
320    /// Explicitly enable TLS.
321    pub fn with_tls(mut self) -> Result<Builder<WantsTimeout>> {
322        self.endpoint = self
323            .endpoint
324            .tls_config(ClientTlsConfig::new().with_native_roots())
325            .context("Failed to configure TLS")?;
326
327        Ok(self.next_state())
328    }
329}
330
331impl Builder<WantsTimeout> {
332    /// Explicitly disable request timeout.
333    pub fn without_timeout(self) -> Builder<WantsVersion> {
334        self.next_state()
335    }
336
337    /// Explicitly configure a request timeout.
338    pub fn with_timeout(mut self, duration: Duration) -> Builder<WantsVersion> {
339        self.endpoint = self.endpoint.timeout(duration);
340        self.next_state()
341    }
342}
343
344impl Builder<WantsVersion> {
345    /// Do not include version in request metadata.
346    pub fn without_metadata_version(mut self) -> Builder<WantsGenesis> {
347        self.metadata_version = None;
348        self.next_state()
349    }
350
351    /// Include a specific version string in request metadata.
352    pub fn with_metadata_version(mut self, version: String) -> Builder<WantsGenesis> {
353        self.metadata_version = Some(version);
354        self.next_state()
355    }
356}
357
358impl Builder<WantsGenesis> {
359    /// Do not include genesis commitment in request metadata.
360    pub fn without_metadata_genesis(mut self) -> Builder<WantsConnection> {
361        self.metadata_genesis = None;
362        self.next_state()
363    }
364
365    /// Include a specific genesis commitment string in request metadata.
366    pub fn with_metadata_genesis(mut self, genesis: String) -> Builder<WantsConnection> {
367        self.metadata_genesis = Some(genesis);
368        self.next_state()
369    }
370}
371
372impl Builder<WantsConnection> {
373    /// Establish an eager connection and return a fully configured client.
374    pub async fn connect<T>(self) -> Result<T::Service>
375    where
376        T: GrpcClientBuilder,
377    {
378        let channel = self.endpoint.connect().await?;
379        let cfg = ClientConfig {
380            metadata_version: self.metadata_version,
381            metadata_genesis: self.metadata_genesis,
382        };
383        Ok(T::with_interceptor(channel, &cfg))
384    }
385
386    /// Establish a lazy connection and return a client that will connect on first use.
387    pub fn connect_lazy<T>(self) -> T::Service
388    where
389        T: GrpcClientBuilder,
390    {
391        let channel = self.endpoint.connect_lazy();
392        let cfg = ClientConfig {
393            metadata_version: self.metadata_version,
394            metadata_genesis: self.metadata_genesis,
395        };
396        T::with_interceptor(channel, &cfg)
397    }
398}