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
127impl std::fmt::Debug for GitLab {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        f.debug_struct("GitLab")
130            .field("client", &"[AsyncGitlab]")
131            .finish()
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use std::sync::Arc;
138
139    use ironflow_core::operation::{NoopSecretResolver, OperationContext};
140
141    use super::*;
142
143    #[tokio::test]
144    #[ignore]
145    async fn new_builds_with_valid_host() {
146        let gitlab = GitLab::new("glpat-xxxx", "gitlab.com").await;
147        assert!(gitlab.is_ok());
148    }
149
150    #[tokio::test]
151    #[ignore]
152    async fn debug_does_not_leak_token() {
153        let gitlab = GitLab::new("super-secret", "gitlab.com").await.unwrap();
154        let debug = format!("{gitlab:?}");
155        assert!(!debug.contains("super-secret"));
156    }
157
158    #[tokio::test]
159    async fn from_context_fails_when_token_missing() {
160        let ctx = OperationContext::new(Arc::new(NoopSecretResolver));
161        let err = GitLab::from_context(&ctx).await.unwrap_err();
162        assert!(err.to_string().contains("gitlab_token"));
163    }
164}