Skip to main content

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/// Sliding-window request-count rate-limit parameters for a GraphQL API target.
82///
83/// Enable by returning a populated [`RateLimitConfig`] from
84/// [`GraphQlTargetPlugin::rate_limit_config`].  This complements the leaky-bucket
85/// [`CostThrottleConfig`] and both can be active simultaneously.
86///
87/// # Example
88///
89/// ```rust
90/// use std::time::Duration;
91/// use stygian_graph::ports::graphql_plugin::RateLimitConfig;
92///
93/// let config = RateLimitConfig {
94///     max_requests: 100,
95///     window: Duration::from_secs(60),
96///     max_delay_ms: 30_000,
97/// };
98/// ```
99#[derive(Debug, Clone)]
100pub struct RateLimitConfig {
101    /// Maximum number of requests allowed in any rolling `window` (default: `100`).
102    pub max_requests: u32,
103    /// Rolling window duration (default: 60 seconds).
104    pub window: Duration,
105    /// Upper bound on any computed pre-flight delay in milliseconds (default: `30_000`).
106    pub max_delay_ms: u64,
107}
108
109impl Default for RateLimitConfig {
110    fn default() -> Self {
111        Self {
112            max_requests: 100,
113            window: Duration::from_secs(60),
114            max_delay_ms: 30_000,
115        }
116    }
117}
118
119/// A named GraphQL target that supplies connection defaults for a specific API.
120///
121/// Plugins are identified by their [`name`](Self::name) and loaded from the
122/// [`GraphQlPluginRegistry`](crate::application::graphql_plugin_registry::GraphQlPluginRegistry)
123/// at pipeline execution time.
124///
125/// # Example
126///
127/// ```rust
128/// use std::collections::HashMap;
129/// use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin;
130/// use stygian_graph::ports::{GraphQlAuth, GraphQlAuthKind};
131///
132/// struct MyApiPlugin;
133///
134/// impl GraphQlTargetPlugin for MyApiPlugin {
135///     fn name(&self) -> &str { "my-api" }
136///     fn endpoint(&self) -> &str { "https://api.example.com/graphql" }
137///     fn version_headers(&self) -> HashMap<String, String> {
138///         [("X-API-VERSION".to_string(), "2025-01-01".to_string())].into()
139///     }
140///     fn default_auth(&self) -> Option<GraphQlAuth> { None }
141/// }
142/// ```
143pub trait GraphQlTargetPlugin: Send + Sync {
144    /// Canonical lowercase plugin name used in pipeline TOML: `plugin = "jobber"`.
145    fn name(&self) -> &str;
146
147    /// The GraphQL endpoint URL for this target.
148    ///
149    /// Used as the request URL when `ServiceInput.url` is empty.
150    fn endpoint(&self) -> &str;
151
152    /// Version or platform headers required by this API.
153    ///
154    /// Injected on every request. Plugin headers take precedence over
155    /// ad-hoc `params.headers` for the same key.
156    ///
157    /// # Example
158    ///
159    /// ```rust
160    /// use std::collections::HashMap;
161    /// use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin;
162    /// use stygian_graph::ports::{GraphQlAuth, GraphQlAuthKind};
163    ///
164    /// struct JobberPlugin;
165    /// impl GraphQlTargetPlugin for JobberPlugin {
166    ///     fn name(&self) -> &str { "jobber" }
167    ///     fn endpoint(&self) -> &str { "https://api.getjobber.com/api/graphql" }
168    ///     fn version_headers(&self) -> HashMap<String, String> {
169    ///         [("X-JOBBER-GRAPHQL-VERSION".to_string(), "2025-04-16".to_string())].into()
170    ///     }
171    /// }
172    /// ```
173    fn version_headers(&self) -> HashMap<String, String> {
174        HashMap::new()
175    }
176
177    /// Default auth to use when `params.auth` is absent.
178    ///
179    /// Implementations should read credentials from environment variables here.
180    ///
181    /// # Example
182    ///
183    /// ```rust
184    /// use std::collections::HashMap;
185    /// use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin;
186    /// use stygian_graph::ports::{GraphQlAuth, GraphQlAuthKind};
187    ///
188    /// struct SecurePlugin;
189    /// impl GraphQlTargetPlugin for SecurePlugin {
190    ///     fn name(&self) -> &str { "secure" }
191    ///     fn endpoint(&self) -> &str { "https://api.secure.com/graphql" }
192    ///     fn default_auth(&self) -> Option<GraphQlAuth> {
193    ///         Some(GraphQlAuth {
194    ///             kind: GraphQlAuthKind::Bearer,
195    ///             token: "${env:SECURE_ACCESS_TOKEN}".to_string(),
196    ///             header_name: None,
197    ///         })
198    ///     }
199    /// }
200    /// ```
201    fn default_auth(&self) -> Option<GraphQlAuth> {
202        None
203    }
204
205    /// Default page size for cursor-paginated queries.
206    fn default_page_size(&self) -> usize {
207        50
208    }
209
210    /// Whether this target uses Relay-style cursor pagination by default.
211    fn supports_cursor_pagination(&self) -> bool {
212        true
213    }
214
215    /// Human-readable description shown in `stygian plugins list`.
216    #[allow(clippy::unnecessary_literal_bound)]
217    fn description(&self) -> &str {
218        ""
219    }
220
221    /// Optional cost-throttle configuration for proactive pre-flight delays.
222    ///
223    /// Return a populated [`CostThrottleConfig`] to enable the
224    /// [`PluginBudget`](crate::adapters::graphql_throttle::PluginBudget)
225    /// pre-flight delay mechanism in `GraphQlService`.
226    ///
227    /// The default implementation returns `None` (no proactive throttling).
228    ///
229    /// # Example
230    ///
231    /// ```rust
232    /// use std::collections::HashMap;
233    /// use stygian_graph::ports::graphql_plugin::{GraphQlTargetPlugin, CostThrottleConfig};
234    /// use stygian_graph::ports::GraphQlAuth;
235    ///
236    /// struct ThrottledPlugin;
237    /// impl GraphQlTargetPlugin for ThrottledPlugin {
238    ///     fn name(&self) -> &str { "throttled" }
239    ///     fn endpoint(&self) -> &str { "https://api.example.com/graphql" }
240    ///     fn cost_throttle_config(&self) -> Option<CostThrottleConfig> {
241    ///         Some(CostThrottleConfig::default())
242    ///     }
243    /// }
244    /// ```
245    fn cost_throttle_config(&self) -> Option<CostThrottleConfig> {
246        None
247    }
248
249    /// Optional sliding-window request-count rate-limit configuration.
250    ///
251    /// Return a populated [`RateLimitConfig`] to enable the
252    /// [`RequestRateLimit`](crate::adapters::graphql_rate_limit::RequestRateLimit)
253    /// pre-flight delay mechanism in `GraphQlService`.
254    ///
255    /// The default implementation returns `None` (no request-count limiting).
256    ///
257    /// # Example
258    ///
259    /// ```rust
260    /// use std::collections::HashMap;
261    /// use std::time::Duration;
262    /// use stygian_graph::ports::graphql_plugin::{GraphQlTargetPlugin, RateLimitConfig};
263    /// use stygian_graph::ports::GraphQlAuth;
264    ///
265    /// struct QuotaPlugin;
266    /// impl GraphQlTargetPlugin for QuotaPlugin {
267    ///     fn name(&self) -> &str { "quota" }
268    ///     fn endpoint(&self) -> &str { "https://api.example.com/graphql" }
269    ///     fn rate_limit_config(&self) -> Option<RateLimitConfig> {
270    ///         Some(RateLimitConfig {
271    ///             max_requests: 200,
272    ///             window: Duration::from_secs(60),
273    ///             max_delay_ms: 30_000,
274    ///         })
275    ///     }
276    /// }
277    /// ```
278    fn rate_limit_config(&self) -> Option<RateLimitConfig> {
279        None
280    }
281}
282
283#[cfg(test)]
284#[allow(clippy::unnecessary_literal_bound, clippy::unwrap_used)]
285mod tests {
286    use super::*;
287    use crate::ports::GraphQlAuthKind;
288
289    struct MinimalPlugin;
290
291    impl GraphQlTargetPlugin for MinimalPlugin {
292        fn name(&self) -> &str {
293            "minimal"
294        }
295        fn endpoint(&self) -> &str {
296            "https://api.example.com/graphql"
297        }
298    }
299
300    #[test]
301    fn default_methods_return_expected_values() {
302        let plugin = MinimalPlugin;
303        assert!(plugin.version_headers().is_empty());
304        assert!(plugin.default_auth().is_none());
305        assert_eq!(plugin.default_page_size(), 50);
306        assert!(plugin.supports_cursor_pagination());
307        assert_eq!(plugin.description(), "");
308    }
309
310    #[test]
311    fn custom_version_headers_are_returned() {
312        struct Versioned;
313        impl GraphQlTargetPlugin for Versioned {
314            fn name(&self) -> &str {
315                "versioned"
316            }
317            fn endpoint(&self) -> &str {
318                "https://api.v.com/graphql"
319            }
320            fn version_headers(&self) -> HashMap<String, String> {
321                [("X-API-VERSION".to_string(), "v2".to_string())].into()
322            }
323        }
324        let headers = Versioned.version_headers();
325        assert_eq!(headers.get("X-API-VERSION").map(String::as_str), Some("v2"));
326    }
327
328    #[test]
329    fn default_auth_can_be_overridden() {
330        struct Authed;
331        impl GraphQlTargetPlugin for Authed {
332            fn name(&self) -> &str {
333                "authed"
334            }
335            fn endpoint(&self) -> &str {
336                "https://api.a.com/graphql"
337            }
338            fn default_auth(&self) -> Option<GraphQlAuth> {
339                Some(GraphQlAuth {
340                    kind: GraphQlAuthKind::Bearer,
341                    token: "${env:TOKEN}".to_string(),
342                    header_name: None,
343                })
344            }
345        }
346        let auth = Authed.default_auth().unwrap();
347        assert_eq!(auth.kind, GraphQlAuthKind::Bearer);
348        assert_eq!(auth.token, "${env:TOKEN}");
349    }
350}