lib_client_gitlab/
client.rs

1use reqwest::header::HeaderMap;
2use serde::de::DeserializeOwned;
3use std::sync::Arc;
4use tracing::{debug, warn};
5
6use crate::auth::AuthStrategy;
7use crate::error::{Error, Result};
8use crate::types::*;
9
10const DEFAULT_BASE_URL: &str = "https://gitlab.com/api/v4";
11
12pub struct ClientBuilder<A> {
13    auth: A,
14    base_url: String,
15}
16
17impl ClientBuilder<()> {
18    pub fn new() -> Self {
19        Self {
20            auth: (),
21            base_url: DEFAULT_BASE_URL.to_string(),
22        }
23    }
24
25    pub fn auth<S: AuthStrategy + 'static>(self, auth: S) -> ClientBuilder<S> {
26        ClientBuilder {
27            auth,
28            base_url: self.base_url,
29        }
30    }
31}
32
33impl Default for ClientBuilder<()> {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl<A: AuthStrategy + 'static> ClientBuilder<A> {
40    pub fn base_url(mut self, url: impl Into<String>) -> Self {
41        self.base_url = url.into();
42        self
43    }
44
45    pub fn build(self) -> Client {
46        Client {
47            http: reqwest::Client::new(),
48            auth: Arc::new(self.auth),
49            base_url: self.base_url,
50        }
51    }
52}
53
54#[derive(Clone)]
55pub struct Client {
56    http: reqwest::Client,
57    auth: Arc<dyn AuthStrategy>,
58    base_url: String,
59}
60
61impl Client {
62    pub fn builder() -> ClientBuilder<()> {
63        ClientBuilder::new()
64    }
65
66    async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
67        self.request(reqwest::Method::GET, path, None::<&()>).await
68    }
69
70    async fn post<T: DeserializeOwned, B: serde::Serialize>(
71        &self,
72        path: &str,
73        body: &B,
74    ) -> Result<T> {
75        self.request(reqwest::Method::POST, path, Some(body)).await
76    }
77
78    async fn put<T: DeserializeOwned, B: serde::Serialize>(
79        &self,
80        path: &str,
81        body: &B,
82    ) -> Result<T> {
83        self.request(reqwest::Method::PUT, path, Some(body)).await
84    }
85
86    async fn request<T: DeserializeOwned>(
87        &self,
88        method: reqwest::Method,
89        path: &str,
90        body: Option<&impl serde::Serialize>,
91    ) -> Result<T> {
92        let url = format!("{}{}", self.base_url, path);
93        debug!("GitLab API request: {} {}", method, url);
94
95        let mut headers = HeaderMap::new();
96        self.auth.apply(&mut headers).await?;
97
98        let mut request = self.http.request(method, &url).headers(headers);
99
100        if let Some(body) = body {
101            request = request.json(body);
102        }
103
104        let response = request.send().await?;
105        self.handle_response(response).await
106    }
107
108    async fn handle_response<T: DeserializeOwned>(&self, response: reqwest::Response) -> Result<T> {
109        let status = response.status();
110
111        if status.is_success() {
112            let body = response.text().await?;
113            serde_json::from_str(&body).map_err(Error::from)
114        } else {
115            let status_code = status.as_u16();
116            let body = response.text().await.unwrap_or_default();
117            warn!("GitLab API error ({}): {}", status_code, body);
118
119            match status_code {
120                401 => Err(Error::Unauthorized),
121                403 => Err(Error::Forbidden(body)),
122                404 => Err(Error::NotFound(body)),
123                409 => Err(Error::Conflict(body)),
124                429 => {
125                    let retry_after = 60;
126                    Err(Error::RateLimited { retry_after })
127                }
128                _ => Err(Error::Api {
129                    status: status_code,
130                    message: body,
131                }),
132            }
133        }
134    }
135
136    fn encode_project(&self, project: &str) -> String {
137        urlencoding::encode(project).to_string()
138    }
139
140    /// Get a project by ID or path.
141    pub async fn get_project(&self, project: &str) -> Result<Project> {
142        let encoded = self.encode_project(project);
143        self.get(&format!("/projects/{}", encoded)).await
144    }
145
146    /// List merge requests for a project.
147    pub async fn list_merge_requests(
148        &self,
149        project: &str,
150        state: Option<MergeRequestState>,
151    ) -> Result<Vec<MergeRequest>> {
152        let encoded = self.encode_project(project);
153        let state_param = state
154            .map(|s| format!("?state={:?}", s).to_lowercase())
155            .unwrap_or_default();
156        self.get(&format!("/projects/{}/merge_requests{}", encoded, state_param))
157            .await
158    }
159
160    /// Get a merge request by IID.
161    pub async fn get_merge_request(&self, project: &str, mr_iid: u64) -> Result<MergeRequest> {
162        let encoded = self.encode_project(project);
163        self.get(&format!("/projects/{}/merge_requests/{}", encoded, mr_iid))
164            .await
165    }
166
167    /// Create a merge request.
168    pub async fn create_merge_request(
169        &self,
170        project: &str,
171        input: CreateMergeRequestInput,
172    ) -> Result<MergeRequest> {
173        let encoded = self.encode_project(project);
174        self.post(&format!("/projects/{}/merge_requests", encoded), &input)
175            .await
176    }
177
178    /// List issues for a project.
179    pub async fn list_issues(
180        &self,
181        project: &str,
182        state: Option<IssueState>,
183    ) -> Result<Vec<Issue>> {
184        let encoded = self.encode_project(project);
185        let state_param = state
186            .map(|s| format!("?state={:?}", s).to_lowercase())
187            .unwrap_or_default();
188        self.get(&format!("/projects/{}/issues{}", encoded, state_param))
189            .await
190    }
191
192    /// Get an issue by IID.
193    pub async fn get_issue(&self, project: &str, issue_iid: u64) -> Result<Issue> {
194        let encoded = self.encode_project(project);
195        self.get(&format!("/projects/{}/issues/{}", encoded, issue_iid))
196            .await
197    }
198
199    /// Create an issue.
200    pub async fn create_issue(&self, project: &str, input: CreateIssueInput) -> Result<Issue> {
201        let encoded = self.encode_project(project);
202        self.post(&format!("/projects/{}/issues", encoded), &input)
203            .await
204    }
205
206    /// List pipelines for a project.
207    pub async fn list_pipelines(&self, project: &str) -> Result<Vec<Pipeline>> {
208        let encoded = self.encode_project(project);
209        self.get(&format!("/projects/{}/pipelines", encoded)).await
210    }
211
212    /// Get a pipeline by ID.
213    pub async fn get_pipeline(&self, project: &str, pipeline_id: u64) -> Result<Pipeline> {
214        let encoded = self.encode_project(project);
215        self.get(&format!("/projects/{}/pipelines/{}", encoded, pipeline_id))
216            .await
217    }
218
219    /// List jobs for a pipeline.
220    pub async fn list_pipeline_jobs(&self, project: &str, pipeline_id: u64) -> Result<Vec<Job>> {
221        let encoded = self.encode_project(project);
222        self.get(&format!(
223            "/projects/{}/pipelines/{}/jobs",
224            encoded, pipeline_id
225        ))
226        .await
227    }
228
229    /// List branches for a project.
230    pub async fn list_branches(&self, project: &str) -> Result<Vec<Branch>> {
231        let encoded = self.encode_project(project);
232        self.get(&format!("/projects/{}/repository/branches", encoded))
233            .await
234    }
235
236    /// Get a file from the repository.
237    pub async fn get_file(&self, project: &str, path: &str, git_ref: &str) -> Result<FileContent> {
238        let encoded_project = self.encode_project(project);
239        let encoded_path = urlencoding::encode(path);
240        self.get(&format!(
241            "/projects/{}/repository/files/{}?ref={}",
242            encoded_project, encoded_path, git_ref
243        ))
244        .await
245    }
246
247    /// List commits for a project.
248    pub async fn list_commits(&self, project: &str, git_ref: Option<&str>) -> Result<Vec<Commit>> {
249        let encoded = self.encode_project(project);
250        let ref_param = git_ref
251            .map(|r| format!("?ref_name={}", r))
252            .unwrap_or_default();
253        self.get(&format!(
254            "/projects/{}/repository/commits{}",
255            encoded, ref_param
256        ))
257        .await
258    }
259
260    /// Get the authenticated user.
261    pub async fn current_user(&self) -> Result<User> {
262        self.get("/user").await
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::auth::PrivateTokenAuth;
270
271    #[test]
272    fn test_builder() {
273        let client = Client::builder()
274            .auth(PrivateTokenAuth::new("glpat-xxx"))
275            .base_url("https://gitlab.example.com/api/v4")
276            .build();
277        assert_eq!(client.base_url, "https://gitlab.example.com/api/v4");
278    }
279
280    #[test]
281    fn test_encode_project() {
282        let client = Client::builder()
283            .auth(PrivateTokenAuth::new("test"))
284            .build();
285        assert_eq!(
286            client.encode_project("group/project"),
287            "group%2Fproject"
288        );
289    }
290}