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}