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 sha2::Digest;
7use std::time::Duration;
8use switchgear_service_api::lnurl::LnUrlOfferMetadata;
9use switchgear_service_api::offer::{
10    HttpOfferClient, Offer, OfferMetadata, OfferMetadataStore, OfferProvider, OfferRecord,
11    OfferStore,
12};
13use switchgear_service_api::service::ServiceErrorSource;
14use url::Url;
15use uuid::Uuid;
16
17#[derive(Clone, Debug)]
18pub struct HttpOfferStore {
19    client: Client,
20    offer_url: String,
21    metadata_url: String,
22    health_check_url: String,
23}
24
25impl HttpOfferStore {
26    pub fn create<U: IntoUrl>(
27        base_url: U,
28        total_timeout: Duration,
29        connect_timeout: Duration,
30        trusted_roots: &[CertificateDer],
31        authorization: String,
32    ) -> Result<Self, OfferStoreError> {
33        let mut headers = HeaderMap::new();
34        let mut auth_value =
35            HeaderValue::from_str(&format!("Bearer {authorization}")).map_err(|e| {
36                OfferStoreError::internal_error(
37                    ServiceErrorSource::Internal,
38                    format!("creating http client with base url: {}", base_url.as_str()),
39                    e.to_string(),
40                )
41            })?;
42        auth_value.set_sensitive(true);
43        headers.insert(reqwest::header::AUTHORIZATION, auth_value);
44
45        let mut builder = ClientBuilder::new();
46        for root in trusted_roots {
47            let root = Certificate::from_der(root).map_err(|e| {
48                OfferStoreError::internal_error(
49                    ServiceErrorSource::Internal,
50                    format!("parsing certificate for url: {}", base_url.as_str()),
51                    e.to_string(),
52                )
53            })?;
54            builder = builder.add_root_certificate(root);
55        }
56
57        let client = builder
58            .default_headers(headers)
59            .use_rustls_tls()
60            .timeout(total_timeout)
61            .connect_timeout(connect_timeout)
62            .build()
63            .map_err(|e| {
64                OfferStoreError::http_error(
65                    ServiceErrorSource::Internal,
66                    format!("creating http client with base url: {}", base_url.as_str()),
67                    e,
68                )
69            })?;
70        Self::with_client(client, base_url)
71    }
72
73    fn with_client<U: IntoUrl>(client: Client, base_url: U) -> Result<Self, OfferStoreError> {
74        let base_url = base_url.as_str().trim_end_matches('/').to_string();
75
76        let offer_url = format!("{base_url}/offers");
77        Url::parse(&offer_url).map_err(|e| {
78            OfferStoreError::internal_error(
79                ServiceErrorSource::Upstream,
80                format!("parsing service url {offer_url}"),
81                e.to_string(),
82            )
83        })?;
84
85        let metadata_url = format!("{base_url}/metadata");
86        Url::parse(&offer_url).map_err(|e| {
87            OfferStoreError::internal_error(
88                ServiceErrorSource::Upstream,
89                format!("parsing service url {metadata_url}"),
90                e.to_string(),
91            )
92        })?;
93
94        let health_check_url = format!("{base_url}/health");
95        Url::parse(&health_check_url).map_err(|e| {
96            OfferStoreError::internal_error(
97                ServiceErrorSource::Upstream,
98                format!("parsing service url {health_check_url}"),
99                e.to_string(),
100            )
101        })?;
102
103        Ok(Self {
104            client,
105            offer_url,
106            metadata_url,
107            health_check_url,
108        })
109    }
110
111    fn offers_partition_url(&self, partition: &str) -> String {
112        format!("{}/{}", self.offer_url, partition)
113    }
114
115    fn offers_partition_id_url(&self, partition: &str, id: &Uuid) -> String {
116        format!("{}/{}", self.offers_partition_url(partition), id)
117    }
118
119    fn metadata_partition_url(&self, partition: &str) -> String {
120        format!("{}/{}", self.metadata_url, partition)
121    }
122
123    fn metadata_partition_id_url(&self, partition: &str, id: &Uuid) -> String {
124        format!("{}/{}", self.metadata_partition_url(partition), id)
125    }
126
127    fn general_error(status: StatusCode, context: &str) -> OfferStoreError {
128        if status.is_success() {
129            return OfferStoreError::internal_error(
130                ServiceErrorSource::Upstream,
131                context.to_string(),
132                format!("unexpected http status {status}"),
133            );
134        }
135        if status.is_client_error() {
136            return OfferStoreError::invalid_input_error(
137                context.to_string(),
138                format!("invalid input, http status: {status}"),
139            );
140        }
141        OfferStoreError::http_status_error(
142            ServiceErrorSource::Upstream,
143            context.to_string(),
144            status.as_u16(),
145        )
146    }
147}
148
149#[async_trait]
150impl OfferStore for HttpOfferStore {
151    type Error = OfferStoreError;
152
153    async fn get_offer(
154        &self,
155        partition: &str,
156        id: &Uuid,
157    ) -> Result<Option<OfferRecord>, Self::Error> {
158        let url = self.offers_partition_id_url(partition, id);
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 OfferProvider for HttpOfferStore {
430    type Error = OfferStoreError;
431
432    async fn offer(
433        &self,
434        _hostname: &str,
435        partition: &str,
436        id: &Uuid,
437    ) -> Result<Option<Offer>, Self::Error> {
438        if let Some(offer) = self.get_offer(partition, id).await? {
439            let offer_metadata = match self
440                .get_metadata(partition, &offer.offer.metadata_id)
441                .await?
442            {
443                Some(metadata) => metadata,
444                None => {
445                    return Ok(None);
446                }
447            };
448
449            let lnurl_metadata = LnUrlOfferMetadata(offer_metadata.metadata);
450            let metadata_json_string = serde_json::to_string(&lnurl_metadata).map_err(|e| {
451                OfferStoreError::serialization_error(
452                    ServiceErrorSource::Internal,
453                    format!("building LNURL offer response for offer {}", offer.id),
454                    e,
455                )
456            })?;
457
458            let metadata_json_hash = sha2::Sha256::digest(metadata_json_string.as_bytes())
459                .to_vec()
460                .try_into()
461                .map_err(|_| {
462                    OfferStoreError::hash_conversion_error(
463                        ServiceErrorSource::Internal,
464                        format!("generating metadata hash for offer {}", offer.id),
465                    )
466                })?;
467
468            Ok(Some(Offer {
469                partition: offer.partition,
470                id: offer.id,
471                max_sendable: offer.offer.max_sendable,
472                min_sendable: offer.offer.min_sendable,
473                metadata_json_string,
474                metadata_json_hash,
475                timestamp: offer.offer.timestamp,
476                expires: offer.offer.expires,
477            }))
478        } else {
479            Ok(None)
480        }
481    }
482}
483
484#[async_trait]
485impl HttpOfferClient for HttpOfferStore {
486    async fn health(&self) -> Result<(), <Self as OfferStore>::Error> {
487        let response = self
488            .client
489            .get(&self.health_check_url)
490            .send()
491            .await
492            .map_err(|e| {
493                OfferStoreError::http_error(ServiceErrorSource::Upstream, "health check", e)
494            })?;
495        if !response.status().is_success() {
496            return Err(OfferStoreError::http_status_error(
497                ServiceErrorSource::Upstream,
498                "health check",
499                response.status().as_u16(),
500            ));
501        }
502        Ok(())
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use crate::offer::http::HttpOfferStore;
509    use url::Url;
510    use uuid::Uuid;
511
512    #[test]
513    fn base_urls() {
514        let client = HttpOfferStore::with_client(
515            reqwest::Client::default(),
516            Url::parse("https://offers-base.com").unwrap(),
517        )
518        .unwrap();
519
520        assert_eq!(&client.offer_url, "https://offers-base.com/offers");
521        assert_eq!(&client.metadata_url, "https://offers-base.com/metadata");
522
523        let client = HttpOfferStore::with_client(
524            reqwest::Client::default(),
525            Url::parse("https://offers-base.com/").unwrap(),
526        )
527        .unwrap();
528
529        assert_eq!(&client.offer_url, "https://offers-base.com/offers");
530        assert_eq!(&client.metadata_url, "https://offers-base.com/metadata");
531
532        assert_eq!(&client.health_check_url, "https://offers-base.com/health");
533
534        let offers_partition_url = client.offers_partition_url("partition");
535        assert_eq!(
536            "https://offers-base.com/offers/partition",
537            offers_partition_url,
538        );
539
540        let id = Uuid::new_v4();
541        let offers_partition_id_url = client.offers_partition_id_url("partition", &id);
542        assert_eq!(
543            format!("https://offers-base.com/offers/partition/{id}"),
544            offers_partition_id_url,
545        );
546
547        let metadata_partition_url = client.metadata_partition_url("partition");
548        assert_eq!(
549            "https://offers-base.com/metadata/partition",
550            metadata_partition_url,
551        );
552
553        let id = Uuid::new_v4();
554        let metadata_partition_id_url = client.metadata_partition_id_url("partition", &id);
555        assert_eq!(
556            format!("https://offers-base.com/metadata/partition/{id}"),
557            metadata_partition_id_url,
558        );
559    }
560}