Skip to main content

registry_cli/
client.rs

1//! HTTP client for the registry REST API.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{CliError, Result};
6
7/// A minimal client over the registry REST surface.
8pub struct RegistryClient {
9    http: reqwest::Client,
10    base: String,
11    token: Option<String>,
12}
13
14/// Body for `POST /api/v1/artifacts`.
15#[derive(Debug, Clone, Serialize)]
16pub struct PublishReq {
17    pub handle: String,
18    pub r#type: String,
19    pub ecosystems: Vec<String>,
20    pub source_repo: String,
21    pub source_path: String,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub visibility: Option<String>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub description: Option<String>,
26}
27
28/// One file to place, from an install manifest.
29#[derive(Debug, Clone, Deserialize)]
30pub struct ManifestFile {
31    pub source_url: String,
32    pub dest_path: String,
33}
34
35/// The install manifest returned by
36/// `/api/v1/artifacts/{ns}/{slug}/install-manifest`.
37#[derive(Debug, Clone, Deserialize)]
38pub struct Manifest {
39    pub handle: String,
40    pub target: String,
41    pub version: String,
42    #[serde(default)]
43    pub source_ref: String,
44    #[serde(default)]
45    pub pinned: bool,
46    pub files: Vec<ManifestFile>,
47}
48
49/// A search/listing row.
50#[derive(Debug, Clone, Deserialize)]
51pub struct ArtifactSummary {
52    pub namespace: String,
53    pub slug: String,
54    #[serde(rename = "type", default)]
55    pub artifact_type: String,
56    #[serde(default)]
57    pub ecosystems: Vec<String>,
58    #[serde(default)]
59    pub stars: i64,
60    #[serde(default)]
61    pub description: String,
62}
63
64impl ArtifactSummary {
65    pub fn handle(&self) -> String {
66        format!("@{}/{}", self.namespace, self.slug)
67    }
68}
69
70#[derive(Deserialize)]
71struct ListResponse {
72    #[serde(default)]
73    artifacts: Vec<ArtifactSummary>,
74}
75
76impl RegistryClient {
77    /// Build a client against `base` (e.g. `http://localhost:18080`).
78    pub fn new(base: impl Into<String>) -> Self {
79        Self {
80            http: reqwest::Client::new(),
81            base: base.into().trim_end_matches('/').to_string(),
82            token: None,
83        }
84    }
85
86    /// Attach a bearer token used for authenticated (publish/unpublish) calls.
87    pub fn with_token(mut self, token: impl Into<String>) -> Self {
88        self.token = Some(token.into());
89        self
90    }
91
92    /// Build a request against `{base}{path}`, applying the bearer token if set.
93    fn req(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
94        let rb = self.http.request(method, format!("{}{path}", self.base));
95        match &self.token {
96            Some(t) => rb.bearer_auth(t),
97            None => rb,
98        }
99    }
100
101    /// `GET /api/v1/artifacts` with optional filters.
102    pub async fn search(
103        &self,
104        query: Option<&str>,
105        artifact_type: Option<&str>,
106        ecosystem: Option<&str>,
107    ) -> Result<Vec<ArtifactSummary>> {
108        let mut url = format!("{}/api/v1/artifacts?limit=50", self.base);
109        if let Some(q) = query {
110            url.push_str(&format!("&q={}", urlencode(q)));
111        }
112        if let Some(t) = artifact_type {
113            url.push_str(&format!("&type={}", urlencode(t)));
114        }
115        if let Some(e) = ecosystem {
116            url.push_str(&format!("&ecosystem={}", urlencode(e)));
117        }
118        let body: ListResponse = self.get_json(&url).await?;
119        Ok(body.artifacts)
120    }
121
122    /// `GET /api/v1/artifacts/{ns}/{slug}` detail.
123    pub async fn info(&self, namespace: &str, slug: &str) -> Result<serde_json::Value> {
124        let url = format!("{}/api/v1/artifacts/{namespace}/{slug}", self.base);
125        self.get_json(&url).await
126    }
127
128    /// `GET …/install-manifest?target=…`.
129    pub async fn manifest(&self, namespace: &str, slug: &str, target: &str) -> Result<Manifest> {
130        let url = format!(
131            "{}/api/v1/artifacts/{namespace}/{slug}/install-manifest?target={}",
132            self.base,
133            urlencode(target)
134        );
135        self.get_json(&url).await
136    }
137
138    /// Download a file's bytes from its (possibly external) source URL.
139    ///
140    /// Deliberately not routed through [`ensure_ok`]: a failure here belongs to
141    /// the artifact's source host, and reporting it as `registry API {status}`
142    /// pointed whoever was debugging an install at the wrong system.
143    pub async fn fetch_file(&self, url: &str) -> Result<Vec<u8>> {
144        let resp = self.http.get(url).send().await?;
145        if !resp.status().is_success() {
146            return Err(CliError::Download {
147                url: url.to_string(),
148                status: resp.status().as_u16(),
149            });
150        }
151        Ok(resp.bytes().await?.to_vec())
152    }
153
154    async fn get_json<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<T> {
155        let resp = self.http.get(url).send().await?;
156        let resp = ensure_ok(resp).await?;
157        Ok(resp.json().await?)
158    }
159
160    /// `POST /api/v1/artifacts` with the bearer token, returning the created
161    /// artifact JSON.
162    pub async fn publish(&self, body: &PublishReq) -> Result<serde_json::Value> {
163        let resp = self
164            .req(reqwest::Method::POST, "/api/v1/artifacts")
165            .json(body)
166            .send()
167            .await?;
168        let resp = ensure_ok_authed(resp).await?;
169        Ok(resp.json().await?)
170    }
171
172    /// `DELETE /api/v1/artifacts/{ns}/{slug}` with the bearer token.
173    pub async fn unpublish(&self, namespace: &str, slug: &str) -> Result<()> {
174        let resp = self
175            .req(
176                reqwest::Method::DELETE,
177                &format!("/api/v1/artifacts/{namespace}/{slug}"),
178            )
179            .send()
180            .await?;
181        ensure_ok_authed(resp).await?;
182        Ok(())
183    }
184}
185
186/// Like [`ensure_ok`] but with a friendlier message for the 403 case that
187/// bearer-authed writes (publish/unpublish) can hit.
188async fn ensure_ok_authed(resp: reqwest::Response) -> Result<reqwest::Response> {
189    if resp.status().is_success() {
190        return Ok(resp);
191    }
192    let status = resp.status().as_u16();
193    let body = if status == 403 {
194        "not authorized to publish".to_string()
195    } else {
196        resp.text().await.unwrap_or_default()
197    };
198    Err(CliError::Api { status, body })
199}
200
201async fn ensure_ok(resp: reqwest::Response) -> Result<reqwest::Response> {
202    if resp.status().is_success() {
203        return Ok(resp);
204    }
205    let status = resp.status().as_u16();
206    let body = resp.text().await.unwrap_or_default();
207    Err(CliError::Api { status, body })
208}
209
210fn urlencode(s: &str) -> String {
211    let mut out = String::with_capacity(s.len());
212    for b in s.bytes() {
213        match b {
214            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
215                out.push(b as char)
216            }
217            _ => out.push_str(&format!("%{b:02X}")),
218        }
219    }
220    out
221}