Skip to main content

toolkit_contract/
wiring.rs

1//! `ClientWiring` — typed config schema consumed by `#[toolkit::provides]`.
2//!
3//! Lives outside the feature-gated `runtime` module so the deserialization
4//! itself is always available: any module loaded into the host must be able
5//! to parse its wiring config regardless of which transport features its
6//! provider SDK compiled in. The actual conversion to a runtime
7//! [`ClientConfig`](crate::runtime::config::ClientConfig) is gated on
8//! `runtime-client`.
9
10use std::time::Duration;
11
12use serde::Deserialize;
13
14/// Fine-tuning knobs forwarded to the transport client when a remote
15/// transport is selected. All fields are optional — missing values fall
16/// back to the SDK defaults baked into
17/// [`ClientConfig`](crate::runtime::config::ClientConfig).
18#[derive(Clone, Debug, Default, Deserialize)]
19pub struct ClientTuning {
20    /// Per-call request deadline (e.g., `"5s"`, `"500ms"`).
21    #[serde(default, with = "toolkit_utils::humantime_serde::option")]
22    pub timeout: Option<Duration>,
23
24    /// Override for the retry policy applied to `#[retryable]` methods.
25    #[serde(default)]
26    pub retry: Option<RetrySettings>,
27
28    /// Override for the stream reconnect policy, of any framing.
29    ///
30    /// `alias = "sse_reconnect"` keeps already-deployed config files working:
31    /// this struct has no `rename_all`, so the Rust field name *is* the JSON
32    /// key, and the field was called `sse_reconnect` before the policy covered
33    /// framings other than SSE.
34    ///
35    /// Supplying **both** `stream_reconnect` and `sse_reconnect` is rejected as
36    /// a duplicate field — the two name the same field — so a config that adds
37    /// the new key without removing the legacy one fails loudly at parse time
38    /// rather than silently honouring one and dropping the other. This holds
39    /// even though the field is `#[serde(flatten)]`-ed into [`ClientWiring`].
40    #[serde(default, alias = "sse_reconnect")]
41    pub stream_reconnect: Option<ReconnectSettings>,
42
43    /// Reject plaintext `http://` endpoints.
44    ///
45    /// Defaults to `false` (the in-mesh convention). Without this knob a
46    /// discovery-resolved client always got the default, so a consumer talking
47    /// to an endpoint outside a trusted boundary had no way to demand TLS —
48    /// while forwarding a tenant bearer token over it.
49    #[serde(default)]
50    pub require_tls: Option<bool>,
51
52    /// Platform-plane credential source forwarded onto the built
53    /// [`ClientConfig`](crate::runtime::config::ClientConfig). Injected by the
54    /// runtime's proxy-wiring phase, never from config (`#[serde(skip)]`); gated
55    /// on `runtime-client` since the type lives there.
56    #[cfg(feature = "runtime-client")]
57    #[serde(skip)]
58    pub internal_token_provider: Option<crate::runtime::config::InternalTokenProvider>,
59}
60
61/// Deserializable mirror of
62/// [`RetryConfig`](crate::runtime::config::RetryConfig). All fields optional;
63/// missing values keep the runtime default.
64#[derive(Clone, Debug, Default, Deserialize)]
65pub struct RetrySettings {
66    pub max_attempts: Option<u32>,
67    #[serde(default, with = "toolkit_utils::humantime_serde::option")]
68    pub base_delay: Option<Duration>,
69    #[serde(default, with = "toolkit_utils::humantime_serde::option")]
70    pub max_delay: Option<Duration>,
71    pub multiplier: Option<f64>,
72}
73
74/// Deserializable mirror of
75/// [`ReconnectConfig`](crate::runtime::config::ReconnectConfig).
76#[derive(Clone, Debug, Default, Deserialize)]
77pub struct ReconnectSettings {
78    pub max_attempts: Option<u32>,
79    #[serde(default, with = "toolkit_utils::humantime_serde::option")]
80    pub base_delay: Option<Duration>,
81    #[serde(default, with = "toolkit_utils::humantime_serde::option")]
82    pub max_delay: Option<Duration>,
83}
84
85/// Transport choice + endpoint + tuning for one provided contract.
86///
87/// Read by `#[toolkit::provides]` from
88/// `gears.<gear>.config.client_wiring.<contract_snake>`. If the key is
89/// absent the wiring defaults to [`ClientWiring::Local`].
90#[derive(Clone, Debug, Default, Deserialize)]
91#[serde(rename_all = "lowercase", tag = "transport")]
92pub enum ClientWiring {
93    /// In-process. The provider gear's local factory is invoked.
94    #[default]
95    Local,
96    /// Generated REST client points at `endpoint`.
97    Rest {
98        endpoint: String,
99        #[serde(default, flatten)]
100        tuning: ClientTuning,
101    },
102    /// Generated gRPC client connects to `endpoint`.
103    Grpc {
104        endpoint: String,
105        #[serde(default, flatten)]
106        tuning: ClientTuning,
107    },
108}
109
110#[cfg(feature = "runtime-client")]
111impl ClientTuning {
112    /// Apply tuning overrides onto a fresh [`ClientConfig`] built from `endpoint`.
113    #[must_use]
114    pub fn apply_to(&self, endpoint: impl Into<String>) -> crate::runtime::config::ClientConfig {
115        use crate::runtime::config::{ClientConfig, ReconnectConfig, RetryConfig};
116
117        let mut cfg = ClientConfig::new(endpoint);
118        if let Some(timeout) = self.timeout {
119            cfg = cfg.with_timeout(timeout);
120        }
121        if let Some(ref r) = self.retry {
122            let base = cfg.retry.clone();
123            cfg = cfg.with_retry(RetryConfig {
124                max_attempts: r.max_attempts.unwrap_or(base.max_attempts),
125                base_delay: r.base_delay.unwrap_or(base.base_delay),
126                max_delay: r.max_delay.unwrap_or(base.max_delay),
127                multiplier: r.multiplier.unwrap_or(base.multiplier),
128            });
129        }
130        if let Some(ref s) = self.stream_reconnect {
131            let base = cfg.stream_reconnect.clone();
132            cfg = cfg.with_stream_reconnect(ReconnectConfig {
133                max_attempts: s.max_attempts.unwrap_or(base.max_attempts),
134                base_delay: s.base_delay.unwrap_or(base.base_delay),
135                max_delay: s.max_delay.unwrap_or(base.max_delay),
136                // Not exposed as wiring keys yet; inherit the base policy.
137                min_healthy_uptime: base.min_healthy_uptime,
138                max_total_reopens: base.max_total_reopens,
139            });
140        }
141        if let Some(require_tls) = self.require_tls {
142            cfg = cfg.with_require_tls(require_tls);
143        }
144        cfg = cfg.with_internal_token_provider(self.internal_token_provider.clone());
145        cfg
146    }
147
148    /// Attach the platform-plane credential source forwarded onto the built
149    /// [`ClientConfig`](crate::runtime::config::ClientConfig). Used by the
150    /// proxy-wiring phase to thread the process credential into a
151    /// directory-resolving (`#[toolkit::consumes]`) client.
152    #[must_use]
153    pub fn with_internal_token_provider(
154        mut self,
155        provider: Option<crate::runtime::config::InternalTokenProvider>,
156    ) -> Self {
157        self.internal_token_provider = provider;
158        self
159    }
160}