agr 0.1.0

agr — install agent artifacts from the public registry into a project
Documentation
//! HTTP client for the registry REST API.

use serde::{Deserialize, Serialize};

use crate::{CliError, Result};

/// A minimal client over the registry REST surface.
pub struct RegistryClient {
    http: reqwest::Client,
    base: String,
    token: Option<String>,
}

/// Body for `POST /api/v1/artifacts`.
#[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>,
}

/// One file to place, from an install manifest.
#[derive(Debug, Clone, Deserialize)]
pub struct ManifestFile {
    pub source_url: String,
    pub dest_path: String,
}

/// The install manifest returned by
/// `/api/v1/artifacts/{ns}/{slug}/install-manifest`.
#[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>,
}

/// A search/listing row.
#[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 {
    /// Build a client against `base` (e.g. `http://localhost:18080`).
    pub fn new(base: impl Into<String>) -> Self {
        Self {
            http: reqwest::Client::new(),
            base: base.into().trim_end_matches('/').to_string(),
            token: None,
        }
    }

    /// Attach a bearer token used for authenticated (publish/unpublish) calls.
    pub fn with_token(mut self, token: impl Into<String>) -> Self {
        self.token = Some(token.into());
        self
    }

    /// Build a request against `{base}{path}`, applying the bearer token if set.
    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,
        }
    }

    /// `GET /api/v1/artifacts` with optional filters.
    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)
    }

    /// `GET /api/v1/artifacts/{ns}/{slug}` detail.
    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
    }

    /// `GET …/install-manifest?target=…`.
    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
    }

    /// Download a file's bytes from its (possibly external) source URL.
    ///
    /// Deliberately not routed through [`ensure_ok`]: a failure here belongs to
    /// the artifact's source host, and reporting it as `registry API {status}`
    /// pointed whoever was debugging an install at the wrong system.
    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?)
    }

    /// `POST /api/v1/artifacts` with the bearer token, returning the created
    /// artifact JSON.
    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?)
    }

    /// `DELETE /api/v1/artifacts/{ns}/{slug}` with the bearer token.
    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(())
    }
}

/// Like [`ensure_ok`] but with a friendlier message for the 403 case that
/// bearer-authed writes (publish/unpublish) can hit.
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
}