1use 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
15pub struct GitLabClient {
17 http: Client,
18 base_url: String,
19 auth: Arc<RwLock<BoxedAuthProvider>>,
20 max_retries: u32,
21}
22
23impl GitLabClient {
24 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 fn url(&self, path: &str) -> String {
50 format!("{}{}", self.base_url, path)
51 }
52
53 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 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 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 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 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 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 let body = response.text().await.unwrap_or_default();
110
111 if status == StatusCode::TOO_MANY_REQUESTS {
113 let retry_after = 60; return Err(GitLabError::RateLimited { retry_after });
116 }
117
118 Err(GitLabError::from_response(status.as_u16(), &body))
119 }
120
121 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 #[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 pub async fn get_json(&self, endpoint: &str) -> GitLabResult<serde_json::Value> {
144 self.get(endpoint).await
145 }
146
147 #[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 #[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 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 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 #[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 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 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 #[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 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 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 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 pub fn encode_project(project: &str) -> String {
298 urlencoding::encode(project).to_string()
299 }
300}
301
302fn 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}