tanuki_mcp/gitlab/
client.rs

1//! GitLab API client
2//!
3//! Provides a typed HTTP client for interacting with the GitLab REST API.
4
5use crate::auth::BoxedAuthProvider;
6use crate::config::GitLabConfig;
7use crate::error::{GitLabError, GitLabResult};
8use reqwest::{Client, Method, RequestBuilder, Response, StatusCode};
9use serde::{Serialize, de::DeserializeOwned};
10use std::sync::Arc;
11use std::time::Duration;
12use tokio::sync::RwLock;
13use tracing::{debug, instrument, warn};
14
15/// GitLab API client
16pub struct GitLabClient {
17    http: Client,
18    base_url: String,
19    auth: Arc<RwLock<BoxedAuthProvider>>,
20    max_retries: u32,
21}
22
23impl GitLabClient {
24    /// Create a new GitLab client from configuration
25    pub fn new(config: &GitLabConfig, auth: BoxedAuthProvider) -> GitLabResult<Self> {
26        let http = Client::builder()
27            .timeout(Duration::from_secs(config.timeout_secs))
28            .pool_max_idle_per_host(10)
29            .pool_idle_timeout(Duration::from_secs(90))
30            .danger_accept_invalid_certs(!config.verify_ssl)
31            .user_agent(
32                config
33                    .user_agent
34                    .clone()
35                    .unwrap_or_else(|| format!("tanuki-mcp/{}", env!("CARGO_PKG_VERSION"))),
36            )
37            .build()
38            .map_err(GitLabError::Request)?;
39
40        Ok(Self {
41            http,
42            base_url: config.api_url(),
43            auth: Arc::new(RwLock::new(auth)),
44            max_retries: config.max_retries,
45        })
46    }
47
48    /// Build a URL for an API endpoint
49    fn url(&self, path: &str) -> String {
50        format!("{}{}", self.base_url, path)
51    }
52
53    /// Add authentication to a request
54    async fn authenticate(&self, request: RequestBuilder) -> GitLabResult<RequestBuilder> {
55        let auth = self.auth.read().await;
56        let header = auth.get_auth_header().await.map_err(|e| GitLabError::Api {
57            status: 401,
58            message: e.to_string(),
59        })?;
60
61        Ok(request.header(header.header_name(), header.header_value()))
62    }
63
64    /// Execute a request with retries
65    async fn execute(&self, request: RequestBuilder) -> GitLabResult<Response> {
66        let mut last_error = None;
67
68        for attempt in 0..=self.max_retries {
69            if attempt > 0 {
70                // Exponential backoff
71                let delay = Duration::from_millis(100 * 2u64.pow(attempt - 1));
72                tokio::time::sleep(delay).await;
73                debug!("Retrying request (attempt {})", attempt + 1);
74            }
75
76            // Clone the request for retry
77            let req = request
78                .try_clone()
79                .ok_or_else(|| GitLabError::InvalidResponse("Cannot clone request".to_string()))?;
80
81            match req.send().await {
82                Ok(response) => {
83                    return self.handle_response(response).await;
84                }
85                Err(e) => {
86                    warn!("Request failed: {}", e);
87                    last_error = Some(GitLabError::Request(e));
88
89                    // Only retry on connection/timeout errors
90                    if !is_retryable(last_error.as_ref().unwrap()) {
91                        break;
92                    }
93                }
94            }
95        }
96
97        Err(last_error.unwrap_or_else(|| GitLabError::InvalidResponse("Unknown error".to_string())))
98    }
99
100    /// Handle API response
101    async fn handle_response(&self, response: Response) -> GitLabResult<Response> {
102        let status = response.status();
103
104        if status.is_success() {
105            return Ok(response);
106        }
107
108        // Extract error details from response body
109        let body = response.text().await.unwrap_or_default();
110
111        // Check for rate limiting
112        if status == StatusCode::TOO_MANY_REQUESTS {
113            // Try to parse retry-after from response
114            let retry_after = 60; // Default
115            return Err(GitLabError::RateLimited { retry_after });
116        }
117
118        Err(GitLabError::from_response(status.as_u16(), &body))
119    }
120
121    /// Execute a request and parse the JSON response
122    async fn execute_and_parse<T: DeserializeOwned>(
123        &self,
124        request: RequestBuilder,
125    ) -> GitLabResult<T> {
126        let response = self.execute(request).await?;
127        response
128            .json()
129            .await
130            .map_err(|e| GitLabError::InvalidResponse(format!("Failed to parse response: {}", e)))
131    }
132
133    /// Make a GET request
134    #[instrument(skip(self), fields(endpoint = %endpoint))]
135    pub async fn get<T: DeserializeOwned>(&self, endpoint: &str) -> GitLabResult<T> {
136        let url = self.url(endpoint);
137        let request = self.http.get(&url);
138        let request = self.authenticate(request).await?;
139        self.execute_and_parse(request).await
140    }
141
142    /// Make a GET request returning raw JSON value
143    pub async fn get_json(&self, endpoint: &str) -> GitLabResult<serde_json::Value> {
144        self.get(endpoint).await
145    }
146
147    /// Make a GET request returning raw text (not JSON)
148    #[instrument(skip(self), fields(endpoint = %endpoint))]
149    pub async fn get_text(&self, endpoint: &str) -> GitLabResult<String> {
150        let url = self.url(endpoint);
151        let request = self.http.get(&url);
152        let request = self.authenticate(request).await?;
153
154        let response = self.execute(request).await?;
155        let text = response.text().await.map_err(|e| {
156            GitLabError::InvalidResponse(format!("Failed to read response text: {}", e))
157        })?;
158
159        Ok(text)
160    }
161
162    /// Make a POST request
163    #[instrument(skip(self, body), fields(endpoint = %endpoint))]
164    pub async fn post<T: DeserializeOwned, B: Serialize + ?Sized>(
165        &self,
166        endpoint: &str,
167        body: &B,
168    ) -> GitLabResult<T> {
169        let url = self.url(endpoint);
170        let request = self.http.post(&url).json(body);
171        let request = self.authenticate(request).await?;
172        self.execute_and_parse(request).await
173    }
174
175    /// Make a POST request returning raw JSON value
176    pub async fn post_json<B: Serialize + ?Sized>(
177        &self,
178        endpoint: &str,
179        body: &B,
180    ) -> GitLabResult<serde_json::Value> {
181        self.post(endpoint, body).await
182    }
183
184    /// Make a POST request that expects no content in response (HTTP 204)
185    pub async fn post_no_content<B: Serialize + ?Sized>(
186        &self,
187        endpoint: &str,
188        body: &B,
189    ) -> GitLabResult<()> {
190        let url = self.url(endpoint);
191        let request = self.http.post(&url).json(body);
192        let request = self.authenticate(request).await?;
193
194        self.execute(request).await?;
195        Ok(())
196    }
197
198    /// Make a PUT request
199    #[instrument(skip(self, body), fields(endpoint = %endpoint))]
200    pub async fn put<T: DeserializeOwned, B: Serialize + ?Sized>(
201        &self,
202        endpoint: &str,
203        body: &B,
204    ) -> GitLabResult<T> {
205        let url = self.url(endpoint);
206        let request = self.http.put(&url).json(body);
207        let request = self.authenticate(request).await?;
208        self.execute_and_parse(request).await
209    }
210
211    /// Make a PUT request returning raw JSON value
212    pub async fn put_json<B: Serialize + ?Sized>(
213        &self,
214        endpoint: &str,
215        body: &B,
216    ) -> GitLabResult<serde_json::Value> {
217        self.put(endpoint, body).await
218    }
219
220    /// Make a PUT request that expects no content in response (HTTP 204)
221    pub async fn put_no_content<B: Serialize + ?Sized>(
222        &self,
223        endpoint: &str,
224        body: &B,
225    ) -> GitLabResult<()> {
226        let url = self.url(endpoint);
227        let request = self.http.put(&url).json(body);
228        let request = self.authenticate(request).await?;
229
230        self.execute(request).await?;
231        Ok(())
232    }
233
234    /// Make a DELETE request
235    #[instrument(skip(self), fields(endpoint = %endpoint))]
236    pub async fn delete(&self, endpoint: &str) -> GitLabResult<()> {
237        let url = self.url(endpoint);
238        let request = self.http.delete(&url);
239        let request = self.authenticate(request).await?;
240
241        self.execute(request).await?;
242        Ok(())
243    }
244
245    /// Make a DELETE request with a body
246    pub async fn delete_with_body<B: Serialize + ?Sized>(
247        &self,
248        endpoint: &str,
249        body: &B,
250    ) -> GitLabResult<()> {
251        let url = self.url(endpoint);
252        let request = self.http.delete(&url).json(body);
253        let request = self.authenticate(request).await?;
254
255        self.execute(request).await?;
256        Ok(())
257    }
258
259    /// Make a request with custom method
260    pub async fn request<T: DeserializeOwned>(
261        &self,
262        method: Method,
263        endpoint: &str,
264    ) -> GitLabResult<T> {
265        let url = self.url(endpoint);
266        let request = self.http.request(method, &url);
267        let request = self.authenticate(request).await?;
268
269        let response = self.execute(request).await?;
270        let data = response.json().await.map_err(|e| {
271            GitLabError::InvalidResponse(format!("Failed to parse response: {}", e))
272        })?;
273
274        Ok(data)
275    }
276
277    /// Make a request with custom method and body
278    pub async fn request_with_body<T: DeserializeOwned, B: Serialize + ?Sized>(
279        &self,
280        method: Method,
281        endpoint: &str,
282        body: &B,
283    ) -> GitLabResult<T> {
284        let url = self.url(endpoint);
285        let request = self.http.request(method, &url).json(body);
286        let request = self.authenticate(request).await?;
287
288        let response = self.execute(request).await?;
289        let data = response.json().await.map_err(|e| {
290            GitLabError::InvalidResponse(format!("Failed to parse response: {}", e))
291        })?;
292
293        Ok(data)
294    }
295
296    /// URL-encode a project path for use in API endpoints
297    pub fn encode_project(project: &str) -> String {
298        urlencoding::encode(project).to_string()
299    }
300}
301
302/// Check if an error is retryable
303fn is_retryable(error: &GitLabError) -> bool {
304    match error {
305        GitLabError::Request(e) => e.is_timeout() || e.is_connect(),
306        GitLabError::RateLimited { .. } => true,
307        GitLabError::Api { status, .. } => *status >= 500,
308        _ => false,
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn test_encode_project() {
318        assert_eq!(
319            GitLabClient::encode_project("group/project"),
320            "group%2Fproject"
321        );
322        assert_eq!(
323            GitLabClient::encode_project("group/subgroup/project"),
324            "group%2Fsubgroup%2Fproject"
325        );
326    }
327
328    #[test]
329    fn test_is_retryable() {
330        assert!(is_retryable(&GitLabError::RateLimited { retry_after: 60 }));
331        assert!(is_retryable(&GitLabError::Api {
332            status: 500,
333            message: "Internal error".to_string()
334        }));
335        assert!(is_retryable(&GitLabError::Api {
336            status: 503,
337            message: "Service unavailable".to_string()
338        }));
339        assert!(!is_retryable(&GitLabError::Api {
340            status: 400,
341            message: "Bad request".to_string()
342        }));
343        assert!(!is_retryable(&GitLabError::Unauthorized));
344    }
345}