1use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
2
3use crate::error::{DkpError, DkpResult};
4
5use super::types::{
6 ConfirmPublishResponse, DownloadUrlResponse, PackVersionResponse, PublishRequest,
7 PublishResponse, SearchResponse, VersionListResponse,
8};
9
10pub struct RegistryClient {
11 base_url: String,
12 token: Option<String>,
13 http: reqwest::Client,
14}
15
16impl RegistryClient {
17 pub fn new(base_url: impl Into<String>, token: Option<String>) -> Self {
18 Self {
19 base_url: base_url.into().trim_end_matches('/').to_owned(),
20 token,
21 http: reqwest::Client::builder()
22 .user_agent(concat!("dkp/", env!("CARGO_PKG_VERSION")))
23 .build()
24 .expect("failed to build HTTP client"),
25 }
26 }
27
28 fn url(&self, path: &str) -> String {
29 format!("{}/api/v1{}", self.base_url, path)
30 }
31
32 fn auth_header(&self) -> Option<String> {
33 self.token.as_ref().map(|t| format!("Bearer {t}"))
34 }
35
36 pub async fn resolve(&self, name: &str, version: &str) -> DkpResult<PackVersionResponse> {
37 let encoded = urlencoding::encode(name);
38 let url = self.url(&format!("/packages/{encoded}/{version}"));
39 let mut req = self.http.get(&url);
40 if let Some(auth) = self.auth_header() {
41 req = req.header(AUTHORIZATION, auth);
42 }
43 let resp = req
44 .send()
45 .await
46 .map_err(|e| DkpError::Registry(e.to_string()))?;
47 if resp.status() == 401 {
48 return Err(DkpError::Registry(
49 "authentication required for this pack".into(),
50 ));
51 }
52 if resp.status() == 403 {
53 return Err(DkpError::Registry(
54 "not authorized to access this pack".into(),
55 ));
56 }
57 if resp.status() == 404 {
58 return Err(DkpError::Registry(format!(
59 "pack {name}@{version} not found"
60 )));
61 }
62 if !resp.status().is_success() {
63 let status = resp.status();
64 let body = resp.text().await.unwrap_or_default();
65 return Err(DkpError::Registry(format!(
66 "registry error {status}: {body}"
67 )));
68 }
69 resp.json()
70 .await
71 .map_err(|e| DkpError::Registry(e.to_string()))
72 }
73
74 pub async fn list_versions(&self, name: &str) -> DkpResult<VersionListResponse> {
75 let encoded = urlencoding::encode(name);
76 let url = self.url(&format!("/packages/{encoded}/versions"));
77 let mut req = self.http.get(&url);
78 if let Some(auth) = self.auth_header() {
79 req = req.header(AUTHORIZATION, auth);
80 }
81 let resp = req
82 .send()
83 .await
84 .map_err(|e| DkpError::Registry(e.to_string()))?;
85 if !resp.status().is_success() {
86 let status = resp.status();
87 let body = resp.text().await.unwrap_or_default();
88 return Err(DkpError::Registry(format!(
89 "registry error {status}: {body}"
90 )));
91 }
92 resp.json()
93 .await
94 .map_err(|e| DkpError::Registry(e.to_string()))
95 }
96
97 pub async fn publish(&self, req: PublishRequest) -> DkpResult<PublishResponse> {
98 let url = self.url("/publish");
99 let auth = self
100 .auth_header()
101 .ok_or_else(|| DkpError::Registry("registry token required for publish".into()))?;
102 let resp = self
103 .http
104 .post(&url)
105 .header(AUTHORIZATION, auth)
106 .header(CONTENT_TYPE, "application/json")
107 .json(&req)
108 .send()
109 .await
110 .map_err(|e| DkpError::Registry(e.to_string()))?;
111 if !resp.status().is_success() {
112 let status = resp.status();
113 let body = resp.text().await.unwrap_or_default();
114 return Err(DkpError::Registry(format!(
115 "publish failed {status}: {body}"
116 )));
117 }
118 resp.json()
119 .await
120 .map_err(|e| DkpError::Registry(e.to_string()))
121 }
122
123 pub async fn confirm_publish(
124 &self,
125 name: &str,
126 version: &str,
127 ) -> DkpResult<ConfirmPublishResponse> {
128 let encoded = urlencoding::encode(name);
129 let url = self.url(&format!("/packages/{encoded}/{version}/confirm"));
130 let auth = self
131 .auth_header()
132 .ok_or_else(|| DkpError::Registry("registry token required for confirm".into()))?;
133 let resp = self
134 .http
135 .post(&url)
136 .header(AUTHORIZATION, auth)
137 .send()
138 .await
139 .map_err(|e| DkpError::Registry(e.to_string()))?;
140 if !resp.status().is_success() {
141 let status = resp.status();
142 let body = resp.text().await.unwrap_or_default();
143 return Err(DkpError::Registry(format!(
144 "confirm failed {status}: {body}"
145 )));
146 }
147 resp.json()
148 .await
149 .map_err(|e| DkpError::Registry(e.to_string()))
150 }
151
152 pub async fn get_download_url(
153 &self,
154 name: &str,
155 version: &str,
156 ) -> DkpResult<DownloadUrlResponse> {
157 let encoded = urlencoding::encode(name);
158 let url = self.url(&format!("/packages/{encoded}/{version}/download-url"));
159 let mut req = self.http.get(&url);
160 if let Some(auth) = self.auth_header() {
161 req = req.header(AUTHORIZATION, auth);
162 }
163 let resp = req
164 .send()
165 .await
166 .map_err(|e| DkpError::Registry(e.to_string()))?;
167 if resp.status() == 401 {
168 return Err(DkpError::Registry("authentication required".into()));
169 }
170 if resp.status() == 403 {
171 return Err(DkpError::Registry("not authorized".into()));
172 }
173 if resp.status() == 404 {
174 return Err(DkpError::Registry(format!("{name}@{version} not found")));
175 }
176 if !resp.status().is_success() {
177 let status = resp.status();
178 let body = resp.text().await.unwrap_or_default();
179 return Err(DkpError::Registry(format!(
180 "registry error {status}: {body}"
181 )));
182 }
183 resp.json()
184 .await
185 .map_err(|e| DkpError::Registry(e.to_string()))
186 }
187
188 pub async fn yank(&self, name: &str, version: &str, reason: &str) -> DkpResult<()> {
189 let url = self.url("/yank");
190 let auth = self
191 .auth_header()
192 .ok_or_else(|| DkpError::Registry("registry token required for yank".into()))?;
193 let body = serde_json::json!({ "name": name, "version": version, "reason": reason });
194 let resp = self
195 .http
196 .post(&url)
197 .header(AUTHORIZATION, auth)
198 .header(CONTENT_TYPE, "application/json")
199 .json(&body)
200 .send()
201 .await
202 .map_err(|e| DkpError::Registry(e.to_string()))?;
203 if !resp.status().is_success() {
204 let status = resp.status();
205 let body = resp.text().await.unwrap_or_default();
206 return Err(DkpError::Registry(format!("yank failed {status}: {body}")));
207 }
208 Ok(())
209 }
210
211 pub async fn deprecate(
212 &self,
213 name: &str,
214 version: &str,
215 deprecated: bool,
216 message: Option<&str>,
217 ) -> DkpResult<()> {
218 let url = self.url("/deprecate");
219 let auth = self
220 .auth_header()
221 .ok_or_else(|| DkpError::Registry("registry token required for deprecate".into()))?;
222 let body = serde_json::json!({
223 "name": name,
224 "version": version,
225 "deprecated": deprecated,
226 "message": message,
227 });
228 let resp = self
229 .http
230 .post(&url)
231 .header(AUTHORIZATION, auth)
232 .header(CONTENT_TYPE, "application/json")
233 .json(&body)
234 .send()
235 .await
236 .map_err(|e| DkpError::Registry(e.to_string()))?;
237 if !resp.status().is_success() {
238 let status = resp.status();
239 let body = resp.text().await.unwrap_or_default();
240 return Err(DkpError::Registry(format!(
241 "deprecate failed {status}: {body}"
242 )));
243 }
244 Ok(())
245 }
246
247 pub async fn search(
248 &self,
249 q: &str,
250 domain: Option<&str>,
251 conformance: Option<&str>,
252 limit: u32,
253 offset: u32,
254 ) -> DkpResult<SearchResponse> {
255 let mut url = reqwest::Url::parse(&self.url("/search"))
256 .map_err(|e| DkpError::Registry(e.to_string()))?;
257 url.query_pairs_mut()
258 .append_pair("q", q)
259 .append_pair("limit", &limit.to_string())
260 .append_pair("offset", &offset.to_string());
261 if let Some(d) = domain {
262 url.query_pairs_mut().append_pair("domain", d);
263 }
264 if let Some(c) = conformance {
265 url.query_pairs_mut().append_pair("conformance", c);
266 }
267 let mut req = self.http.get(url);
268 if let Some(auth) = self.auth_header() {
269 req = req.header(AUTHORIZATION, auth);
270 }
271 let resp = req
272 .send()
273 .await
274 .map_err(|e| DkpError::Registry(e.to_string()))?;
275 if !resp.status().is_success() {
276 let status = resp.status();
277 let body = resp.text().await.unwrap_or_default();
278 return Err(DkpError::Registry(format!(
279 "search failed {status}: {body}"
280 )));
281 }
282 resp.json()
283 .await
284 .map_err(|e| DkpError::Registry(e.to_string()))
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use wiremock::matchers::{method, path};
292 use wiremock::{Mock, MockServer, ResponseTemplate};
293
294 fn sample_manifest_json() -> serde_json::Value {
295 serde_json::json!({
296 "spec": "1.0.0",
297 "name": "test-pack",
298 "version": "1.0.0",
299 "domain": "testing",
300 "audience": "internal",
301 "intended_use": "test",
302 "known_limitations": "none",
303 "update_date": "2026-01-01"
304 })
305 }
306
307 fn sample_pack_version_response() -> serde_json::Value {
308 serde_json::json!({
309 "name": "test-pack",
310 "version": "1.0.0",
311 "manifest": sample_manifest_json(),
312 "checksums": {},
313 "bundle_sig": "sig",
314 "archive_format": "tar.xz",
315 "publisher_public_key": "pubkey",
316 "published_at": "2026-01-01T00:00:00Z",
317 "conformance": "dkp-conformant",
318 "visibility": "public",
319 "yanked": false,
320 "yank_reason": null,
321 "deprecated": false,
322 "deprecation_message": null,
323 "eval_summary": null,
324 "readme": null,
325 "download_count": 0
326 })
327 }
328
329 #[tokio::test]
330 async fn resolve_200_returns_pack_version() {
331 let server = MockServer::start().await;
332 Mock::given(method("GET"))
333 .and(path("/api/v1/packages/test-pack/1.0.0"))
334 .respond_with(ResponseTemplate::new(200).set_body_json(sample_pack_version_response()))
335 .mount(&server)
336 .await;
337
338 let client = RegistryClient::new(server.uri(), None);
339 let result = client.resolve("test-pack", "1.0.0").await.unwrap();
340 assert_eq!(result.name, "test-pack");
341 assert_eq!(result.version, "1.0.0");
342 }
343
344 #[tokio::test]
345 async fn resolve_401_maps_to_authentication_error() {
346 let server = MockServer::start().await;
347 Mock::given(method("GET"))
348 .and(path("/api/v1/packages/test-pack/1.0.0"))
349 .respond_with(ResponseTemplate::new(401))
350 .mount(&server)
351 .await;
352
353 let client = RegistryClient::new(server.uri(), None);
354 let err = client.resolve("test-pack", "1.0.0").await.unwrap_err();
355 assert!(matches!(err, DkpError::Registry(msg) if msg.contains("authentication required")));
356 }
357
358 #[tokio::test]
359 async fn resolve_403_maps_to_authorization_error() {
360 let server = MockServer::start().await;
361 Mock::given(method("GET"))
362 .and(path("/api/v1/packages/test-pack/1.0.0"))
363 .respond_with(ResponseTemplate::new(403))
364 .mount(&server)
365 .await;
366
367 let client = RegistryClient::new(server.uri(), None);
368 let err = client.resolve("test-pack", "1.0.0").await.unwrap_err();
369 assert!(matches!(err, DkpError::Registry(msg) if msg.contains("not authorized")));
370 }
371
372 #[tokio::test]
373 async fn resolve_404_maps_to_not_found_error() {
374 let server = MockServer::start().await;
375 Mock::given(method("GET"))
376 .and(path("/api/v1/packages/test-pack/1.0.0"))
377 .respond_with(ResponseTemplate::new(404))
378 .mount(&server)
379 .await;
380
381 let client = RegistryClient::new(server.uri(), None);
382 let err = client.resolve("test-pack", "1.0.0").await.unwrap_err();
383 assert!(matches!(err, DkpError::Registry(msg) if msg.contains("not found")));
384 }
385}