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;
10
11use crate::ports::GraphQlAuth;
12
13// ─────────────────────────────────────────────────────────────────────────────
14// CostThrottleConfig
15// ─────────────────────────────────────────────────────────────────────────────
16
17/// Static cost-throttle parameters for a GraphQL API target.
18///
19/// Set these to match the API documentation.  After the first successful
20/// response the [`LiveBudget`](crate::adapters::graphql_throttle::LiveBudget)
21/// will update itself from the `extensions.cost.throttleStatus` envelope.
22///
23/// # Example
24///
25/// ```rust
26/// use stygian_graph::ports::graphql_plugin::CostThrottleConfig;
27///
28/// let config = CostThrottleConfig {
29///     max_points: 10_000.0,
30///     restore_per_sec: 500.0,
31///     min_available: 50.0,
32///     max_delay_ms: 30_000,
33/// };
34/// ```
35#[derive(Debug, Clone)]
36pub struct CostThrottleConfig {
37    /// Maximum point budget (e.g. `10_000.0` for Jobber / Shopify).
38    pub max_points: f64,
39    /// Points restored per second (e.g. `500.0`).
40    pub restore_per_sec: f64,
41    /// Minimum available points before a pre-flight delay is applied
42    /// (default: `50.0`).
43    pub min_available: f64,
44    /// Upper bound on any computed pre-flight delay in milliseconds
45    /// (default: `30_000`).
46    pub max_delay_ms: u64,
47}
48
49impl Default for CostThrottleConfig {
50    fn default() -> Self {
51        Self {
52            max_points: 10_000.0,
53            restore_per_sec: 500.0,
54            min_available: 50.0,
55            max_delay_ms: 30_000,
56        }
57    }
58}
59
60/// A named GraphQL target that supplies connection defaults for a specific API.
61///
62/// Plugins are identified by their [`name`](Self::name) and loaded from the
63/// [`GraphQlPluginRegistry`](crate::application::graphql_plugin_registry::GraphQlPluginRegistry)
64/// at pipeline execution time.
65///
66/// # Example
67///
68/// ```rust
69/// use std::collections::HashMap;
70/// use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin;
71/// use stygian_graph::ports::{GraphQlAuth, GraphQlAuthKind};
72///
73/// struct MyApiPlugin;
74///
75/// impl GraphQlTargetPlugin for MyApiPlugin {
76///     fn name(&self) -> &str { "my-api" }
77///     fn endpoint(&self) -> &str { "https://api.example.com/graphql" }
78///     fn version_headers(&self) -> HashMap<String, String> {
79///         [("X-API-VERSION".to_string(), "2025-01-01".to_string())].into()
80///     }
81///     fn default_auth(&self) -> Option<GraphQlAuth> { None }
82/// }
83/// ```
84pub trait GraphQlTargetPlugin: Send + Sync {
85    /// Canonical lowercase plugin name used in pipeline TOML: `plugin = "jobber"`.
86    fn name(&self) -> &str;
87
88    /// The GraphQL endpoint URL for this target.
89    ///
90    /// Used as the request URL when `ServiceInput.url` is empty.
91    fn endpoint(&self) -> &str;
92
93    /// Version or platform headers required by this API.
94    ///
95    /// Injected on every request. Plugin headers take precedence over
96    /// ad-hoc `params.headers` for the same key.
97    ///
98    /// # Example
99    ///
100    /// ```rust
101    /// use std::collections::HashMap;
102    /// use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin;
103    /// use stygian_graph::ports::{GraphQlAuth, GraphQlAuthKind};
104    ///
105    /// struct JobberPlugin;
106    /// impl GraphQlTargetPlugin for JobberPlugin {
107    ///     fn name(&self) -> &str { "jobber" }
108    ///     fn endpoint(&self) -> &str { "https://api.getjobber.com/api/graphql" }
109    ///     fn version_headers(&self) -> HashMap<String, String> {
110    ///         [("X-JOBBER-GRAPHQL-VERSION".to_string(), "2025-04-16".to_string())].into()
111    ///     }
112    /// }
113    /// ```
114    fn version_headers(&self) -> HashMap<String, String> {
115        HashMap::new()
116    }
117
118    /// Default auth to use when `params.auth` is absent.
119    ///
120    /// Implementations should read credentials from environment variables here.
121    ///
122    /// # Example
123    ///
124    /// ```rust
125    /// use std::collections::HashMap;
126    /// use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin;
127    /// use stygian_graph::ports::{GraphQlAuth, GraphQlAuthKind};
128    ///
129    /// struct SecurePlugin;
130    /// impl GraphQlTargetPlugin for SecurePlugin {
131    ///     fn name(&self) -> &str { "secure" }
132    ///     fn endpoint(&self) -> &str { "https://api.secure.com/graphql" }
133    ///     fn default_auth(&self) -> Option<GraphQlAuth> {
134    ///         Some(GraphQlAuth {
135    ///             kind: GraphQlAuthKind::Bearer,
136    ///             token: "${env:SECURE_ACCESS_TOKEN}".to_string(),
137    ///             header_name: None,
138    ///         })
139    ///     }
140    /// }
141    /// ```
142    fn default_auth(&self) -> Option<GraphQlAuth> {
143        None
144    }
145
146    /// Default page size for cursor-paginated queries.
147    fn default_page_size(&self) -> usize {
148        50
149    }
150
151    /// Whether this target uses Relay-style cursor pagination by default.
152    fn supports_cursor_pagination(&self) -> bool {
153        true
154    }
155
156    /// Human-readable description shown in `stygian plugins list`.
157    #[allow(clippy::unnecessary_literal_bound)]
158    fn description(&self) -> &str {
159        ""
160    }
161
162    /// Optional cost-throttle configuration for proactive pre-flight delays.
163    ///
164    /// Return a populated [`CostThrottleConfig`] to enable the
165    /// [`PluginBudget`](crate::adapters::graphql_throttle::PluginBudget)
166    /// pre-flight delay mechanism in `GraphQlService`.
167    ///
168    /// The default implementation returns `None` (no proactive throttling).
169    ///
170    /// # Example
171    ///
172    /// ```rust
173    /// use std::collections::HashMap;
174    /// use stygian_graph::ports::graphql_plugin::{GraphQlTargetPlugin, CostThrottleConfig};
175    /// use stygian_graph::ports::GraphQlAuth;
176    ///
177    /// struct ThrottledPlugin;
178    /// impl GraphQlTargetPlugin for ThrottledPlugin {
179    ///     fn name(&self) -> &str { "throttled" }
180    ///     fn endpoint(&self) -> &str { "https://api.example.com/graphql" }
181    ///     fn cost_throttle_config(&self) -> Option<CostThrottleConfig> {
182    ///         Some(CostThrottleConfig::default())
183    ///     }
184    /// }
185    /// ```
186    fn cost_throttle_config(&self) -> Option<CostThrottleConfig> {
187        None
188    }
189}
190
191#[cfg(test)]
192#[allow(clippy::unnecessary_literal_bound, clippy::unwrap_used)]
193mod tests {
194    use super::*;
195    use crate::ports::GraphQlAuthKind;
196
197    struct MinimalPlugin;
198
199    impl GraphQlTargetPlugin for MinimalPlugin {
200        fn name(&self) -> &str {
201            "minimal"
202        }
203        fn endpoint(&self) -> &str {
204            "https://api.example.com/graphql"
205        }
206    }
207
208    #[test]
209    fn default_methods_return_expected_values() {
210        let plugin = MinimalPlugin;
211        assert!(plugin.version_headers().is_empty());
212        assert!(plugin.default_auth().is_none());
213        assert_eq!(plugin.default_page_size(), 50);
214        assert!(plugin.supports_cursor_pagination());
215        assert_eq!(plugin.description(), "");
216    }
217
218    #[test]
219    fn custom_version_headers_are_returned() {
220        struct Versioned;
221        impl GraphQlTargetPlugin for Versioned {
222            fn name(&self) -> &str {
223                "versioned"
224            }
225            fn endpoint(&self) -> &str {
226                "https://api.v.com/graphql"
227            }
228            fn version_headers(&self) -> HashMap<String, String> {
229                [("X-API-VERSION".to_string(), "v2".to_string())].into()
230            }
231        }
232        let headers = Versioned.version_headers();
233        assert_eq!(headers.get("X-API-VERSION").map(String::as_str), Some("v2"));
234    }
235
236    #[test]
237    fn default_auth_can_be_overridden() {
238        struct Authed;
239        impl GraphQlTargetPlugin for Authed {
240            fn name(&self) -> &str {
241                "authed"
242            }
243            fn endpoint(&self) -> &str {
244                "https://api.a.com/graphql"
245            }
246            fn default_auth(&self) -> Option<GraphQlAuth> {
247                Some(GraphQlAuth {
248                    kind: GraphQlAuthKind::Bearer,
249                    token: "${env:TOKEN}".to_string(),
250                    header_name: None,
251                })
252            }
253        }
254        let auth = Authed.default_auth().unwrap();
255        assert_eq!(auth.kind, GraphQlAuthKind::Bearer);
256        assert_eq!(auth.token, "${env:TOKEN}");
257    }
258}