confium_registry/
client.rs1use std::collections::HashMap;
15
16use crate::error::{Error, Result};
17use crate::manifest::{Manifest, PluginIndex, RegistryIndex, TrustRootsFile};
18
19const INDEX_PATH: &str = "/index.toml";
21const TRUST_ROOTS_PATH: &str = "/trust-roots.toml";
22
23pub trait Fetcher {
30 fn fetch(&self, path: &str) -> Result<Vec<u8>>;
31}
32
33#[derive(Default, Clone)]
39pub struct MemoryFetcher {
40 base_url: String,
41 docs: HashMap<String, Vec<u8>>,
42}
43
44impl MemoryFetcher {
45 pub fn new(base_url: impl Into<String>) -> Self {
47 MemoryFetcher {
48 base_url: base_url.into(),
49 docs: HashMap::new(),
50 }
51 }
52
53 pub fn base_url(&self) -> &str {
55 &self.base_url
56 }
57
58 pub fn with(mut self, path: impl Into<String>, body: impl Into<Vec<u8>>) -> Self {
61 self.docs.insert(path.into(), body.into());
62 self
63 }
64}
65
66impl Fetcher for MemoryFetcher {
67 fn fetch(&self, path: &str) -> Result<Vec<u8>> {
68 self.docs.get(path).cloned().ok_or_else(|| Error::NotFound {
69 path: path.to_string(),
70 })
71 }
72}
73
74pub struct Client<F: Fetcher = MemoryFetcher> {
79 base_url: String,
80 fetcher: F,
81}
82
83impl Client<MemoryFetcher> {
84 pub fn new(base_url: impl Into<String>) -> Self {
88 let base_url = base_url.into();
89 Client {
90 base_url: base_url.clone(),
91 fetcher: MemoryFetcher::new(base_url),
92 }
93 }
94}
95
96impl<F: Fetcher> Client<F> {
97 pub fn with_fetcher(base_url: impl Into<String>, fetcher: F) -> Self {
99 Client {
100 base_url: base_url.into(),
101 fetcher,
102 }
103 }
104
105 pub fn base_url(&self) -> &str {
107 &self.base_url
108 }
109
110 pub fn index(&self) -> Result<RegistryIndex> {
112 let body = self.fetch(INDEX_PATH)?;
113 self.parse(INDEX_PATH, &body)
114 }
115
116 pub fn trust_roots(&self) -> Result<TrustRootsFile> {
118 let body = self.fetch(TRUST_ROOTS_PATH)?;
119 self.parse(TRUST_ROOTS_PATH, &body)
120 }
121
122 pub fn plugin_index(&self, versions_url: &str) -> Result<PluginIndex> {
127 let body = self.fetch(versions_url)?;
128 self.parse(versions_url, &body)
129 }
130
131 pub fn manifest(&self, manifest_url: &str) -> Result<Manifest> {
133 let body = self.fetch(manifest_url)?;
134 self.parse(manifest_url, &body)
135 }
136
137 pub fn resolve(&self, name: &str, version: Option<&str>) -> Result<Manifest> {
141 let index = self.index()?;
142 let entry = index
143 .plugins
144 .iter()
145 .find(|p| p.name == name)
146 .ok_or_else(|| Error::PluginNotFound {
147 name: name.to_string(),
148 })?;
149 let plugin_index = self.plugin_index(&entry.versions_url)?;
150 let target_version = match version {
151 Some(v) => v.to_string(),
152 None => plugin_index.latest.clone(),
153 };
154 let version_entry = plugin_index
155 .versions
156 .iter()
157 .find(|v| v.version == target_version)
158 .ok_or_else(|| Error::VersionNotFound {
159 name: name.to_string(),
160 version: target_version.clone(),
161 })?;
162 self.manifest(&version_entry.manifest_url)
163 }
164
165 fn fetch(&self, path: &str) -> Result<Vec<u8>> {
167 self.fetcher.fetch(path)
168 }
169
170 fn parse<T>(&self, path: &str, body: &[u8]) -> Result<T>
171 where
172 T: serde::de::DeserializeOwned,
173 {
174 let src = std::str::from_utf8(body).map_err(|e| Error::Fetch {
175 path: path.to_string(),
176 message: format!("invalid UTF-8: {e}"),
177 })?;
178 toml::from_str(src).map_err(|e| Error::TomlParse {
179 path: path.to_string(),
180 source: e,
181 })
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 fn sample_fetcher() -> MemoryFetcher {
190 MemoryFetcher::new("https://example.test")
191 .with(
192 INDEX_PATH,
193 r#"
194[[plugin]]
195name = "botan"
196latest = "3.2.0"
197description = "Botan"
198publishers = ["ribose"]
199versions-url = "/plugins/botan/index.toml"
200"#,
201 )
202 .with(
203 "/plugins/botan/index.toml",
204 r#"
205name = "botan"
206latest = "3.2.0"
207description = "Botan"
208
209[[version]]
210version = "3.2.0"
211manifest-url = "/plugins/botan/3.2.0/manifest.toml"
212"#,
213 )
214 .with(
215 "/plugins/botan/3.2.0/manifest.toml",
216 r#"
217[plugin]
218name = "botan"
219version = "3.2.0"
220publisher = "ribose"
221
222[artifact]
223url = "https://example.test/x.so"
224size = 10
225sha256 = "abcd"
226"#,
227 )
228 }
229
230 #[test]
231 fn client_resolves_latest() {
232 let client = Client::with_fetcher("https://example.test", sample_fetcher());
233 let manifest = client.resolve("botan", None).expect("resolve");
234 assert_eq!(manifest.plugin.name, "botan");
235 assert_eq!(manifest.plugin.version, "3.2.0");
236 }
237
238 #[test]
239 fn client_resolves_pinned_version() {
240 let client = Client::with_fetcher("https://example.test", sample_fetcher());
241 let manifest = client
242 .resolve("botan", Some("3.2.0"))
243 .expect("resolve pinned");
244 assert_eq!(manifest.plugin.version, "3.2.0");
245 }
246
247 #[test]
248 fn missing_plugin_errors() {
249 let client = Client::with_fetcher("https://example.test", sample_fetcher());
250 let err = client.resolve("ghost", None).unwrap_err();
251 assert!(matches!(
252 err,
253 Error::PluginNotFound { ref name } if name == "ghost"
254 ));
255 }
256
257 #[test]
258 fn missing_version_errors() {
259 let client = Client::with_fetcher("https://example.test", sample_fetcher());
260 let err = client.resolve("botan", Some("9.9.9")).unwrap_err();
261 assert!(matches!(
262 err,
263 Error::VersionNotFound { ref name, ref version }
264 if name == "botan" && version == "9.9.9"
265 ));
266 }
267}