torii_lib/platforms/gitlab/
package.rs1use 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#[cfg(test)]
139mod tests {
140 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}