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}