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}