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    pub fn build(self) -> Result<GenericGraphQlPlugin, BuildError> {
334        Ok(GenericGraphQlPlugin {
335            name: self.name.ok_or(BuildError::MissingName)?,
336            endpoint: self.endpoint.ok_or(BuildError::MissingEndpoint)?,
337            headers: self.headers,
338            auth: self.auth,
339            throttle: self.throttle,
340            page_size: if self.page_size == 0 {
341                50
342            } else {
343                self.page_size
344            },
345            description: self.description,
346        })
347    }
348}
349
350// ─────────────────────────────────────────────────────────────────────────────
351// BuildError
352// ─────────────────────────────────────────────────────────────────────────────
353
354/// Errors that can occur when building a [`GenericGraphQlPlugin`].
355#[derive(Debug, thiserror::Error)]
356pub enum BuildError {
357    /// The `name` field was not set.
358    #[error("plugin name is required — call .name(\"...\")")]
359    MissingName,
360    /// The `endpoint` field was not set.
361    #[error("plugin endpoint is required — call .endpoint(\"...\")")]
362    MissingEndpoint,
363}
364
365// ─────────────────────────────────────────────────────────────────────────────
366// Tests
367// ─────────────────────────────────────────────────────────────────────────────
368
369#[cfg(test)]
370#[allow(clippy::unwrap_used)]
371mod tests {
372    use super::*;
373
374    fn minimal_plugin() -> GenericGraphQlPlugin {
375        GenericGraphQlPlugin::builder()
376            .name("test")
377            .endpoint("https://api.example.com/graphql")
378            .build()
379            .unwrap()
380    }
381
382    #[test]
383    fn builder_minimal_roundtrip() {
384        let p = minimal_plugin();
385        assert_eq!(p.name(), "test");
386        assert_eq!(p.endpoint(), "https://api.example.com/graphql");
387        assert_eq!(p.default_page_size(), 50); // default
388        assert!(p.default_auth().is_none());
389        assert!(p.cost_throttle_config().is_none());
390        assert!(p.version_headers().is_empty());
391    }
392
393    #[test]
394    fn builder_full_roundtrip() {
395        let plugin = GenericGraphQlPlugin::builder()
396            .name("github")
397            .endpoint("https://api.github.com/graphql")
398            .bearer_auth("ghp_test")
399            .header("X-Github-Next-Global-ID", "1")
400            .cost_throttle(CostThrottleConfig::default())
401            .page_size(30)
402            .description("GitHub v4")
403            .build()
404            .unwrap();
405
406        assert_eq!(plugin.name(), "github");
407        assert_eq!(plugin.default_page_size(), 30);
408        assert_eq!(plugin.description(), "GitHub v4");
409        assert!(plugin.default_auth().is_some());
410        assert!(plugin.cost_throttle_config().is_some());
411        let headers = plugin.version_headers();
412        assert_eq!(
413            headers.get("X-Github-Next-Global-ID").map(String::as_str),
414            Some("1")
415        );
416    }
417
418    #[test]
419    fn builder_error_missing_name() {
420        let result = GenericGraphQlPlugin::builder()
421            .endpoint("https://api.example.com/graphql")
422            .build();
423        assert!(matches!(result, Err(BuildError::MissingName)));
424    }
425
426    #[test]
427    fn builder_error_missing_endpoint() {
428        let result = GenericGraphQlPlugin::builder().name("api").build();
429        assert!(matches!(result, Err(BuildError::MissingEndpoint)));
430    }
431
432    #[test]
433    fn page_size_zero_defaults_to_50() {
434        let plugin = GenericGraphQlPlugin::builder()
435            .name("api")
436            .endpoint("https://api.example.com/graphql")
437            .page_size(0)
438            .build()
439            .unwrap();
440        assert_eq!(plugin.default_page_size(), 50);
441    }
442
443    #[test]
444    fn headers_map_replacement() {
445        use std::collections::HashMap;
446        let mut map = HashMap::new();
447        map.insert("X-Foo".to_string(), "bar".to_string());
448        let plugin = GenericGraphQlPlugin::builder()
449            .name("api")
450            .endpoint("https://api.example.com/graphql")
451            .headers(map)
452            .build()
453            .unwrap();
454        assert_eq!(
455            plugin.version_headers().get("X-Foo").map(String::as_str),
456            Some("bar")
457        );
458    }
459}