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    /// Max idle keep-alive connections per upstream host. Raise this to at least
53    /// the expected per-upstream request concurrency so HTTP/1.1 connections are
54    /// reused instead of churned under load. Missing keeps the SDK default
55    /// ([`ClientConfig::pool_max_idle_per_host`](crate::runtime::config::ClientConfig::pool_max_idle_per_host)).
56    ///
57    /// **REST transport only.** A `transport: grpc` wiring accepts this key but
58    /// it has no effect — the gRPC client reads only `endpoint`, `timeout` and
59    /// `require_tls` (a warning is logged at gRPC client construction).
60    #[serde(default)]
61    pub pool_max_idle_per_host: Option<usize>,
62
63    /// How long idle keep-alive connections are retained (e.g. `"90s"`, `"2m"`)
64    /// before being closed and reopened on the next request. Companion of
65    /// `pool_max_idle_per_host`; raise it above the gap between successive bursts
66    /// to a given upstream to keep connections warm. Missing keeps the SDK
67    /// default ([`ClientConfig::pool_idle_timeout`](crate::runtime::config::ClientConfig::pool_idle_timeout)).
68    ///
69    /// **REST transport only** (see `pool_max_idle_per_host`).
70    #[serde(default, with = "toolkit_utils::humantime_serde::option")]
71    pub pool_idle_timeout: Option<Duration>,
72
73    /// Max concurrent in-flight requests through this client at once. Requests
74    /// beyond the cap are shed immediately (`HttpError::Overloaded`). Keep it at
75    /// or above `pool_max_idle_per_host` so the pool can be fully reused. Missing
76    /// keeps the SDK default
77    /// ([`ClientConfig::max_concurrent_requests`](crate::runtime::config::ClientConfig::max_concurrent_requests)).
78    ///
79    /// **REST transport only** (see `pool_max_idle_per_host`).
80    #[serde(default)]
81    pub max_concurrent_requests: Option<usize>,
82
83    /// Platform-plane credential source forwarded onto the built
84    /// [`ClientConfig`](crate::runtime::config::ClientConfig). Injected by the
85    /// runtime's proxy-wiring phase, never from config (`#[serde(skip)]`); gated
86    /// on `runtime-client` since the type lives there.
87    #[cfg(feature = "runtime-client")]
88    #[serde(skip)]
89    pub internal_token_provider: Option<crate::runtime::config::InternalTokenProvider>,
90}
91
92impl ClientTuning {
93    /// REST-only transport knobs that were explicitly set on this tuning.
94    ///
95    /// Used to warn when a non-REST transport is selected: the gRPC client reads
96    /// only `endpoint`, `timeout` and `require_tls`, so
97    /// `pool_max_idle_per_host`, `pool_idle_timeout` and
98    /// `max_concurrent_requests` on a `transport: grpc` wiring silently go
99    /// nowhere. Returns their names (empty when none are set).
100    #[must_use]
101    pub fn rest_only_knobs_set(&self) -> Vec<&'static str> {
102        let mut set = Vec::new();
103        if self.pool_max_idle_per_host.is_some() {
104            set.push("pool_max_idle_per_host");
105        }
106        if self.pool_idle_timeout.is_some() {
107            set.push("pool_idle_timeout");
108        }
109        if self.max_concurrent_requests.is_some() {
110            set.push("max_concurrent_requests");
111        }
112        set
113    }
114}
115
116/// Deserializable mirror of
117/// [`RetryConfig`](crate::runtime::config::RetryConfig). All fields optional;
118/// missing values keep the runtime default.
119#[derive(Clone, Debug, Default, Deserialize)]
120pub struct RetrySettings {
121    pub max_attempts: Option<u32>,
122    #[serde(default, with = "toolkit_utils::humantime_serde::option")]
123    pub base_delay: Option<Duration>,
124    #[serde(default, with = "toolkit_utils::humantime_serde::option")]
125    pub max_delay: Option<Duration>,
126    pub multiplier: Option<f64>,
127}
128
129/// Deserializable mirror of
130/// [`ReconnectConfig`](crate::runtime::config::ReconnectConfig).
131#[derive(Clone, Debug, Default, Deserialize)]
132pub struct ReconnectSettings {
133    pub max_attempts: Option<u32>,
134    #[serde(default, with = "toolkit_utils::humantime_serde::option")]
135    pub base_delay: Option<Duration>,
136    #[serde(default, with = "toolkit_utils::humantime_serde::option")]
137    pub max_delay: Option<Duration>,
138}
139
140/// Transport choice + endpoint + tuning for one provided contract.
141///
142/// Read by `#[toolkit::provides]` from
143/// `gears.<gear>.config.client_wiring.<contract_snake>`. If the key is
144/// absent the wiring defaults to [`ClientWiring::Local`].
145#[derive(Clone, Debug, Default, Deserialize)]
146#[serde(rename_all = "lowercase", tag = "transport")]
147pub enum ClientWiring {
148    /// In-process. The provider gear's local factory is invoked.
149    #[default]
150    Local,
151    /// Generated REST client points at `endpoint`.
152    Rest {
153        endpoint: String,
154        #[serde(default, flatten)]
155        tuning: ClientTuning,
156    },
157    /// Generated gRPC client connects to `endpoint`.
158    Grpc {
159        endpoint: String,
160        #[serde(default, flatten)]
161        tuning: ClientTuning,
162    },
163}
164
165/// Whether `s` is an absolute URI (scheme + authority) — the shape a usable
166/// static endpoint override must have. A bare host, relative path or empty string
167/// would otherwise be marked resolved with no probe (`/readyz` 200 while every
168/// call fails). Enforced at parse time by [`ConsumerWiring`]'s `endpoint` deserializer.
169#[must_use]
170fn is_absolute_endpoint(s: &str) -> bool {
171    s.parse::<http::Uri>()
172        .is_ok_and(|uri| uri.scheme().is_some() && uri.authority().is_some())
173}
174
175/// `deserialize_with` for [`ConsumerWiring::endpoint`]: keeps the field a plain
176/// `Option<String>` while rejecting a present, non-absolute value at parse time
177/// (see [`is_absolute_endpoint`]).
178fn deserialize_optional_endpoint<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
179where
180    D: serde::Deserializer<'de>,
181{
182    let endpoint = Option::<String>::deserialize(deserializer)?;
183    if let Some(ref s) = endpoint
184        && !is_absolute_endpoint(s)
185    {
186        return Err(serde::de::Error::custom(format!(
187            "`{s}` is not an absolute URI (needs scheme + host, e.g. `http://host:port`)"
188        )));
189    }
190    Ok(endpoint)
191}
192
193/// Consumer-side wiring for one dependency, read from
194/// `gears.<owner>.config.consumer_wiring.<dep>` by the runtime's proxy-wiring
195/// phase and applied to the directory-resolving REST client that
196/// `#[toolkit::consumes]` registers.
197///
198/// An object with an optional `endpoint` (omit to keep discovery) plus a
199/// flattened [`ClientTuning`]:
200///
201/// ```yaml
202/// consumer_wiring:
203///   api-contracts:
204///     timeout: "5s"
205///     max_concurrent_requests: 256
206///     # endpoint: "http://..."   # optional; omit to keep discovery
207/// ```
208///
209/// Unlike [`ClientWiring`] there is **no** `transport` tag: the
210/// `#[toolkit::consumes]` path is REST-only, so every tuning knob applies. (A
211/// hand-written [`ConsumerRegistration`](crate::runtime) may bind a non-REST
212/// transport, which reads only the knobs it understands.)
213///
214/// Must be an object; a bare `<dep>: "http://host"` string (the old shape) is
215/// rejected at parse time — use the `endpoint` field.
216#[derive(Clone, Debug, Default, Deserialize)]
217pub struct ConsumerWiring {
218    /// Static-endpoint override (ADR-0004 dev/test escape hatch): when set, the
219    /// dep is wired to this fixed endpoint instead of being discovered. Rejected
220    /// at parse time unless it is an absolute URI (see [`is_absolute_endpoint`]).
221    #[serde(default, deserialize_with = "deserialize_optional_endpoint")]
222    pub endpoint: Option<String>,
223    /// REST client tuning, flattened into the same map as `endpoint`.
224    #[serde(default, flatten)]
225    pub tuning: ClientTuning,
226}
227
228impl ConsumerWiring {
229    /// Split the wiring into its static-endpoint override (if any) and the
230    /// [`ClientTuning`] to apply.
231    #[must_use]
232    pub fn into_parts(self) -> (Option<String>, ClientTuning) {
233        (self.endpoint, self.tuning)
234    }
235}
236
237#[cfg(feature = "runtime-client")]
238impl ClientTuning {
239    /// Apply tuning overrides onto a fresh [`ClientConfig`] built from `endpoint`.
240    #[must_use]
241    pub fn apply_to(&self, endpoint: impl Into<String>) -> crate::runtime::config::ClientConfig {
242        use crate::runtime::config::{ClientConfig, ReconnectConfig, RetryConfig};
243
244        let mut cfg = ClientConfig::new(endpoint);
245        if let Some(timeout) = self.timeout {
246            cfg = cfg.with_timeout(timeout);
247        }
248        if let Some(ref r) = self.retry {
249            let base = cfg.retry.clone();
250            cfg = cfg.with_retry(RetryConfig {
251                max_attempts: r.max_attempts.unwrap_or(base.max_attempts),
252                base_delay: r.base_delay.unwrap_or(base.base_delay),
253                max_delay: r.max_delay.unwrap_or(base.max_delay),
254                multiplier: r.multiplier.unwrap_or(base.multiplier),
255            });
256        }
257        if let Some(ref s) = self.stream_reconnect {
258            let base = cfg.stream_reconnect.clone();
259            cfg = cfg.with_stream_reconnect(ReconnectConfig {
260                max_attempts: s.max_attempts.unwrap_or(base.max_attempts),
261                base_delay: s.base_delay.unwrap_or(base.base_delay),
262                max_delay: s.max_delay.unwrap_or(base.max_delay),
263                // Not exposed as wiring keys yet; inherit the base policy.
264                min_healthy_uptime: base.min_healthy_uptime,
265                max_total_reopens: base.max_total_reopens,
266            });
267        }
268        if let Some(require_tls) = self.require_tls {
269            cfg = cfg.with_require_tls(require_tls);
270        }
271        if let Some(max) = self.pool_max_idle_per_host {
272            cfg = cfg.with_pool_max_idle_per_host(max);
273        }
274        if let Some(timeout) = self.pool_idle_timeout {
275            cfg = cfg.with_pool_idle_timeout(Some(timeout));
276        }
277        if let Some(max) = self.max_concurrent_requests {
278            cfg = cfg.with_max_concurrent_requests(Some(max));
279        }
280        cfg = cfg.with_internal_token_provider(self.internal_token_provider.clone());
281        cfg
282    }
283
284    /// Attach the platform-plane credential source forwarded onto the built
285    /// [`ClientConfig`](crate::runtime::config::ClientConfig). Used by the
286    /// proxy-wiring phase to thread the process credential into a
287    /// directory-resolving (`#[toolkit::consumes]`) client.
288    #[must_use]
289    pub fn with_internal_token_provider(
290        mut self,
291        provider: Option<crate::runtime::config::InternalTokenProvider>,
292    ) -> Self {
293        self.internal_token_provider = provider;
294        self
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    /// A static override is only usable if it is an absolute URI. An empty string,
303    /// a bare host, or a relative path would otherwise reach the wiring code, take
304    /// the static branch, and mark the dep resolved with no probe — 200 on
305    /// `/readyz` while every call fails.
306    #[test]
307    fn is_absolute_endpoint_accepts_only_scheme_plus_authority() {
308        assert!(is_absolute_endpoint("http://host:8081"));
309        assert!(is_absolute_endpoint("https://billing.svc/base"));
310        assert!(!is_absolute_endpoint(""));
311        assert!(!is_absolute_endpoint("billing"));
312        assert!(!is_absolute_endpoint("/path/only"));
313        assert!(!is_absolute_endpoint("not a uri"));
314    }
315
316    /// `ConsumerWiring` validates the endpoint at parse time: a present-but-invalid
317    /// value fails to deserialize (rather than yielding an unchecked string), the
318    /// valid value survives verbatim, and an omitted `endpoint` keeps discovery
319    /// while still parsing the tuning.
320    #[test]
321    fn consumer_wiring_validates_endpoint_on_deserialize() {
322        let ok: ConsumerWiring = serde_json::from_value(serde_json::json!({
323            "endpoint": "http://billing:8080",
324            "max_concurrent_requests": 256
325        }))
326        .expect("valid absolute endpoint must parse");
327        let (endpoint, tuning) = ok.into_parts();
328        assert_eq!(endpoint.as_deref(), Some("http://billing:8080"));
329        assert_eq!(tuning.max_concurrent_requests, Some(256));
330
331        assert!(
332            serde_json::from_value::<ConsumerWiring>(serde_json::json!({ "endpoint": "" }))
333                .is_err(),
334            "an empty endpoint must be rejected at deserialize time"
335        );
336        assert!(
337            serde_json::from_value::<ConsumerWiring>(serde_json::json!({ "endpoint": "billing" }))
338                .is_err(),
339            "a bare host must be rejected at deserialize time"
340        );
341
342        let no_endpoint: ConsumerWiring =
343            serde_json::from_value(serde_json::json!({ "max_concurrent_requests": 1 }))
344                .expect("omitted endpoint keeps discovery");
345        assert_eq!(no_endpoint.endpoint, None);
346        assert_eq!(no_endpoint.tuning.max_concurrent_requests, Some(1));
347    }
348}