Skip to main content

molt_registry_client/
wasm_v1.rs

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    /// Create a client with extra default headers (in addition to User-Agent).
21    ///
22    /// Note: `Authorization` should be provided via `auth`, not as a raw header.
23    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}