Skip to main content

torii_lib/platforms/gitlab/
package.rs

1//! GitLab — package client.
2
3use crate::error::{Result, ToriiError};
4use crate::platforms::package::*;
5use reqwest::blocking::Client;
6
7pub struct GitLabPackageClient {
8    token: String,
9    base_url: String,
10}
11
12impl GitLabPackageClient {
13    pub fn new() -> Result<Self> {
14        let token = crate::auth::resolve_token("gitlab", ".")
15            .value
16            .ok_or_else(|| ToriiError::Auth {
17                provider: "gitlab".into(),
18                message: "GitLab token not found. Run: torii auth set gitlab YOUR_TOKEN"
19                    .to_string(),
20            })?;
21        let base_url =
22            std::env::var("GITLAB_URL").unwrap_or_else(|_| "https://gitlab.com/api/v4".to_string());
23        Ok(Self { token, base_url })
24    }
25
26    fn client(&self) -> Client {
27        crate::http::make_client()
28    }
29
30    fn project_path(owner: &str, repo: &str) -> String {
31        crate::url::encode(&format!("{}/{}", owner, repo))
32    }
33}
34
35impl PackageClient for GitLabPackageClient {
36    fn list(&self, owner: &str, repo: &str, filters: &PackageListFilters) -> Result<Vec<Package>> {
37        let mut url = format!(
38            "{}/projects/{}/packages?per_page={}",
39            self.base_url,
40            Self::project_path(owner, repo),
41            filters.per_page.clamp(1, 100)
42        );
43        if let Some(t) = &filters.package_type {
44            url.push_str(&format!("&package_type={}", t));
45        }
46        if let Some(n) = &filters.name_search {
47            url.push_str(&format!("&package_name={}", crate::url::encode(n)));
48        }
49        let req = self
50            .client()
51            .get(&url)
52            .header("Authorization", format!("Bearer {}", self.token));
53        let json = crate::http::send_json(req, &format!("GitLab (url: {})", url))?;
54        crate::http::extract_array(&json, &url)?
55            .iter()
56            .map(parse_gitlab_package)
57            .collect()
58    }
59
60    fn delete(&self, owner: &str, repo: &str, id: &str) -> Result<()> {
61        let url = format!(
62            "{}/projects/{}/packages/{}",
63            self.base_url,
64            Self::project_path(owner, repo),
65            id
66        );
67        let req = self
68            .client()
69            .delete(&url)
70            .header("Authorization", format!("Bearer {}", self.token));
71        crate::http::send_empty(req, "GitLab delete package")
72    }
73
74    fn list_files(&self, owner: &str, repo: &str, id: &str) -> Result<Vec<PackageFile>> {
75        let url = format!(
76            "{}/projects/{}/packages/{}/package_files?per_page=100",
77            self.base_url,
78            Self::project_path(owner, repo),
79            id
80        );
81        let req = self
82            .client()
83            .get(&url)
84            .header("Authorization", format!("Bearer {}", self.token));
85        let json = crate::http::send_json(req, &format!("GitLab (url: {})", url))?;
86        crate::http::extract_array(&json, &url)?
87            .iter()
88            .map(|v| parse_gitlab_package_file(v, id))
89            .collect()
90    }
91}
92
93pub(crate) fn parse_gitlab_package(v: &serde_json::Value) -> Result<Package> {
94    let id = v["id"]
95        .as_u64()
96        .map(|n| n.to_string())
97        .or_else(|| v["id"].as_str().map(String::from))
98        .ok_or_else(|| ToriiError::MalformedResponse {
99            provider: "gitlab".into(),
100            message: "GitLab package missing id".into(),
101        })?;
102    Ok(Package {
103        id,
104        name: v["name"].as_str().unwrap_or("").to_string(),
105        version: v["version"].as_str().unwrap_or("").to_string(),
106        package_type: v["package_type"].as_str().unwrap_or("").to_string(),
107        status: v["status"].as_str().unwrap_or("").to_string(),
108        created_at: v["created_at"].as_str().unwrap_or("").to_string(),
109        web_url: v["_links"]["web_path"].as_str().unwrap_or("").to_string(),
110    })
111}
112
113pub(crate) fn parse_gitlab_package_file(
114    v: &serde_json::Value,
115    package_id: &str,
116) -> Result<PackageFile> {
117    let id = v["id"]
118        .as_u64()
119        .map(|n| n.to_string())
120        .or_else(|| v["id"].as_str().map(String::from))
121        .ok_or_else(|| ToriiError::MalformedResponse {
122            provider: "gitlab".into(),
123            message: "GitLab package_file missing id".into(),
124        })?;
125    Ok(PackageFile {
126        id,
127        package_id: package_id.to_string(),
128        file_name: v["file_name"].as_str().unwrap_or("").to_string(),
129        size_bytes: v["size"].as_u64().unwrap_or(0),
130        created_at: v["created_at"].as_str().unwrap_or("").to_string(),
131    })
132}
133
134// ============================================================================
135// Factory + helpers
136// ============================================================================
137
138#[cfg(test)]
139mod tests {
140    // NOTE: `parse_gitlab_package` / `parse_gitlab_package_file` are already
141    // covered by the tests in `src/platforms/package.rs` — only the HTTP
142    // client is tested here.
143    use super::*;
144    use httpmock::prelude::*;
145
146    fn client(server: &MockServer) -> GitLabPackageClient {
147        GitLabPackageClient {
148            token: "test-token".into(),
149            base_url: server.base_url(),
150        }
151    }
152
153    #[test]
154    fn list_passes_type_and_name_filters() {
155        let server = MockServer::start();
156        let m = server.mock(|when, then| {
157            when.method(GET)
158                .path("/projects/acme%2Fwidget/packages")
159                .query_param("per_page", "20")
160                .query_param("package_type", "generic")
161                .query_param("package_name", "torii")
162                .header("Authorization", "Bearer test-token");
163            then.status(200).json_body(serde_json::json!([{
164                "id": 12345u64, "name": "torii", "version": "v0.9.2",
165                "package_type": "generic", "status": "default",
166                "created_at": "", "_links": { "web_path": "/acme/widget/-/packages/12345" }
167            }]));
168        });
169        let filters = PackageListFilters {
170            package_type: Some("generic".into()),
171            name_search: Some("torii".into()),
172            per_page: 20,
173        };
174        let packages = client(&server).list("acme", "widget", &filters).unwrap();
175        m.assert();
176        assert_eq!(packages.len(), 1);
177        assert_eq!(packages[0].id, "12345");
178        assert_eq!(packages[0].package_type, "generic");
179    }
180
181    #[test]
182    fn list_files_parses_package_files() {
183        let server = MockServer::start();
184        let m = server.mock(|when, then| {
185            when.method(GET)
186                .path("/projects/acme%2Fwidget/packages/12345/package_files")
187                .header("Authorization", "Bearer test-token");
188            then.status(200).json_body(serde_json::json!([{
189                "id": 99u64, "file_name": "torii-linux-x86_64",
190                "size": 1024u64, "created_at": ""
191            }]));
192        });
193        let files = client(&server)
194            .list_files("acme", "widget", "12345")
195            .unwrap();
196        m.assert();
197        assert_eq!(files.len(), 1);
198        assert_eq!(files[0].package_id, "12345");
199        assert_eq!(files[0].file_name, "torii-linux-x86_64");
200    }
201
202    #[test]
203    fn delete_sends_delete_with_bearer_auth() {
204        let server = MockServer::start();
205        let m = server.mock(|when, then| {
206            when.method(DELETE)
207                .path("/projects/acme%2Fwidget/packages/12345")
208                .header("Authorization", "Bearer test-token");
209            then.status(204);
210        });
211        client(&server).delete("acme", "widget", "12345").unwrap();
212        m.assert();
213    }
214
215    #[test]
216    fn list_files_non_2xx_maps_to_platform_api_error() {
217        let server = MockServer::start();
218        server.mock(|when, then| {
219            when.method(GET)
220                .path("/projects/acme%2Fwidget/packages/12345/package_files");
221            then.status(500)
222                .json_body(serde_json::json!({ "message": "boom" }));
223        });
224        let err = client(&server)
225            .list_files("acme", "widget", "12345")
226            .unwrap_err();
227        assert!(
228            matches!(err, ToriiError::PlatformApi { .. }),
229            "expected PlatformApi, got: {err:?}"
230        );
231    }
232}