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}