Skip to main content

ironflow_ops_gitlab/
client.rs

1//! [`GitLab`] client built from an [`OperationContext`]'s secret store.
2
3use gitlab::api::{Endpoint, Pageable, Pagination};
4use gitlab::{AsyncGitlab, GitlabBuilder};
5use ironflow_core::error::OperationError;
6use ironflow_core::operation::OperationContext;
7
8use crate::operation::GitLabOp;
9use crate::paged_operation::GitLabPagedOp;
10
11/// A GitLab client that resolves credentials from the workflow's secret store.
12///
13/// Wraps [`AsyncGitlab`] and provides a convenience [`op`](GitLab::op) method
14/// to turn any endpoint into a tracked [`Operation`](ironflow_core::operation::Operation).
15///
16/// # Examples
17///
18/// ```no_run
19/// use ironflow_ops_gitlab::GitLab;
20/// use ironflow_core::operation::{OperationContext, NoopSecretResolver};
21/// use std::sync::Arc;
22///
23/// # async fn example() -> Result<(), ironflow_core::error::OperationError> {
24/// let ctx = OperationContext::new(Arc::new(NoopSecretResolver));
25///
26/// // gitlab.com (default)
27/// let gitlab = GitLab::from_context(&ctx).await?;
28///
29/// // Self-hosted
30/// let gitlab = GitLab::from_context_with_host(&ctx, "gitlab.example.com").await?;
31/// # Ok(())
32/// # }
33/// ```
34pub struct GitLab {
35    inner: AsyncGitlab,
36}
37
38impl GitLab {
39    /// Build a client from an [`OperationContext`], defaulting to `gitlab.com`.
40    ///
41    /// Reads the `gitlab_token` secret from the workflow's secret store.
42    ///
43    /// # Errors
44    ///
45    /// Returns [`OperationError::Secret`] if the token is missing, or
46    /// [`OperationError::Http`] if the client cannot be built.
47    pub async fn from_context(ctx: &OperationContext) -> Result<Self, OperationError> {
48        Self::from_context_with_host(ctx, "gitlab.com").await
49    }
50
51    /// Build a client from an [`OperationContext`] with a custom host.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`OperationError::Secret`] if the token is missing, or
56    /// [`OperationError::Http`] if the client cannot be built.
57    pub async fn from_context_with_host(
58        ctx: &OperationContext,
59        host: &str,
60    ) -> Result<Self, OperationError> {
61        let secret =
62            ctx.secrets()
63                .get("gitlab_token")
64                .await?
65                .ok_or_else(|| OperationError::Secret {
66                    message: "gitlab_token secret not found".to_string(),
67                })?;
68        Self::new(&secret.value, host).await
69    }
70
71    /// Build a client with an explicit token and host.
72    ///
73    /// # Errors
74    ///
75    /// Returns [`OperationError::Http`] if the client cannot be built.
76    ///
77    /// # Examples
78    ///
79    /// ```no_run
80    /// use ironflow_ops_gitlab::GitLab;
81    ///
82    /// # async fn example() -> Result<(), ironflow_core::error::OperationError> {
83    /// let gitlab = GitLab::new("glpat-xxxx", "gitlab.com").await?;
84    /// # Ok(())
85    /// # }
86    /// ```
87    pub async fn new(token: &str, host: &str) -> Result<Self, OperationError> {
88        let inner = GitlabBuilder::new(host, token)
89            .build_async()
90            .await
91            .map_err(|e| OperationError::Http {
92                status: None,
93                message: e.to_string(),
94            })?;
95        Ok(Self { inner })
96    }
97
98    /// The underlying [`AsyncGitlab`] client.
99    ///
100    /// Use this with [`AsyncQuery::query_async`](gitlab::api::AsyncQuery::query_async)
101    /// for typed endpoint calls.
102    pub fn client(&self) -> &AsyncGitlab {
103        &self.inner
104    }
105
106    /// Wrap an endpoint as a tracked [`Operation`](ironflow_core::operation::Operation).
107    ///
108    /// The returned [`GitLabOp`] implements `Operation` so it can be passed
109    /// to `WorkflowContext::operation()` for step lifecycle tracking.
110    ///
111    /// # Examples
112    ///
113    /// ```no_run
114    /// use ironflow_ops_gitlab::GitLab;
115    /// use gitlab::api::projects;
116    ///
117    /// # async fn example() -> Result<(), ironflow_core::error::OperationError> {
118    /// let gitlab = GitLab::new("glpat-xxxx", "gitlab.com").await?;
119    /// let endpoint = projects::Project::builder().project(42).build().unwrap();
120    /// let op = gitlab.op(endpoint);
121    /// # Ok(())
122    /// # }
123    /// ```
124    pub fn op<E>(&self, endpoint: E) -> GitLabOp<E> {
125        GitLabOp::new(self.inner.clone(), endpoint)
126    }
127
128    /// Wrap a pageable endpoint as a tracked [`Operation`](ironflow_core::operation::Operation)
129    /// that fetches every page requested by `pagination` and concatenates the results.
130    ///
131    /// Unlike [`GitLab::op`], this accepts endpoints that implement
132    /// [`Pageable`](gitlab::api::Pageable) (e.g. any "list ..." endpoint) and drives
133    /// pagination itself, so a single tracked step can retrieve more than one page
134    /// (`GitLabOp` only ever issues a single request and therefore only ever returns
135    /// the first page).
136    ///
137    /// # Examples
138    ///
139    /// ```no_run
140    /// use ironflow_ops_gitlab::GitLab;
141    /// use gitlab::api::projects::merge_requests::MergeRequests;
142    /// use gitlab::api::Pagination;
143    ///
144    /// # async fn example() -> Result<(), ironflow_core::error::OperationError> {
145    /// let gitlab = GitLab::new("glpat-xxxx", "gitlab.com").await?;
146    /// let endpoint = MergeRequests::builder().project(42).build().unwrap();
147    /// let op = gitlab.paged_op(endpoint, Pagination::All);
148    /// # Ok(())
149    /// # }
150    /// ```
151    pub fn paged_op<E>(&self, endpoint: E, pagination: Pagination) -> GitLabPagedOp<E>
152    where
153        E: Pageable + Endpoint + Send + Sync,
154    {
155        GitLabPagedOp::new(self.inner.clone(), endpoint, pagination)
156    }
157}
158
159/// Build a [`GitLab`] client from an already configured [`AsyncGitlab`].
160///
161/// Use this when [`GitLab::new`] is not flexible enough -- for example to
162/// point the client at an HTTP test double (wiremock) or a self-hosted
163/// instance with custom TLS settings, by building the client directly via
164/// [`GitlabBuilder`] and converting the result.
165///
166/// # Examples
167///
168/// ```no_run
169/// use ironflow_ops_gitlab::GitLab;
170/// use gitlab::GitlabBuilder;
171///
172/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
173/// // `.insecure()` allows plain HTTP, e.g. against a wiremock server in tests.
174/// let client = GitlabBuilder::new("gitlab.example.com", "glpat-xxxx")
175///     .insecure()
176///     .build_async()
177///     .await?;
178/// let gitlab: GitLab = client.into();
179/// # Ok(())
180/// # }
181/// ```
182impl From<AsyncGitlab> for GitLab {
183    fn from(inner: AsyncGitlab) -> Self {
184        Self { inner }
185    }
186}
187
188impl std::fmt::Debug for GitLab {
189    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190        f.debug_struct("GitLab")
191            .field("client", &"[AsyncGitlab]")
192            .finish()
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use std::sync::Arc;
199
200    use gitlab::GitlabBuilder;
201    use gitlab::api::projects;
202    use gitlab::api::projects::merge_requests::MergeRequests;
203    use ironflow_core::operation::{NoopSecretResolver, Operation, OperationContext};
204    use serde_json::{Value, json};
205    use wiremock::matchers::{method, path, query_param};
206    use wiremock::{Mock, MockServer, ResponseTemplate};
207
208    use super::*;
209
210    fn ctx() -> OperationContext {
211        OperationContext::new(Arc::new(NoopSecretResolver))
212    }
213
214    async fn insecure_client(server: &MockServer) -> AsyncGitlab {
215        Mock::given(method("GET"))
216            .and(path("/api/v4/user"))
217            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": 1})))
218            .mount(server)
219            .await;
220
221        GitlabBuilder::new(server.address().to_string(), "token")
222            .insecure()
223            .build_async()
224            .await
225            .unwrap()
226    }
227
228    #[tokio::test]
229    #[ignore]
230    async fn new_builds_with_valid_host() {
231        let gitlab = GitLab::new("glpat-xxxx", "gitlab.com").await;
232        assert!(gitlab.is_ok());
233    }
234
235    #[tokio::test]
236    #[ignore]
237    async fn debug_does_not_leak_token() {
238        let gitlab = GitLab::new("super-secret", "gitlab.com").await.unwrap();
239        let debug = format!("{gitlab:?}");
240        assert!(!debug.contains("super-secret"));
241    }
242
243    #[tokio::test]
244    async fn from_context_fails_when_token_missing() {
245        let ctx = OperationContext::new(Arc::new(NoopSecretResolver));
246        let err = GitLab::from_context(&ctx).await.unwrap_err();
247        assert!(err.to_string().contains("gitlab_token"));
248    }
249
250    #[tokio::test]
251    async fn from_async_gitlab_hits_mock_server() {
252        let server = MockServer::start().await;
253        Mock::given(method("GET"))
254            .and(path("/api/v4/user"))
255            .respond_with(
256                ResponseTemplate::new(200).set_body_string(r#"{"id":1,"username":"demo"}"#),
257            )
258            .mount(&server)
259            .await;
260        Mock::given(method("GET"))
261            .and(path("/api/v4/projects/42"))
262            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"id":42,"name":"demo"}"#))
263            .mount(&server)
264            .await;
265
266        let client = GitlabBuilder::new(server.address().to_string(), "test-token")
267            .insecure()
268            .build_async()
269            .await
270            .unwrap();
271        let gitlab: GitLab = client.into();
272
273        let endpoint = projects::Project::builder().project(42).build().unwrap();
274        let op = gitlab.op(endpoint);
275        let ctx = OperationContext::new(Arc::new(NoopSecretResolver));
276        let result = op.execute(&ctx).await.unwrap();
277
278        assert_eq!(result["id"], 42);
279        assert_eq!(result["name"], "demo");
280
281        let requests = server.received_requests().await.unwrap();
282        assert_eq!(requests.len(), 2);
283        let paths: Vec<&str> = requests.iter().map(|r| r.url.path()).collect();
284        assert!(paths.contains(&"/api/v4/user"));
285        assert!(paths.contains(&"/api/v4/projects/42"));
286    }
287
288    #[tokio::test]
289    async fn paged_op_all_pagination_concatenates_every_page() {
290        let server = MockServer::start().await;
291        let page1: Vec<Value> = (0..100).map(|i| json!({"iid": i})).collect();
292        let page2: Vec<Value> = vec![
293            json!({"iid": 100}),
294            json!({"iid": 101}),
295            json!({"iid": 102}),
296        ];
297
298        Mock::given(method("GET"))
299            .and(path("/api/v4/projects/42/merge_requests"))
300            .and(query_param("page", "1"))
301            .and(query_param("per_page", "100"))
302            .respond_with(ResponseTemplate::new(200).set_body_json(&page1))
303            .mount(&server)
304            .await;
305        Mock::given(method("GET"))
306            .and(path("/api/v4/projects/42/merge_requests"))
307            .and(query_param("page", "2"))
308            .and(query_param("per_page", "100"))
309            .respond_with(ResponseTemplate::new(200).set_body_json(&page2))
310            .mount(&server)
311            .await;
312
313        let client = insecure_client(&server).await;
314        let gitlab = GitLab { inner: client };
315        let endpoint = MergeRequests::builder().project(42).build().unwrap();
316        let op = gitlab.paged_op(endpoint, Pagination::All);
317
318        let result = op.execute(&ctx()).await.unwrap();
319        let items = result.as_array().unwrap();
320        assert_eq!(items.len(), 103);
321        assert_eq!(items[0]["iid"], 0);
322        assert_eq!(items[100]["iid"], 100);
323        assert_eq!(items[102]["iid"], 102);
324    }
325
326    #[tokio::test]
327    async fn paged_op_limit_truncates_output() {
328        let server = MockServer::start().await;
329        let page: Vec<Value> = vec![json!({"iid": 0})];
330
331        Mock::given(method("GET"))
332            .and(path("/api/v4/projects/42/merge_requests"))
333            .and(query_param("page", "1"))
334            .and(query_param("per_page", "1"))
335            .respond_with(ResponseTemplate::new(200).set_body_json(&page))
336            .mount(&server)
337            .await;
338
339        let client = insecure_client(&server).await;
340        let gitlab = GitLab { inner: client };
341        let endpoint = MergeRequests::builder().project(42).build().unwrap();
342        let op = gitlab.paged_op(endpoint, Pagination::Limit(1));
343
344        let result = op.execute(&ctx()).await.unwrap();
345        let items = result.as_array().unwrap();
346        assert_eq!(items.len(), 1);
347    }
348
349    #[tokio::test]
350    async fn paged_op_kind_and_input() {
351        let server = MockServer::start().await;
352        let client = insecure_client(&server).await;
353        let gitlab = GitLab { inner: client };
354        let endpoint = MergeRequests::builder().project(42).build().unwrap();
355        let op = gitlab.paged_op(endpoint, Pagination::Limit(5));
356
357        assert_eq!(op.kind(), "gitlab");
358        let input = op.input().unwrap();
359        assert_eq!(input["endpoint"], "projects/42/merge_requests");
360        assert_eq!(input["pagination"], "limit(5)");
361    }
362}