Skip to main content

runx_runtime/registry/
http.rs

1use serde_json::{Value, json};
2use url::Url;
3
4use super::payload::{parse_acquire, parse_read, parse_search};
5use super::refs::{RegistryResolveError, resolve_remote_registry_ref};
6use super::types::{
7    AcquiredRegistrySkill, RegistrySearchResult, RegistrySkillDetail, ResolvedRegistryRef,
8};
9
10use crate::http::strip_one_trailing_slash;
11pub use crate::http::{
12    HttpMethod, ReqwestHttpTransport as DefaultRuntimeHttpTransport, RuntimeHttpError,
13    RuntimeHttpHeader, RuntimeHttpRequest as HttpRequest, RuntimeHttpResponse as HttpResponse,
14    RuntimeHttpTransport as Transport,
15};
16
17#[derive(Clone, Debug)]
18pub struct RegistryClient<T = DefaultRuntimeHttpTransport> {
19    base_url: String,
20    transport: T,
21}
22
23#[cfg(feature = "async-http")]
24impl RegistryClient<DefaultRuntimeHttpTransport> {
25    pub fn new(base_url: impl AsRef<str>) -> Result<Self, RegistryClientError> {
26        Self::with_transport(base_url, DefaultRuntimeHttpTransport::new()?)
27    }
28}
29
30impl<T: Transport> RegistryClient<T> {
31    pub fn with_transport(
32        base_url: impl AsRef<str>,
33        transport: T,
34    ) -> Result<Self, RegistryClientError> {
35        let base_url = strip_one_trailing_slash(base_url.as_ref());
36        let url = Url::parse(&base_url).map_err(|error| {
37            RegistryClientError::RuntimeHttp(RuntimeHttpError::InvalidUrl(error))
38        })?;
39        if !matches!(url.scheme(), "http" | "https") {
40            return Err(RegistryClientError::RuntimeHttp(
41                RuntimeHttpError::UnsupportedUrlScheme {
42                    scheme: url.scheme().to_owned(),
43                },
44            ));
45        }
46        Ok(Self {
47            base_url,
48            transport,
49        })
50    }
51
52    pub fn search(&self, query: &str) -> Result<Vec<RegistrySearchResult>, RegistryClientError> {
53        self.search_with_limit(query, 20)
54    }
55
56    pub fn search_with_limit(
57        &self,
58        query: &str,
59        limit: usize,
60    ) -> Result<Vec<RegistrySearchResult>, RegistryClientError> {
61        let mut url = Url::parse(&format!("{}/v1/skills", self.base_url)).map_err(|error| {
62            RegistryClientError::RuntimeHttp(RuntimeHttpError::InvalidUrl(error))
63        })?;
64        {
65            let mut pairs = url.query_pairs_mut();
66            let trimmed = query.trim();
67            if !trimmed.is_empty() {
68                pairs.append_pair("q", trimmed);
69            }
70            pairs.append_pair("limit", &limit.to_string());
71        }
72        let route = route_path(url.path(), url.query());
73        let response = self.transport.send(HttpRequest {
74            method: HttpMethod::Get,
75            url: url.to_string(),
76            headers: Vec::new(),
77            body: None,
78        })?;
79        ensure_success(&route, response.status)?;
80        let payload = json_body(&route, &response.body)?;
81        parse_search(&route, &payload)
82    }
83
84    pub fn read(
85        &self,
86        skill_id: &str,
87        version: Option<&str>,
88    ) -> Result<Option<RegistrySkillDetail>, RegistryClientError> {
89        let (owner, name) = split_skill_id(skill_id)?;
90        let suffix = version
91            .map(|version| format!("{name}@{version}"))
92            .unwrap_or_else(|| name.to_owned());
93        let route = format!(
94            "/v1/skills/{}/{}",
95            encode_segment(owner),
96            encode_segment(&suffix)
97        );
98        let response = self.transport.send(HttpRequest {
99            method: HttpMethod::Get,
100            url: format!("{}{}", self.base_url, route),
101            headers: Vec::new(),
102            body: None,
103        })?;
104        if response.status == 404 {
105            return Ok(None);
106        }
107        ensure_success(&route, response.status)?;
108        let payload = json_body(&route, &response.body)?;
109        parse_read(&route, &payload).map(Some)
110    }
111
112    pub fn acquire(
113        &self,
114        skill_id: &str,
115        options: AcquireOptions<'_>,
116    ) -> Result<AcquiredRegistrySkill, RegistryClientError> {
117        if options.installation_id.trim().is_empty() {
118            return Err(RegistryClientError::MissingInstallationId);
119        }
120        let (owner, name) = split_skill_id(skill_id)?;
121        let route = format!(
122            "/v1/skills/{}/{}/acquire",
123            encode_segment(owner),
124            encode_segment(name)
125        );
126        let channel = options.channel.unwrap_or("cli");
127        let body = json!({
128            "installation_id": options.installation_id,
129            "version": options.version,
130            "channel": channel,
131        })
132        .to_string();
133        let response = self.transport.send(HttpRequest {
134            method: HttpMethod::Post,
135            url: format!("{}{}", self.base_url, route),
136            headers: vec![RuntimeHttpHeader::new("content-type", "application/json")],
137            body: Some(body),
138        })?;
139        ensure_success(&route, response.status)?;
140        let payload = json_body(&route, &response.body)?;
141        parse_acquire(&route, &payload)
142    }
143
144    pub fn resolve_ref(
145        &self,
146        registry_ref: &str,
147        version_override: Option<&str>,
148    ) -> Result<Option<ResolvedRegistryRef>, RegistryResolveError> {
149        resolve_remote_registry_ref(self, registry_ref, version_override)
150    }
151}
152
153#[derive(Clone, Copy, Debug, PartialEq, Eq)]
154pub struct AcquireOptions<'a> {
155    pub installation_id: &'a str,
156    pub version: Option<&'a str>,
157    pub channel: Option<&'a str>,
158}
159
160#[derive(Debug, thiserror::Error)]
161pub enum RegistryClientError {
162    #[error(transparent)]
163    RuntimeHttp(#[from] RuntimeHttpError),
164    #[error("invalid registry skill id '{0}'. Expected '<owner>/<name>'.")]
165    InvalidSkillId(String),
166    #[error("registry route {route} failed with HTTP {status}")]
167    HttpStatus { route: String, status: u16 },
168    #[error("registry route {route} returned invalid JSON: {message}")]
169    InvalidJson { route: String, message: String },
170    #[error("registry route {route} contract error at {field_path}: {message}")]
171    Contract {
172        route: String,
173        field_path: String,
174        message: String,
175    },
176    #[error("remote registry installs require an installation id")]
177    MissingInstallationId,
178}
179
180fn ensure_success(route: &str, status: u16) -> Result<(), RegistryClientError> {
181    if (200..=299).contains(&status) {
182        Ok(())
183    } else {
184        Err(RegistryClientError::HttpStatus {
185            route: route.to_owned(),
186            status,
187        })
188    }
189}
190
191fn json_body(route: &str, body: &str) -> Result<Value, RegistryClientError> {
192    serde_json::from_str(body).map_err(|error| RegistryClientError::InvalidJson {
193        route: route.to_owned(),
194        message: error.to_string(),
195    })
196}
197
198fn split_skill_id(skill_id: &str) -> Result<(&str, &str), RegistryClientError> {
199    let mut parts = skill_id.split('/');
200    let owner = parts.next().unwrap_or_default();
201    let name = parts.next().unwrap_or_default();
202    if owner.is_empty()
203        || name.is_empty()
204        || is_dot_segment(owner)
205        || is_dot_segment(name)
206        || parts.next().is_some()
207    {
208        return Err(RegistryClientError::InvalidSkillId(skill_id.to_owned()));
209    }
210    Ok((owner, name))
211}
212
213fn is_dot_segment(value: &str) -> bool {
214    matches!(value, "." | "..")
215}
216
217fn encode_segment(value: &str) -> String {
218    let mut output = String::new();
219    for byte in value.bytes() {
220        let keep = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'~');
221        if keep {
222            output.push(char::from(byte));
223        } else {
224            output.push_str(&format!("%{byte:02X}"));
225        }
226    }
227    output
228}
229
230fn route_path(path: &str, query: Option<&str>) -> String {
231    match query {
232        Some(query) if !query.is_empty() => format!("{path}?{query}"),
233        _ => path.to_owned(),
234    }
235}