Przeglądaj źródła

Add cdk-http-client crate

Extract HTTP client functionality from cdk-common into a dedicated crate to
improve modularity and separation of concerns.

Changes:
- Create new cdk-http-client crate with HttpClient, HttpClientBuilder,
  RequestBuilder, RawResponse, and HttpError types
- Move HTTP client code from cdk-common/src/http/ to the new crate
- Add comprehensive test suite:
  - 20 unit tests for error handling, client/builder construction
  - 28 integration tests using mockito for HTTP mocking
- Add proxy support with optional regex-based host matching (non-WASM)
- Add TLS certificate validation bypass option (non-WASM)

The new crate provides a clean abstraction over reqwest with:
- Simple convenience methods (fetch, post_json, post_form, patch_json)
- Raw response access for streaming/custom handling
- Fluent RequestBuilder API for complex requests
- Cross-platform support (native and WASM)
Cesar Rodas 4 dni temu
rodzic
commit
0c14b839df

+ 67 - 2
Cargo.lock

@@ -348,6 +348,16 @@ dependencies = [
 ]
 
 [[package]]
+name = "assert-json-diff"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
+[[package]]
 name = "async-compat"
 version = "0.2.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1297,6 +1307,7 @@ dependencies = [
  "bitcoin 0.32.8",
  "cashu",
  "cbor-diag",
+ "cdk-http-client",
  "cdk-prometheus",
  "ciborium",
  "criterion",
@@ -1307,8 +1318,6 @@ dependencies = [
  "parking_lot",
  "paste",
  "rand 0.9.2",
- "regex",
- "reqwest",
  "serde",
  "serde_json",
  "serde_with",
@@ -1374,6 +1383,20 @@ dependencies = [
 ]
 
 [[package]]
+name = "cdk-http-client"
+version = "0.14.0"
+dependencies = [
+ "mockito",
+ "regex",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tokio",
+ "url",
+]
+
+[[package]]
 name = "cdk-integration-tests"
 version = "0.14.0"
 dependencies = [
@@ -1389,6 +1412,7 @@ dependencies = [
  "cdk-common",
  "cdk-fake-wallet",
  "cdk-ffi",
+ "cdk-http-client",
  "cdk-ldk-node",
  "cdk-lnd",
  "cdk-mintd",
@@ -1552,6 +1576,7 @@ dependencies = [
  "cashu",
  "cdk",
  "cdk-common",
+ "cdk-http-client",
  "cdk-sqlite",
  "chrono",
  "nostr-sdk",
@@ -1909,6 +1934,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
 
 [[package]]
+name = "colored"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
 name = "combine"
 version = "4.6.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4490,6 +4524,31 @@ dependencies = [
 ]
 
 [[package]]
+name = "mockito"
+version = "1.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e0603425789b4a70fcc4ac4f5a46a566c116ee3e2a6b768dc623f7719c611de"
+dependencies = [
+ "assert-json-diff",
+ "bytes",
+ "colored",
+ "futures-core",
+ "http 1.4.0",
+ "http-body 1.0.1",
+ "http-body-util",
+ "hyper 1.8.1",
+ "hyper-util",
+ "log",
+ "pin-project-lite",
+ "rand 0.9.2",
+ "regex",
+ "serde_json",
+ "serde_urlencoded",
+ "similar",
+ "tokio",
+]
+
+[[package]]
 name = "moka"
 version = "0.12.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6700,6 +6759,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
 
 [[package]]
+name = "similar"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
+
+[[package]]
 name = "simple_asn1"
 version = "0.6.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"

+ 1 - 0
Cargo.toml

@@ -66,6 +66,7 @@ cdk-signatory = { path = "./crates/cdk-signatory", version = "=0.14.0", default-
 cdk-mintd = { path = "./crates/cdk-mintd", version = "=0.14.0", default-features = false }
 cdk-prometheus = { path = "./crates/cdk-prometheus", version = "=0.14.0", default-features = false }
 cdk-npubcash = { path = "./crates/cdk-npubcash", version = "=0.14.0" }
+cdk-http-client = { path = "./crates/cdk-http-client", version = "=0.14.0" }
 clap = { version = "4.5.31", features = ["derive"] }
 ciborium = { version = "0.2.2", default-features = false, features = ["std"] }
 cbor-diag = "0.1.12"

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

@@ -20,9 +20,10 @@ mint = ["cashu/mint", "dep:uuid"]
 auth = ["cashu/auth"]
 nostr = ["wallet", "cashu/nostr"]
 prometheus = ["cdk-prometheus/default"]
-http = ["dep:reqwest", "dep:regex"]
+http = ["dep:cdk-http-client"]
 
 [dependencies]
+cdk-http-client = { workspace = true, optional = true }
 async-trait.workspace = true
 bitcoin.workspace = true
 cashu.workspace = true
@@ -44,10 +45,8 @@ 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]
@@ -56,7 +55,6 @@ 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

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

@@ -1,62 +0,0 @@
-//! 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())
-    }
-}

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

@@ -11,8 +11,6 @@ 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")]
@@ -36,7 +34,10 @@ 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;
+/// Re-export cdk-http-client types
 #[cfg(feature = "http")]
-pub use http::{HttpClient, HttpClientBuilder, HttpError};
+pub use cdk_http_client::{
+    fetch, HttpClient, HttpClientBuilder, HttpError, RawResponse, RequestBuilder, Response,
+};
 /// Re-export parking_lot for reuse
 pub use parking_lot;

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

@@ -105,7 +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: MempoolPricesResponse = cdk_common::http::fetch(url)
+        let response: MempoolPricesResponse = cdk_common::fetch(url)
             .await
             .map_err(|_| Error::UnknownInvoiceAmount)?;
 

+ 32 - 0
crates/cdk-http-client/Cargo.toml

@@ -0,0 +1,32 @@
+[package]
+name = "cdk-http-client"
+version.workspace = true
+authors = ["CDK Developers"]
+description = "HTTP client abstraction for CDK"
+homepage = "https://github.com/cashubtc/cdk"
+repository = "https://github.com/cashubtc/cdk.git"
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+readme = "README.md"
+
+[dependencies]
+serde.workspace = true
+serde_json.workspace = true
+thiserror.workspace = true
+url.workspace = true
+
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+reqwest = { workspace = true }
+regex = { workspace = true }
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+reqwest = { version = "0.12", default-features = false, features = ["json"] }
+
+[dev-dependencies]
+tokio = { workspace = true, features = ["rt", "macros"] }
+mockito = "1"
+serde = { workspace = true }
+
+[lints]
+workspace = true

+ 36 - 0
crates/cdk-http-client/README.md

@@ -0,0 +1,36 @@
+# cdk-http-client
+
+HTTP client abstraction for the Cashu Development Kit (CDK).
+
+This crate provides an HTTP client wrapper that abstracts the underlying HTTP library (reqwest),
+allowing other CDK crates to avoid direct dependencies on reqwest.
+
+## Usage
+
+```rust
+use cdk_http_client::{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
+}
+```
+
+## API
+
+### Builder methods (return `RequestBuilder`):
+- `get(url)` - GET request builder
+- `post(url)` - POST request builder
+- `patch(url)` - PATCH request builder
+
+### Convenience methods (return deserialized JSON):
+- `fetch<R>(url)` - simple GET returning JSON
+- `post_json<B, R>(url, body)` - POST with JSON body
+- `post_form<F, R>(url, form)` - POST with form data
+- `patch_json<B, R>(url, body)` - PATCH with JSON body

+ 109 - 3
crates/cdk-common/src/http/client.rs → crates/cdk-http-client/src/client.rs

@@ -3,9 +3,9 @@
 use serde::de::DeserializeOwned;
 use serde::Serialize;
 
-use super::error::HttpError;
-use super::request::RequestBuilder;
-use super::response::{RawResponse, Response};
+use crate::error::HttpError;
+use crate::request::RequestBuilder;
+use crate::response::{RawResponse, Response};
 
 /// HTTP client wrapper
 #[derive(Debug, Clone)]
@@ -225,3 +225,109 @@ impl HttpClientBuilder {
 pub async fn fetch<R: DeserializeOwned>(url: &str) -> Response<R> {
     HttpClient::new().fetch(url).await
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_client_new() {
+        let client = HttpClient::new();
+        // Client should be constructable without panicking
+        let _ = format!("{:?}", client);
+    }
+
+    #[test]
+    fn test_client_default() {
+        let client = HttpClient::default();
+        // Default should produce a valid client
+        let _ = format!("{:?}", client);
+    }
+
+    #[test]
+    fn test_builder_returns_builder() {
+        let builder = HttpClient::builder();
+        let _ = format!("{:?}", builder);
+    }
+
+    #[test]
+    fn test_builder_build() {
+        let result = HttpClientBuilder::default().build();
+        assert!(result.is_ok());
+    }
+
+    #[test]
+    fn test_from_reqwest() {
+        let reqwest_client = reqwest::Client::new();
+        let client = HttpClient::from_reqwest(reqwest_client);
+        let _ = format!("{:?}", client);
+    }
+
+    #[cfg(not(target_arch = "wasm32"))]
+    mod non_wasm {
+        use super::*;
+
+        #[test]
+        fn test_builder_accept_invalid_certs() {
+            let result = HttpClientBuilder::default()
+                .danger_accept_invalid_certs(true)
+                .build();
+            assert!(result.is_ok());
+        }
+
+        #[test]
+        fn test_builder_accept_invalid_certs_false() {
+            let result = HttpClientBuilder::default()
+                .danger_accept_invalid_certs(false)
+                .build();
+            assert!(result.is_ok());
+        }
+
+        #[test]
+        fn test_builder_proxy() {
+            let proxy_url =
+                url::Url::parse("http://localhost:8080").expect("Valid proxy URL");
+            let result = HttpClientBuilder::default().proxy(proxy_url).build();
+            assert!(result.is_ok());
+        }
+
+        #[test]
+        fn test_builder_proxy_with_valid_matcher() {
+            let proxy_url =
+                url::Url::parse("http://localhost:8080").expect("Valid proxy URL");
+            let result = HttpClientBuilder::default()
+                .proxy_with_matcher(proxy_url, r".*\.example\.com$");
+            assert!(result.is_ok());
+
+            let builder = result.expect("Valid matcher should succeed");
+            let client_result = builder.build();
+            assert!(client_result.is_ok());
+        }
+
+        #[test]
+        fn test_builder_proxy_with_invalid_matcher() {
+            let proxy_url =
+                url::Url::parse("http://localhost:8080").expect("Valid proxy URL");
+            // Invalid regex pattern (unclosed bracket)
+            let result = HttpClientBuilder::default().proxy_with_matcher(proxy_url, r"[invalid");
+            assert!(result.is_err());
+
+            if let Err(HttpError::Proxy(msg)) = result {
+                assert!(msg.contains("Invalid proxy pattern"));
+            } else {
+                panic!("Expected HttpError::Proxy");
+            }
+        }
+
+        #[test]
+        fn test_builder_chained_config() {
+            let proxy_url =
+                url::Url::parse("http://localhost:8080").expect("Valid proxy URL");
+            let result = HttpClientBuilder::default()
+                .danger_accept_invalid_certs(true)
+                .proxy(proxy_url)
+                .build();
+            assert!(result.is_ok());
+        }
+    }
+}

+ 127 - 0
crates/cdk-http-client/src/error.rs

@@ -0,0 +1,127 @@
+//! 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())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_http_error_status_display() {
+        let error = HttpError::Status {
+            status: 404,
+            message: "Not Found".to_string(),
+        };
+        assert_eq!(format!("{}", error), "HTTP error (404): Not Found");
+    }
+
+    #[test]
+    fn test_http_error_connection_display() {
+        let error = HttpError::Connection("connection refused".to_string());
+        assert_eq!(format!("{}", error), "Connection error: connection refused");
+    }
+
+    #[test]
+    fn test_http_error_timeout_display() {
+        let error = HttpError::Timeout;
+        assert_eq!(format!("{}", error), "Request timeout");
+    }
+
+    #[test]
+    fn test_http_error_serialization_display() {
+        let error = HttpError::Serialization("invalid JSON".to_string());
+        assert_eq!(format!("{}", error), "Serialization error: invalid JSON");
+    }
+
+    #[test]
+    fn test_http_error_proxy_display() {
+        let error = HttpError::Proxy("proxy unreachable".to_string());
+        assert_eq!(format!("{}", error), "Proxy error: proxy unreachable");
+    }
+
+    #[test]
+    fn test_http_error_build_display() {
+        let error = HttpError::Build("invalid config".to_string());
+        assert_eq!(format!("{}", error), "Client build error: invalid config");
+    }
+
+    #[test]
+    fn test_http_error_other_display() {
+        let error = HttpError::Other("unknown error".to_string());
+        assert_eq!(format!("{}", error), "unknown error");
+    }
+
+    #[test]
+    fn test_from_serde_json_error() {
+        // Create an invalid JSON parse to get a serde_json::Error
+        let result: Result<String, _> = serde_json::from_str("not valid json");
+        let json_error = result.expect_err("Invalid JSON should produce an error");
+        let http_error: HttpError = json_error.into();
+
+        match http_error {
+            HttpError::Serialization(msg) => {
+                assert!(msg.contains("expected"), "Error message should describe JSON error");
+            }
+            _ => panic!("Expected HttpError::Serialization"),
+        }
+    }
+}

+ 4 - 4
crates/cdk-common/src/http/mod.rs → crates/cdk-http-client/src/lib.rs

@@ -1,12 +1,12 @@
-//! HTTP client abstraction
+//! HTTP client abstraction for CDK
 //!
-//! 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.
+//! This crate provides an HTTP client wrapper that abstracts the underlying HTTP library (reqwest).
+//! Using this crate allows other CDK crates to avoid direct dependencies on reqwest.
 //!
 //! # Example
 //!
 //! ```no_run
-//! use cdk_common::http::{HttpClient, Response};
+//! use cdk_http_client::{HttpClient, Response};
 //! use serde::Deserialize;
 //!
 //! #[derive(Deserialize)]

+ 2 - 2
crates/cdk-common/src/http/request.rs → crates/cdk-http-client/src/request.rs

@@ -3,8 +3,8 @@
 use serde::de::DeserializeOwned;
 use serde::Serialize;
 
-use super::error::HttpError;
-use super::response::{RawResponse, Response};
+use crate::error::HttpError;
+use crate::response::{RawResponse, Response};
 
 /// HTTP request builder for complex requests
 #[derive(Debug)]

+ 21 - 1
crates/cdk-common/src/http/response.rs → crates/cdk-http-client/src/response.rs

@@ -2,7 +2,7 @@
 
 use serde::de::DeserializeOwned;
 
-use super::error::HttpError;
+use crate::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
@@ -63,3 +63,23 @@ impl RawResponse {
             .map_err(HttpError::from)
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // Note: RawResponse tests require a real reqwest::Response,
+    // so they are in tests/integration.rs using mockito.
+
+    #[test]
+    fn test_response_type_is_result() {
+        // Response<R, E> is just a type alias for Result<R, E>
+        let success: Response<i32> = Ok(42);
+        assert!(success.is_ok());
+        assert!(matches!(success, Ok(42)));
+
+        let error: Response<i32> = Err(HttpError::Timeout);
+        assert!(error.is_err());
+        assert!(matches!(error, Err(HttpError::Timeout)));
+    }
+}

+ 748 - 0
crates/cdk-http-client/tests/integration.rs

@@ -0,0 +1,748 @@
+//! Integration tests for cdk-http-client using mockito
+
+use cdk_http_client::{HttpClient, HttpError};
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Serialize, Deserialize, PartialEq)]
+struct TestPayload {
+    name: String,
+    value: i32,
+}
+
+#[derive(Debug, Serialize, Deserialize, PartialEq)]
+struct TestResponse {
+    success: bool,
+    data: String,
+}
+
+// === HttpClient::fetch tests ===
+
+#[tokio::test]
+async fn test_fetch_success() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("GET", "/api/data")
+        .with_status(200)
+        .with_header("content-type", "application/json")
+        .with_body(r#"{"success": true, "data": "hello"}"#)
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let url = format!("{}/api/data", server.url());
+    let result: Result<TestResponse, _> = client.fetch(&url).await;
+
+    assert!(result.is_ok());
+    let response = result.expect("Fetch should succeed");
+    assert!(response.success);
+    assert_eq!(response.data, "hello");
+
+    mock.assert_async().await;
+}
+
+#[tokio::test]
+async fn test_fetch_error_status() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("GET", "/api/error")
+        .with_status(404)
+        .with_body("Not Found")
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let url = format!("{}/api/error", server.url());
+    let result: Result<TestResponse, _> = client.fetch(&url).await;
+
+    assert!(result.is_err());
+    if let Err(HttpError::Status { status, message }) = result {
+        assert_eq!(status, 404);
+        assert_eq!(message, "Not Found");
+    } else {
+        panic!("Expected HttpError::Status");
+    }
+
+    mock.assert_async().await;
+}
+
+#[tokio::test]
+async fn test_fetch_server_error() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("GET", "/api/server-error")
+        .with_status(500)
+        .with_body("Internal Server Error")
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let url = format!("{}/api/server-error", server.url());
+    let result: Result<TestResponse, _> = client.fetch(&url).await;
+
+    assert!(result.is_err());
+    if let Err(HttpError::Status { status, .. }) = result {
+        assert_eq!(status, 500);
+    } else {
+        panic!("Expected HttpError::Status");
+    }
+
+    mock.assert_async().await;
+}
+
+// === HttpClient::post_json tests ===
+
+#[tokio::test]
+async fn test_post_json_success() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("POST", "/api/submit")
+        .match_header("content-type", "application/json")
+        .match_body(mockito::Matcher::Json(serde_json::json!({
+            "name": "test",
+            "value": 42
+        })))
+        .with_status(200)
+        .with_header("content-type", "application/json")
+        .with_body(r#"{"success": true, "data": "received"}"#)
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let url = format!("{}/api/submit", server.url());
+    let payload = TestPayload {
+        name: "test".to_string(),
+        value: 42,
+    };
+    let result: Result<TestResponse, _> = client.post_json(&url, &payload).await;
+
+    assert!(result.is_ok());
+    let response = result.expect("POST JSON should succeed");
+    assert!(response.success);
+    assert_eq!(response.data, "received");
+
+    mock.assert_async().await;
+}
+
+#[tokio::test]
+async fn test_post_json_error_status() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("POST", "/api/submit")
+        .with_status(400)
+        .with_body("Bad Request")
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let url = format!("{}/api/submit", server.url());
+    let payload = TestPayload {
+        name: "test".to_string(),
+        value: 42,
+    };
+    let result: Result<TestResponse, _> = client.post_json(&url, &payload).await;
+
+    assert!(result.is_err());
+    if let Err(HttpError::Status { status, message }) = result {
+        assert_eq!(status, 400);
+        assert_eq!(message, "Bad Request");
+    } else {
+        panic!("Expected HttpError::Status");
+    }
+
+    mock.assert_async().await;
+}
+
+// === HttpClient::get_raw tests ===
+
+#[tokio::test]
+async fn test_get_raw_success() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("GET", "/api/raw")
+        .with_status(200)
+        .with_body("raw content")
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let url = format!("{}/api/raw", server.url());
+    let result = client.get_raw(&url).await;
+
+    assert!(result.is_ok());
+    let response = result.expect("GET raw should succeed");
+    assert_eq!(response.status(), 200);
+    assert!(response.is_success());
+
+    mock.assert_async().await;
+}
+
+// === RawResponse tests ===
+
+#[tokio::test]
+async fn test_raw_response_is_success_with_200() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("GET", "/")
+        .with_status(200)
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let response = client
+        .get_raw(&server.url())
+        .await
+        .expect("Request should succeed");
+
+    assert!(response.is_success());
+    assert!(!response.is_client_error());
+    assert!(!response.is_server_error());
+    assert_eq!(response.status(), 200);
+
+    mock.assert_async().await;
+}
+
+#[tokio::test]
+async fn test_raw_response_is_success_with_201() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("GET", "/")
+        .with_status(201)
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let response = client
+        .get_raw(&server.url())
+        .await
+        .expect("Request should succeed");
+
+    assert!(response.is_success());
+    assert_eq!(response.status(), 201);
+
+    mock.assert_async().await;
+}
+
+#[tokio::test]
+async fn test_raw_response_is_success_with_299() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("GET", "/")
+        .with_status(299)
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let response = client
+        .get_raw(&server.url())
+        .await
+        .expect("Request should succeed");
+
+    assert!(response.is_success());
+    assert_eq!(response.status(), 299);
+
+    mock.assert_async().await;
+}
+
+#[tokio::test]
+async fn test_raw_response_is_not_success_with_100() {
+    // Note: HTTP 1xx informational responses are special and may not be
+    // fully supported by all HTTP libraries. We test with 100 Continue.
+    // mockito may convert some 1xx codes to 500, so we just verify the
+    // logic works with the boundary check (200..300).
+    let mut server = mockito::Server::new_async().await;
+
+    // Use 301 redirect as a more reliable "not success" boundary test
+    let mock = server
+        .mock("GET", "/")
+        .with_status(301)
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let response = client
+        .get_raw(&server.url())
+        .await
+        .expect("Request should succeed");
+
+    // 301 is a redirect, not success
+    assert!(!response.is_success());
+    assert_eq!(response.status(), 301);
+
+    mock.assert_async().await;
+}
+
+#[tokio::test]
+async fn test_raw_response_is_not_success_with_300() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("GET", "/")
+        .with_status(300)
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let response = client
+        .get_raw(&server.url())
+        .await
+        .expect("Request should succeed");
+
+    assert!(!response.is_success());
+    assert_eq!(response.status(), 300);
+
+    mock.assert_async().await;
+}
+
+#[tokio::test]
+async fn test_raw_response_is_client_error_with_400() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("GET", "/")
+        .with_status(400)
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let response = client
+        .get_raw(&server.url())
+        .await
+        .expect("Request should succeed");
+
+    assert!(response.is_client_error());
+    assert!(!response.is_success());
+    assert!(!response.is_server_error());
+    assert_eq!(response.status(), 400);
+
+    mock.assert_async().await;
+}
+
+#[tokio::test]
+async fn test_raw_response_is_client_error_with_499() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("GET", "/")
+        .with_status(499)
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let response = client
+        .get_raw(&server.url())
+        .await
+        .expect("Request should succeed");
+
+    assert!(response.is_client_error());
+    assert_eq!(response.status(), 499);
+
+    mock.assert_async().await;
+}
+
+#[tokio::test]
+async fn test_raw_response_is_not_client_error_with_399() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("GET", "/")
+        .with_status(399)
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let response = client
+        .get_raw(&server.url())
+        .await
+        .expect("Request should succeed");
+
+    assert!(!response.is_client_error());
+    assert_eq!(response.status(), 399);
+
+    mock.assert_async().await;
+}
+
+#[tokio::test]
+async fn test_raw_response_is_server_error_with_500() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("GET", "/")
+        .with_status(500)
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let response = client
+        .get_raw(&server.url())
+        .await
+        .expect("Request should succeed");
+
+    assert!(response.is_server_error());
+    assert!(!response.is_success());
+    assert!(!response.is_client_error());
+    assert_eq!(response.status(), 500);
+
+    mock.assert_async().await;
+}
+
+#[tokio::test]
+async fn test_raw_response_is_server_error_with_599() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("GET", "/")
+        .with_status(599)
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let response = client
+        .get_raw(&server.url())
+        .await
+        .expect("Request should succeed");
+
+    assert!(response.is_server_error());
+    assert_eq!(response.status(), 599);
+
+    mock.assert_async().await;
+}
+
+#[tokio::test]
+async fn test_raw_response_is_not_server_error_with_499() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("GET", "/")
+        .with_status(499)
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let response = client
+        .get_raw(&server.url())
+        .await
+        .expect("Request should succeed");
+
+    assert!(!response.is_server_error());
+    assert_eq!(response.status(), 499);
+
+    mock.assert_async().await;
+}
+
+#[tokio::test]
+async fn test_raw_response_text() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("GET", "/")
+        .with_status(200)
+        .with_body("Hello, World!")
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let response = client
+        .get_raw(&server.url())
+        .await
+        .expect("Request should succeed");
+    let text = response.text().await.expect("Text extraction should succeed");
+
+    assert_eq!(text, "Hello, World!");
+
+    mock.assert_async().await;
+}
+
+#[tokio::test]
+async fn test_raw_response_json() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("GET", "/")
+        .with_status(200)
+        .with_header("content-type", "application/json")
+        .with_body(r#"{"success": true, "data": "json_test"}"#)
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let response = client
+        .get_raw(&server.url())
+        .await
+        .expect("Request should succeed");
+    let json: TestResponse = response.json().await.expect("JSON parsing should succeed");
+
+    assert!(json.success);
+    assert_eq!(json.data, "json_test");
+
+    mock.assert_async().await;
+}
+
+#[tokio::test]
+async fn test_raw_response_bytes() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("GET", "/")
+        .with_status(200)
+        .with_body(vec![0x01, 0x02, 0x03, 0x04])
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let response = client
+        .get_raw(&server.url())
+        .await
+        .expect("Request should succeed");
+    let bytes = response.bytes().await.expect("Bytes extraction should succeed");
+
+    assert_eq!(bytes, vec![0x01, 0x02, 0x03, 0x04]);
+
+    mock.assert_async().await;
+}
+
+// === RequestBuilder tests ===
+
+#[tokio::test]
+async fn test_request_builder_send() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("GET", "/api/builder")
+        .with_status(200)
+        .with_body("builder response")
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let url = format!("{}/api/builder", server.url());
+    let response = client
+        .get(&url)
+        .send()
+        .await
+        .expect("Request should succeed");
+
+    assert_eq!(response.status(), 200);
+    assert_eq!(
+        response.text().await.expect("Text extraction should succeed"),
+        "builder response"
+    );
+
+    mock.assert_async().await;
+}
+
+#[tokio::test]
+async fn test_request_builder_send_json() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("POST", "/api/json")
+        .with_status(200)
+        .with_header("content-type", "application/json")
+        .with_body(r#"{"success": true, "data": "builder_json"}"#)
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let url = format!("{}/api/json", server.url());
+    let payload = TestPayload {
+        name: "builder".to_string(),
+        value: 100,
+    };
+
+    let result: TestResponse = client
+        .post(&url)
+        .json(&payload)
+        .send_json()
+        .await
+        .expect("Request should succeed");
+
+    assert!(result.success);
+    assert_eq!(result.data, "builder_json");
+
+    mock.assert_async().await;
+}
+
+#[tokio::test]
+async fn test_request_builder_with_headers() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("GET", "/api/headers")
+        .match_header("X-Custom-Header", "custom-value")
+        .match_header("Authorization", "Bearer token123")
+        .with_status(200)
+        .with_body("headers received")
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let url = format!("{}/api/headers", server.url());
+    let response = client
+        .get(&url)
+        .header("X-Custom-Header", "custom-value")
+        .header("Authorization", "Bearer token123")
+        .send()
+        .await
+        .expect("Request should succeed");
+
+    assert_eq!(response.status(), 200);
+    assert_eq!(
+        response.text().await.expect("Text extraction should succeed"),
+        "headers received"
+    );
+
+    mock.assert_async().await;
+}
+
+#[tokio::test]
+async fn test_request_builder_post_with_form() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("POST", "/api/form")
+        .match_header(
+            "content-type",
+            mockito::Matcher::Regex("application/x-www-form-urlencoded.*".to_string()),
+        )
+        .with_status(200)
+        .with_header("content-type", "application/json")
+        .with_body(r#"{"success": true, "data": "form_received"}"#)
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let url = format!("{}/api/form", server.url());
+    let form_data = [("field1", "value1"), ("field2", "value2")];
+
+    let response: TestResponse = client
+        .post(&url)
+        .form(&form_data)
+        .send_json()
+        .await
+        .expect("Request should succeed");
+
+    assert!(response.success);
+    assert_eq!(response.data, "form_received");
+
+    mock.assert_async().await;
+}
+
+#[tokio::test]
+async fn test_request_builder_patch() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("PATCH", "/api/resource")
+        .with_status(200)
+        .with_header("content-type", "application/json")
+        .with_body(r#"{"success": true, "data": "patched"}"#)
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let url = format!("{}/api/resource", server.url());
+    let payload = TestPayload {
+        name: "update".to_string(),
+        value: 99,
+    };
+
+    let result: TestResponse = client
+        .patch(&url)
+        .json(&payload)
+        .send_json()
+        .await
+        .expect("Request should succeed");
+
+    assert!(result.success);
+    assert_eq!(result.data, "patched");
+
+    mock.assert_async().await;
+}
+
+// === Convenience function test ===
+
+#[tokio::test]
+async fn test_fetch_convenience_function() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("GET", "/api/convenience")
+        .with_status(200)
+        .with_header("content-type", "application/json")
+        .with_body(r#"{"success": true, "data": "convenience"}"#)
+        .create_async()
+        .await;
+
+    let url = format!("{}/api/convenience", server.url());
+    let result: Result<TestResponse, _> = cdk_http_client::fetch(&url).await;
+
+    assert!(result.is_ok());
+    let response = result.expect("Fetch should succeed");
+    assert!(response.success);
+    assert_eq!(response.data, "convenience");
+
+    mock.assert_async().await;
+}
+
+// === Error handling tests ===
+
+#[tokio::test]
+async fn test_json_deserialization_error() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("GET", "/api/invalid-json")
+        .with_status(200)
+        .with_header("content-type", "application/json")
+        .with_body("not valid json")
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let url = format!("{}/api/invalid-json", server.url());
+    let result: Result<TestResponse, _> = client.fetch(&url).await;
+
+    assert!(result.is_err());
+    // The error should be about JSON parsing, which becomes HttpError::Other from reqwest
+    let err = result.expect_err("Should be a deserialization error");
+    let err_str = format!("{}", err);
+    assert!(
+        err_str.contains("expected") || err_str.contains("JSON") || err_str.contains("error"),
+        "Error should mention parsing issue: {}",
+        err_str
+    );
+
+    mock.assert_async().await;
+}
+
+#[tokio::test]
+async fn test_raw_response_json_deserialization_error() {
+    let mut server = mockito::Server::new_async().await;
+
+    let mock = server
+        .mock("GET", "/")
+        .with_status(200)
+        .with_body("invalid json")
+        .create_async()
+        .await;
+
+    let client = HttpClient::new();
+    let response = client
+        .get_raw(&server.url())
+        .await
+        .expect("Request should succeed");
+    let result: Result<TestResponse, _> = response.json().await;
+
+    assert!(result.is_err());
+
+    mock.assert_async().await;
+}

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

@@ -29,6 +29,7 @@ cdk-sqlite = { workspace = true }
 cdk-redb = { workspace = true }
 cdk-fake-wallet = { workspace = true }
 cdk-common = { workspace = true, features = ["mint", "wallet", "auth", "http"] }
+cdk-http-client = { workspace = true }
 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",

+ 1 - 1
crates/cdk-integration-tests/tests/fake_auth.rs

@@ -14,7 +14,7 @@ use cdk::nuts::{
 };
 use cdk::wallet::{AuthHttpClient, AuthMintConnector, HttpClient, MintConnector, WalletBuilder};
 use cdk::{Error, OidcClient};
-use cdk_common::HttpClient as CommonHttpClient;
+use cdk_http_client::HttpClient as CommonHttpClient;
 use cdk_fake_wallet::create_fake_invoice;
 use cdk_integration_tests::fund_wallet;
 use cdk_sqlite::wallet::memory;

+ 1 - 1
crates/cdk-integration-tests/tests/ldk_node.rs

@@ -1,5 +1,5 @@
 use anyhow::Result;
-use cdk_common::HttpClient;
+use cdk_http_client::HttpClient;
 use cdk_integration_tests::get_mint_url_from_env;
 
 #[tokio::test]

+ 1 - 1
crates/cdk-integration-tests/tests/nutshell_wallet.rs

@@ -1,6 +1,6 @@
 use std::time::Duration;
 
-use cdk_common::HttpClient;
+use cdk_http_client::HttpClient;
 use cdk_fake_wallet::create_fake_invoice;
 use serde::{Deserialize, Serialize};
 use serde_json::Value;

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

@@ -12,6 +12,7 @@ repository.workspace = true
 async-trait = { workspace = true }
 cashu = { workspace = true }
 cdk-common = { workspace = true, features = ["wallet", "http"] }
+cdk-http-client = { workspace = true }
 nostr-sdk = { workspace = true }
 serde = { workspace = true }
 serde_json = { workspace = true }

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

@@ -108,7 +108,7 @@ impl JwtAuthProvider {
         &self,
         auth_url: &str,
         nostr_token: &str,
-    ) -> Result<cdk_common::http::RawResponse> {
+    ) -> Result<cdk_common::RawResponse> {
         tracing::debug!("Sending request to: {}", auth_url);
         tracing::debug!(
             "Authorization header: Nostr {}",
@@ -130,7 +130,7 @@ impl JwtAuthProvider {
     }
 
     /// Parse the JWT response from the API
-    async fn parse_jwt_response(&self, response: cdk_common::http::RawResponse) -> Result<String> {
+    async fn parse_jwt_response(&self, response: cdk_common::RawResponse) -> Result<String> {
         let status = response.status();
 
         if !response.is_success() {

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

@@ -2,7 +2,7 @@
 
 use std::sync::Arc;
 
-use cdk_common::http::{HttpClient, RawResponse};
+use cdk_http_client::{HttpClient, RawResponse};
 use tracing::instrument;
 
 use crate::auth::JwtAuthProvider;

+ 10 - 0
crates/cdk/src/lib.rs

@@ -75,6 +75,16 @@ pub type Result<T, E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
 
 /// Re-export subscription
 pub use cdk_common::subscription;
+/// Re-export HTTP client types from cdk-http-client (via cdk-common)
+#[cfg(any(feature = "wallet", feature = "mint"))]
+pub mod http_client {
+    //! HTTP client abstraction
+    //!
+    //! Re-exports from [`cdk_http_client`] for making HTTP requests.
+    pub use cdk_common::{
+        fetch, HttpClient, HttpClientBuilder, HttpError, RawResponse, RequestBuilder, Response,
+    };
+}
 /// Re-export futures::Stream
 #[cfg(any(feature = "wallet", feature = "mint"))]
 pub use futures::{Stream, StreamExt};

+ 1 - 1
crates/cdk/src/oidc_client.rs

@@ -4,7 +4,7 @@ use std::collections::HashMap;
 use std::ops::Deref;
 use std::sync::Arc;
 
-use cdk_common::http::HttpClient;
+use cdk_common::HttpClient;
 use jsonwebtoken::jwk::{AlgorithmParameters, JwkSet};
 use jsonwebtoken::{decode, decode_header, DecodingKey, Validation};
 use serde::Deserialize;

+ 1 - 1
crates/cdk/src/wallet/mint_connector/transport.rs

@@ -1,7 +1,7 @@
 //! HTTP Transport trait with a default implementation
 use std::fmt::Debug;
 
-use cdk_common::http::{HttpClient, HttpClientBuilder};
+use cdk_common::{HttpClient, HttpClientBuilder};
 use cdk_common::AuthToken;
 #[cfg(all(feature = "bip353", not(target_arch = "wasm32")))]
 use hickory_resolver::config::ResolverConfig;

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

@@ -8,7 +8,7 @@ use std::str::FromStr;
 
 use anyhow::Result;
 use bitcoin::hashes::sha256::Hash as Sha256Hash;
-use cdk_common::http::HttpClient;
+use cdk_common::HttpClient;
 use cdk_common::{Amount, PaymentRequest, PaymentRequestPayload, TransportType};
 #[cfg(feature = "nostr")]
 use nostr_sdk::nips::nip19::Nip19Profile;