Skip to main content

stygian_graph/adapters/graphql_plugins/
generic.rs

1//! Generic GraphQL target plugin with a fluent builder API.
2//!
3//! Use `GenericGraphQlPlugin` when you need a quick, ad-hoc plugin without
4//! writing a dedicated implementation struct.  Supply the endpoint, optional
5//! auth, headers, and cost-throttle configuration via the builder.
6//!
7//! # Example
8//!
9//! ```rust
10//! use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin;
11//! use stygian_graph::adapters::graphql_throttle::CostThrottleConfig;
12//! use stygian_graph::ports::{GraphQlAuth, GraphQlAuthKind};
13//! use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin;
14//!
15//! let plugin = GenericGraphQlPlugin::builder()
16//!     .name("github")
17//!     .endpoint("https://api.github.com/graphql")
18//!     .auth(GraphQlAuth {
19//!         kind: GraphQlAuthKind::Bearer,
20//!         token: "${env:GITHUB_TOKEN}".to_string(),
21//!         header_name: None,
22//!     })
23//!     .header("X-Github-Next-Global-ID", "1")
24//!     .cost_throttle(CostThrottleConfig::default())
25//!     .page_size(30)
26//!     .description("GitHub GraphQL API")
27//!     .build()
28//!     .expect("required fields: name and endpoint");
29//!
30//! assert_eq!(plugin.name(), "github");
31//! assert_eq!(plugin.default_page_size(), 30);
32//! ```
33
34use std::collections::HashMap;
35
36use crate::ports::graphql_plugin::{CostThrottleConfig, GraphQlTargetPlugin};
37use crate::ports::{GraphQlAuth, GraphQlAuthKind};
38
39// ─────────────────────────────────────────────────────────────────────────────
40// Plugin struct
41// ─────────────────────────────────────────────────────────────────────────────
42
43/// A fully generic GraphQL target plugin built via [`GenericGraphQlPluginBuilder`].
44///
45/// Implements [`GraphQlTargetPlugin`] and can be registered with
46/// `GraphQlPluginRegistry` like any other plugin.
47#[derive(Debug, Clone)]
48pub struct GenericGraphQlPlugin {
49    name: String,
50    endpoint: String,
51    headers: HashMap<String, String>,
52    auth: Option<GraphQlAuth>,
53    throttle: Option<CostThrottleConfig>,
54    page_size: usize,
55    description: String,
56}
57
58impl GenericGraphQlPlugin {
59    /// Return a fresh [`GenericGraphQlPluginBuilder`].
60    ///
61    /// # Example
62    ///
63    /// ```rust
64    /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin;
65    /// use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin;
66    ///
67    /// let plugin = GenericGraphQlPlugin::builder()
68    ///     .name("my-api")
69    ///     .endpoint("https://api.example.com/graphql")
70    ///     .build()
71    ///     .expect("name and endpoint are required");
72    ///
73    /// assert_eq!(plugin.name(), "my-api");
74    /// ```
75    #[must_use]
76    pub fn builder() -> GenericGraphQlPluginBuilder {
77        GenericGraphQlPluginBuilder::default()
78    }
79
80    /// Return the configured cost-throttle config if any.
81    ///
82    /// # Example
83    ///
84    /// ```rust
85    /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin;
86    /// use stygian_graph::adapters::graphql_throttle::CostThrottleConfig;
87    ///
88    /// let plugin = GenericGraphQlPlugin::builder()
89    ///     .name("api")
90    ///     .endpoint("https://api.example.com/graphql")
91    ///     .cost_throttle(CostThrottleConfig::default())
92    ///     .build()
93    ///     .expect("ok");
94    ///
95    /// assert!(plugin.cost_throttle_config().is_some());
96    /// ```
97    #[must_use]
98    pub const fn cost_throttle_config(&self) -> Option<&CostThrottleConfig> {
99        self.throttle.as_ref()
100    }
101}
102
103impl GraphQlTargetPlugin for GenericGraphQlPlugin {
104    fn name(&self) -> &str {
105        &self.name
106    }
107
108    fn endpoint(&self) -> &str {
109        &self.endpoint
110    }
111
112    fn version_headers(&self) -> HashMap<String, String> {
113        self.headers.clone()
114    }
115
116    fn default_auth(&self) -> Option<GraphQlAuth> {
117        self.auth.clone()
118    }
119
120    fn default_page_size(&self) -> usize {
121        self.page_size
122    }
123
124    fn description(&self) -> &str {
125        &self.description
126    }
127
128    fn supports_cursor_pagination(&self) -> bool {
129        true
130    }
131
132    fn cost_throttle_config(&self) -> Option<CostThrottleConfig> {
133        self.throttle.clone()
134    }
135}
136
137// ─────────────────────────────────────────────────────────────────────────────
138// Builder
139// ─────────────────────────────────────────────────────────────────────────────
140
141/// Builder for [`GenericGraphQlPlugin`].
142///
143/// Obtain via [`GenericGraphQlPlugin::builder()`].  The only required fields
144/// are `name` and `endpoint`; everything else has sensible defaults.
145#[derive(Debug, Default)]
146pub struct GenericGraphQlPluginBuilder {
147    name: Option<String>,
148    endpoint: Option<String>,
149    headers: HashMap<String, String>,
150    auth: Option<GraphQlAuth>,
151    throttle: Option<CostThrottleConfig>,
152    page_size: usize,
153    description: String,
154}
155
156impl GenericGraphQlPluginBuilder {
157    /// Set the plugin name (required).
158    ///
159    /// # Example
160    ///
161    /// ```rust
162    /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin;
163    ///
164    /// let _builder = GenericGraphQlPlugin::builder().name("my-api");
165    /// ```
166    #[must_use]
167    pub fn name(mut self, name: impl Into<String>) -> Self {
168        self.name = Some(name.into());
169        self
170    }
171
172    /// Set the GraphQL endpoint URL (required).
173    ///
174    /// # Example
175    ///
176    /// ```rust
177    /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin;
178    ///
179    /// let _builder = GenericGraphQlPlugin::builder()
180    ///     .endpoint("https://api.example.com/graphql");
181    /// ```
182    #[must_use]
183    pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
184        self.endpoint = Some(endpoint.into());
185        self
186    }
187
188    /// Add a single request header.
189    ///
190    /// May be called multiple times to accumulate headers.
191    ///
192    /// # Example
193    ///
194    /// ```rust
195    /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin;
196    ///
197    /// let _builder = GenericGraphQlPlugin::builder()
198    ///     .header("X-Api-Version", "2025-01-01")
199    ///     .header("Accept-Language", "en");
200    /// ```
201    #[must_use]
202    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
203        self.headers.insert(key.into(), value.into());
204        self
205    }
206
207    /// Replace all headers with a pre-built map.
208    ///
209    /// # Example
210    ///
211    /// ```rust
212    /// use std::collections::HashMap;
213    /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin;
214    ///
215    /// let headers: HashMap<_, _> = [("X-Version", "1")].into_iter()
216    ///     .map(|(k, v)| (k.to_string(), v.to_string()))
217    ///     .collect();
218    /// let _builder = GenericGraphQlPlugin::builder().headers(headers);
219    /// ```
220    #[must_use]
221    pub fn headers(mut self, headers: HashMap<String, String>) -> Self {
222        self.headers = headers;
223        self
224    }
225
226    /// Set the default auth credentials.
227    ///
228    /// # Example
229    ///
230    /// ```rust
231    /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin;
232    /// use stygian_graph::ports::{GraphQlAuth, GraphQlAuthKind};
233    ///
234    /// let _builder = GenericGraphQlPlugin::builder()
235    ///     .auth(GraphQlAuth {
236    ///         kind: GraphQlAuthKind::Bearer,
237    ///         token: "${env:GITHUB_TOKEN}".to_string(),
238    ///         header_name: None,
239    ///     });
240    /// ```
241    #[must_use]
242    pub fn auth(mut self, auth: GraphQlAuth) -> Self {
243        self.auth = Some(auth);
244        self
245    }
246
247    /// Convenience helper: set a Bearer-token auth from a string.
248    ///
249    /// # Example
250    ///
251    /// ```rust
252    /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin;
253    ///
254    /// let _builder = GenericGraphQlPlugin::builder()
255    ///     .bearer_auth("${env:MY_TOKEN}");
256    /// ```
257    #[must_use]
258    pub fn bearer_auth(mut self, token: impl Into<String>) -> Self {
259        self.auth = Some(GraphQlAuth {
260            kind: GraphQlAuthKind::Bearer,
261            token: token.into(),
262            header_name: None,
263        });
264        self
265    }
266
267    /// Attach a cost-throttle configuration for proactive pre-flight delays.
268    ///
269    /// # Example
270    ///
271    /// ```rust
272    /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin;
273    /// use stygian_graph::adapters::graphql_throttle::CostThrottleConfig;
274    ///
275    /// let _builder = GenericGraphQlPlugin::builder()
276    ///     .cost_throttle(CostThrottleConfig::default());
277    /// ```
278    #[must_use]
279    pub const fn cost_throttle(mut self, throttle: CostThrottleConfig) -> Self {
280        self.throttle = Some(throttle);
281        self
282    }
283
284    /// Override the default page size (default: `50`).
285    ///
286    /// # Example
287    ///
288    /// ```rust
289    /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin;
290    ///
291    /// let _builder = GenericGraphQlPlugin::builder().page_size(30);
292    /// ```
293    #[must_use]
294    pub const fn page_size(mut self, page_size: usize) -> Self {
295        self.page_size = page_size;
296        self
297    }
298
299    /// Set a human-readable description of the plugin.
300    ///
301    /// # Example
302    ///
303    /// ```rust
304    /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin;
305    ///
306    /// let _builder = GenericGraphQlPlugin::builder()
307    ///     .description("GitHub public API v4");
308    /// ```
309    #[must_use]
310    pub fn description(mut self, description: impl Into<String>) -> Self {
311        self.description = description.into();
312        self
313    }
314
315    /// Consume the builder and produce a [`GenericGraphQlPlugin`].
316    ///
317    /// Returns `Err` if `name` or `endpoint` were not set.
318    ///
319    /// # Example
320    ///
321    /// ```rust
322    /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin;
323    /// use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin;
324    ///
325    /// let plugin = GenericGraphQlPlugin::builder()
326    ///     .name("github")
327    ///     .endpoint("https://api.github.com/graphql")
328    ///     .build()
329    ///     .expect("ok");
330    ///
331    /// assert_eq!(plugin.name(), "github");
332    /// ```
333    ///
334    /// # Errors
335    ///
336    /// Returns [`BuildError::MissingName`] or [`BuildError::MissingEndpoint`]
337    /// when the required builder fields are not supplied.
338    pub fn build(self) -> Result<GenericGraphQlPlugin, BuildError> {
339        Ok(GenericGraphQlPlugin {
340            name: self.name.ok_or(BuildError::MissingName)?,
341            endpoint: self.endpoint.ok_or(BuildError::MissingEndpoint)?,
342            headers: self.headers,
343            auth: self.auth,
344            throttle: self.throttle,
345            page_size: if self.page_size == 0 {
346                50
347            } else {
348                self.page_size
349            },
350            description: self.description,
351        })
352    }
353}
354
355// ─────────────────────────────────────────────────────────────────────────────
356// BuildError
357// ─────────────────────────────────────────────────────────────────────────────
358
359/// Errors that can occur when building a [`GenericGraphQlPlugin`].
360#[derive(Debug, thiserror::Error)]
361pub enum BuildError {
362    /// The `name` field was not set.
363    #[error("plugin name is required — call .name(\"...\")")]
364    MissingName,
365    /// The `endpoint` field was not set.
366    #[error("plugin endpoint is required — call .endpoint(\"...\")")]
367    MissingEndpoint,
368}
369
370// ─────────────────────────────────────────────────────────────────────────────
371// Tests
372// ─────────────────────────────────────────────────────────────────────────────
373
374#[cfg(test)]
375#[allow(clippy::unwrap_used)]
376mod tests {
377    use super::*;
378
379    fn minimal_plugin() -> GenericGraphQlPlugin {
380        GenericGraphQlPlugin::builder()
381            .name("test")
382            .endpoint("https://api.example.com/graphql")
383            .build()
384            .unwrap()
385    }
386
387    #[test]
388    fn builder_minimal_roundtrip() {
389        let p = minimal_plugin();
390        assert_eq!(p.name(), "test");
391        assert_eq!(p.endpoint(), "https://api.example.com/graphql");
392        assert_eq!(p.default_page_size(), 50); // default
393        assert!(p.default_auth().is_none());
394        assert!(p.cost_throttle_config().is_none());
395        assert!(p.version_headers().is_empty());
396    }
397
398    #[test]
399    fn builder_full_roundtrip() {
400        let plugin = GenericGraphQlPlugin::builder()
401            .name("github")
402            .endpoint("https://api.github.com/graphql")
403            .bearer_auth("ghp_test")
404            .header("X-Github-Next-Global-ID", "1")
405            .cost_throttle(CostThrottleConfig::default())
406            .page_size(30)
407            .description("GitHub v4")
408            .build()
409            .unwrap();
410
411        assert_eq!(plugin.name(), "github");
412        assert_eq!(plugin.default_page_size(), 30);
413        assert_eq!(plugin.description(), "GitHub v4");
414        assert!(plugin.default_auth().is_some());
415        assert!(plugin.cost_throttle_config().is_some());
416        let headers = plugin.version_headers();
417        assert_eq!(
418            headers.get("X-Github-Next-Global-ID").map(String::as_str),
419            Some("1")
420        );
421    }
422
423    #[test]
424    fn builder_error_missing_name() {
425        let result = GenericGraphQlPlugin::builder()
426            .endpoint("https://api.example.com/graphql")
427            .build();
428        assert!(matches!(result, Err(BuildError::MissingName)));
429    }
430
431    #[test]
432    fn builder_error_missing_endpoint() {
433        let result = GenericGraphQlPlugin::builder().name("api").build();
434        assert!(matches!(result, Err(BuildError::MissingEndpoint)));
435    }
436
437    #[test]
438    fn page_size_zero_defaults_to_50() {
439        let plugin = GenericGraphQlPlugin::builder()
440            .name("api")
441            .endpoint("https://api.example.com/graphql")
442            .page_size(0)
443            .build()
444            .unwrap();
445        assert_eq!(plugin.default_page_size(), 50);
446    }
447
448    #[test]
449    fn headers_map_replacement() {
450        use std::collections::HashMap;
451        let mut map = HashMap::new();
452        map.insert("X-Foo".to_string(), "bar".to_string());
453        let plugin = GenericGraphQlPlugin::builder()
454            .name("api")
455            .endpoint("https://api.example.com/graphql")
456            .headers(map)
457            .build()
458            .unwrap();
459        assert_eq!(
460            plugin.version_headers().get("X-Foo").map(String::as_str),
461            Some("bar")
462        );
463    }
464}