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}