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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}