use serde::{Deserialize, Serialize};
use crate::{CliError, Result};
pub struct RegistryClient {
http: reqwest::Client,
base: String,
token: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PublishReq {
pub handle: String,
pub r#type: String,
pub ecosystems: Vec<String>,
pub source_repo: String,
pub source_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub visibility: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ManifestFile {
pub source_url: String,
pub dest_path: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Manifest {
pub handle: String,
pub target: String,
pub version: String,
#[serde(default)]
pub source_ref: String,
#[serde(default)]
pub pinned: bool,
pub files: Vec<ManifestFile>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ArtifactSummary {
pub namespace: String,
pub slug: String,
#[serde(rename = "type", default)]
pub artifact_type: String,
#[serde(default)]
pub ecosystems: Vec<String>,
#[serde(default)]
pub stars: i64,
#[serde(default)]
pub description: String,
}
impl ArtifactSummary {
pub fn handle(&self) -> String {
format!("@{}/{}", self.namespace, self.slug)
}
}
#[derive(Deserialize)]
struct ListResponse {
#[serde(default)]
artifacts: Vec<ArtifactSummary>,
}
impl RegistryClient {
pub fn new(base: impl Into<String>) -> Self {
Self {
http: reqwest::Client::new(),
base: base.into().trim_end_matches('/').to_string(),
token: None,
}
}
pub fn with_token(mut self, token: impl Into<String>) -> Self {
self.token = Some(token.into());
self
}
fn req(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
let rb = self.http.request(method, format!("{}{path}", self.base));
match &self.token {
Some(t) => rb.bearer_auth(t),
None => rb,
}
}
pub async fn search(
&self,
query: Option<&str>,
artifact_type: Option<&str>,
ecosystem: Option<&str>,
) -> Result<Vec<ArtifactSummary>> {
let mut url = format!("{}/api/v1/artifacts?limit=50", self.base);
if let Some(q) = query {
url.push_str(&format!("&q={}", urlencode(q)));
}
if let Some(t) = artifact_type {
url.push_str(&format!("&type={}", urlencode(t)));
}
if let Some(e) = ecosystem {
url.push_str(&format!("&ecosystem={}", urlencode(e)));
}
let body: ListResponse = self.get_json(&url).await?;
Ok(body.artifacts)
}
pub async fn info(&self, namespace: &str, slug: &str) -> Result<serde_json::Value> {
let url = format!("{}/api/v1/artifacts/{namespace}/{slug}", self.base);
self.get_json(&url).await
}
pub async fn manifest(&self, namespace: &str, slug: &str, target: &str) -> Result<Manifest> {
let url = format!(
"{}/api/v1/artifacts/{namespace}/{slug}/install-manifest?target={}",
self.base,
urlencode(target)
);
self.get_json(&url).await
}
pub async fn fetch_file(&self, url: &str) -> Result<Vec<u8>> {
let resp = self.http.get(url).send().await?;
if !resp.status().is_success() {
return Err(CliError::Download {
url: url.to_string(),
status: resp.status().as_u16(),
});
}
Ok(resp.bytes().await?.to_vec())
}
async fn get_json<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<T> {
let resp = self.http.get(url).send().await?;
let resp = ensure_ok(resp).await?;
Ok(resp.json().await?)
}
pub async fn publish(&self, body: &PublishReq) -> Result<serde_json::Value> {
let resp = self
.req(reqwest::Method::POST, "/api/v1/artifacts")
.json(body)
.send()
.await?;
let resp = ensure_ok_authed(resp).await?;
Ok(resp.json().await?)
}
pub async fn unpublish(&self, namespace: &str, slug: &str) -> Result<()> {
let resp = self
.req(
reqwest::Method::DELETE,
&format!("/api/v1/artifacts/{namespace}/{slug}"),
)
.send()
.await?;
ensure_ok_authed(resp).await?;
Ok(())
}
}
async fn ensure_ok_authed(resp: reqwest::Response) -> Result<reqwest::Response> {
if resp.status().is_success() {
return Ok(resp);
}
let status = resp.status().as_u16();
let body = if status == 403 {
"not authorized to publish".to_string()
} else {
resp.text().await.unwrap_or_default()
};
Err(CliError::Api { status, body })
}
async fn ensure_ok(resp: reqwest::Response) -> Result<reqwest::Response> {
if resp.status().is_success() {
return Ok(resp);
}
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
Err(CliError::Api { status, body })
}
fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}