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}