runx-runtime 0.6.19

Native Rust runtime for local runx execution, adapters, harness replay, receipts, and sandboxing.
Documentation
use serde_json::{Value, json};
use url::Url;

use super::payload::{parse_acquire, parse_read, parse_search};
use super::refs::{RegistryResolveError, resolve_remote_registry_ref};
use super::types::{
    AcquiredRegistrySkill, RegistrySearchResult, RegistrySkillDetail, ResolvedRegistryRef,
};

use crate::http::strip_one_trailing_slash;
pub use crate::http::{
    HttpMethod, ReqwestHttpTransport as DefaultRuntimeHttpTransport, RuntimeHttpError,
    RuntimeHttpHeader, RuntimeHttpRequest as HttpRequest, RuntimeHttpResponse as HttpResponse,
    RuntimeHttpTransport as Transport,
};

#[derive(Clone, Debug)]
pub struct RegistryClient<T = DefaultRuntimeHttpTransport> {
    base_url: String,
    transport: T,
}

#[cfg(feature = "async-http")]
impl RegistryClient<DefaultRuntimeHttpTransport> {
    pub fn new(base_url: impl AsRef<str>) -> Result<Self, RegistryClientError> {
        Self::with_transport(base_url, DefaultRuntimeHttpTransport::new()?)
    }
}

impl<T: Transport> RegistryClient<T> {
    pub fn with_transport(
        base_url: impl AsRef<str>,
        transport: T,
    ) -> Result<Self, RegistryClientError> {
        let base_url = strip_one_trailing_slash(base_url.as_ref());
        let url = Url::parse(&base_url).map_err(|error| {
            RegistryClientError::RuntimeHttp(RuntimeHttpError::InvalidUrl(error))
        })?;
        if !matches!(url.scheme(), "http" | "https") {
            return Err(RegistryClientError::RuntimeHttp(
                RuntimeHttpError::UnsupportedUrlScheme {
                    scheme: url.scheme().to_owned(),
                },
            ));
        }
        Ok(Self {
            base_url,
            transport,
        })
    }

    pub fn search(&self, query: &str) -> Result<Vec<RegistrySearchResult>, RegistryClientError> {
        self.search_with_limit(query, 20)
    }

    pub fn search_with_limit(
        &self,
        query: &str,
        limit: usize,
    ) -> Result<Vec<RegistrySearchResult>, RegistryClientError> {
        let mut url = Url::parse(&format!("{}/v1/skills", self.base_url)).map_err(|error| {
            RegistryClientError::RuntimeHttp(RuntimeHttpError::InvalidUrl(error))
        })?;
        {
            let mut pairs = url.query_pairs_mut();
            let trimmed = query.trim();
            if !trimmed.is_empty() {
                pairs.append_pair("q", trimmed);
            }
            pairs.append_pair("limit", &limit.to_string());
        }
        let route = route_path(url.path(), url.query());
        let response = self.transport.send(HttpRequest {
            method: HttpMethod::Get,
            url: url.to_string(),
            headers: Vec::new(),
            body: None,
        })?;
        ensure_success(&route, response.status)?;
        let payload = json_body(&route, &response.body)?;
        parse_search(&route, &payload)
    }

    pub fn read(
        &self,
        skill_id: &str,
        version: Option<&str>,
    ) -> Result<Option<RegistrySkillDetail>, RegistryClientError> {
        let (owner, name) = split_skill_id(skill_id)?;
        let suffix = version
            .map(|version| format!("{name}@{version}"))
            .unwrap_or_else(|| name.to_owned());
        let route = format!(
            "/v1/skills/{}/{}",
            encode_segment(owner),
            encode_segment(&suffix)
        );
        let response = self.transport.send(HttpRequest {
            method: HttpMethod::Get,
            url: format!("{}{}", self.base_url, route),
            headers: Vec::new(),
            body: None,
        })?;
        if response.status == 404 {
            return Ok(None);
        }
        ensure_success(&route, response.status)?;
        let payload = json_body(&route, &response.body)?;
        parse_read(&route, &payload).map(Some)
    }

    pub fn acquire(
        &self,
        skill_id: &str,
        options: AcquireOptions<'_>,
    ) -> Result<AcquiredRegistrySkill, RegistryClientError> {
        if options.installation_id.trim().is_empty() {
            return Err(RegistryClientError::MissingInstallationId);
        }
        let (owner, name) = split_skill_id(skill_id)?;
        let route = format!(
            "/v1/skills/{}/{}/acquire",
            encode_segment(owner),
            encode_segment(name)
        );
        let channel = options.channel.unwrap_or("cli");
        let body = json!({
            "installation_id": options.installation_id,
            "version": options.version,
            "channel": channel,
        })
        .to_string();
        let response = self.transport.send(HttpRequest {
            method: HttpMethod::Post,
            url: format!("{}{}", self.base_url, route),
            headers: vec![RuntimeHttpHeader::new("content-type", "application/json")],
            body: Some(body),
        })?;
        ensure_success(&route, response.status)?;
        let payload = json_body(&route, &response.body)?;
        parse_acquire(&route, &payload)
    }

    pub fn resolve_ref(
        &self,
        registry_ref: &str,
        version_override: Option<&str>,
    ) -> Result<Option<ResolvedRegistryRef>, RegistryResolveError> {
        resolve_remote_registry_ref(self, registry_ref, version_override)
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AcquireOptions<'a> {
    pub installation_id: &'a str,
    pub version: Option<&'a str>,
    pub channel: Option<&'a str>,
}

#[derive(Debug, thiserror::Error)]
pub enum RegistryClientError {
    #[error(transparent)]
    RuntimeHttp(#[from] RuntimeHttpError),
    #[error("invalid registry skill id '{0}'. Expected '<owner>/<name>'.")]
    InvalidSkillId(String),
    #[error("registry route {route} failed with HTTP {status}")]
    HttpStatus { route: String, status: u16 },
    #[error("registry route {route} returned invalid JSON: {message}")]
    InvalidJson { route: String, message: String },
    #[error("registry route {route} contract error at {field_path}: {message}")]
    Contract {
        route: String,
        field_path: String,
        message: String,
    },
    #[error("remote registry installs require an installation id")]
    MissingInstallationId,
}

fn ensure_success(route: &str, status: u16) -> Result<(), RegistryClientError> {
    if (200..=299).contains(&status) {
        Ok(())
    } else {
        Err(RegistryClientError::HttpStatus {
            route: route.to_owned(),
            status,
        })
    }
}

fn json_body(route: &str, body: &str) -> Result<Value, RegistryClientError> {
    serde_json::from_str(body).map_err(|error| RegistryClientError::InvalidJson {
        route: route.to_owned(),
        message: error.to_string(),
    })
}

fn split_skill_id(skill_id: &str) -> Result<(&str, &str), RegistryClientError> {
    let mut parts = skill_id.split('/');
    let owner = parts.next().unwrap_or_default();
    let name = parts.next().unwrap_or_default();
    if owner.is_empty()
        || name.is_empty()
        || is_dot_segment(owner)
        || is_dot_segment(name)
        || parts.next().is_some()
    {
        return Err(RegistryClientError::InvalidSkillId(skill_id.to_owned()));
    }
    Ok((owner, name))
}

fn is_dot_segment(value: &str) -> bool {
    matches!(value, "." | "..")
}

fn encode_segment(value: &str) -> String {
    let mut output = String::new();
    for byte in value.bytes() {
        let keep = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'~');
        if keep {
            output.push(char::from(byte));
        } else {
            output.push_str(&format!("%{byte:02X}"));
        }
    }
    output
}

fn route_path(path: &str, query: Option<&str>) -> String {
    match query {
        Some(query) if !query.is_empty() => format!("{path}?{query}"),
        _ => path.to_owned(),
    }
}