client.rs 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. //! HTTP client for NpubCash API
  2. use std::sync::Arc;
  3. use cdk_http_client::{HttpClient, RawResponse};
  4. use tracing::instrument;
  5. use crate::auth::JwtAuthProvider;
  6. use crate::error::{Error, Result};
  7. use crate::types::{Quote, QuotesResponse};
  8. const API_PATHS_QUOTES: &str = "/api/v2/wallet/quotes";
  9. const PAGINATION_LIMIT: usize = 50;
  10. const THROTTLE_DELAY_MS: u64 = 200;
  11. /// Main client for interacting with the NpubCash API
  12. pub struct NpubCashClient {
  13. base_url: String,
  14. auth_provider: Arc<JwtAuthProvider>,
  15. http_client: HttpClient,
  16. }
  17. impl std::fmt::Debug for NpubCashClient {
  18. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  19. f.debug_struct("NpubCashClient")
  20. .field("base_url", &self.base_url)
  21. .field("auth_provider", &self.auth_provider)
  22. .finish_non_exhaustive()
  23. }
  24. }
  25. impl NpubCashClient {
  26. /// Create a new NpubCash client
  27. ///
  28. /// # Arguments
  29. ///
  30. /// * `base_url` - Base URL of the NpubCash service (e.g., <https://npubx.cash>)
  31. /// * `auth_provider` - Authentication provider for signing requests
  32. pub fn new(base_url: String, auth_provider: Arc<JwtAuthProvider>) -> Self {
  33. Self {
  34. base_url,
  35. auth_provider,
  36. http_client: HttpClient::new(),
  37. }
  38. }
  39. /// Fetch quotes, optionally filtered by timestamp
  40. ///
  41. /// # Arguments
  42. ///
  43. /// * `since` - Optional Unix timestamp to fetch quotes from. If `None`, fetches all quotes.
  44. ///
  45. /// # Errors
  46. ///
  47. /// Returns an error if the API request fails or authentication fails
  48. ///
  49. /// # Examples
  50. ///
  51. /// ```no_run
  52. /// # use cdk_npubcash::{NpubCashClient, JwtAuthProvider};
  53. /// # use nostr_sdk::Keys;
  54. /// # use std::sync::Arc;
  55. /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
  56. /// # let base_url = "https://npubx.cash".to_string();
  57. /// # let keys = Keys::generate();
  58. /// # let auth_provider = Arc::new(JwtAuthProvider::new(base_url.clone(), keys));
  59. /// # let client = NpubCashClient::new(base_url, auth_provider);
  60. /// // Fetch all quotes
  61. /// let all_quotes = client.get_quotes(None).await?;
  62. ///
  63. /// // Fetch quotes since a specific timestamp
  64. /// let recent_quotes = client.get_quotes(Some(1234567890)).await?;
  65. /// # Ok(())
  66. /// # }
  67. /// ```
  68. #[instrument(skip(self))]
  69. pub async fn get_quotes(&self, since: Option<u64>) -> Result<Vec<Quote>> {
  70. if let Some(ts) = since {
  71. tracing::debug!("Fetching quotes since timestamp: {}", ts);
  72. } else {
  73. tracing::debug!("Fetching all quotes");
  74. }
  75. self.fetch_paginated_quotes(since).await
  76. }
  77. /// Fetch quotes with pagination support
  78. ///
  79. /// This method handles automatic pagination, fetching all available quotes
  80. /// matching the criteria. It throttles requests to avoid overwhelming the API.
  81. ///
  82. /// # Arguments
  83. ///
  84. /// * `since` - Optional timestamp to filter quotes created after this time
  85. ///
  86. /// # Errors
  87. ///
  88. /// Returns an error if any page fetch fails
  89. async fn fetch_paginated_quotes(&self, since: Option<u64>) -> Result<Vec<Quote>> {
  90. let mut all_quotes = Vec::new();
  91. let mut offset = 0;
  92. loop {
  93. // Build the URL for this page
  94. let url = self.build_quotes_url(offset, since)?;
  95. // Fetch the current page
  96. let response: QuotesResponse = self.authenticated_request(url.as_str(), "GET").await?;
  97. // Collect quotes from this page
  98. let fetched_count = response.data.quotes.len();
  99. all_quotes.extend(response.data.quotes);
  100. tracing::debug!(
  101. "Fetched {} quotes. Total fetched: {}",
  102. fetched_count,
  103. all_quotes.len()
  104. );
  105. // Check if we should continue paginating
  106. offset += PAGINATION_LIMIT;
  107. if !Self::should_fetch_next_page(offset, response.metadata.total) {
  108. break;
  109. }
  110. // Throttle to avoid overwhelming the API
  111. self.throttle_request().await;
  112. }
  113. tracing::info!(
  114. "Successfully fetched a total of {} quotes",
  115. all_quotes.len()
  116. );
  117. Ok(all_quotes)
  118. }
  119. /// Build the URL for fetching quotes with pagination and filters
  120. fn build_quotes_url(&self, offset: usize, since: Option<u64>) -> Result<url::Url> {
  121. let mut url = url::Url::parse(&format!("{}{}", self.base_url, API_PATHS_QUOTES))?;
  122. // Add pagination parameters
  123. url.query_pairs_mut()
  124. .append_pair("offset", &offset.to_string())
  125. .append_pair("limit", &PAGINATION_LIMIT.to_string());
  126. // Add optional timestamp filter
  127. if let Some(since_val) = since {
  128. url.query_pairs_mut()
  129. .append_pair("since", &since_val.to_string());
  130. }
  131. Ok(url)
  132. }
  133. /// Set the mint URL for the user
  134. ///
  135. /// Updates the default mint URL used by the NpubCash server when creating quotes.
  136. ///
  137. /// # Arguments
  138. ///
  139. /// * `mint_url` - URL of the Cashu mint to use
  140. ///
  141. /// # Errors
  142. ///
  143. /// Returns an error if the API request fails or authentication fails.
  144. /// Returns `UnsupportedEndpoint` if the server doesn't support this feature.
  145. #[instrument(skip(self, mint_url))]
  146. pub async fn set_mint_url(
  147. &self,
  148. mint_url: impl Into<String>,
  149. ) -> Result<crate::types::UserResponse> {
  150. use serde::Serialize;
  151. const MINT_URL_PATH: &str = "/api/v2/user/mint";
  152. #[derive(Serialize)]
  153. struct MintUrlPayload {
  154. mint_url: String,
  155. }
  156. let url = format!("{}{}", self.base_url, MINT_URL_PATH);
  157. let payload = MintUrlPayload {
  158. mint_url: mint_url.into(),
  159. };
  160. // Get NIP-98 authentication header (not JWT Bearer)
  161. let auth_header = self.auth_provider.get_nip98_auth_header(&url, "PATCH")?;
  162. // Send PATCH request
  163. let response = self
  164. .http_client
  165. .patch(&url)
  166. .header("Authorization", auth_header)
  167. .header("Content-Type", "application/json")
  168. .header("Accept", "application/json")
  169. .header("User-Agent", "cdk-npubcash/0.13.0")
  170. .json(&payload)
  171. .send()
  172. .await?;
  173. let status = response.status();
  174. // Handle error responses
  175. if !response.is_success() {
  176. let error_text = response.text().await.unwrap_or_default();
  177. return Err(Error::Api {
  178. message: error_text,
  179. status,
  180. });
  181. }
  182. // Get response text for debugging
  183. let response_text = response.text().await?;
  184. tracing::debug!("set_mint_url response: {}", response_text);
  185. // Parse JSON response
  186. serde_json::from_str(&response_text).map_err(|e| {
  187. tracing::error!("Failed to parse response: {} - Body: {}", e, response_text);
  188. Error::Custom(format!("JSON parse error: {e}"))
  189. })
  190. }
  191. /// Determine if we should fetch the next page of results
  192. const fn should_fetch_next_page(current_offset: usize, total_available: usize) -> bool {
  193. current_offset < total_available
  194. }
  195. /// Throttle requests to avoid overwhelming the API
  196. async fn throttle_request(&self) {
  197. tracing::debug!("Throttling for {}ms...", THROTTLE_DELAY_MS);
  198. tokio::time::sleep(tokio::time::Duration::from_millis(THROTTLE_DELAY_MS)).await;
  199. }
  200. /// Make an authenticated HTTP request to the API
  201. ///
  202. /// This method handles authentication, sends the request, and parses the response.
  203. ///
  204. /// # Arguments
  205. ///
  206. /// * `url` - Full URL to request
  207. /// * `method` - HTTP method (e.g., "GET", "POST")
  208. ///
  209. /// # Errors
  210. ///
  211. /// Returns an error if authentication fails, request fails, or response parsing fails
  212. async fn authenticated_request<T>(&self, url: &str, method: &str) -> Result<T>
  213. where
  214. T: serde::de::DeserializeOwned,
  215. {
  216. // Extract URL for authentication (without query parameters)
  217. let url_for_auth = crate::extract_auth_url(url)?;
  218. // Get authentication token
  219. let auth_token = self
  220. .auth_provider
  221. .get_auth_token(&url_for_auth, method)
  222. .await?;
  223. // Send the HTTP request with authentication headers
  224. tracing::debug!("Making {} request to {}", method, url);
  225. let response = self
  226. .http_client
  227. .get(url)
  228. .header("Authorization", auth_token)
  229. .header("Content-Type", "application/json")
  230. .header("Accept", "application/json")
  231. .header("User-Agent", "cdk-npubcash/0.13.0")
  232. .send()
  233. .await?;
  234. tracing::debug!("Response status: {}", response.status());
  235. // Parse and return the JSON response
  236. self.parse_response(response).await
  237. }
  238. /// Parse the HTTP response and deserialize the JSON body
  239. async fn parse_response<T>(&self, response: RawResponse) -> Result<T>
  240. where
  241. T: serde::de::DeserializeOwned,
  242. {
  243. let status = response.status();
  244. // Get the response text
  245. let response_text = response.text().await?;
  246. // Handle error status codes
  247. if !(200..300).contains(&status) {
  248. tracing::debug!("Error response ({}): {}", status, response_text);
  249. return Err(Error::Api {
  250. message: response_text,
  251. status,
  252. });
  253. }
  254. // Parse successful JSON response
  255. tracing::debug!("Response body: {}", response_text);
  256. let data = serde_json::from_str::<T>(&response_text).map_err(|e| {
  257. tracing::error!("JSON parse error: {} - Body: {}", e, response_text);
  258. Error::Custom(format!("JSON parse error: {e}"))
  259. })?;
  260. tracing::debug!("Request successful");
  261. Ok(data)
  262. }
  263. }