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}