1use crate::offer::error::OfferStoreError;
2use async_trait::async_trait;
3use axum::http::{HeaderMap, HeaderValue};
4use reqwest::{Certificate, Client, ClientBuilder, IntoUrl, StatusCode};
5use rustls::pki_types::CertificateDer;
6use std::time::Duration;
7use switchgear_service_api::offer::{
8 HttpOfferClient, OfferMetadata, OfferMetadataStore, OfferRecord, OfferStore,
9};
10use switchgear_service_api::service::ServiceErrorSource;
11use url::Url;
12use uuid::Uuid;
13
14#[derive(Clone, Debug)]
15pub struct HttpOfferStore {
16 client: Client,
17 offer_url: String,
18 metadata_url: String,
19 health_check_url: String,
20}
21
22impl HttpOfferStore {
23 pub fn create<U: IntoUrl>(
24 base_url: U,
25 total_timeout: Duration,
26 connect_timeout: Duration,
27 trusted_roots: &[CertificateDer],
28 authorization: String,
29 ) -> Result<Self, OfferStoreError> {
30 let mut headers = HeaderMap::new();
31 let mut auth_value =
32 HeaderValue::from_str(&format!("Bearer {authorization}")).map_err(|e| {
33 OfferStoreError::internal_error(
34 ServiceErrorSource::Internal,
35 format!("creating http client with base url: {}", base_url.as_str()),
36 e.to_string(),
37 )
38 })?;
39 auth_value.set_sensitive(true);
40 headers.insert(reqwest::header::AUTHORIZATION, auth_value);
41
42 let mut builder = ClientBuilder::new();
43 for root in trusted_roots {
44 let root = Certificate::from_der(root).map_err(|e| {
45 OfferStoreError::internal_error(
46 ServiceErrorSource::Internal,
47 format!("parsing certificate for url: {}", base_url.as_str()),
48 e.to_string(),
49 )
50 })?;
51 builder = builder.add_root_certificate(root);
52 }
53
54 let client = builder
55 .default_headers(headers)
56 .use_rustls_tls()
57 .timeout(total_timeout)
58 .connect_timeout(connect_timeout)
59 .build()
60 .map_err(|e| {
61 OfferStoreError::http_error(
62 ServiceErrorSource::Internal,
63 format!("creating http client with base url: {}", base_url.as_str()),
64 e,
65 )
66 })?;
67 Self::with_client(client, base_url)
68 }
69
70 fn with_client<U: IntoUrl>(client: Client, base_url: U) -> Result<Self, OfferStoreError> {
71 let base_url = base_url.as_str().trim_end_matches('/').to_string();
72
73 let offer_url = format!("{base_url}/offers");
74 Url::parse(&offer_url).map_err(|e| {
75 OfferStoreError::internal_error(
76 ServiceErrorSource::Upstream,
77 format!("parsing service url {offer_url}"),
78 e.to_string(),
79 )
80 })?;
81
82 let metadata_url = format!("{base_url}/metadata");
83 Url::parse(&offer_url).map_err(|e| {
84 OfferStoreError::internal_error(
85 ServiceErrorSource::Upstream,
86 format!("parsing service url {metadata_url}"),
87 e.to_string(),
88 )
89 })?;
90
91 let health_check_url = format!("{base_url}/health");
92 Url::parse(&health_check_url).map_err(|e| {
93 OfferStoreError::internal_error(
94 ServiceErrorSource::Upstream,
95 format!("parsing service url {health_check_url}"),
96 e.to_string(),
97 )
98 })?;
99
100 Ok(Self {
101 client,
102 offer_url,
103 metadata_url,
104 health_check_url,
105 })
106 }
107
108 fn offers_partition_url(&self, partition: &str) -> String {
109 format!("{}/{}", self.offer_url, partition)
110 }
111
112 fn offers_partition_id_url(&self, partition: &str, id: &Uuid) -> String {
113 format!("{}/{}", self.offers_partition_url(partition), id)
114 }
115
116 fn metadata_partition_url(&self, partition: &str) -> String {
117 format!("{}/{}", self.metadata_url, partition)
118 }
119
120 fn metadata_partition_id_url(&self, partition: &str, id: &Uuid) -> String {
121 format!("{}/{}", self.metadata_partition_url(partition), id)
122 }
123
124 fn general_error(status: StatusCode, context: &str) -> OfferStoreError {
125 if status.is_success() {
126 return OfferStoreError::internal_error(
127 ServiceErrorSource::Upstream,
128 context.to_string(),
129 format!("unexpected http status {status}"),
130 );
131 }
132 if status.is_client_error() {
133 return OfferStoreError::invalid_input_error(
134 context.to_string(),
135 format!("invalid input, http status: {status}"),
136 );
137 }
138 OfferStoreError::http_status_error(
139 ServiceErrorSource::Upstream,
140 context.to_string(),
141 status.as_u16(),
142 )
143 }
144}
145
146#[async_trait]
147impl OfferStore for HttpOfferStore {
148 type Error = OfferStoreError;
149
150 async fn get_offer(
151 &self,
152 partition: &str,
153 id: &Uuid,
154 sparse: Option<bool>,
155 ) -> Result<Option<OfferRecord>, Self::Error> {
156 let sparse = sparse.unwrap_or(true);
157 let url = self.offers_partition_id_url(partition, id);
158 let url = format!("{url}?sparse={sparse}");
159 let response = self.client.get(&url).send().await.map_err(|e| {
160 OfferStoreError::http_error(ServiceErrorSource::Upstream, format!("get offer {url}"), e)
161 })?;
162
163 match response.status() {
164 StatusCode::OK => {
165 let offer = response.json::<OfferRecord>().await.map_err(|e| {
166 OfferStoreError::deserialization_error(
167 ServiceErrorSource::Upstream,
168 format!("parsing offer {id}"),
169 e,
170 )
171 })?;
172 Ok(Some(offer))
173 }
174 StatusCode::NOT_FOUND => Ok(None),
175 status => Err(Self::general_error(status, &format!("get offer {url}"))),
176 }
177 }
178
179 async fn get_offers(
180 &self,
181 partition: &str,
182 start: usize,
183 count: usize,
184 ) -> Result<Vec<OfferRecord>, Self::Error> {
185 let url = self.offers_partition_url(partition);
186 let url = format!("{url}?start={start}&count={count}");
187 let response = self.client.get(&url).send().await.map_err(|e| {
188 OfferStoreError::http_error(
189 ServiceErrorSource::Upstream,
190 format!("get all offers {url}"),
191 e,
192 )
193 })?;
194
195 match response.status() {
196 StatusCode::OK => {
197 let offer_records = response.json::<Vec<OfferRecord>>().await.map_err(|e| {
198 OfferStoreError::deserialization_error(
199 ServiceErrorSource::Upstream,
200 format!("parsing all offers for {url}"),
201 e,
202 )
203 })?;
204 Ok(offer_records)
205 }
206 status => Err(Self::general_error(
207 status,
208 &format!("get all offers {url}"),
209 )),
210 }
211 }
212
213 async fn post_offer(&self, offer: OfferRecord) -> Result<Option<Uuid>, Self::Error> {
214 let response = self
215 .client
216 .post(&self.offer_url)
217 .json(&offer)
218 .send()
219 .await
220 .map_err(|e| {
221 OfferStoreError::http_error(
222 ServiceErrorSource::Upstream,
223 format!("post offer: {}, url: {}", offer.id, &self.offer_url),
224 e,
225 )
226 })?;
227
228 match response.status() {
229 StatusCode::CREATED => Ok(Some(offer.id)),
230 StatusCode::CONFLICT => Ok(None),
231 status => Err(Self::general_error(
232 status,
233 &format!("post offer: {}, url: {}", offer.id, &self.offer_url),
234 )),
235 }
236 }
237
238 async fn put_offer(&self, offer: OfferRecord) -> Result<bool, Self::Error> {
239 let url = self.offers_partition_id_url(&offer.partition, &offer.id);
240 let response = self
241 .client
242 .put(&url)
243 .json(&offer)
244 .send()
245 .await
246 .map_err(|e| {
247 OfferStoreError::http_error(
248 ServiceErrorSource::Upstream,
249 format!("put offer {url}"),
250 e,
251 )
252 })?;
253
254 match response.status() {
255 StatusCode::CREATED => Ok(true),
256 StatusCode::NO_CONTENT => Ok(false),
257 status => Err(Self::general_error(status, &format!("put offer {url}"))),
258 }
259 }
260
261 async fn delete_offer(&self, partition: &str, id: &Uuid) -> Result<bool, Self::Error> {
262 let url = self.offers_partition_id_url(partition, id);
263 let response = self.client.delete(&url).send().await.map_err(|e| {
264 OfferStoreError::http_error(
265 ServiceErrorSource::Upstream,
266 format!("delete offer {url}"),
267 e,
268 )
269 })?;
270
271 match response.status() {
272 StatusCode::NO_CONTENT => Ok(true),
273 StatusCode::NOT_FOUND => Ok(false),
274 status => Err(Self::general_error(status, &format!("delete offer {url}"))),
275 }
276 }
277}
278
279#[async_trait]
280impl OfferMetadataStore for HttpOfferStore {
281 type Error = OfferStoreError;
282
283 async fn get_metadata(
284 &self,
285 partition: &str,
286 id: &Uuid,
287 ) -> Result<Option<OfferMetadata>, Self::Error> {
288 let url = self.metadata_partition_id_url(partition, id);
289 let response = self.client.get(&url).send().await.map_err(|e| {
290 OfferStoreError::http_error(
291 ServiceErrorSource::Upstream,
292 format!("get offer metadata {url}"),
293 e,
294 )
295 })?;
296
297 match response.status() {
298 StatusCode::OK => {
299 let metadata = response.json::<OfferMetadata>().await.map_err(|e| {
300 OfferStoreError::deserialization_error(
301 ServiceErrorSource::Upstream,
302 format!("parse offer metadata {url}"),
303 e,
304 )
305 })?;
306 Ok(Some(metadata))
307 }
308 StatusCode::NOT_FOUND => Ok(None),
309 status => Err(Self::general_error(
310 status,
311 &format!("get offer metadata {url}"),
312 )),
313 }
314 }
315
316 async fn get_all_metadata(
317 &self,
318 partition: &str,
319 start: usize,
320 count: usize,
321 ) -> Result<Vec<OfferMetadata>, Self::Error> {
322 let url = self.metadata_partition_url(partition);
323 let url = format!("{url}?start={start}&count={count}");
324 let response = self.client.get(&url).send().await.map_err(|e| {
325 OfferStoreError::http_error(
326 ServiceErrorSource::Upstream,
327 format!("get all metadata {url}"),
328 e,
329 )
330 })?;
331
332 match response.status() {
333 StatusCode::OK => {
334 let metadata_all = response.json::<Vec<OfferMetadata>>().await.map_err(|e| {
335 OfferStoreError::deserialization_error(
336 ServiceErrorSource::Upstream,
337 format!("parse all metadata {url}"),
338 e,
339 )
340 })?;
341 Ok(metadata_all)
342 }
343 status => Err(Self::general_error(
344 status,
345 &format!("get all metadata {url}"),
346 )),
347 }
348 }
349
350 async fn post_metadata(&self, metadata: OfferMetadata) -> Result<Option<Uuid>, Self::Error> {
351 let response = self
352 .client
353 .post(&self.metadata_url)
354 .json(&metadata)
355 .send()
356 .await
357 .map_err(|e| {
358 OfferStoreError::http_error(
359 ServiceErrorSource::Upstream,
360 format!(
361 "post offer metadata {}, url: {}",
362 metadata.id, &self.metadata_url
363 ),
364 e,
365 )
366 })?;
367
368 match response.status() {
369 StatusCode::CREATED => Ok(Some(metadata.id)),
370 StatusCode::CONFLICT => Ok(None),
371 status => Err(Self::general_error(
372 status,
373 &format!(
374 "post offer metadata {}, url: {}",
375 metadata.id, &self.metadata_url
376 ),
377 )),
378 }
379 }
380
381 async fn put_metadata(&self, metadata: OfferMetadata) -> Result<bool, Self::Error> {
382 let url = self.metadata_partition_id_url(&metadata.partition, &metadata.id);
383 let response = self
384 .client
385 .put(&url)
386 .json(&metadata)
387 .send()
388 .await
389 .map_err(|e| {
390 OfferStoreError::http_error(
391 ServiceErrorSource::Upstream,
392 format!("put offer metadata {url}"),
393 e,
394 )
395 })?;
396
397 match response.status() {
398 StatusCode::CREATED => Ok(true),
399 StatusCode::NO_CONTENT => Ok(false),
400 status => Err(Self::general_error(
401 status,
402 &format!("put offer metadata {url}"),
403 )),
404 }
405 }
406
407 async fn delete_metadata(&self, partition: &str, id: &Uuid) -> Result<bool, Self::Error> {
408 let url = self.metadata_partition_id_url(partition, id);
409 let response = self.client.delete(&url).send().await.map_err(|e| {
410 OfferStoreError::http_error(
411 ServiceErrorSource::Upstream,
412 format!("delete offer metadata {url}"),
413 e,
414 )
415 })?;
416
417 match response.status() {
418 StatusCode::NO_CONTENT => Ok(true),
419 StatusCode::NOT_FOUND => Ok(false),
420 status => Err(Self::general_error(
421 status,
422 &format!("delete offer metadata {url}"),
423 )),
424 }
425 }
426}
427
428#[async_trait]
429impl HttpOfferClient for HttpOfferStore {
430 async fn health(&self) -> Result<(), <Self as OfferStore>::Error> {
431 let response = self
432 .client
433 .get(&self.health_check_url)
434 .send()
435 .await
436 .map_err(|e| {
437 OfferStoreError::http_error(ServiceErrorSource::Upstream, "health check", e)
438 })?;
439 if !response.status().is_success() {
440 return Err(OfferStoreError::http_status_error(
441 ServiceErrorSource::Upstream,
442 "health check",
443 response.status().as_u16(),
444 ));
445 }
446 Ok(())
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 use crate::offer::http::HttpOfferStore;
453 use url::Url;
454 use uuid::Uuid;
455
456 #[test]
457 fn base_urls() {
458 let client = HttpOfferStore::with_client(
459 reqwest::Client::default(),
460 Url::parse("https://offers-base.com").unwrap(),
461 )
462 .unwrap();
463
464 assert_eq!(&client.offer_url, "https://offers-base.com/offers");
465 assert_eq!(&client.metadata_url, "https://offers-base.com/metadata");
466
467 let client = HttpOfferStore::with_client(
468 reqwest::Client::default(),
469 Url::parse("https://offers-base.com/").unwrap(),
470 )
471 .unwrap();
472
473 assert_eq!(&client.offer_url, "https://offers-base.com/offers");
474 assert_eq!(&client.metadata_url, "https://offers-base.com/metadata");
475
476 assert_eq!(&client.health_check_url, "https://offers-base.com/health");
477
478 let offers_partition_url = client.offers_partition_url("partition");
479 assert_eq!(
480 "https://offers-base.com/offers/partition",
481 offers_partition_url,
482 );
483
484 let id = Uuid::new_v4();
485 let offers_partition_id_url = client.offers_partition_id_url("partition", &id);
486 assert_eq!(
487 format!("https://offers-base.com/offers/partition/{id}"),
488 offers_partition_id_url,
489 );
490
491 let metadata_partition_url = client.metadata_partition_url("partition");
492 assert_eq!(
493 "https://offers-base.com/metadata/partition",
494 metadata_partition_url,
495 );
496
497 let id = Uuid::new_v4();
498 let metadata_partition_id_url = client.metadata_partition_id_url("partition", &id);
499 assert_eq!(
500 format!("https://offers-base.com/metadata/partition/{id}"),
501 metadata_partition_id_url,
502 );
503 }
504}