stygian_graph/ports/
graphql_plugin.rs

1//! `GraphQlTargetPlugin` port — one implementation per GraphQL API target.
2//!
3//! Each target (Jobber, GitHub, Shopify, …) registers a plugin that supplies
4//! its endpoint, required version headers, default auth, and pagination defaults.
5//! The generic [`crate::adapters::graphql::GraphQlService`] adapter resolves the
6//! plugin at execution time; no target-specific knowledge lives in the adapter
7//! itself.
8
9use std::collections::HashMap;
10use std::time::Duration;
11
12use crate::ports::GraphQlAuth;
13
14// ─────────────────────────────────────────────────────────────────────────────
15// CostThrottleConfig
16// ─────────────────────────────────────────────────────────────────────────────
17
18/// Static cost-throttle parameters for a GraphQL API target.
19///
20/// Set these to match the API documentation.  After the first successful
21/// response the [`LiveBudget`](crate::adapters::graphql_throttle::LiveBudget)
22/// will update itself from the `extensions.cost.throttleStatus` envelope.
23///
24/// # Example
25///
26/// ```rust
27/// use stygian_graph::ports::graphql_plugin::CostThrottleConfig;
28///
29/// let config = CostThrottleConfig {
30///     max_points: 10_000.0,
31///     restore_per_sec: 500.0,
32///     min_available: 50.0,
33///     max_delay_ms: 30_000,
34///     estimated_cost_per_request: 100.0,
35/// };
36/// ```
37#[derive(Debug, Clone)]
38pub struct CostThrottleConfig {
39    /// Maximum point budget (e.g. `10_000.0` for Jobber / Shopify).
40    pub max_points: f64,
41    /// Points restored per second (e.g. `500.0`).
42    pub restore_per_sec: f64,
43    /// Minimum available points before a pre-flight delay is applied
44    /// (default: `50.0`).
45    pub min_available: f64,
46    /// Upper bound on any computed pre-flight delay in milliseconds
47    /// (default: `30_000`).
48    pub max_delay_ms: u64,
49    /// Pessimistic per-request cost reserved before each request is sent.
50    ///
51    /// The actual cost is only known from the response's
52    /// `extensions.cost.requestedQueryCost`.  Reserving this estimate before
53    /// sending prevents concurrent tasks from all passing the pre-flight check
54    /// against the same stale balance.  Tune this to match your API's typical
55    /// query cost (default: `100.0`).
56    ///
57    // TODO(async-drop): When `AsyncDrop` is stabilised on the stable toolchain
58    // (tracked at <https://github.com/rust-lang/rust/issues/126482>), replace
59    // the explicit `release_reservation` call sites in `graphql.rs` with a
60    // `BudgetReservation` RAII guard, eliminating manual cleanup at every
61    // early-return path.
62    pub estimated_cost_per_request: f64,
63}
64
65impl Default for CostThrottleConfig {
66    fn default() -> Self {
67        Self {
68            max_points: 10_000.0,
69            restore_per_sec: 500.0,
70            min_available: 50.0,
71            max_delay_ms: 30_000,
72            estimated_cost_per_request: 100.0,
73        }
74    }
75}
76
77// ─────────────────────────────────────────────────────────────────────────────
78// RateLimitConfig
79// ─────────────────────────────────────────────────────────────────────────────
80
81/// Selects which local pre-flight algorithm guards outgoing GraphQL requests.
82///
83/// Both strategies also honour server-returned `Retry-After` headers,
84/// regardless of which algorithm is active.
85///
86/// # Example
87///
88/// ```rust
89/// use stygian_graph::ports::graphql_plugin::RateLimitStrategy;
90///
91/// let strategy = RateLimitStrategy::TokenBucket; // burst-friendly
92/// ```
93#[derive(Debug, Clone, Default, PartialEq, Eq)]
94pub enum RateLimitStrategy {
95    /// Counts requests inside a rolling time window.  Most predictable for
96    /// server-side fixed-window quotas.
97    #[default]
98    SlidingWindow,
99    /// Refills tokens at a steady rate; short bursts are absorbed by the
100    /// bucket capacity before the window limit is reached.  Better for
101    /// endpoints that advertise burst allowances.
102    TokenBucket,
103}
104
105/// Request-count rate-limit parameters for a GraphQL API target.
106///
107/// Enable by returning a populated [`RateLimitConfig`] from
108/// [`GraphQlTargetPlugin::rate_limit_config`].  This complements the leaky-bucket
109/// [`CostThrottleConfig`] and both can be active simultaneously.
110///
111/// # Example
112///
113/// ```rust
114/// use std::time::Duration;
115/// use stygian_graph::ports::graphql_plugin::{RateLimitConfig, RateLimitStrategy};
116///
117/// let config = RateLimitConfig {
118///     max_requests: 100,
119///     window: Duration::from_secs(60),
120///     max_delay_ms: 30_000,
121///     strategy: RateLimitStrategy::TokenBucket,
122/// };
123/// ```
124#[derive(Debug, Clone)]
125pub struct RateLimitConfig {
126    /// Maximum number of requests allowed in any rolling `window` (default: `100`).
127    pub max_requests: u32,
128    /// Rolling window duration (default: 60 seconds).
129    pub window: Duration,
130    /// Upper bound on any computed pre-flight delay in milliseconds (default: `30_000`).
131    pub max_delay_ms: u64,
132    /// Rate-limiting algorithm to use (default: [`RateLimitStrategy::SlidingWindow`]).
133    pub strategy: RateLimitStrategy,
134}
135
136impl Default for RateLimitConfig {
137    fn default() -> Self {
138        Self {
139            max_requests: 100,
140            window: Duration::from_secs(60),
141            max_delay_ms: 30_000,
142            strategy: RateLimitStrategy::SlidingWindow,
143        }
144    }
145}
146
147/// A named GraphQL target that supplies connection defaults for a specific API.
148///
149/// Plugins are identified by their [`name`](Self::name) and loaded from the
150/// [`GraphQlPluginRegistry`](crate::application::graphql_plugin_registry::GraphQlPluginRegistry)
151/// at pipeline execution time.
152///
153/// # Example
154///
155/// ```rust
156/// use std::collections::HashMap;
157/// use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin;
158/// use stygian_graph::ports::{GraphQlAuth, GraphQlAuthKind};
159///
160/// struct MyApiPlugin;
161///
162/// impl GraphQlTargetPlugin for MyApiPlugin {
163///     fn name(&self) -> &str { "my-api" }
164///     fn endpoint(&self) -> &str { "https://api.example.com/graphql" }
165///     fn version_headers(&self) -> HashMap<String, String> {
166///         [("X-API-VERSION".to_string(), "2025-01-01".to_string())].into()
167///     }
168///     fn default_auth(&self) -> Option<GraphQlAuth> { None }
169/// }
170/// ```
171pub trait GraphQlTargetPlugin: Send + Sync {
172    /// Canonical lowercase plugin name used in pipeline TOML: `plugin = "jobber"`.
173    fn name(&self) -> &str;
174
175    /// The GraphQL endpoint URL for this target.
176    ///
177    /// Used as the request URL when `ServiceInput.url` is empty.
178    fn endpoint(&self) -> &str;
179
180    /// Version or platform headers required by this API.
181    ///
182    /// Injected on every request. Plugin headers take precedence over
183    /// ad-hoc `params.headers` for the same key.
184    ///
185    /// # Example
186    ///
187    /// ```rust
188    /// use std::collections::HashMap;
189    /// use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin;
190    /// use stygian_graph::ports::{GraphQlAuth, GraphQlAuthKind};
191    ///
192    /// struct JobberPlugin;
193    /// impl GraphQlTargetPlugin for JobberPlugin {
194    ///     fn name(&self) -> &str { "jobber" }
195    ///     fn endpoint(&self) -> &str { "https://api.getjobber.com/api/graphql" }
196    ///     fn version_headers(&self) -> HashMap<String, String> {
197    ///         [("X-JOBBER-GRAPHQL-VERSION".to_string(), "2025-04-16".to_string())].into()
198    ///     }
199    /// }
200    /// ```
201    fn version_headers(&self) -> HashMap<String, String> {
202        HashMap::new()
203    }
204
205    /// Default auth to use when `params.auth` is absent.
206    ///
207    /// Implementations should read credentials from environment variables here.
208    ///
209    /// # Example
210    ///
211    /// ```rust
212    /// use std::collections::HashMap;
213    /// use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin;
214    /// use stygian_graph::ports::{GraphQlAuth, GraphQlAuthKind};
215    ///
216    /// struct SecurePlugin;
217    /// impl GraphQlTargetPlugin for SecurePlugin {
218    ///     fn name(&self) -> &str { "secure" }
219    ///     fn endpoint(&self) -> &str { "https://api.secure.com/graphql" }
220    ///     fn default_auth(&self) -> Option<GraphQlAuth> {
221    ///         Some(GraphQlAuth {
222    ///             kind: GraphQlAuthKind::Bearer,
223    ///             token: "${env:SECURE_ACCESS_TOKEN}".to_string(),
224    ///             header_name: None,
225    ///         })
226    ///     }
227    /// }
228    /// ```
229    fn default_auth(&self) -> Option<GraphQlAuth> {
230        None
231    }
232
233    /// Default page size for cursor-paginated queries.
234    fn default_page_size(&self) -> usize {
235        50
236    }
237
238    /// Whether this target uses Relay-style cursor pagination by default.
239    fn supports_cursor_pagination(&self) -> bool {
240        true
241    }
242
243    /// Human-readable description shown in `stygian plugins list`.
244    #[allow(clippy::unnecessary_literal_bound)]
245    fn description(&self) -> &str {
246        ""
247    }
248
249    /// Optional cost-throttle configuration for proactive pre-flight delays.
250    ///
251    /// Return a populated [`CostThrottleConfig`] to enable the
252    /// [`PluginBudget`](crate::adapters::graphql_throttle::PluginBudget)
253    /// pre-flight delay mechanism in `GraphQlService`.
254    ///
255    /// The default implementation returns `None` (no proactive throttling).
256    ///
257    /// # Example
258    ///
259    /// ```rust
260    /// use std::collections::HashMap;
261    /// use stygian_graph::ports::graphql_plugin::{GraphQlTargetPlugin, CostThrottleConfig};
262    /// use stygian_graph::ports::GraphQlAuth;
263    ///
264    /// struct ThrottledPlugin;
265    /// impl GraphQlTargetPlugin for ThrottledPlugin {
266    ///     fn name(&self) -> &str { "throttled" }
267    ///     fn endpoint(&self) -> &str { "https://api.example.com/graphql" }
268    ///     fn cost_throttle_config(&self) -> Option<CostThrottleConfig> {
269    ///         Some(CostThrottleConfig::default())
270    ///     }
271    /// }
272    /// ```
273    fn cost_throttle_config(&self) -> Option<CostThrottleConfig> {
274        None
275    }
276
277    /// Optional sliding-window request-count rate-limit configuration.
278    ///
279    /// Return a populated [`RateLimitConfig`] to enable the
280    /// [`RequestRateLimit`](crate::adapters::graphql_rate_limit::RequestRateLimit)
281    /// pre-flight delay mechanism in `GraphQlService`.
282    ///
283    /// The default implementation returns `None` (no request-count limiting).
284    ///
285    /// # Example
286    ///
287    /// ```rust
288    /// use std::collections::HashMap;
289    /// use std::time::Duration;
290    /// use stygian_graph::ports::graphql_plugin::{GraphQlTargetPlugin, RateLimitConfig};
291    /// use stygian_graph::ports::GraphQlAuth;
292    ///
293    /// struct QuotaPlugin;
294    /// impl GraphQlTargetPlugin for QuotaPlugin {
295    ///     fn name(&self) -> &str { "quota" }
296    ///     fn endpoint(&self) -> &str { "https://api.example.com/graphql" }
297    ///     fn rate_limit_config(&self) -> Option<RateLimitConfig> {
298    ///         Some(RateLimitConfig {
299    ///             max_requests: 200,
300    ///             window: Duration::from_secs(60),
301    ///             max_delay_ms: 30_000,
302    ///             ..Default::default()
303    ///         })
304    ///     }
305    /// }
306    /// ```
307    fn rate_limit_config(&self) -> Option<RateLimitConfig> {
308        None
309    }
310}
311
312#[cfg(test)]
313#[allow(clippy::unnecessary_literal_bound, clippy::unwrap_used)]
314mod tests {
315    use super::*;
316    use crate::ports::GraphQlAuthKind;
317
318    struct MinimalPlugin;
319
320    impl GraphQlTargetPlugin for MinimalPlugin {
321        fn name(&self) -> &str {
322            "minimal"
323        }
324        fn endpoint(&self) -> &str {
325            "https://api.example.com/graphql"
326        }
327    }
328
329    #[test]
330    fn default_methods_return_expected_values() {
331        let plugin = MinimalPlugin;
332        assert!(plugin.version_headers().is_empty());
333        assert!(plugin.default_auth().is_none());
334        assert_eq!(plugin.default_page_size(), 50);
335        assert!(plugin.supports_cursor_pagination());
336        assert_eq!(plugin.description(), "");
337    }
338
339    #[test]
340    fn custom_version_headers_are_returned() {
341        struct Versioned;
342        impl GraphQlTargetPlugin for Versioned {
343            fn name(&self) -> &str {
344                "versioned"
345            }
346            fn endpoint(&self) -> &str {
347                "https://api.v.com/graphql"
348            }
349            fn version_headers(&self) -> HashMap<String, String> {
350                [("X-API-VERSION".to_string(), "v2".to_string())].into()
351            }
352        }
353        let headers = Versioned.version_headers();
354        assert_eq!(headers.get("X-API-VERSION").map(String::as_str), Some("v2"));
355    }
356
357    #[test]
358    fn default_auth_can_be_overridden() {
359        struct Authed;
360        impl GraphQlTargetPlugin for Authed {
361            fn name(&self) -> &str {
362                "authed"
363            }
364            fn endpoint(&self) -> &str {
365                "https://api.a.com/graphql"
366            }
367            fn default_auth(&self) -> Option<GraphQlAuth> {
368                Some(GraphQlAuth {
369                    kind: GraphQlAuthKind::Bearer,
370                    token: "${env:TOKEN}".to_string(),
371                    header_name: None,
372                })
373            }
374        }
375        let auth = Authed.default_auth().unwrap();
376        assert_eq!(auth.kind, GraphQlAuthKind::Bearer);
377        assert_eq!(auth.token, "${env:TOKEN}");
378    }
379}