* Copyright(c) 2024-2026 China Telecom Cloud Technologies Co., Ltd. All rights
* reserved. ctscat is licensed under Mulan PSL v2. You can use this software
* according to the terms and conditions of the Mulan PSL V2. You may obtain a
* copy of Mulan PSL v2 at: http://license.coscl.org.cn/MulanPSL2.
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY
* KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
* MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. See the Mulan PSL v2 for
* more details.
*/
use async_trait::async_trait;
use reqwest::Client;
use std::time::Duration;
use crate::collectors::{
error::{ApiError, ApiResult},
traits::{Branch, Collector, Commit, CommitsParams, FileContent, GitClient, Repository},
};
use super::models::{GitLabBranch, GitLabCommit, GitLabFileContent, GitLabRepository};
const GITLAB_API_BASE: &str = "https://gitlab.com/api/v4";
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_RETRIES: u32 = 3;
pub struct GitLabClient {
client: Client,
token: Option<String>,
base_url: String,
}
impl GitLabClient {
pub fn new(token: impl Into<String>) -> ApiResult<Self> {
Self::with_base_url(GITLAB_API_BASE, token)
}
pub fn with_base_url(base_url: impl Into<String>, token: impl Into<String>) -> ApiResult<Self> {
let client = Client::builder()
.timeout(DEFAULT_TIMEOUT)
.user_agent("track-system/0.1.0")
.no_proxy()
.build()?;
let token_str = token.into();
let token_opt = if token_str.is_empty() {
None
} else {
Some(token_str)
};
Ok(Self {
client,
token: token_opt,
base_url: base_url.into(),
})
}
pub fn with_config(
base_url: impl Into<String>,
token: impl Into<String>,
timeout: Duration,
) -> ApiResult<Self> {
let client = Client::builder()
.timeout(timeout)
.user_agent("track-system/0.1.0")
.no_proxy()
.build()?;
let token_str = token.into();
let token_opt = if token_str.is_empty() {
None
} else {
Some(token_str)
};
Ok(Self {
client,
token: token_opt,
base_url: base_url.into(),
})
}
pub fn as_collector(self) -> impl Collector {
use crate::collectors::{adapters::GitClientCollectorAdapter, traits::Platform};
GitClientCollectorAdapter::new(self, Platform::GitLab)
}
fn encode_project_path(&self, owner: &str, repo: &str) -> String {
let path = format!("{}/{}", owner, repo);
urlencoding::encode(&path).to_string()
}
async fn get<T: serde::de::DeserializeOwned>(&self, url: &str) -> ApiResult<T> {
let mut retries = 0;
loop {
let mut request = self.client.get(url);
if let Some(token) = &self.token {
request = request.header("PRIVATE-TOKEN", token);
}
let response = request.send().await?;
let status = response.status();
if status.is_success() {
return response.json::<T>().await.map_err(ApiError::from);
}
let body = response.text().await.unwrap_or_default();
let message = if let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) {
json.get("message")
.and_then(|m| m.as_str())
.unwrap_or(body.as_str())
.to_string()
} else {
body
};
let error = ApiError::from_status(status.as_u16(), message.clone());
if error.is_retryable() && retries < MAX_RETRIES {
retries += 1;
tokio::time::sleep(Duration::from_secs(2u64.pow(retries))).await;
continue;
}
return Err(error);
}
}
}
#[async_trait]
impl GitClient for GitLabClient {
async fn get_repository(&self, owner: &str, repo: &str) -> ApiResult<Repository> {
let project_path = self.encode_project_path(owner, repo);
let url = format!("{}/projects/{}", self.base_url, project_path);
let gitlab_repo: GitLabRepository = self.get(&url).await?;
Ok(gitlab_repo.into())
}
async fn get_branches(&self, owner: &str, repo: &str) -> ApiResult<Vec<Branch>> {
let project_path = self.encode_project_path(owner, repo);
let url = format!(
"{}/projects/{}/repository/branches",
self.base_url, project_path
);
let gitlab_branches: Vec<GitLabBranch> = self.get(&url).await?;
Ok(gitlab_branches.into_iter().map(|b| b.into()).collect())
}
async fn get_commits(
&self,
owner: &str,
repo: &str,
params: CommitsParams,
) -> ApiResult<Vec<Commit>> {
let project_path = self.encode_project_path(owner, repo);
let mut url = format!(
"{}/projects/{}/repository/commits?ref_name={}&per_page={}&page={}",
self.base_url, project_path, params.branch, params.per_page, params.page
);
if let Some(since) = params.since {
url.push_str(&format!("&since={}", since.to_rfc3339()));
}
if let Some(until) = params.until {
url.push_str(&format!("&until={}", until.to_rfc3339()));
}
url.push_str("&with_stats=true");
let gitlab_commits: Vec<GitLabCommit> = self.get(&url).await?;
Ok(gitlab_commits.into_iter().map(|c| c.into()).collect())
}
async fn get_file_content(
&self,
owner: &str,
repo: &str,
path: &str,
branch: &str,
) -> ApiResult<FileContent> {
let project_path = self.encode_project_path(owner, repo);
let file_path = urlencoding::encode(path);
let url = format!(
"{}/projects/{}/repository/files/{}?ref={}",
self.base_url, project_path, file_path, branch
);
let gitlab_file: GitLabFileContent = self.get(&url).await?;
Ok(gitlab_file.into())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::collectors::traits::CommitsParams;
use httpmock::prelude::*;
use serde_json::json;
#[test]
fn test_encode_project_path() {
let client = GitLabClient::new("test-token").unwrap();
let encoded = client.encode_project_path("gitlab-org", "gitlab");
assert_eq!(encoded, "gitlab-org%2Fgitlab");
}
#[test]
fn test_client_creation() {
let client = GitLabClient::new("test-token");
assert!(client.is_ok());
let client = client.unwrap();
assert_eq!(client.base_url, GITLAB_API_BASE);
assert!(client.token.is_some());
}
#[test]
fn test_custom_base_url() {
let client = GitLabClient::with_base_url("https://gitlab.example.com/api/v4", "test-token");
assert!(client.is_ok());
let client = client.unwrap();
assert_eq!(client.base_url, "https://gitlab.example.com/api/v4");
}
#[tokio::test]
async fn test_gitlab_client_as_collector() {
let client = GitLabClient::new("token").unwrap();
let _collector = client.as_collector();
}
#[tokio::test]
async fn test_get_repository() {
let server = MockServer::start();
let client = GitLabClient::with_base_url(server.base_url(), "token").unwrap();
let repo_response = json!({
"id": 1,
"name": "test-repo",
"path_with_namespace": "owner/test-repo",
"web_url": "http://localhost/owner/test-repo",
"description": "test repo",
"visibility": "public",
"created_at": "2023-01-01T00:00:00Z",
"last_activity_at": "2023-01-01T00:00:00Z",
"default_branch": "main",
"http_url_to_repo": "http://localhost/owner/test-repo.git"
});
let mock = server.mock(|when, then| {
when.method(GET)
.path("/projects/owner%2Ftest-repo")
.header("PRIVATE-TOKEN", "token");
then.status(200).json_body(repo_response);
});
let result = client.get_repository("owner", "test-repo").await;
mock.assert();
assert!(result.is_ok());
let repo = result.unwrap();
assert_eq!(repo.name, "test-repo");
}
#[tokio::test]
async fn test_get_branches() {
let server = MockServer::start();
let client = GitLabClient::with_base_url(server.base_url(), "token").unwrap();
let branch_response = json!([
{
"name": "main",
"commit": {
"id": "sha",
"web_url": "url"
},
"protected": true
}
]);
let mock = server.mock(|when, then| {
when.method(GET)
.path("/projects/owner%2Ftest-repo/repository/branches")
.header("PRIVATE-TOKEN", "token");
then.status(200).json_body(branch_response);
});
let result = client.get_branches("owner", "test-repo").await;
mock.assert();
assert!(result.is_ok());
let branches = result.unwrap();
assert_eq!(branches.len(), 1);
assert_eq!(branches[0].name, "main");
}
#[tokio::test]
async fn test_get_commits() {
let server = MockServer::start();
let client = GitLabClient::with_base_url(server.base_url(), "token").unwrap();
let commits_response = json!([
{
"id": "sha",
"short_id": "short_sha",
"message": "message",
"author_name": "author",
"author_email": "email",
"authored_date": "2023-01-01T00:00:00Z",
"committer_name": "committer",
"committer_email": "email",
"committed_date": "2023-01-01T00:00:00Z",
"title": "title",
"web_url": "url",
"stats": {
"total": 10,
"additions": 5,
"deletions": 5
}
}
]);
let _mock = server.mock(|when, then| {
when.method(GET)
.path("/projects/owner%2Ftest-repo/repository/commits")
.query_param("ref_name", "main")
.query_param("page", "1")
.query_param("per_page", "30")
.query_param("with_stats", "true")
.header("PRIVATE-TOKEN", "token");
then.status(200).json_body(commits_response);
});
let params = CommitsParams::new("main");
let result = client.get_commits("owner", "test-repo", params).await;
assert!(result.is_ok(), "Result error: {:?}", result.err());
let commits = result.unwrap();
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].sha, "sha");
}
#[tokio::test]
async fn test_get_file_content() {
let server = MockServer::start();
let client = GitLabClient::with_base_url(server.base_url(), "token").unwrap();
let file_response = json!({
"file_name": "file.txt",
"file_path": "file.txt",
"commit_id": "sha",
"size": 100,
"content": "SGVsbG8gV29ybGQ=",
"encoding": "base64",
"blob_id": "blob_id",
"content_sha256": "sha256",
"ref_name": "main"
});
let _mock = server.mock(|when, then| {
when.method(GET)
.path("/projects/owner%2Ftest-repo/repository/files/file.txt")
.query_param("ref", "main")
.header("PRIVATE-TOKEN", "token");
then.status(200).json_body(file_response);
});
let result = client
.get_file_content("owner", "test-repo", "file.txt", "main")
.await;
assert!(result.is_ok(), "Result error: {:?}", result.err());
let file = result.unwrap();
assert_eq!(file.name, "file.txt");
}
#[tokio::test]
async fn test_retry_mechanism() {
let server = MockServer::start();
let client = GitLabClient::with_base_url(server.base_url(), "token").unwrap();
let mock = server.mock(|when, then| {
when.method(GET).path("/projects/owner%2Ftest-repo");
then.status(500).body("Internal Server Error");
});
let result = client.get_repository("owner", "test-repo").await;
assert!(result.is_err());
mock.assert_calls(4);
}
}