switchgear_service/components/offer/
http.rs

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