Skip to main content

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;
10
11use crate::ports::GraphQlAuth;
12
13/// A named GraphQL target that supplies connection defaults for a specific API.
14///
15/// Plugins are identified by their [`name`](Self::name) and loaded from the
16/// [`GraphQlPluginRegistry`](crate::application::graphql_plugin_registry::GraphQlPluginRegistry)
17/// at pipeline execution time.
18///
19/// # Example
20///
21/// ```rust
22/// use std::collections::HashMap;
23/// use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin;
24/// use stygian_graph::ports::{GraphQlAuth, GraphQlAuthKind};
25///
26/// struct MyApiPlugin;
27///
28/// impl GraphQlTargetPlugin for MyApiPlugin {
29///     fn name(&self) -> &str { "my-api" }
30///     fn endpoint(&self) -> &str { "https://api.example.com/graphql" }
31///     fn version_headers(&self) -> HashMap<String, String> {
32///         [("X-API-VERSION".to_string(), "2025-01-01".to_string())].into()
33///     }
34///     fn default_auth(&self) -> Option<GraphQlAuth> { None }
35/// }
36/// ```
37pub trait GraphQlTargetPlugin: Send + Sync {
38    /// Canonical lowercase plugin name used in pipeline TOML: `plugin = "jobber"`.
39    fn name(&self) -> &str;
40
41    /// The GraphQL endpoint URL for this target.
42    ///
43    /// Used as the request URL when `ServiceInput.url` is empty.
44    fn endpoint(&self) -> &str;
45
46    /// Version or platform headers required by this API.
47    ///
48    /// Injected on every request. Plugin headers take precedence over
49    /// ad-hoc `params.headers` for the same key.
50    ///
51    /// # Example
52    ///
53    /// ```rust
54    /// use std::collections::HashMap;
55    /// use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin;
56    /// use stygian_graph::ports::{GraphQlAuth, GraphQlAuthKind};
57    ///
58    /// struct JobberPlugin;
59    /// impl GraphQlTargetPlugin for JobberPlugin {
60    ///     fn name(&self) -> &str { "jobber" }
61    ///     fn endpoint(&self) -> &str { "https://api.getjobber.com/api/graphql" }
62    ///     fn version_headers(&self) -> HashMap<String, String> {
63    ///         [("X-JOBBER-GRAPHQL-VERSION".to_string(), "2025-04-16".to_string())].into()
64    ///     }
65    /// }
66    /// ```
67    fn version_headers(&self) -> HashMap<String, String> {
68        HashMap::new()
69    }
70
71    /// Default auth to use when `params.auth` is absent.
72    ///
73    /// Implementations should read credentials from environment variables here.
74    ///
75    /// # Example
76    ///
77    /// ```rust
78    /// use std::collections::HashMap;
79    /// use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin;
80    /// use stygian_graph::ports::{GraphQlAuth, GraphQlAuthKind};
81    ///
82    /// struct SecurePlugin;
83    /// impl GraphQlTargetPlugin for SecurePlugin {
84    ///     fn name(&self) -> &str { "secure" }
85    ///     fn endpoint(&self) -> &str { "https://api.secure.com/graphql" }
86    ///     fn default_auth(&self) -> Option<GraphQlAuth> {
87    ///         Some(GraphQlAuth {
88    ///             kind: GraphQlAuthKind::Bearer,
89    ///             token: "${env:SECURE_ACCESS_TOKEN}".to_string(),
90    ///             header_name: None,
91    ///         })
92    ///     }
93    /// }
94    /// ```
95    fn default_auth(&self) -> Option<GraphQlAuth> {
96        None
97    }
98
99    /// Default page size for cursor-paginated queries.
100    fn default_page_size(&self) -> usize {
101        50
102    }
103
104    /// Whether this target uses Relay-style cursor pagination by default.
105    fn supports_cursor_pagination(&self) -> bool {
106        true
107    }
108
109    /// Human-readable description shown in `stygian plugins list`.
110    #[allow(clippy::unnecessary_literal_bound)]
111    fn description(&self) -> &str {
112        ""
113    }
114}
115
116#[cfg(test)]
117#[allow(clippy::unnecessary_literal_bound, clippy::unwrap_used)]
118mod tests {
119    use super::*;
120    use crate::ports::GraphQlAuthKind;
121
122    struct MinimalPlugin;
123
124    impl GraphQlTargetPlugin for MinimalPlugin {
125        fn name(&self) -> &str {
126            "minimal"
127        }
128        fn endpoint(&self) -> &str {
129            "https://api.example.com/graphql"
130        }
131    }
132
133    #[test]
134    fn default_methods_return_expected_values() {
135        let plugin = MinimalPlugin;
136        assert!(plugin.version_headers().is_empty());
137        assert!(plugin.default_auth().is_none());
138        assert_eq!(plugin.default_page_size(), 50);
139        assert!(plugin.supports_cursor_pagination());
140        assert_eq!(plugin.description(), "");
141    }
142
143    #[test]
144    fn custom_version_headers_are_returned() {
145        struct Versioned;
146        impl GraphQlTargetPlugin for Versioned {
147            fn name(&self) -> &str {
148                "versioned"
149            }
150            fn endpoint(&self) -> &str {
151                "https://api.v.com/graphql"
152            }
153            fn version_headers(&self) -> HashMap<String, String> {
154                [("X-API-VERSION".to_string(), "v2".to_string())].into()
155            }
156        }
157        let headers = Versioned.version_headers();
158        assert_eq!(headers.get("X-API-VERSION").map(String::as_str), Some("v2"));
159    }
160
161    #[test]
162    fn default_auth_can_be_overridden() {
163        struct Authed;
164        impl GraphQlTargetPlugin for Authed {
165            fn name(&self) -> &str {
166                "authed"
167            }
168            fn endpoint(&self) -> &str {
169                "https://api.a.com/graphql"
170            }
171            fn default_auth(&self) -> Option<GraphQlAuth> {
172                Some(GraphQlAuth {
173                    kind: GraphQlAuthKind::Bearer,
174                    token: "${env:TOKEN}".to_string(),
175                    header_name: None,
176                })
177            }
178        }
179        let auth = Authed.default_auth().unwrap();
180        assert_eq!(auth.kind, GraphQlAuthKind::Bearer);
181        assert_eq!(auth.token, "${env:TOKEN}");
182    }
183}