switchgear_components/offer/
http.rs

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}