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}