浏览代码

Abstract HTTP client behind cdk_common::HttpClient

Introduce a unified HttpClient wrapper in cdk-common that abstracts
reqwest usage across the codebase. This provides a cleaner API and
centralizes HTTP client configuration.

Changes:
- Add http module to cdk-common with HttpClient, HttpClientBuilder,
  and supporting types for requests/responses/errors
- Update cdk examples to use cdk_common::HttpClient instead of
  reqwest::Client directly
- Update cdk-cli, cdk-fake-wallet, cdk-integration-tests, and
  cdk-npubcash to use the new HttpClient
- Simplify wallet transport and OIDC client to use HttpClient
- Remove direct reqwest dependency from cdk dev-dependencies
Cesar Rodas 4 天之前
父节点
当前提交
b1c810d595

+ 2 - 5
Cargo.lock

@@ -1202,7 +1202,6 @@ dependencies = [
  "nostr-sdk",
  "rand 0.9.2",
  "regex",
- "reqwest",
  "ring 0.17.14",
  "rustls 0.23.36",
  "serde",
@@ -1262,7 +1261,6 @@ dependencies = [
  "home",
  "lightning 0.2.0",
  "nostr-sdk",
- "reqwest",
  "serde",
  "serde_json",
  "serde_with",
@@ -1309,6 +1307,8 @@ dependencies = [
  "parking_lot",
  "paste",
  "rand 0.9.2",
+ "regex",
+ "reqwest",
  "serde",
  "serde_json",
  "serde_with",
@@ -1334,7 +1334,6 @@ dependencies = [
  "futures",
  "lightning 0.2.0",
  "lightning-invoice 0.34.0",
- "reqwest",
  "serde",
  "serde_json",
  "thiserror 2.0.18",
@@ -1403,7 +1402,6 @@ dependencies = [
  "ln-regtest-rs",
  "once_cell",
  "rand 0.9.2",
- "reqwest",
  "serde",
  "serde_json",
  "tokio",
@@ -1557,7 +1555,6 @@ dependencies = [
  "cdk-sqlite",
  "chrono",
  "nostr-sdk",
- "reqwest",
  "rustls 0.23.36",
  "serde",
  "serde_json",

+ 1 - 2
crates/cdk-cli/Cargo.toml

@@ -25,7 +25,7 @@ bitcoin.workspace = true
 cdk = { workspace = true, default-features = false, features = ["wallet", "auth", "nostr", "bip353"]}
 cdk-redb = { workspace = true, features = ["wallet"], optional = true }
 cdk-sqlite = { workspace = true, features = ["wallet"] }
-cdk-common = { workspace = true, features = ["wallet"] }
+cdk-common = { workspace = true, features = ["wallet", "http"] }
 clap.workspace = true
 serde.workspace = true
 serde_json.workspace = true
@@ -34,7 +34,6 @@ tracing.workspace = true
 tracing-subscriber.workspace = true
 home.workspace = true
 nostr-sdk = { workspace = true }
-reqwest.workspace = true
 url.workspace = true
 serde_with.workspace = true
 lightning.workspace = true

+ 10 - 14
crates/cdk-cli/src/sub_commands/cat_device_login.rs

@@ -82,22 +82,18 @@ async fn get_device_code_token(mint_info: &MintInfo) -> (String, String) {
     let device_auth_url = oidc_config.device_authorization_endpoint;
 
     // Make the device code request
-    let client = reqwest::Client::new();
-    let device_code_response = client
-        .post(device_auth_url)
-        .form(&[
-            ("client_id", client_id.clone().as_str()),
-            ("scope", "openid offline_access"),
-        ])
-        .send()
+    let client = cdk_common::HttpClient::new();
+    let device_code_data: serde_json::Value = client
+        .post_form(
+            &device_auth_url,
+            &[
+                ("client_id", client_id.clone().as_str()),
+                ("scope", "openid offline_access"),
+            ],
+        )
         .await
         .expect("Failed to send device code request");
 
-    let device_code_data: serde_json::Value = device_code_response
-        .json()
-        .await
-        .expect("Failed to parse device code response");
-
     let device_code = device_code_data["device_code"]
         .as_str()
         .expect("No device code in response");
@@ -140,7 +136,7 @@ async fn get_device_code_token(mint_info: &MintInfo) -> (String, String) {
             .await
             .expect("Failed to send token request");
 
-        if token_response.status().is_success() {
+        if token_response.is_success() {
             let token_data: serde_json::Value = token_response
                 .json()
                 .await

+ 3 - 10
crates/cdk-cli/src/sub_commands/cat_login.rs

@@ -95,19 +95,12 @@ async fn get_access_token(mint_info: &MintInfo, user: &str, password: &str) -> (
     ];
 
     // Make the token request directly
-    let client = reqwest::Client::new();
-    let response = client
-        .post(token_url)
-        .form(&params)
-        .send()
+    let client = cdk_common::HttpClient::new();
+    let token_response: serde_json::Value = client
+        .post_form(&token_url, &params)
         .await
         .expect("Failed to send token request");
 
-    let token_response: serde_json::Value = response
-        .json()
-        .await
-        .expect("Failed to parse token response");
-
     let access_token = token_response["access_token"]
         .as_str()
         .expect("No access token in response")

+ 3 - 3
crates/cdk-cli/src/sub_commands/mint_blind_auth.rs

@@ -171,10 +171,10 @@ async fn refresh_access_token(
     ];
 
     // Make the token refresh request
-    let client = reqwest::Client::new();
-    let response = client.post(token_url).form(&params).send().await?;
+    let client = cdk_common::HttpClient::new();
+    let response = client.post(&token_url).form(&params).send().await?;
 
-    if !response.status().is_success() {
+    if !response.is_success() {
         return Err(anyhow::anyhow!(
             "Token refresh failed with status: {}",
             response.status()

+ 4 - 1
crates/cdk-common/Cargo.toml

@@ -20,6 +20,7 @@ mint = ["cashu/mint", "dep:uuid"]
 auth = ["cashu/auth"]
 nostr = ["wallet", "cashu/nostr"]
 prometheus = ["cdk-prometheus/default"]
+http = ["dep:reqwest", "dep:regex"]
 
 [dependencies]
 async-trait.workspace = true
@@ -43,9 +44,10 @@ serde_with.workspace = true
 web-time.workspace = true
 parking_lot = "0.12.5"
 paste = "1.0.15"
-
+regex = { workspace = true, optional = true }
 
 [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+reqwest = { workspace = true, optional = true }
 tokio = { version = "1", default-features = false, features = ["rt", "rt-multi-thread", "macros", "test-util", "sync"] }
 
 [target.'cfg(target_arch = "wasm32")'.dependencies]
@@ -54,6 +56,7 @@ getrandom = { version = "0.2", features = ["js"] }
 tokio.workspace = true
 wasm-bindgen = "0.2"
 wasm-bindgen-futures = "0.4"
+reqwest = { version = "0.12", default-features = false, features = ["json"], optional = true }
 
 [dev-dependencies]
 rand.workspace = true

+ 227 - 0
crates/cdk-common/src/http/client.rs

@@ -0,0 +1,227 @@
+//! HTTP client wrapper
+
+use serde::de::DeserializeOwned;
+use serde::Serialize;
+
+use super::error::HttpError;
+use super::request::RequestBuilder;
+use super::response::{RawResponse, Response};
+
+/// HTTP client wrapper
+#[derive(Debug, Clone)]
+pub struct HttpClient {
+    inner: reqwest::Client,
+}
+
+impl Default for HttpClient {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl HttpClient {
+    /// Create a new HTTP client with default settings
+    pub fn new() -> Self {
+        Self {
+            inner: reqwest::Client::new(),
+        }
+    }
+
+    /// Create a new HTTP client builder
+    pub fn builder() -> HttpClientBuilder {
+        HttpClientBuilder::default()
+    }
+
+    /// Create an HttpClient from a reqwest::Client
+    pub fn from_reqwest(client: reqwest::Client) -> Self {
+        Self { inner: client }
+    }
+
+    // === Simple convenience methods ===
+
+    /// GET request, returns JSON deserialized to R
+    pub async fn fetch<R>(&self, url: &str) -> Response<R>
+    where
+        R: DeserializeOwned,
+    {
+        let response = self.inner.get(url).send().await?;
+        let status = response.status();
+
+        if !status.is_success() {
+            let message = response.text().await.unwrap_or_default();
+            return Err(HttpError::Status {
+                status: status.as_u16(),
+                message,
+            });
+        }
+
+        response.json().await.map_err(HttpError::from)
+    }
+
+    /// POST with JSON body, returns JSON deserialized to R
+    pub async fn post_json<B, R>(&self, url: &str, body: &B) -> Response<R>
+    where
+        B: Serialize + ?Sized,
+        R: DeserializeOwned,
+    {
+        let response = self.inner.post(url).json(body).send().await?;
+        let status = response.status();
+
+        if !status.is_success() {
+            let message = response.text().await.unwrap_or_default();
+            return Err(HttpError::Status {
+                status: status.as_u16(),
+                message,
+            });
+        }
+
+        response.json().await.map_err(HttpError::from)
+    }
+
+    /// POST with form data, returns JSON deserialized to R
+    pub async fn post_form<F, R>(&self, url: &str, form: &F) -> Response<R>
+    where
+        F: Serialize + ?Sized,
+        R: DeserializeOwned,
+    {
+        let response = self.inner.post(url).form(form).send().await?;
+        let status = response.status();
+
+        if !status.is_success() {
+            let message = response.text().await.unwrap_or_default();
+            return Err(HttpError::Status {
+                status: status.as_u16(),
+                message,
+            });
+        }
+
+        response.json().await.map_err(HttpError::from)
+    }
+
+    /// PATCH with JSON body, returns JSON deserialized to R
+    pub async fn patch_json<B, R>(&self, url: &str, body: &B) -> Response<R>
+    where
+        B: Serialize + ?Sized,
+        R: DeserializeOwned,
+    {
+        let response = self.inner.patch(url).json(body).send().await?;
+        let status = response.status();
+
+        if !status.is_success() {
+            let message = response.text().await.unwrap_or_default();
+            return Err(HttpError::Status {
+                status: status.as_u16(),
+                message,
+            });
+        }
+
+        response.json().await.map_err(HttpError::from)
+    }
+
+    // === Raw request methods ===
+
+    /// GET request returning raw response body
+    pub async fn get_raw(&self, url: &str) -> Response<RawResponse> {
+        let response = self.inner.get(url).send().await?;
+        Ok(RawResponse::new(response))
+    }
+
+    // === Request builder methods ===
+
+    /// POST request builder for complex cases (custom headers, form data, etc.)
+    pub fn post(&self, url: &str) -> RequestBuilder {
+        RequestBuilder::new(self.inner.post(url))
+    }
+
+    /// GET request builder for complex cases (custom headers, etc.)
+    pub fn get(&self, url: &str) -> RequestBuilder {
+        RequestBuilder::new(self.inner.get(url))
+    }
+
+    /// PATCH request builder for complex cases (custom headers, JSON body, etc.)
+    pub fn patch(&self, url: &str) -> RequestBuilder {
+        RequestBuilder::new(self.inner.patch(url))
+    }
+}
+
+/// HTTP client builder for configuring proxy and TLS settings
+#[derive(Debug, Default)]
+pub struct HttpClientBuilder {
+    #[cfg(not(target_arch = "wasm32"))]
+    accept_invalid_certs: bool,
+    #[cfg(not(target_arch = "wasm32"))]
+    proxy: Option<ProxyConfig>,
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+#[derive(Debug)]
+struct ProxyConfig {
+    url: url::Url,
+    matcher: Option<regex::Regex>,
+}
+
+impl HttpClientBuilder {
+    /// Accept invalid TLS certificates (non-WASM only)
+    #[cfg(not(target_arch = "wasm32"))]
+    pub fn danger_accept_invalid_certs(mut self, accept: bool) -> Self {
+        self.accept_invalid_certs = accept;
+        self
+    }
+
+    /// Set a proxy URL (non-WASM only)
+    #[cfg(not(target_arch = "wasm32"))]
+    pub fn proxy(mut self, url: url::Url) -> Self {
+        self.proxy = Some(ProxyConfig { url, matcher: None });
+        self
+    }
+
+    /// Set a proxy URL with a host pattern matcher (non-WASM only)
+    #[cfg(not(target_arch = "wasm32"))]
+    pub fn proxy_with_matcher(mut self, url: url::Url, pattern: &str) -> Response<Self> {
+        let matcher = regex::Regex::new(pattern)
+            .map_err(|e| HttpError::Proxy(format!("Invalid proxy pattern: {}", e)))?;
+        self.proxy = Some(ProxyConfig {
+            url,
+            matcher: Some(matcher),
+        });
+        Ok(self)
+    }
+
+    /// Build the HTTP client
+    pub fn build(self) -> Response<HttpClient> {
+        #[cfg(not(target_arch = "wasm32"))]
+        {
+            let mut builder =
+                reqwest::Client::builder().danger_accept_invalid_certs(self.accept_invalid_certs);
+
+            if let Some(proxy_config) = self.proxy {
+                let proxy_url = proxy_config.url.to_string();
+                let proxy = if let Some(matcher) = proxy_config.matcher {
+                    reqwest::Proxy::custom(move |url| {
+                        if matcher.is_match(url.host_str().unwrap_or("")) {
+                            Some(proxy_url.clone())
+                        } else {
+                            None
+                        }
+                    })
+                } else {
+                    reqwest::Proxy::all(&proxy_url).map_err(|e| HttpError::Proxy(e.to_string()))?
+                };
+                builder = builder.proxy(proxy);
+            }
+
+            let client = builder.build().map_err(HttpError::from)?;
+            Ok(HttpClient { inner: client })
+        }
+
+        #[cfg(target_arch = "wasm32")]
+        {
+            Ok(HttpClient::new())
+        }
+    }
+}
+
+/// Convenience function for simple GET requests (replaces reqwest::get)
+pub async fn fetch<R: DeserializeOwned>(url: &str) -> Response<R> {
+    HttpClient::new().fetch(url).await
+}

+ 62 - 0
crates/cdk-common/src/http/error.rs

@@ -0,0 +1,62 @@
+//! HTTP error types
+
+use thiserror::Error;
+
+/// HTTP errors that can occur during requests
+#[derive(Debug, Error)]
+pub enum HttpError {
+    /// HTTP error with status code
+    #[error("HTTP error ({status}): {message}")]
+    Status {
+        /// HTTP status code
+        status: u16,
+        /// Error message
+        message: String,
+    },
+    /// Connection error
+    #[error("Connection error: {0}")]
+    Connection(String),
+    /// Request timeout
+    #[error("Request timeout")]
+    Timeout,
+    /// Serialization error
+    #[error("Serialization error: {0}")]
+    Serialization(String),
+    /// Proxy error
+    #[error("Proxy error: {0}")]
+    Proxy(String),
+    /// Client build error
+    #[error("Client build error: {0}")]
+    Build(String),
+    /// Other error
+    #[error("{0}")]
+    Other(String),
+}
+
+impl From<reqwest::Error> for HttpError {
+    fn from(err: reqwest::Error) -> Self {
+        if err.is_timeout() {
+            HttpError::Timeout
+        } else if err.is_builder() {
+            HttpError::Build(err.to_string())
+        } else if let Some(status) = err.status() {
+            HttpError::Status {
+                status: status.as_u16(),
+                message: err.to_string(),
+            }
+        } else {
+            // is_connect() is not available on wasm32
+            #[cfg(not(target_arch = "wasm32"))]
+            if err.is_connect() {
+                return HttpError::Connection(err.to_string());
+            }
+            HttpError::Other(err.to_string())
+        }
+    }
+}
+
+impl From<serde_json::Error> for HttpError {
+    fn from(err: serde_json::Error) -> Self {
+        HttpError::Serialization(err.to_string())
+    }
+}

+ 31 - 0
crates/cdk-common/src/http/mod.rs

@@ -0,0 +1,31 @@
+//! HTTP client abstraction
+//!
+//! This module provides an HTTP client wrapper that abstracts the underlying HTTP library (reqwest).
+//! Using this module allows other crates to avoid direct dependencies on reqwest.
+//!
+//! # Example
+//!
+//! ```no_run
+//! use cdk_common::http::{HttpClient, Response};
+//! use serde::Deserialize;
+//!
+//! #[derive(Deserialize)]
+//! struct ApiResponse {
+//!     message: String,
+//! }
+//!
+//! async fn example() -> Response<ApiResponse> {
+//!     let client = HttpClient::new();
+//!     client.fetch("https://api.example.com/data").await
+//! }
+//! ```
+
+mod client;
+mod error;
+mod request;
+mod response;
+
+pub use client::{fetch, HttpClient, HttpClientBuilder};
+pub use error::HttpError;
+pub use request::RequestBuilder;
+pub use response::{RawResponse, Response};

+ 63 - 0
crates/cdk-common/src/http/request.rs

@@ -0,0 +1,63 @@
+//! HTTP request builder
+
+use serde::de::DeserializeOwned;
+use serde::Serialize;
+
+use super::error::HttpError;
+use super::response::{RawResponse, Response};
+
+/// HTTP request builder for complex requests
+#[derive(Debug)]
+pub struct RequestBuilder {
+    inner: reqwest::RequestBuilder,
+}
+
+impl RequestBuilder {
+    /// Create a new RequestBuilder from a reqwest::RequestBuilder
+    pub(crate) fn new(inner: reqwest::RequestBuilder) -> Self {
+        Self { inner }
+    }
+
+    /// Add a header to the request
+    pub fn header(self, key: impl AsRef<str>, value: impl AsRef<str>) -> Self {
+        Self {
+            inner: self.inner.header(key.as_ref(), value.as_ref()),
+        }
+    }
+
+    /// Set the request body as JSON
+    pub fn json<T: Serialize + ?Sized>(self, body: &T) -> Self {
+        Self {
+            inner: self.inner.json(body),
+        }
+    }
+
+    /// Set the request body as form data
+    pub fn form<T: Serialize + ?Sized>(self, body: &T) -> Self {
+        Self {
+            inner: self.inner.form(body),
+        }
+    }
+
+    /// Send the request and return a raw response
+    pub async fn send(self) -> Response<RawResponse> {
+        let response = self.inner.send().await?;
+        Ok(RawResponse::new(response))
+    }
+
+    /// Send the request and deserialize the response as JSON
+    pub async fn send_json<R: DeserializeOwned>(self) -> Response<R> {
+        let response = self.inner.send().await?;
+        let status = response.status();
+
+        if !status.is_success() {
+            let message = response.text().await.unwrap_or_default();
+            return Err(HttpError::Status {
+                status: status.as_u16(),
+                message,
+            });
+        }
+
+        response.json().await.map_err(HttpError::from)
+    }
+}

+ 65 - 0
crates/cdk-common/src/http/response.rs

@@ -0,0 +1,65 @@
+//! HTTP response types
+
+use serde::de::DeserializeOwned;
+
+use super::error::HttpError;
+
+/// HTTP Response type - generic over the body type R and error type E
+/// This is the primary return type for all HTTP operations
+pub type Response<R, E = HttpError> = Result<R, E>;
+
+/// Raw HTTP response with status code and body access
+#[derive(Debug)]
+pub struct RawResponse {
+    status: u16,
+    inner: reqwest::Response,
+}
+
+impl RawResponse {
+    /// Create a new RawResponse from a reqwest::Response
+    pub(crate) fn new(response: reqwest::Response) -> Self {
+        Self {
+            status: response.status().as_u16(),
+            inner: response,
+        }
+    }
+
+    /// Get the HTTP status code
+    pub fn status(&self) -> u16 {
+        self.status
+    }
+
+    /// Check if the response status is a success (2xx)
+    pub fn is_success(&self) -> bool {
+        (200..300).contains(&self.status)
+    }
+
+    /// Check if the response status is a client error (4xx)
+    pub fn is_client_error(&self) -> bool {
+        (400..500).contains(&self.status)
+    }
+
+    /// Check if the response status is a server error (5xx)
+    pub fn is_server_error(&self) -> bool {
+        (500..600).contains(&self.status)
+    }
+
+    /// Get the response body as text
+    pub async fn text(self) -> Response<String> {
+        self.inner.text().await.map_err(HttpError::from)
+    }
+
+    /// Get the response body as JSON
+    pub async fn json<T: DeserializeOwned>(self) -> Response<T> {
+        self.inner.json().await.map_err(HttpError::from)
+    }
+
+    /// Get the response body as bytes
+    pub async fn bytes(self) -> Response<Vec<u8>> {
+        self.inner
+            .bytes()
+            .await
+            .map(|b| b.to_vec())
+            .map_err(HttpError::from)
+    }
+}

+ 4 - 0
crates/cdk-common/src/lib.rs

@@ -11,6 +11,8 @@ pub mod task;
 pub mod common;
 pub mod database;
 pub mod error;
+#[cfg(feature = "http")]
+pub mod http;
 #[cfg(feature = "mint")]
 pub mod melt;
 #[cfg(feature = "mint")]
@@ -34,5 +36,7 @@ pub use cashu::nuts::{self, *};
 pub use cashu::quote_id::{self, *};
 pub use cashu::{dhke, ensure_cdk, mint_url, secret, util, SECP256K1};
 pub use error::Error;
+#[cfg(feature = "http")]
+pub use http::{HttpClient, HttpClientBuilder, HttpError};
 /// Re-export parking_lot for reuse
 pub use parking_lot;

+ 1 - 0
crates/cdk-common/src/task.rs

@@ -1,6 +1,7 @@
 //! Thin wrapper for spawn and spawn_local for native and wasm.
 
 use std::future::Future;
+#[cfg(not(target_arch = "wasm32"))]
 use std::sync::OnceLock;
 
 use tokio::task::JoinHandle;

+ 1 - 2
crates/cdk-fake-wallet/Cargo.toml

@@ -13,7 +13,7 @@ readme = "README.md"
 [dependencies]
 async-trait.workspace = true
 bitcoin.workspace = true
-cdk-common = { workspace = true, features = ["mint"] }
+cdk-common = { workspace = true, features = ["mint", "http"] }
 futures.workspace = true
 tokio.workspace = true
 tokio-util.workspace = true
@@ -24,7 +24,6 @@ serde_json.workspace = true
 lightning-invoice.workspace = true
 lightning.workspace = true
 tokio-stream.workspace = true
-reqwest.workspace = true
 uuid.workspace = true
 
 [lints]

+ 1 - 4
crates/cdk-fake-wallet/src/lib.rs

@@ -105,10 +105,7 @@ impl ExchangeRateCache {
     /// Fetch fresh rate and update cache
     async fn fetch_fresh_rate(&self, currency: &CurrencyUnit) -> Result<f64, Error> {
         let url = "https://mempool.space/api/v1/prices";
-        let response = reqwest::get(url)
-            .await
-            .map_err(|_| Error::UnknownInvoiceAmount)?
-            .json::<MempoolPricesResponse>()
+        let response: MempoolPricesResponse = cdk_common::http::fetch(url)
             .await
             .map_err(|_| Error::UnknownInvoiceAmount)?;
 

+ 1 - 2
crates/cdk-integration-tests/Cargo.toml

@@ -28,7 +28,7 @@ cdk-axum = { workspace = true, features = ["auth"] }
 cdk-sqlite = { workspace = true }
 cdk-redb = { workspace = true }
 cdk-fake-wallet = { workspace = true }
-cdk-common = { workspace = true, features = ["mint", "wallet", "auth"] }
+cdk-common = { workspace = true, features = ["mint", "wallet", "auth", "http"] }
 cdk-mintd = { workspace = true, features = ["cln", "lnd", "fakewallet", "grpc-processor", "auth", "lnbits", "management-rpc", "sqlite", "postgres", "ldk-node", "prometheus"] }
 futures = { workspace = true, default-features = false, features = [
     "executor",
@@ -47,7 +47,6 @@ tokio-tungstenite.workspace = true
 tower-http = { workspace = true, features = ["cors"] }
 tower-service = "0.3.3"
 tokio-util.workspace = true
-reqwest.workspace = true
 bitcoin = "0.32.0"
 clap = { workspace = true, features = ["derive"] }
 web-time.workspace = true

+ 3 - 2
crates/cdk-integration-tests/src/shared.rs

@@ -35,6 +35,7 @@ pub async fn wait_for_mint_ready_with_shutdown(
 ) -> Result<()> {
     let url = format!("http://127.0.0.1:{port}/v1/info");
     let start_time = std::time::Instant::now();
+    let http_client = cdk_common::HttpClient::new();
 
     println!("Waiting for mint on port {port} to be ready...");
 
@@ -50,10 +51,10 @@ pub async fn wait_for_mint_ready_with_shutdown(
 
         tokio::select! {
             // Try to make a request to the mint info endpoint
-            result = reqwest::get(&url) => {
+            result = http_client.get_raw(&url) => {
                 match result {
                     Ok(response) => {
-                        if response.status().is_success() {
+                        if response.is_success() {
                             println!("Mint on port {port} is ready");
                             return Ok(());
                         } else {

+ 12 - 18
crates/cdk-integration-tests/tests/nutshell_wallet.rs

@@ -1,7 +1,7 @@
 use std::time::Duration;
 
+use cdk_common::HttpClient;
 use cdk_fake_wallet::create_fake_invoice;
-use reqwest::Client;
 use serde::{Deserialize, Serialize};
 use serde_json::Value;
 use tokio::time::sleep;
@@ -22,17 +22,17 @@ const DEFAULT_TEST_AMOUNT: u64 = 10000;
 
 /// Helper function to mint tokens via Lightning invoice
 async fn mint_tokens(base_url: &str, amount: u64) -> String {
-    let client = Client::new();
+    let client = HttpClient::new();
 
     // Create an invoice for the specified amount
     let invoice_url = format!("{}/lightning/create_invoice?amount={}", base_url, amount);
 
-    let invoice_response = client
+    let invoice_response: InvoiceResponse = client
         .post(&invoice_url)
         .send()
         .await
         .expect("Failed to send invoice creation request")
-        .json::<InvoiceResponse>()
+        .json()
         .await
         .expect("Failed to parse invoice response");
 
@@ -43,7 +43,7 @@ async fn mint_tokens(base_url: &str, amount: u64) -> String {
 
 /// Helper function to wait for payment confirmation
 async fn wait_for_payment_confirmation(base_url: &str, payment_request: &str) {
-    let client = Client::new();
+    let client = HttpClient::new();
     let check_url = format!(
         "{}/lightning/invoice_state?payment_request={}",
         base_url, payment_request
@@ -63,7 +63,7 @@ async fn wait_for_payment_confirmation(base_url: &str, payment_request: &str) {
             .await
             .expect("Failed to send payment check request");
 
-        if response.status().is_success() {
+        if response.is_success() {
             let state: Value = response
                 .json()
                 .await
@@ -90,19 +90,13 @@ async fn wait_for_payment_confirmation(base_url: &str, payment_request: &str) {
 
 /// Helper function to get the current wallet balance
 async fn get_wallet_balance(base_url: &str) -> u64 {
-    let client = Client::new();
+    let client = HttpClient::new();
     let balance_url = format!("{}/balance", base_url);
 
-    let balance_response = client
-        .get(&balance_url)
-        .send()
-        .await
-        .expect("Failed to send balance request");
-
-    let balance: Value = balance_response
-        .json()
+    let balance: Value = client
+        .fetch(&balance_url)
         .await
-        .expect("Failed to parse balance response");
+        .expect("Failed to fetch balance");
 
     balance["balance"]
         .as_u64()
@@ -149,7 +143,7 @@ async fn test_nutshell_wallet_swap() {
 
     let send_amount = 100;
     let send_url = format!("{}/send?amount={}", base_url, send_amount);
-    let client = Client::new();
+    let client = HttpClient::new();
 
     let response: Value = client
         .post(&send_url)
@@ -217,7 +211,7 @@ async fn test_nutshell_wallet_melt() {
     let payment_amount = 1000; // 1000 sats
     let fake_invoice = create_fake_invoice(payment_amount, "Test payment".to_string());
     let pay_url = format!("{}/lightning/pay_invoice?bolt11={}", base_url, fake_invoice);
-    let client = Client::new();
+    let client = HttpClient::new();
 
     // Step 4: Pay the invoice
     let _response: Value = client

+ 1 - 2
crates/cdk-npubcash/Cargo.toml

@@ -11,9 +11,8 @@ repository.workspace = true
 # Use workspace dependencies
 async-trait = { workspace = true }
 cashu = { workspace = true }
-cdk-common = { workspace = true, features = ["wallet"] }
+cdk-common = { workspace = true, features = ["wallet", "http"] }
 nostr-sdk = { workspace = true }
-reqwest = { workspace = true }
 serde = { workspace = true }
 serde_json = { workspace = true }
 thiserror = { workspace = true }

+ 5 - 5
crates/cdk-npubcash/src/auth.rs

@@ -23,7 +23,7 @@ struct CachedToken {
 pub struct JwtAuthProvider {
     base_url: String,
     keys: Keys,
-    http_client: reqwest::Client,
+    http_client: cdk_common::HttpClient,
     cached_token: Arc<RwLock<Option<CachedToken>>>,
 }
 
@@ -38,7 +38,7 @@ impl JwtAuthProvider {
         Self {
             base_url,
             keys,
-            http_client: reqwest::Client::new(),
+            http_client: cdk_common::HttpClient::new(),
             cached_token: Arc::new(RwLock::new(None)),
         }
     }
@@ -108,7 +108,7 @@ impl JwtAuthProvider {
         &self,
         auth_url: &str,
         nostr_token: &str,
-    ) -> Result<reqwest::Response> {
+    ) -> Result<cdk_common::http::RawResponse> {
         tracing::debug!("Sending request to: {}", auth_url);
         tracing::debug!(
             "Authorization header: Nostr {}",
@@ -130,10 +130,10 @@ impl JwtAuthProvider {
     }
 
     /// Parse the JWT response from the API
-    async fn parse_jwt_response(&self, response: reqwest::Response) -> Result<String> {
+    async fn parse_jwt_response(&self, response: cdk_common::http::RawResponse) -> Result<String> {
         let status = response.status();
 
-        if !status.is_success() {
+        if !response.is_success() {
             let error_text = response.text().await.unwrap_or_default();
             tracing::error!("Auth failed - Status: {}, Body: {}", status, error_text);
             return Err(Error::Auth(format!(

+ 6 - 6
crates/cdk-npubcash/src/client.rs

@@ -2,7 +2,7 @@
 
 use std::sync::Arc;
 
-use reqwest::Client as HttpClient;
+use cdk_common::http::{HttpClient, RawResponse};
 use tracing::instrument;
 
 use crate::auth::JwtAuthProvider;
@@ -200,11 +200,11 @@ impl NpubCashClient {
         let status = response.status();
 
         // Handle error responses
-        if !status.is_success() {
+        if !response.is_success() {
             let error_text = response.text().await.unwrap_or_default();
             return Err(Error::Api {
                 message: error_text,
-                status: status.as_u16(),
+                status,
             });
         }
 
@@ -274,7 +274,7 @@ impl NpubCashClient {
     }
 
     /// Parse the HTTP response and deserialize the JSON body
-    async fn parse_response<T>(&self, response: reqwest::Response) -> Result<T>
+    async fn parse_response<T>(&self, response: RawResponse) -> Result<T>
     where
         T: serde::de::DeserializeOwned,
     {
@@ -284,11 +284,11 @@ impl NpubCashClient {
         let response_text = response.text().await?;
 
         // Handle error status codes
-        if !status.is_success() {
+        if !(200..300).contains(&status) {
             tracing::debug!("Error response ({}): {}", status, response_text);
             return Err(Error::Api {
                 message: response_text,
-                status: status.as_u16(),
+                status,
             });
         }
 

+ 1 - 1
crates/cdk-npubcash/src/error.rs

@@ -23,7 +23,7 @@ pub enum Error {
 
     /// HTTP request failed
     #[error("HTTP request failed: {0}")]
-    Http(#[from] reqwest::Error),
+    Http(#[from] cdk_common::HttpError),
 
     /// JSON serialization/deserialization error
     #[error("JSON serialization error: {0}")]

+ 2 - 4
crates/cdk/Cargo.toml

@@ -12,10 +12,10 @@ license.workspace = true
 
 [features]
 default = ["mint", "wallet", "auth", "nostr", "bip353"]
-wallet = ["dep:futures", "dep:reqwest", "cdk-common/wallet", "dep:rustls"]
+wallet = ["dep:futures", "cdk-common/wallet", "cdk-common/http", "dep:rustls"]
 nostr = ["wallet", "dep:nostr-sdk", "cdk-common/nostr"]
 npubcash = ["wallet", "nostr", "dep:cdk-npubcash"]
-mint = ["dep:futures", "dep:reqwest", "cdk-common/mint", "cdk-signatory"]
+mint = ["dep:futures", "cdk-common/mint", "cdk-common/http", "cdk-signatory"]
 auth = ["dep:jsonwebtoken", "cdk-common/auth", "cdk-common/auth"]
 bip353 = ["dep:hickory-resolver"]
 # We do not commit to a MSRV with swagger enabled
@@ -46,7 +46,6 @@ ciborium.workspace = true
 lightning.workspace = true
 lightning-invoice.workspace = true
 regex.workspace = true
-reqwest = { workspace = true, optional = true }
 serde.workspace = true
 serde_json.workspace = true
 serde_with.workspace = true
@@ -175,7 +174,6 @@ cdk-fake-wallet.workspace = true
 bip39.workspace = true
 tracing-subscriber.workspace = true
 criterion.workspace = true
-reqwest = { workspace = true }
 anyhow.workspace = true
 ureq = { version = "3.1.0", features = ["json"] }
 tokio = { workspace = true, features = ["full"] }

+ 3 - 10
crates/cdk/examples/auth_wallet.rs

@@ -120,19 +120,12 @@ async fn get_access_token(mint_info: &MintInfo) -> String {
     ];
 
     // Make the token request directly
-    let client = reqwest::Client::new();
-    let response = client
-        .post(token_url)
-        .form(&params)
-        .send()
+    let client = cdk_common::HttpClient::new();
+    let token_response: serde_json::Value = client
+        .post_form(&token_url, &params)
         .await
         .expect("Failed to send token request");
 
-    let token_response: serde_json::Value = response
-        .json()
-        .await
-        .expect("Failed to parse token response");
-
     token_response["access_token"]
         .as_str()
         .expect("No access token in response")

+ 3 - 5
crates/cdk/examples/multimint-npubcash.rs

@@ -143,19 +143,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
 
 /// Request an invoice via LNURL-pay
 async fn request_invoice(npub: &str, amount_msats: u64) -> Result<(), Box<dyn std::error::Error>> {
-    let http_client = reqwest::Client::new();
+    let http_client = cdk_common::HttpClient::new();
 
     let lnurlp_url = format!("{}/.well-known/lnurlp/{}", NPUBCASH_URL, npub);
-    let lnurlp_response: serde_json::Value =
-        http_client.get(&lnurlp_url).send().await?.json().await?;
+    let lnurlp_response: serde_json::Value = http_client.fetch(&lnurlp_url).await?;
 
     let callback = lnurlp_response["callback"]
         .as_str()
         .ok_or("No callback URL")?;
 
     let invoice_url = format!("{}?amount={}", callback, amount_msats);
-    let invoice_response: serde_json::Value =
-        http_client.get(&invoice_url).send().await?.json().await?;
+    let invoice_response: serde_json::Value = http_client.fetch(&invoice_url).await?;
 
     let pr = invoice_response["pr"]
         .as_str()

+ 3 - 5
crates/cdk/examples/npubcash.rs

@@ -136,19 +136,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
 
 /// Request an invoice via LNURL-pay
 async fn request_invoice(npub: &str, amount_msats: u64) -> Result<(), Box<dyn std::error::Error>> {
-    let http_client = reqwest::Client::new();
+    let http_client = cdk_common::HttpClient::new();
 
     let lnurlp_url = format!("{}/.well-known/lnurlp/{}", NPUBCASH_URL, npub);
-    let lnurlp_response: serde_json::Value =
-        http_client.get(&lnurlp_url).send().await?.json().await?;
+    let lnurlp_response: serde_json::Value = http_client.fetch(&lnurlp_url).await?;
 
     let callback = lnurlp_response["callback"]
         .as_str()
         .ok_or("No callback URL")?;
 
     let invoice_url = format!("{}?amount={}", callback, amount_msats);
-    let invoice_response: serde_json::Value =
-        http_client.get(&invoice_url).send().await?.json().await?;
+    let invoice_response: serde_json::Value = http_client.fetch(&invoice_url).await?;
 
     let pr = invoice_response["pr"]
         .as_str()

+ 9 - 28
crates/cdk/src/oidc_client.rs

@@ -4,9 +4,9 @@ use std::collections::HashMap;
 use std::ops::Deref;
 use std::sync::Arc;
 
+use cdk_common::http::HttpClient;
 use jsonwebtoken::jwk::{AlgorithmParameters, JwkSet};
 use jsonwebtoken::{decode, decode_header, DecodingKey, Validation};
-use reqwest::Client;
 use serde::Deserialize;
 #[cfg(feature = "wallet")]
 use serde::Serialize;
@@ -17,10 +17,10 @@ use tracing::instrument;
 /// OIDC Error
 #[derive(Debug, Error)]
 pub enum Error {
-    /// From Reqwest error
+    /// From HTTP error
     #[error(transparent)]
-    Reqwest(#[from] reqwest::Error),
-    /// From Reqwest error
+    Http(#[from] cdk_common::HttpError),
+    /// From JWT error
     #[error(transparent)]
     Jwt(#[from] jsonwebtoken::errors::Error),
     /// Missing kid header
@@ -56,7 +56,7 @@ pub struct OidcConfig {
 /// Http Client
 #[derive(Debug, Clone)]
 pub struct OidcClient {
-    client: Client,
+    client: HttpClient,
     openid_discovery: String,
     client_id: Option<String>,
     oidc_config: Arc<RwLock<Option<OidcConfig>>>,
@@ -91,7 +91,7 @@ impl OidcClient {
     /// Create new [`OidcClient`]
     pub fn new(openid_discovery: String, client_id: Option<String>) -> Self {
         Self {
-            client: Client::new(),
+            client: HttpClient::new(),
             openid_discovery,
             client_id,
             oidc_config: Arc::new(RwLock::new(None)),
@@ -103,13 +103,7 @@ impl OidcClient {
     #[instrument(skip(self))]
     pub async fn get_oidc_config(&self) -> Result<OidcConfig, Error> {
         tracing::debug!("Getting oidc config");
-        let oidc_config = self
-            .client
-            .get(&self.openid_discovery)
-            .send()
-            .await?
-            .json::<OidcConfig>()
-            .await?;
+        let oidc_config: OidcConfig = self.client.fetch(&self.openid_discovery).await?;
 
         let mut current_config = self.oidc_config.write().await;
 
@@ -122,13 +116,7 @@ impl OidcClient {
     #[instrument(skip(self))]
     pub async fn get_jwkset(&self, jwks_uri: &str) -> Result<JwkSet, Error> {
         tracing::debug!("Getting jwks set");
-        let jwks_set = self
-            .client
-            .get(jwks_uri)
-            .send()
-            .await?
-            .json::<JwkSet>()
-            .await?;
+        let jwks_set: JwkSet = self.client.fetch(jwks_uri).await?;
 
         let mut current_set = self.jwks_set.write().await;
 
@@ -248,14 +236,7 @@ impl OidcClient {
             refresh_token,
         };
 
-        let response = self
-            .client
-            .post(token_url)
-            .form(&request)
-            .send()
-            .await?
-            .json::<TokenResponse>()
-            .await?;
+        let response: TokenResponse = self.client.post_form(&token_url, &request).await?;
 
         Ok(response)
     }

+ 25 - 41
crates/cdk/src/wallet/mint_connector/transport.rs

@@ -1,6 +1,7 @@
 //! HTTP Transport trait with a default implementation
 use std::fmt::Debug;
 
+use cdk_common::http::{HttpClient, HttpClientBuilder};
 use cdk_common::AuthToken;
 #[cfg(all(feature = "bip353", not(target_arch = "wasm32")))]
 use hickory_resolver::config::ResolverConfig;
@@ -8,7 +9,6 @@ use hickory_resolver::config::ResolverConfig;
 use hickory_resolver::name_server::TokioConnectionProvider;
 #[cfg(all(feature = "bip353", not(target_arch = "wasm32")))]
 use hickory_resolver::Resolver;
-use reqwest::Client;
 use serde::de::DeserializeOwned;
 use serde::Serialize;
 use url::Url;
@@ -56,7 +56,7 @@ pub trait Transport: Default + Send + Sync + Debug + Clone {
 /// Async transport for Http
 #[derive(Debug, Clone)]
 pub struct Async {
-    inner: Client,
+    inner: HttpClient,
 }
 
 impl Default for Async {
@@ -67,7 +67,7 @@ impl Default for Async {
         }
 
         Self {
-            inner: Client::new(),
+            inner: HttpClient::new(),
         }
     }
 }
@@ -92,27 +92,23 @@ impl Transport for Async {
         host_matcher: Option<&str>,
         accept_invalid_certs: bool,
     ) -> Result<(), Error> {
-        let builder = reqwest::Client::builder().danger_accept_invalid_certs(accept_invalid_certs);
+        let builder =
+            HttpClientBuilder::default().danger_accept_invalid_certs(accept_invalid_certs);
 
         let builder = match host_matcher {
             Some(pattern) => {
                 // When a matcher is provided, only apply the proxy to matched hosts
-                let regex = regex::Regex::new(pattern).map_err(|e| Error::Custom(e.to_string()))?;
-                builder.proxy(reqwest::Proxy::custom(move |url| {
-                    url.host_str()
-                        .filter(|host| regex.is_match(host))
-                        .map(|_| proxy.clone())
-                }))
+                builder
+                    .proxy_with_matcher(proxy, pattern)
+                    .map_err(|e| Error::Custom(e.to_string()))?
             }
             // Apply proxy to all requests when no matcher is provided
-            None => {
-                builder.proxy(reqwest::Proxy::all(proxy).map_err(|e| Error::Custom(e.to_string()))?)
-            }
+            None => builder.proxy(proxy),
         };
 
         self.inner = builder
             .build()
-            .map_err(|e| Error::HttpError(e.status().map(|s| s.as_u16()), e.to_string()))?;
+            .map_err(|e| Error::HttpError(None, e.to_string()))?;
         Ok(())
     }
 
@@ -144,7 +140,8 @@ impl Transport for Async {
     where
         R: DeserializeOwned,
     {
-        let mut request = self.inner.get(url);
+        let url_str = url.to_string();
+        let mut request = self.inner.get(&url_str);
 
         if let Some(auth) = auth {
             request = request.header(auth.header_key(), auth.to_string());
@@ -153,20 +150,10 @@ impl Transport for Async {
         let response = request
             .send()
             .await
-            .map_err(|e| {
-                Error::HttpError(
-                    e.status().map(|status_code| status_code.as_u16()),
-                    e.to_string(),
-                )
-            })?
+            .map_err(|e| Error::HttpError(None, e.to_string()))?
             .text()
             .await
-            .map_err(|e| {
-                Error::HttpError(
-                    e.status().map(|status_code| status_code.as_u16()),
-                    e.to_string(),
-                )
-            })?;
+            .map_err(|e| Error::HttpError(None, e.to_string()))?;
 
         serde_json::from_str::<R>(&response).map_err(|err| {
             tracing::warn!("Http Response error: {}", err);
@@ -187,25 +174,22 @@ impl Transport for Async {
         P: Serialize + ?Sized + Send + Sync,
         R: DeserializeOwned,
     {
-        let mut request = self.inner.post(url).json(&payload);
+        let url_str = url.to_string();
+        let mut request = self.inner.post(&url_str).json(&payload);
 
         if let Some(auth) = auth_token {
             request = request.header(auth.header_key(), auth.to_string());
         }
 
-        let response = request.send().await.map_err(|e| {
-            Error::HttpError(
-                e.status().map(|status_code| status_code.as_u16()),
-                e.to_string(),
-            )
-        })?;
-
-        let response = response.text().await.map_err(|e| {
-            Error::HttpError(
-                e.status().map(|status_code| status_code.as_u16()),
-                e.to_string(),
-            )
-        })?;
+        let response = request
+            .send()
+            .await
+            .map_err(|e| Error::HttpError(None, e.to_string()))?;
+
+        let response = response
+            .text()
+            .await
+            .map_err(|e| Error::HttpError(None, e.to_string()))?;
 
         serde_json::from_str::<R>(&response).map_err(|err| {
             tracing::warn!("Http Response error: {}", err);

+ 5 - 5
crates/cdk/src/wallet/payment_request.rs

@@ -8,6 +8,7 @@ use std::str::FromStr;
 
 use anyhow::Result;
 use bitcoin::hashes::sha256::Hash as Sha256Hash;
+use cdk_common::http::HttpClient;
 use cdk_common::{Amount, PaymentRequest, PaymentRequestPayload, TransportType};
 #[cfg(feature = "nostr")]
 use nostr_sdk::nips::nip19::Nip19Profile;
@@ -15,7 +16,6 @@ use nostr_sdk::nips::nip19::Nip19Profile;
 use nostr_sdk::prelude::*;
 #[cfg(feature = "nostr")]
 use nostr_sdk::{Client as NostrClient, EventBuilder, FromBech32, Keys, ToBech32};
-use reqwest::Client;
 
 use crate::error::Error;
 use crate::mint_url::MintUrl;
@@ -164,22 +164,22 @@ impl Wallet {
                 }
 
                 TransportType::HttpPost => {
-                    let client = Client::new();
+                    let client = HttpClient::new();
 
                     let res = client
-                        .post(transport.target.clone())
+                        .post(&transport.target)
                         .json(&payload)
                         .send()
                         .await
                         .map_err(|e| Error::HttpError(None, e.to_string()))?;
 
                     let status = res.status();
-                    if status.is_success() {
+                    if res.is_success() {
                         println!("Successfully posted payment");
                         Ok(())
                     } else {
                         let body = res.text().await.unwrap_or_default();
-                        Err(Error::HttpError(Some(status.as_u16()), body))
+                        Err(Error::HttpError(Some(status), body))
                     }
                 }
                 TransportType::InBand => {