Skip to main content

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
9//! # use miden_node_proto::clients::{Builder, WantsTls, RpcClient};
10//! # use url::Url;
11//!
12//! # async fn example() -> anyhow::Result<()> {
13//! // Create an RPC client with OTEL and TLS
14//! let url = Url::parse("https://example.com:8080")?;
15//! let client: RpcClient = Builder::new(url)
16//!     .with_tls()?                   // or `.without_tls()`
17//!     .without_timeout()             // or `.with_timeout(Duration::from_secs(10))`
18//!     .without_metadata_version()    // or `.with_metadata_version("1.0".into())`
19//!     .without_metadata_genesis()    // or `.with_metadata_genesis(genesis)`
20//!     .without_auth_header()         // or `.with_auth_header_value(AsciiMetadataValue::from_static("value"))`
21//!     .with_otel_context_injection() // or `.without_otel_context_injection()`
22//!     .connect::<RpcClient>()
23//!     .await?;
24//! # Ok(())
25//! # }
26//! ```
27
28use std::marker::PhantomData;
29use std::ops::{Deref, DerefMut};
30use std::str::FromStr;
31use std::time::Duration;
32
33use http::header::ACCEPT;
34use miden_node_utils::tracing::grpc::OtelInterceptor;
35use tonic::metadata::AsciiMetadataValue;
36use tonic::service::interceptor::InterceptedService;
37use tonic::transport::{Channel, ClientTlsConfig, Endpoint, Error as TransportError};
38use tonic::{Request, Status};
39use url::Url;
40
41use crate::generated;
42
43#[derive(Clone)]
44pub struct Interceptor {
45    otel: Option<OtelInterceptor>,
46    accept: AsciiMetadataValue,
47    auth_header_value: Option<AsciiMetadataValue>,
48}
49
50impl Default for Interceptor {
51    fn default() -> Self {
52        Self {
53            otel: None,
54            accept: AsciiMetadataValue::from_static(Self::MEDIA_TYPE),
55            auth_header_value: None,
56        }
57    }
58}
59
60impl Interceptor {
61    const MEDIA_TYPE: &str = "application/vnd.miden";
62    const VERSION: &str = "version";
63    const GENESIS: &str = "genesis";
64    const NETWORK_TX_AUTH_HEADER_NAME: &str = "x-miden-network-tx-auth";
65
66    fn new(
67        enable_otel: bool,
68        version: Option<&str>,
69        genesis: Option<&str>,
70        auth_header: Option<AsciiMetadataValue>,
71    ) -> Self {
72        if let Some(version) = version
73            && !version.is_ascii()
74        {
75            panic!("version contains non-ascii values: {version}");
76        }
77
78        if let Some(genesis) = genesis
79            && !genesis.is_ascii()
80        {
81            panic!("genesis contains non-ascii values: {genesis}");
82        }
83
84        let accept = match (version, genesis) {
85            (None, None) => Self::MEDIA_TYPE.to_string(),
86            (None, Some(genesis)) => format!("{}; {}={genesis}", Self::MEDIA_TYPE, Self::GENESIS),
87            (Some(version), None) => format!("{}; {}={version}", Self::MEDIA_TYPE, Self::VERSION),
88            (Some(version), Some(genesis)) => format!(
89                "{}; {}={version}, {}={genesis}",
90                Self::MEDIA_TYPE,
91                Self::VERSION,
92                Self::GENESIS
93            ),
94        };
95        Self {
96            otel: enable_otel.then_some(OtelInterceptor),
97            // SAFETY: we checked that all values are ascii at the top of the function.
98            accept: AsciiMetadataValue::from_str(&accept).unwrap(),
99            auth_header_value: auth_header,
100        }
101    }
102}
103
104impl tonic::service::Interceptor for Interceptor {
105    fn call(&mut self, mut request: tonic::Request<()>) -> Result<Request<()>, Status> {
106        if let Some(mut otel) = self.otel {
107            request = otel.call(request)?;
108        }
109
110        if request.metadata().get(ACCEPT.as_str()).is_none() {
111            request.metadata_mut().insert(ACCEPT.as_str(), self.accept.clone());
112        }
113
114        if let Some(value) = &self.auth_header_value {
115            request.metadata_mut().insert(Self::NETWORK_TX_AUTH_HEADER_NAME, value.clone());
116        }
117
118        Ok(request)
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn interceptor_preserves_existing_accept_metadata() {
128        let original_accept =
129            AsciiMetadataValue::from_static("application/vnd.miden; version=1.2; genesis=0x1234");
130        let mut request = Request::new(());
131        request.metadata_mut().insert(ACCEPT.as_str(), original_accept.clone());
132
133        let mut interceptor = Interceptor::new(false, Some("9.9"), Some("0xabcd"), None);
134        let request = tonic::service::Interceptor::call(&mut interceptor, request)
135            .expect("interceptor should succeed");
136
137        assert_eq!(request.metadata().get(ACCEPT.as_str()), Some(&original_accept));
138    }
139
140    #[test]
141    fn interceptor_inserts_accept_metadata_when_missing() {
142        let mut interceptor = Interceptor::new(false, Some("9.9"), Some("0xabcd"), None);
143
144        let request = tonic::service::Interceptor::call(&mut interceptor, Request::new(()))
145            .expect("interceptor should succeed");
146
147        assert_eq!(
148            request.metadata().get(ACCEPT.as_str()).and_then(|value| value.to_str().ok()),
149            Some("application/vnd.miden; version=9.9, genesis=0xabcd"),
150        );
151    }
152}
153
154// TYPE ALIASES TO AID LEGIBILITY
155// ================================================================================================
156
157type InterceptedChannel = InterceptedService<Channel, Interceptor>;
158type GeneratedRpcClient = generated::rpc::api_client::ApiClient<InterceptedChannel>;
159type GeneratedProxyStatusClient =
160    generated::remote_prover::proxy_status_api_client::ProxyStatusApiClient<InterceptedChannel>;
161type GeneratedProverClient = generated::remote_prover::api_client::ApiClient<InterceptedChannel>;
162type GeneratedValidatorClient = generated::validator::api_client::ApiClient<InterceptedChannel>;
163type GeneratedNtxBuilderClient = generated::ntx_builder::api_client::ApiClient<InterceptedChannel>;
164
165// gRPC CLIENTS
166// ================================================================================================
167
168#[derive(Debug, Clone)]
169pub struct RpcClient(GeneratedRpcClient);
170#[derive(Debug, Clone)]
171pub struct RemoteProverProxyStatusClient(GeneratedProxyStatusClient);
172#[derive(Debug, Clone)]
173pub struct RemoteProverClient(GeneratedProverClient);
174#[derive(Debug, Clone)]
175pub struct ValidatorClient(GeneratedValidatorClient);
176#[derive(Debug, Clone)]
177pub struct NtxBuilderClient(GeneratedNtxBuilderClient);
178
179impl DerefMut for RpcClient {
180    fn deref_mut(&mut self) -> &mut Self::Target {
181        &mut self.0
182    }
183}
184
185impl Deref for RpcClient {
186    type Target = GeneratedRpcClient;
187
188    fn deref(&self) -> &Self::Target {
189        &self.0
190    }
191}
192
193impl DerefMut for RemoteProverProxyStatusClient {
194    fn deref_mut(&mut self) -> &mut Self::Target {
195        &mut self.0
196    }
197}
198
199impl Deref for RemoteProverProxyStatusClient {
200    type Target = GeneratedProxyStatusClient;
201
202    fn deref(&self) -> &Self::Target {
203        &self.0
204    }
205}
206
207impl DerefMut for RemoteProverClient {
208    fn deref_mut(&mut self) -> &mut Self::Target {
209        &mut self.0
210    }
211}
212
213impl Deref for RemoteProverClient {
214    type Target = GeneratedProverClient;
215
216    fn deref(&self) -> &Self::Target {
217        &self.0
218    }
219}
220
221impl DerefMut for ValidatorClient {
222    fn deref_mut(&mut self) -> &mut Self::Target {
223        &mut self.0
224    }
225}
226
227impl Deref for ValidatorClient {
228    type Target = GeneratedValidatorClient;
229
230    fn deref(&self) -> &Self::Target {
231        &self.0
232    }
233}
234
235impl DerefMut for NtxBuilderClient {
236    fn deref_mut(&mut self) -> &mut Self::Target {
237        &mut self.0
238    }
239}
240
241impl Deref for NtxBuilderClient {
242    type Target = GeneratedNtxBuilderClient;
243
244    fn deref(&self) -> &Self::Target {
245        &self.0
246    }
247}
248
249// GRPC CLIENT BUILDER TRAIT
250// ================================================================================================
251
252/// Trait for building gRPC clients from a common [`Builder`] configuration.
253pub trait GrpcClient {
254    fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self;
255}
256
257impl GrpcClient for RpcClient {
258    fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self {
259        Self(GeneratedRpcClient::new(InterceptedService::new(channel, interceptor)))
260    }
261}
262
263impl GrpcClient for RemoteProverProxyStatusClient {
264    fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self {
265        Self(GeneratedProxyStatusClient::new(InterceptedService::new(channel, interceptor)))
266    }
267}
268
269impl GrpcClient for RemoteProverClient {
270    fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self {
271        Self(GeneratedProverClient::new(InterceptedService::new(channel, interceptor)))
272    }
273}
274
275impl GrpcClient for ValidatorClient {
276    fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self {
277        Self(GeneratedValidatorClient::new(InterceptedService::new(channel, interceptor)))
278    }
279}
280
281impl GrpcClient for NtxBuilderClient {
282    fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self {
283        Self(GeneratedNtxBuilderClient::new(InterceptedService::new(channel, interceptor)))
284    }
285}
286
287// STRICT TYPE-SAFE BUILDER (NO DEFAULTS)
288// ================================================================================================
289
290/// A type-safe builder that forces the caller to make an explicit decision for each
291/// configuration item (TLS, timeout, metadata version, metadata genesis) before connecting.
292///
293/// This builder replaces the previous defaulted builder. Callers must explicitly choose TLS,
294/// timeout, and metadata options before connecting.
295///
296/// Usage example:
297///
298/// ```rust
299/// # use miden_node_proto::clients::{Builder, WantsTls, RpcClient};
300/// # use url::Url;
301/// # use std::time::Duration;
302///
303/// # async fn example() -> anyhow::Result<()> {
304/// let url = Url::parse("https://rpc.example.com:8080")?;
305/// let client: RpcClient = Builder::new(url)
306///     .with_tls()?                          // or `.without_tls()`
307///     .with_timeout(Duration::from_secs(5)) // or `.without_timeout()`
308///     .with_metadata_version("1.0".into())  // or `.without_metadata_version()`
309///     .without_metadata_genesis()           // or `.with_metadata_genesis(genesis)`
310///     .without_auth_header()                // or `.with_auth_header_value(AsciiMetadataValue::from_static("value"))`
311///     .with_otel_context_injection()        // or `.without_otel_context_injection()`
312///     .connect::<RpcClient>()
313///     .await?;
314/// # Ok(())
315/// # }
316/// ```
317#[derive(Clone, Debug)]
318pub struct Builder<State> {
319    endpoint: Endpoint,
320    metadata_version: Option<String>,
321    metadata_genesis: Option<String>,
322    metadata_auth_header_value: Option<AsciiMetadataValue>,
323    enable_otel: bool,
324    _state: PhantomData<State>,
325}
326
327#[derive(Copy, Clone, Debug)]
328pub struct WantsTls;
329#[derive(Copy, Clone, Debug)]
330pub struct WantsTimeout;
331#[derive(Copy, Clone, Debug)]
332pub struct WantsVersion;
333#[derive(Copy, Clone, Debug)]
334pub struct WantsGenesis;
335#[derive(Copy, Clone, Debug)]
336pub struct WantsOTel;
337#[derive(Copy, Clone, Debug)]
338pub struct WantsConnection;
339
340impl<State> Builder<State> {
341    /// Convenience function to cast the state type and carry internal configuration forward.
342    fn next_state<Next>(self) -> Builder<Next> {
343        Builder {
344            endpoint: self.endpoint,
345            metadata_version: self.metadata_version,
346            metadata_genesis: self.metadata_genesis,
347            metadata_auth_header_value: self.metadata_auth_header_value,
348            enable_otel: self.enable_otel,
349            _state: PhantomData::<Next>,
350        }
351    }
352}
353
354impl Builder<WantsTls> {
355    /// Create a new strict builder from a gRPC endpoint URL such as `http://localhost:8080` or
356    /// `https://api.example.com:443`.
357    pub fn new(url: Url) -> Builder<WantsTls> {
358        let endpoint = Endpoint::from_shared(String::from(url))
359            .expect("Url type always results in valid endpoint");
360
361        Builder {
362            endpoint,
363            metadata_version: None,
364            metadata_genesis: None,
365            metadata_auth_header_value: None,
366            enable_otel: false,
367            _state: PhantomData,
368        }
369    }
370
371    /// Explicitly disable TLS.
372    pub fn without_tls(self) -> Builder<WantsTimeout> {
373        self.next_state()
374    }
375
376    /// Explicitly enable TLS.
377    pub fn with_tls(mut self) -> Result<Builder<WantsTimeout>, TransportError> {
378        self.endpoint = self.endpoint.tls_config(ClientTlsConfig::new().with_native_roots())?;
379
380        Ok(self.next_state())
381    }
382}
383
384impl Builder<WantsTimeout> {
385    /// Explicitly disable request timeout.
386    pub fn without_timeout(self) -> Builder<WantsVersion> {
387        self.next_state()
388    }
389
390    /// Explicitly configure a request timeout.
391    pub fn with_timeout(mut self, duration: Duration) -> Builder<WantsVersion> {
392        self.endpoint = self.endpoint.timeout(duration);
393        self.next_state()
394    }
395}
396
397impl Builder<WantsVersion> {
398    /// Do not include version in request metadata.
399    pub fn without_metadata_version(mut self) -> Builder<WantsGenesis> {
400        self.metadata_version = None;
401        self.next_state()
402    }
403
404    /// Include a specific version string in request metadata.
405    pub fn with_metadata_version(mut self, version: String) -> Builder<WantsGenesis> {
406        self.metadata_version = Some(version);
407        self.next_state()
408    }
409}
410
411impl Builder<WantsGenesis> {
412    /// Do not include genesis commitment in request metadata.
413    pub fn without_metadata_genesis(mut self) -> Builder<WantsOTel> {
414        self.metadata_genesis = None;
415        self.next_state()
416    }
417
418    /// Include a specific genesis commitment string in request metadata.
419    pub fn with_metadata_genesis(mut self, genesis: String) -> Builder<WantsOTel> {
420        self.metadata_genesis = Some(genesis);
421        self.next_state()
422    }
423}
424
425impl Builder<WantsOTel> {
426    /// Do not include any additional metadata header in request metadata.
427    #[must_use]
428    pub fn without_auth_header(mut self) -> Self {
429        self.metadata_auth_header_value = None;
430        self
431    }
432
433    /// Include an additional ASCII metadata header in request metadata.
434    #[must_use]
435    pub fn with_auth_header_value(mut self, value: AsciiMetadataValue) -> Self {
436        self.metadata_auth_header_value = Some(value);
437        self
438    }
439
440    /// Enables OpenTelemetry context propagation via gRPC.
441    ///
442    /// This is used to by OpenTelemetry to connect traces across network boundaries. The server on
443    /// the other end must be configured to receive and use the injected trace context.
444    pub fn with_otel_context_injection(mut self) -> Builder<WantsConnection> {
445        self.enable_otel = true;
446        self.next_state()
447    }
448
449    /// Disables OpenTelemetry context propagation. This should be disabled when interfacing with
450    /// external third party gRPC servers.
451    pub fn without_otel_context_injection(mut self) -> Builder<WantsConnection> {
452        self.enable_otel = false;
453        self.next_state()
454    }
455}
456
457impl Builder<WantsConnection> {
458    /// Establish an eager connection and return a fully configured client.
459    pub async fn connect<T>(self) -> Result<T, TransportError>
460    where
461        T: GrpcClient,
462    {
463        let channel = self.endpoint.connect().await?;
464        Ok(self.connect_with_channel::<T>(channel))
465    }
466
467    /// Establish a lazy connection and return a client that will connect on first use.
468    pub fn connect_lazy<T>(self) -> T
469    where
470        T: GrpcClient,
471    {
472        let channel = self.endpoint.connect_lazy();
473        self.connect_with_channel::<T>(channel)
474    }
475
476    fn connect_with_channel<T>(self, channel: Channel) -> T
477    where
478        T: GrpcClient,
479    {
480        let interceptor = Interceptor::new(
481            self.enable_otel,
482            self.metadata_version.as_deref(),
483            self.metadata_genesis.as_deref(),
484            self.metadata_auth_header_value,
485        );
486        T::with_interceptor(channel, interceptor)
487    }
488}