Skip to main content

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}