Skip to main content

dkp_core/registry/
client.rs

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}