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}