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}