1use std::{borrow::Cow, sync::Arc};
23
24use anapaya_ead_client::client::{CrpcEndhostApiDiscoveryClient, EndhostApiDiscoveryClient};
25use anapaya_ead_models::{EndhostApiGroup, EndhostApiInfo};
26use snap_control::reexport::TokenSource;
27use url::Url;
28
29pub mod models {
31 pub use anapaya_ead_models::{EndhostApiGroup, EndhostApiInfo};
32}
33
34#[derive(Debug, thiserror::Error)]
39#[error("failed to retrieve endhost APIs: {message}")]
40#[non_exhaustive]
41pub struct EndhostApiSourceError {
42 message: Cow<'static, str>,
43 transient: bool,
44 #[source]
45 source: Option<Box<dyn std::error::Error + Send + Sync>>,
46}
47
48impl EndhostApiSourceError {
49 #[must_use]
53 pub fn new(message: impl Into<Cow<'static, str>>, transient: bool) -> Self {
54 Self {
55 message: message.into(),
56 transient,
57 source: None,
58 }
59 }
60
61 #[must_use]
65 pub fn with_source(
66 message: impl Into<Cow<'static, str>>,
67 transient: bool,
68 source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
69 ) -> Self {
70 Self {
71 message: message.into(),
72 transient,
73 source: Some(source.into()),
74 }
75 }
76
77 #[must_use]
79 pub fn is_transient(&self) -> bool {
80 self.transient
81 }
82}
83
84#[async_trait::async_trait]
89pub trait EndhostApiSource: Send + Sync + 'static {
90 async fn endhost_apis(&self) -> Result<Vec<EndhostApiGroup>, EndhostApiSourceError>;
92}
93
94pub struct StaticEndhostApiDiscovery {
97 discovery_apis: Vec<Url>,
98 http_client: Option<reqwest::Client>,
99}
100
101impl StaticEndhostApiDiscovery {
102 const GLOBAL_DISCOVERY_APIS: &[&'static str] = &["https://discovery.scion.anapaya.net"];
103
104 #[must_use]
106 pub fn new(discovery_apis: Vec<Url>) -> Self {
107 Self {
108 discovery_apis,
109 http_client: None,
110 }
111 }
112
113 #[must_use]
115 pub fn global() -> Self {
116 let discovery_apis = Self::GLOBAL_DISCOVERY_APIS
117 .iter()
118 .map(|url_str| Url::parse(url_str).expect("Invalid URL in GLOBAL_DISCOVERY_APIS"))
119 .collect();
120
121 Self::new(discovery_apis)
122 }
123
124 #[must_use]
126 pub fn with_http_client(mut self, http_client: reqwest::Client) -> Self {
127 self.http_client = Some(http_client);
128 self
129 }
130}
131
132#[async_trait::async_trait]
133impl EndhostApiSource for StaticEndhostApiDiscovery {
134 async fn endhost_apis(&self) -> Result<Vec<EndhostApiGroup>, EndhostApiSourceError> {
136 if self.discovery_apis.is_empty() {
137 return Err(EndhostApiSourceError::new(
138 "no endhost API discovery APIs configured in StaticEndhostApiDiscovery",
139 false,
140 ));
141 }
142
143 discover_endhost_apis(&self.discovery_apis, self.http_client.as_ref(), None).await
144 }
145}
146
147#[derive(Default)]
149pub struct StaticEndhostApis {
150 groups: Vec<EndhostApiGroup>,
152}
153
154impl StaticEndhostApis {
155 #[must_use]
157 pub fn new() -> Self {
158 Self { groups: Vec::new() }
159 }
160
161 #[must_use]
171 pub fn add_group(mut self, group: Vec<Url>) -> Self {
172 self.groups.push(EndhostApiGroup {
173 apis: group
174 .into_iter()
175 .map(|url| EndhostApiInfo { address: url })
176 .collect(),
177 });
178
179 self
180 }
181}
182
183#[async_trait::async_trait]
184impl EndhostApiSource for StaticEndhostApis {
185 async fn endhost_apis(&self) -> Result<Vec<EndhostApiGroup>, EndhostApiSourceError> {
187 Ok(self.groups.clone())
188 }
189}
190
191async fn discover_endhost_apis(
197 discovery_apis: &[Url],
198 http_client: Option<&reqwest::Client>,
199 token_source: Option<Arc<dyn TokenSource>>,
200) -> Result<Vec<EndhostApiGroup>, EndhostApiSourceError> {
201 let mut last_error = None;
202 for discovery_api in discovery_apis {
203 let client = {
205 let client = match http_client {
206 Some(http_client) => {
207 CrpcEndhostApiDiscoveryClient::new_with_client(
208 discovery_api,
209 http_client.clone(),
210 )
211 }
212 None => CrpcEndhostApiDiscoveryClient::new(discovery_api),
213 };
214 let mut client = match client {
215 Ok(client) => client,
216 Err(e) => {
217 tracing::warn!(%discovery_api, error = ?e, "Failed to create Endhost API discovery client");
218 last_error = Some(EndhostApiSourceError::with_source(
220 format!(
221 "failed to create endhost API discovery client for {discovery_api}"
222 ),
223 false,
224 e,
225 ));
226 continue;
227 }
228 };
229
230 if let Some(token_source) = token_source.clone() {
231 client.use_token_source(token_source);
232 }
233
234 client
235 };
236
237 match client.discover_endhost_apis().await {
238 Ok(discovered_apis) => {
239 tracing::debug!(%discovery_api, "Successfully discovered Endhost APIs");
240 return Ok(discovered_apis);
241 }
242 Err(e) => {
243 tracing::warn!(%discovery_api, error = ?e, "Failed to discover Endhost APIs");
244 last_error = Some(EndhostApiSourceError::with_source(
246 format!("failed to discover endhost APIs via {discovery_api}"),
247 true,
248 e,
249 ));
250 }
251 }
252 }
253
254 match last_error {
257 Some(e) => {
258 let transient = e.is_transient();
259 Err(EndhostApiSourceError::with_source(
260 "failed to discover endhost APIs using any configured discovery API",
261 transient,
262 e,
263 ))
264 }
265 None => {
266 Err(EndhostApiSourceError::new(
267 "attempted to discover endhost APIs with empty list of discovery APIs",
268 false,
269 ))
270 }
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[test]
279 fn endhost_api_source_error_transient_classification() {
280 assert!(EndhostApiSourceError::new("boom", true).is_transient());
281 assert!(!EndhostApiSourceError::new("boom", false).is_transient());
282
283 let src = || std::io::Error::other("cause");
284 assert!(EndhostApiSourceError::with_source("boom", true, src()).is_transient());
285 assert!(!EndhostApiSourceError::with_source("boom", false, src()).is_transient());
286 }
287}