1use serde::{Deserialize, Serialize};
4
5use crate::{CliError, Result};
6
7pub struct RegistryClient {
9 http: reqwest::Client,
10 base: String,
11 token: Option<String>,
12}
13
14#[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#[derive(Debug, Clone, Deserialize)]
30pub struct ManifestFile {
31 pub source_url: String,
32 pub dest_path: String,
33}
34
35#[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#[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 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 pub fn with_token(mut self, token: impl Into<String>) -> Self {
88 self.token = Some(token.into());
89 self
90 }
91
92 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 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 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 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 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 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 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
186async 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}