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}