stygian_graph/adapters/graphql_plugins/jobber.rs
1//! Jobber GraphQL plugin — see T36 for the full implementation.
2//!
3//! Jobber is a field-service management platform whose GraphQL API lives at
4//! `https://api.getjobber.com/api/graphql` and requires the version header
5//! `X-JOBBER-GRAPHQL-VERSION: 2025-04-16` on every request.
6
7use std::collections::HashMap;
8
9use crate::ports::graphql_plugin::GraphQlTargetPlugin;
10use crate::ports::{GraphQlAuth, GraphQlAuthKind};
11
12/// Jobber GraphQL API plugin.
13///
14/// Supplies the endpoint, required version header, and default bearer-token
15/// auth for all Jobber pipeline nodes. The access token is read from the
16/// `JOBBER_ACCESS_TOKEN` environment variable **at construction time** via
17/// [`JobberPlugin::new`] (or [`Default`]), so `default_auth` performs no
18/// environment access after the plugin is built. Use [`JobberPlugin::with_token`]
19/// to inject a token directly (useful in tests and programmatic usage).
20///
21/// # Example
22///
23/// ```rust
24/// use stygian_graph::adapters::graphql_plugins::jobber::JobberPlugin;
25/// use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin;
26///
27/// let plugin = JobberPlugin::new();
28/// assert_eq!(plugin.name(), "jobber");
29/// assert_eq!(plugin.endpoint(), "https://api.getjobber.com/api/graphql");
30/// ```
31pub struct JobberPlugin {
32 token: Option<String>,
33}
34
35impl JobberPlugin {
36 /// Creates a new [`JobberPlugin`], reading the access token from the
37 /// `JOBBER_ACCESS_TOKEN` environment variable.
38 ///
39 /// # Example
40 ///
41 /// ```rust
42 /// use stygian_graph::adapters::graphql_plugins::jobber::JobberPlugin;
43 ///
44 /// let plugin = JobberPlugin::new();
45 /// ```
46 #[must_use]
47 pub fn new() -> Self {
48 Self::default()
49 }
50
51 /// Creates a [`JobberPlugin`] with an explicit access token, bypassing the
52 /// environment entirely.
53 ///
54 /// This is useful when credentials are already available at call-site (e.g.,
55 /// fetched from a secret store) or when writing tests without mutating
56 /// process environment variables.
57 ///
58 /// # Example
59 ///
60 /// ```rust
61 /// use stygian_graph::adapters::graphql_plugins::jobber::JobberPlugin;
62 /// use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin;
63 ///
64 /// let plugin = JobberPlugin::with_token("my-secret-token");
65 /// assert!(plugin.default_auth().is_some());
66 /// ```
67 #[must_use]
68 pub fn with_token(token: impl Into<String>) -> Self {
69 Self {
70 token: Some(token.into()),
71 }
72 }
73}
74
75impl Default for JobberPlugin {
76 /// Creates a [`JobberPlugin`] by reading `JOBBER_ACCESS_TOKEN` from the
77 /// environment at construction time.
78 fn default() -> Self {
79 Self {
80 token: std::env::var("JOBBER_ACCESS_TOKEN").ok(),
81 }
82 }
83}
84
85impl GraphQlTargetPlugin for JobberPlugin {
86 fn name(&self) -> &'static str {
87 "jobber"
88 }
89
90 fn endpoint(&self) -> &'static str {
91 "https://api.getjobber.com/api/graphql"
92 }
93
94 fn version_headers(&self) -> HashMap<String, String> {
95 [(
96 "X-JOBBER-GRAPHQL-VERSION".to_string(),
97 "2025-04-16".to_string(),
98 )]
99 .into()
100 }
101
102 fn default_auth(&self) -> Option<GraphQlAuth> {
103 self.token.as_ref().map(|token| GraphQlAuth {
104 kind: GraphQlAuthKind::Bearer,
105 token: token.clone(),
106 header_name: None,
107 })
108 }
109
110 fn description(&self) -> &'static str {
111 "Jobber field-service management GraphQL API"
112 }
113}
114
115#[cfg(test)]
116#[allow(clippy::expect_used)]
117mod tests {
118 use super::*;
119
120 #[test]
121 fn plugin_name_is_jobber() {
122 assert_eq!(JobberPlugin::new().name(), "jobber");
123 }
124
125 #[test]
126 fn endpoint_is_correct() {
127 assert_eq!(
128 JobberPlugin::new().endpoint(),
129 "https://api.getjobber.com/api/graphql"
130 );
131 }
132
133 #[test]
134 fn version_header_is_set() {
135 let headers = JobberPlugin::new().version_headers();
136 assert_eq!(
137 headers.get("X-JOBBER-GRAPHQL-VERSION").map(String::as_str),
138 Some("2025-04-16")
139 );
140 }
141
142 #[test]
143 fn default_auth_with_injected_token() {
144 let plugin = JobberPlugin::with_token("test-token-abc");
145 let auth = plugin.default_auth();
146 assert!(auth.is_some(), "auth should be Some when token is injected");
147 let auth = auth.expect("auth should be Some when token is injected");
148 assert_eq!(auth.kind, GraphQlAuthKind::Bearer);
149 assert_eq!(auth.token, "test-token-abc");
150 assert!(auth.header_name.is_none());
151 }
152
153 #[test]
154 fn default_auth_absent_when_no_token() {
155 let plugin = JobberPlugin { token: None };
156 assert!(plugin.default_auth().is_none());
157 }
158
159 #[test]
160 fn description_is_non_empty() {
161 assert!(!JobberPlugin::new().description().is_empty());
162 }
163}