Skip to main content

ironflow_ops_gitlab/
client.rs

1//! [`GitLab`] client built from an [`OperationContext`]'s secret store.
2
3use gitlab::{AsyncGitlab, GitlabBuilder};
4use ironflow_core::error::OperationError;
5use ironflow_core::operation::OperationContext;
6
7use crate::operation::GitLabOp;
8
9/// A GitLab client that resolves credentials from the workflow's secret store.
10///
11/// Wraps [`AsyncGitlab`] and provides a convenience [`op`](GitLab::op) method
12/// to turn any endpoint into a tracked [`Operation`](ironflow_core::operation::Operation).
13///
14/// # Examples
15///
16/// ```no_run
17/// use ironflow_ops_gitlab::GitLab;
18/// use ironflow_core::operation::{OperationContext, NoopSecretResolver};
19/// use std::sync::Arc;
20///
21/// # async fn example() -> Result<(), ironflow_core::error::OperationError> {
22/// let ctx = OperationContext::new(Arc::new(NoopSecretResolver));
23///
24/// // gitlab.com (default)
25/// let gitlab = GitLab::from_context(&ctx).await?;
26///
27/// // Self-hosted
28/// let gitlab = GitLab::from_context_with_host(&ctx, "gitlab.example.com").await?;
29/// # Ok(())
30/// # }
31/// ```
32pub struct GitLab {
33    inner: AsyncGitlab,
34}
35
36impl GitLab {
37    /// Build a client from an [`OperationContext`], defaulting to `gitlab.com`.
38    ///
39    /// Reads the `gitlab_token` secret from the workflow's secret store.
40    ///
41    /// # Errors
42    ///
43    /// Returns [`OperationError::Secret`] if the token is missing, or
44    /// [`OperationError::Http`] if the client cannot be built.
45    pub async fn from_context(ctx: &OperationContext) -> Result<Self, OperationError> {
46        Self::from_context_with_host(ctx, "gitlab.com").await
47    }
48
49    /// Build a client from an [`OperationContext`] with a custom host.
50    ///
51    /// # Errors
52    ///
53    /// Returns [`OperationError::Secret`] if the token is missing, or
54    /// [`OperationError::Http`] if the client cannot be built.
55    pub async fn from_context_with_host(
56        ctx: &OperationContext,
57        host: &str,
58    ) -> Result<Self, OperationError> {
59        let secret =
60            ctx.secrets()
61                .get("gitlab_token")
62                .await?
63                .ok_or_else(|| OperationError::Secret {
64                    message: "gitlab_token secret not found".to_string(),
65                })?;
66        Self::new(&secret.value, host).await
67    }
68
69    /// Build a client with an explicit token and host.
70    ///
71    /// # Errors
72    ///
73    /// Returns [`OperationError::Http`] if the client cannot be built.
74    ///
75    /// # Examples
76    ///
77    /// ```no_run
78    /// use ironflow_ops_gitlab::GitLab;
79    ///
80    /// # async fn example() -> Result<(), ironflow_core::error::OperationError> {
81    /// let gitlab = GitLab::new("glpat-xxxx", "gitlab.com").await?;
82    /// # Ok(())
83    /// # }
84    /// ```
85    pub async fn new(token: &str, host: &str) -> Result<Self, OperationError> {
86        let inner = GitlabBuilder::new(host, token)
87            .build_async()
88            .await
89            .map_err(|e| OperationError::Http {
90                status: None,
91                message: e.to_string(),
92            })?;
93        Ok(Self { inner })
94    }
95
96    /// The underlying [`AsyncGitlab`] client.
97    ///
98    /// Use this with [`AsyncQuery::query_async`](gitlab::api::AsyncQuery::query_async)
99    /// for typed endpoint calls.
100    pub fn client(&self) -> &AsyncGitlab {
101        &self.inner
102    }
103
104    /// Wrap an endpoint as a tracked [`Operation`](ironflow_core::operation::Operation).
105    ///
106    /// The returned [`GitLabOp`] implements `Operation` so it can be passed
107    /// to `WorkflowContext::operation()` for step lifecycle tracking.
108    ///
109    /// # Examples
110    ///
111    /// ```no_run
112    /// use ironflow_ops_gitlab::GitLab;
113    /// use gitlab::api::projects;
114    ///
115    /// # async fn example() -> Result<(), ironflow_core::error::OperationError> {
116    /// let gitlab = GitLab::new("glpat-xxxx", "gitlab.com").await?;
117    /// let endpoint = projects::Project::builder().project(42).build().unwrap();
118    /// let op = gitlab.op(endpoint);
119    /// # Ok(())
120    /// # }
121    /// ```
122    pub fn op<E>(&self, endpoint: E) -> GitLabOp<E> {
123        GitLabOp::new(self.inner.clone(), endpoint)
124    }
125}
126
127/// Build a [`GitLab`] client from an already configured [`AsyncGitlab`].
128///
129/// Use this when [`GitLab::new`] is not flexible enough -- for example to
130/// point the client at an HTTP test double (wiremock) or a self-hosted
131/// instance with custom TLS settings, by building the client directly via
132/// [`GitlabBuilder`] and converting the result.
133///
134/// # Examples
135///
136/// ```no_run
137/// use ironflow_ops_gitlab::GitLab;
138/// use gitlab::GitlabBuilder;
139///
140/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
141/// // `.insecure()` allows plain HTTP, e.g. against a wiremock server in tests.
142/// let client = GitlabBuilder::new("gitlab.example.com", "glpat-xxxx")
143///     .insecure()
144///     .build_async()
145///     .await?;
146/// let gitlab: GitLab = client.into();
147/// # Ok(())
148/// # }
149/// ```
150impl From<AsyncGitlab> for GitLab {
151    fn from(inner: AsyncGitlab) -> Self {
152        Self { inner }
153    }
154}
155
156impl std::fmt::Debug for GitLab {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        f.debug_struct("GitLab")
159            .field("client", &"[AsyncGitlab]")
160            .finish()
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use std::sync::Arc;
167
168    use gitlab::GitlabBuilder;
169    use gitlab::api::projects;
170    use ironflow_core::operation::{NoopSecretResolver, Operation, OperationContext};
171    use wiremock::matchers::{method, path};
172    use wiremock::{Mock, MockServer, ResponseTemplate};
173
174    use super::*;
175
176    #[tokio::test]
177    #[ignore]
178    async fn new_builds_with_valid_host() {
179        let gitlab = GitLab::new("glpat-xxxx", "gitlab.com").await;
180        assert!(gitlab.is_ok());
181    }
182
183    #[tokio::test]
184    #[ignore]
185    async fn debug_does_not_leak_token() {
186        let gitlab = GitLab::new("super-secret", "gitlab.com").await.unwrap();
187        let debug = format!("{gitlab:?}");
188        assert!(!debug.contains("super-secret"));
189    }
190
191    #[tokio::test]
192    async fn from_context_fails_when_token_missing() {
193        let ctx = OperationContext::new(Arc::new(NoopSecretResolver));
194        let err = GitLab::from_context(&ctx).await.unwrap_err();
195        assert!(err.to_string().contains("gitlab_token"));
196    }
197
198    #[tokio::test]
199    async fn from_async_gitlab_hits_mock_server() {
200        let server = MockServer::start().await;
201        Mock::given(method("GET"))
202            .and(path("/api/v4/user"))
203            .respond_with(
204                ResponseTemplate::new(200).set_body_string(r#"{"id":1,"username":"demo"}"#),
205            )
206            .mount(&server)
207            .await;
208        Mock::given(method("GET"))
209            .and(path("/api/v4/projects/42"))
210            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"id":42,"name":"demo"}"#))
211            .mount(&server)
212            .await;
213
214        let client = GitlabBuilder::new(server.address().to_string(), "test-token")
215            .insecure()
216            .build_async()
217            .await
218            .unwrap();
219        let gitlab: GitLab = client.into();
220
221        let endpoint = projects::Project::builder().project(42).build().unwrap();
222        let op = gitlab.op(endpoint);
223        let ctx = OperationContext::new(Arc::new(NoopSecretResolver));
224        let result = op.execute(&ctx).await.unwrap();
225
226        assert_eq!(result["id"], 42);
227        assert_eq!(result["name"], "demo");
228
229        let requests = server.received_requests().await.unwrap();
230        assert_eq!(requests.len(), 2);
231        let paths: Vec<&str> = requests.iter().map(|r| r.url.path()).collect();
232        assert!(paths.contains(&"/api/v4/user"));
233        assert!(paths.contains(&"/api/v4/projects/42"));
234    }
235}