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}