1use crate::media_types::WIT_ARTIFACT_TYPE_V1;
2use crate::util::{RegistryEndpoint, apply_reqwest_auth, auth_from_env};
3use anyhow::{Context, Result, bail};
4use oci_client::secrets::RegistryAuth;
5use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone)]
9pub struct WasmV1Client {
10 endpoint: RegistryEndpoint,
11 auth: RegistryAuth,
12 http: reqwest::Client,
13}
14
15impl WasmV1Client {
16 pub fn new(endpoint: RegistryEndpoint, auth: RegistryAuth) -> Result<Self> {
17 Self::new_with_headers(endpoint, auth, HeaderMap::new())
18 }
19
20 pub fn new_with_headers(
24 endpoint: RegistryEndpoint,
25 auth: RegistryAuth,
26 extra: HeaderMap,
27 ) -> Result<Self> {
28 let mut headers = HeaderMap::new();
29 headers.insert(
30 USER_AGENT,
31 HeaderValue::from_str(concat!("molt-registry-client/", env!("CARGO_PKG_VERSION")))
32 .context("invalid user-agent header")?,
33 );
34 headers.extend(extra);
35
36 let http = reqwest::Client::builder()
37 .default_headers(headers)
38 .build()
39 .context("failed to build HTTP client")?;
40
41 Ok(Self {
42 endpoint,
43 auth,
44 http,
45 })
46 }
47
48 pub fn from_env() -> Result<Option<Self>> {
49 let Ok(base_url) = std::env::var("MOLT_REGISTRY") else {
50 return Ok(None);
51 };
52 if base_url.trim().is_empty() {
53 return Ok(None);
54 }
55
56 let endpoint = RegistryEndpoint::parse(&base_url)?;
57 let auth = auth_from_env()?;
58 Ok(Some(Self::new(endpoint, auth)?))
59 }
60
61 pub fn endpoint(&self) -> &RegistryEndpoint {
62 &self.endpoint
63 }
64
65 fn url(&self, path: &str) -> String {
66 let base = self.endpoint.base_url.as_str().trim_end_matches('/');
67 let path = path.trim_start_matches('/');
68 format!("{base}/{path}")
69 }
70
71 pub async fn wit_text(
72 &self,
73 repo: &str,
74 reference: &str,
75 opts: &WitRequest,
76 ) -> Result<WitTextResponse> {
77 let url = self.url(&format!(
78 "/wasm/v1/{}/wit/{}",
79 repo.trim_matches('/'),
80 reference.trim_matches('/')
81 ));
82
83 let artifact_type = opts
84 .artifact_type
85 .as_deref()
86 .unwrap_or(WIT_ARTIFACT_TYPE_V1);
87
88 let mut req = self.http.get(url);
89 req = apply_reqwest_auth(req, &self.auth);
90 req = req.query(&[("artifactType", artifact_type)]);
91 if let Some(package) = opts.package.as_deref() {
92 req = req.query(&[("package", package)]);
93 }
94
95 let resp = req.send().await.context("failed to call registry")?;
96 let status = resp.status();
97
98 let etag = resp
99 .headers()
100 .get("ETag")
101 .and_then(|v| v.to_str().ok())
102 .map(|s| s.to_string());
103 let subject = resp
104 .headers()
105 .get("OCI-Subject")
106 .and_then(|v| v.to_str().ok())
107 .map(|s| s.to_string());
108 let referrer_digest = resp
109 .headers()
110 .get("WIT-Referrer-Digest")
111 .and_then(|v| v.to_str().ok())
112 .map(|s| s.to_string());
113
114 let body = resp.text().await.unwrap_or_default();
115
116 match status.as_u16() {
117 200 => Ok(WitTextResponse {
118 text: body,
119 etag,
120 subject_digest: subject,
121 referrer_manifest_digest: referrer_digest,
122 }),
123 202 => bail!("WIT not available yet (202 Accepted). retry later.\n{body}"),
124 404 => bail!("WIT not found (404).\n{body}"),
125 409 => bail!("WIT referrer is ambiguous (409).\n{body}"),
126 code => bail!("registry returned HTTP {code}.\n{body}"),
127 }
128 }
129
130 pub async fn interfaces(&self, repo: &str, reference: &str) -> Result<InterfacesResponse> {
131 let url = self.url(&format!(
132 "/wasm/v1/{}/interfaces/{}",
133 repo.trim_matches('/'),
134 reference.trim_matches('/')
135 ));
136 let req = apply_reqwest_auth(self.http.get(url), &self.auth);
137 let resp = req.send().await.context("failed to call registry")?;
138 let status = resp.status();
139 let body = resp.text().await.unwrap_or_default();
140 if !status.is_success() {
141 bail!("registry returned HTTP {}.\n{body}", status.as_u16());
142 }
143 serde_json::from_str(&body).context("failed to parse interfaces JSON")
144 }
145
146 pub async fn dependencies(&self, repo: &str, reference: &str) -> Result<InterfacesResponse> {
147 let url = self.url(&format!(
148 "/wasm/v1/{}/dependencies/{}",
149 repo.trim_matches('/'),
150 reference.trim_matches('/')
151 ));
152 let req = apply_reqwest_auth(self.http.get(url), &self.auth);
153 let resp = req.send().await.context("failed to call registry")?;
154 let status = resp.status();
155 let body = resp.text().await.unwrap_or_default();
156 if !status.is_success() {
157 bail!("registry returned HTTP {}.\n{body}", status.as_u16());
158 }
159 serde_json::from_str(&body).context("failed to parse dependencies JSON")
160 }
161
162 pub async fn search(&self, q: &SearchQuery) -> Result<SearchResponse> {
163 let url = self.url("/wasm/v1/search");
164 let mut req = apply_reqwest_auth(self.http.get(url), &self.auth);
165
166 for v in &q.exports {
167 req = req.query(&[("export", v)]);
168 }
169 for v in &q.imports {
170 req = req.query(&[("import", v)]);
171 }
172 if let Some(os) = q.os.as_deref() {
173 req = req.query(&[("os", os)]);
174 }
175 if let Some(limit) = q.limit {
176 req = req.query(&[("limit", &limit.to_string())]);
177 }
178 if let Some(cursor) = q.cursor.as_deref() {
179 req = req.query(&[("cursor", cursor)]);
180 }
181
182 let resp = req.send().await.context("failed to call registry")?;
183 let status = resp.status();
184 let body = resp.text().await.unwrap_or_default();
185 if !status.is_success() {
186 bail!("registry returned HTTP {}.\n{body}", status.as_u16());
187 }
188 serde_json::from_str(&body).context("failed to parse search JSON")
189 }
190}
191
192#[derive(Debug, Clone, Default)]
193pub struct WitRequest {
194 pub artifact_type: Option<String>,
195 pub package: Option<String>,
196}
197
198#[derive(Debug, Clone)]
199pub struct WitTextResponse {
200 pub text: String,
201 pub etag: Option<String>,
202 pub subject_digest: Option<String>,
203 pub referrer_manifest_digest: Option<String>,
204}
205
206#[derive(Debug, Deserialize, Serialize)]
207#[serde(rename_all = "camelCase")]
208pub struct ReferrerDescriptor {
209 pub digest: String,
210 #[serde(default)]
211 pub artifact_type: Option<String>,
212 #[serde(default)]
213 pub media_type: Option<String>,
214 #[serde(default)]
215 pub size: Option<u64>,
216}
217
218#[derive(Debug, Deserialize, Serialize)]
219#[serde(rename_all = "camelCase")]
220pub struct InterfacesResponse {
221 pub repo: String,
222 pub reference: String,
223 pub digest: String,
224 pub os: String,
225 pub imports: Vec<String>,
226 pub exports: Vec<String>,
227 #[serde(default)]
228 pub target: Option<String>,
229 #[serde(default)]
230 pub subject_digest: Option<String>,
231 #[serde(default)]
232 pub referrers: Vec<ReferrerDescriptor>,
233}
234
235#[derive(Debug, Clone, Default)]
236pub struct SearchQuery {
237 pub exports: Vec<String>,
238 pub imports: Vec<String>,
239 pub os: Option<String>,
240 pub limit: Option<u32>,
241 pub cursor: Option<String>,
242}
243
244#[derive(Debug, Deserialize, Serialize)]
245#[serde(rename_all = "camelCase")]
246pub struct SearchResult {
247 pub repo: String,
248 pub digest: String,
249 #[serde(default)]
250 pub tags: Vec<String>,
251 pub os: String,
252 pub imports: Vec<String>,
253 pub exports: Vec<String>,
254 pub updated_at: i64,
255}
256
257#[derive(Debug, Deserialize, Serialize)]
258#[serde(rename_all = "camelCase")]
259pub struct SearchResponse {
260 pub results: Vec<SearchResult>,
261 #[serde(default)]
262 pub next_cursor: Option<String>,
263}