From a69895f9bc2888a549dd9f4a73d196c557252c0b Mon Sep 17 00:00:00 2001 From: Luis Araujo <7045685+luis-araujo-dev@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:38:02 +0000 Subject: [PATCH 1/4] feature: Allow to customize reqwest builder --- typesense/Cargo.toml | 2 + typesense/src/client/mod.rs | 154 +++++++++++++++--- typesense/src/lib.rs | 9 +- typesense/tests/client/client_test.rs | 2 - .../tests/client/conversation_models_test.rs | 1 - .../http_builder_tls_test.rs | 114 +++++++++++++ .../tests/client/http_builder_test/mod.rs | 132 +++++++++++++++ typesense/tests/client/mod.rs | 2 +- xtask/src/test_clean.rs | 1 - 9 files changed, 385 insertions(+), 32 deletions(-) create mode 100644 typesense/tests/client/http_builder_test/http_builder_tls_test.rs create mode 100644 typesense/tests/client/http_builder_test/mod.rs diff --git a/typesense/Cargo.toml b/typesense/Cargo.toml index f86b5dd5..302dd1d5 100644 --- a/typesense/Cargo.toml +++ b/typesense/Cargo.toml @@ -48,6 +48,8 @@ trybuild = "1.0.42" # native-only dev deps [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] tokio = { workspace = true} +tokio-rustls = "0.26" +rcgen = "0.14" wiremock = "0.6" # wasm test deps diff --git a/typesense/src/client/mod.rs b/typesense/src/client/mod.rs index 8a5f1e60..80561a88 100644 --- a/typesense/src/client/mod.rs +++ b/typesense/src/client/mod.rs @@ -28,7 +28,6 @@ //! .api_key("xyz") //! .healthcheck_interval(Duration::from_secs(60)) //! .retry_policy(ExponentialBackoff::builder().build_with_max_retries(3)) -//! .connection_timeout(Duration::from_secs(5)) //! .build() //! .unwrap(); //! @@ -59,8 +58,7 @@ //! ### WebAssembly (Wasm) Usage //! //! When compiling for a WebAssembly target (`wasm32-unknown-unknown`), -//! Tokio-based features such as middleware, retries, and connection -//! timeouts are **not available**. +//! Tokio-based features such as middleware and retries are **not available**. //! //! Example: //! @@ -78,8 +76,7 @@ //! .nodes(vec!["http://localhost:8108"]) //! .api_key("xyz") //! .healthcheck_interval(Duration::from_secs(60)) -//! // .retry_policy(...) <-- not supported in Wasm -//! // .connection_timeout(...) <-- not supported in Wasm +//! // .retry_policy(...) <-- not supported in Wasm //! .build() //! .unwrap(); //! @@ -182,6 +179,111 @@ macro_rules! execute_wrapper { }; } +/// Configuration for a single Typesense node. +/// +/// Use this to customize the HTTP client for specific nodes, +/// for example to add custom TLS root certificates or configure proxies. +/// +/// For simple cases, you can pass a plain URL string to the builder's +/// `.nodes()` method, which will be automatically converted. +/// +/// # Examples +/// +/// ``` +/// use typesense::NodeConfig; +/// +/// // Simple URL (same as passing a string directly) +/// let node = NodeConfig::new("https://node1.example.com"); +/// +/// // With custom HTTP client configuration +/// // (add timeouts, headers, TLS, etc. on native targets) +/// let node = NodeConfig::new("https://node2.example.com") +/// .http_builder(|builder| { +/// // This closure receives a `reqwest::ClientBuilder` and must return it. +/// // You can call any supported builder methods here; for example, +/// // `builder.connect_timeout(...)` on native targets. +/// builder +/// }); +/// ``` +pub struct NodeConfig { + url: String, + http_builder: Option reqwest::ClientBuilder>>, +} + +impl std::fmt::Debug for NodeConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NodeConfig") + .field("url", &self.url) + .field("http_builder", &self.http_builder.as_ref().map(|_| "..")) + .finish() + } +} + +impl NodeConfig { + /// Creates a new `NodeConfig` with the given URL. + pub fn new(url: impl Into) -> Self { + Self { + url: url.into(), + http_builder: None, + } + } + + /// Sets a custom HTTP client builder for this node. + /// + /// The closure receives a default [`reqwest::ClientBuilder`] and should return + /// a configured builder. This is useful for adding custom TLS certificates, + /// proxies, or other reqwest settings. + /// + /// When not set, a default builder with a 5-second connect timeout is used + /// (native targets only; WASM uses the browser's defaults). + /// + /// # Examples + /// + /// ```no_run + /// use typesense::NodeConfig; + /// + /// // You can capture arbitrary configuration here (certs, proxies, etc.) + /// // and apply it to the `reqwest::ClientBuilder` on platforms that support it. + /// let node = NodeConfig::new("https://secure.example.com") + /// .http_builder(move |builder| { + /// // Example (native-only, not shown here to keep the example + /// // portable across native and WASM): + /// // + /// // builder + /// // .add_root_certificate(cert.clone()) + /// // .connect_timeout(std::time::Duration::from_secs(10)) + /// // + /// // For this doctest, we just return the builder unchanged. + /// builder + /// }); + /// ``` + pub fn http_builder( + mut self, + f: impl Fn(reqwest::ClientBuilder) -> reqwest::ClientBuilder + 'static, + ) -> Self { + self.http_builder = Some(Box::new(f)); + self + } +} + +impl From for NodeConfig { + fn from(url: String) -> Self { + Self::new(url) + } +} + +impl<'a> From<&'a str> for NodeConfig { + fn from(url: &'a str) -> Self { + Self::new(url) + } +} + +impl From for NodeConfig { + fn from(url: reqwest::Url) -> Self { + Self::new(url) + } +} + // This is an internal detail to track the state of each node. #[derive(Debug)] struct Node { @@ -219,54 +321,64 @@ impl Client { /// - **nearest_node**: None. /// - **healthcheck_interval**: 60 seconds. /// - **retry_policy**: Exponential backoff with a maximum of 3 retries. (disabled on WASM) - /// - **connection_timeout**: 5 seconds. (disabled on WASM) + /// - **http_builder**: An `Fn(reqwest::ClientBuilder) -> reqwest::ClientBuilder` closure + /// for per-node HTTP client customization (optional, via [`NodeConfig`]). + /// + /// When no custom `http_builder` is configured, a default `reqwest::ClientBuilder` with + /// a 5-second connect timeout is used (native targets only). #[builder] pub fn new( /// The Typesense API key used for authentication. #[builder(into)] api_key: String, /// A list of all nodes in the Typesense cluster. + /// + /// Accepts plain URL strings or [`NodeConfig`] instances for per-node + /// HTTP client customization. #[builder( - with = |iter: impl IntoIterator>| - iter.into_iter().map(Into::into).collect::>() + with = |iter: impl IntoIterator>| + iter.into_iter().map(Into::into).collect::>() )] - nodes: Vec, + nodes: Vec, #[builder(into)] /// An optional, preferred node to try first for every request. /// This is for your server-side load balancer. /// Do not add this node to all nodes list, should be a separate one. - nearest_node: Option, + nearest_node: Option, #[builder(default = Duration::from_secs(60))] /// The duration after which an unhealthy node will be retried for requests. healthcheck_interval: Duration, #[builder(default = ExponentialBackoff::builder().build_with_max_retries(3))] /// The retry policy for transient network errors on a *single* node. retry_policy: ExponentialBackoff, - #[builder(default = Duration::from_secs(5))] - /// The timeout for each individual network request. - connection_timeout: Duration, ) -> Result { let is_nearest_node_set = nearest_node.is_some(); let nodes: Vec<_> = nodes .into_iter() .chain(nearest_node) - .map(|mut url| { + .map(|node_config| { + let builder = match node_config.http_builder { + Some(f) => f(reqwest::Client::builder()), + None => { + let b = reqwest::Client::builder(); + #[cfg(not(target_arch = "wasm32"))] + let b = b.connect_timeout(Duration::from_secs(5)); + b + } + }; + #[cfg(target_arch = "wasm32")] - let http_client = reqwest::Client::builder() - .build() - .expect("Failed to build reqwest client"); + let http_client = builder.build().expect("Failed to build reqwest client"); #[cfg(not(target_arch = "wasm32"))] let http_client = ReqwestMiddlewareClientBuilder::new( - reqwest::Client::builder() - .timeout(connection_timeout) - .build() - .expect("Failed to build reqwest client"), + builder.build().expect("Failed to build reqwest client"), ) .with(RetryTransientMiddleware::new_with_policy(retry_policy)) .build(); + let mut url = node_config.url; if url.len() > 1 && matches!(url.chars().last(), Some('/')) { url.pop(); } diff --git a/typesense/src/lib.rs b/typesense/src/lib.rs index 9afac339..5ea23ee1 100644 --- a/typesense/src/lib.rs +++ b/typesense/src/lib.rs @@ -48,7 +48,6 @@ //! .api_key("xyz") //! .healthcheck_interval(Duration::from_secs(60)) //! .retry_policy(ExponentialBackoff::builder().build_with_max_retries(3)) -//! .connection_timeout(Duration::from_secs(5)) //! .build()?; //! //! // Create the collection in Typesense @@ -68,8 +67,7 @@ //! ### WebAssembly (Wasm) //! //! This example is tailored for a WebAssembly target. -//! Key difference: Tokio-dependent features like `.retry_policy()` and `.connection_timeout()` -//! are disabled. You can still set them in the client builder but it will do nothing. +//! Key difference: Tokio-dependent features like `.retry_policy()` are disabled. //! //! ```no_run //! #[cfg(target_family = "wasm")] @@ -98,8 +96,7 @@ //! .nodes(vec!["http://localhost:8108"]) //! .api_key("xyz") //! .healthcheck_interval(Duration::from_secs(60)) -//! // .retry_policy(...) <-- disabled in Wasm -//! // .connection_timeout(...) <-- disabled in Wasm +//! // .retry_policy(...) <-- disabled in Wasm //! .build() //! .unwrap(); //! @@ -119,7 +116,7 @@ pub mod error; pub mod models; pub mod prelude; -pub use client::{Client, ExponentialBackoff}; +pub use client::{Client, ExponentialBackoff, NodeConfig}; pub use error::*; pub use typesense_codegen as legacy; diff --git a/typesense/tests/client/client_test.rs b/typesense/tests/client/client_test.rs index 83658838..97a4a642 100644 --- a/typesense/tests/client/client_test.rs +++ b/typesense/tests/client/client_test.rs @@ -49,7 +49,6 @@ fn get_client(nodes: Vec, nearest_node: Option) -> Client { .api_key("test-key") .healthcheck_interval(Duration::from_secs(60)) .retry_policy(ExponentialBackoff::builder().build_with_max_retries(0)) - .connection_timeout(Duration::from_secs(1)) .build() .expect("Failed to create client") } @@ -186,7 +185,6 @@ async fn test_health_check_and_node_recovery() { .api_key("test-key") .healthcheck_interval(Duration::from_millis(500)) // Use a very short healthcheck interval for the test .retry_policy(ExponentialBackoff::builder().build_with_max_retries(0)) - .connection_timeout(Duration::from_secs(1)) .build() .expect("Failed to create client"); diff --git a/typesense/tests/client/conversation_models_test.rs b/typesense/tests/client/conversation_models_test.rs index be5cd6fb..30c17a35 100644 --- a/typesense/tests/client/conversation_models_test.rs +++ b/typesense/tests/client/conversation_models_test.rs @@ -116,7 +116,6 @@ fn get_test_client(uri: &str) -> Client { .api_key("TEST_API_KEY") .healthcheck_interval(Duration::from_secs(60)) .retry_policy(ExponentialBackoff::builder().build_with_max_retries(0)) - .connection_timeout(Duration::from_secs(1)) .build() .expect("Failed to create client") } diff --git a/typesense/tests/client/http_builder_test/http_builder_tls_test.rs b/typesense/tests/client/http_builder_test/http_builder_tls_test.rs new file mode 100644 index 00000000..307e1733 --- /dev/null +++ b/typesense/tests/client/http_builder_test/http_builder_tls_test.rs @@ -0,0 +1,114 @@ +use std::{ + net::{IpAddr, Ipv4Addr}, + sync::Arc, + time::Duration, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt as _}, + net::TcpListener, +}; +use tokio_rustls::{ + TlsAcceptor, + rustls::{ + self, ServerConfig, + pki_types::{CertificateDer, PrivateKeyDer}, + }, +}; +use typesense::{ExponentialBackoff, NodeConfig}; + +/// Exercise the per-node `http_builder` option by setting up a custom root TLS certificate. +/// +/// If the customization doesn't work, reqwest would be unable to connect +/// to the mocked Typesense node because the self-signed cert is not trusted +/// by the system root store. +/// +/// This test is non-WASM as it needs TCP. +pub(super) async fn test_http_builder_tls() { + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .expect("Failed to install crypto provider"); + + let api_key = "xxx-api-key"; + + let (cert, key) = generate_self_signed_cert(); + let tls_config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert.clone()], key) + .expect("failed to build TLS config"); + + let localhost = IpAddr::V4(Ipv4Addr::LOCALHOST); + let listener = TcpListener::bind((localhost, 0)) + .await + .expect("Failed to bind to address"); + let server_addr = listener.local_addr().expect("Failed to get local address"); + + let handler = tokio::spawn(mock_node_handler(listener, tls_config, api_key)); + + let client_cert = reqwest::Certificate::from_der(&cert) + .expect("Failed to convert certificate to Certificate"); + let client = typesense::Client::builder() + .nodes(vec![ + NodeConfig::new(format!("https://localhost:{}", server_addr.port())).http_builder( + move |builder| { + builder + .add_root_certificate(client_cert.clone()) + .https_only(true) + }, + ), + ]) + .api_key(api_key) + .healthcheck_interval(Duration::from_secs(9001)) + .retry_policy(ExponentialBackoff::builder().build_with_max_retries(0)) + .build() + .expect("Failed to create Typesense client"); + + client + .operations() + .health() + .await + .expect("Failed to get collection health"); + + handler.await.expect("Failed to join handler"); +} + +fn generate_self_signed_cert() -> (CertificateDer<'static>, PrivateKeyDer<'static>) { + let pair = rcgen::generate_simple_self_signed(["localhost".into()]) + .expect("Failed to generate self-signed certificate"); + let cert = pair.cert.der().clone(); + let signing_key = pair.signing_key.serialize_der(); + let signing_key = PrivateKeyDer::try_from(signing_key) + .expect("Failed to convert signing key to PrivateKeyDer"); + (cert, signing_key) +} + +async fn mock_node_handler(listener: TcpListener, tls_config: ServerConfig, api_key: &'static str) { + let tls_acceptor = TlsAcceptor::from(Arc::new(tls_config)); + let (stream, _addr) = listener + .accept() + .await + .expect("Failed to accept connection"); + let mut stream = tls_acceptor + .accept(stream) + .await + .expect("Failed to accept TLS connection"); + + let mut buf = vec![0u8; 1024]; + stream + .read(&mut buf[..]) + .await + .expect("Failed to read request"); + let request = String::from_utf8(buf).expect("Failed to parse request as UTF-8"); + assert!(request.contains("/health")); + assert!(request.contains(api_key)); + + let response = "HTTP/1.1 200 OK\r\n\ + Content-Type: application/json\r\n\ + Connection: close\r\n\ + \r\n\ + {\"ok\": true}"; + stream + .write_all(response.as_bytes()) + .await + .expect("Failed to write to stream"); + stream.shutdown().await.expect("Failed to shutdown stream"); +} diff --git a/typesense/tests/client/http_builder_test/mod.rs b/typesense/tests/client/http_builder_test/mod.rs new file mode 100644 index 00000000..2709d574 --- /dev/null +++ b/typesense/tests/client/http_builder_test/mod.rs @@ -0,0 +1,132 @@ +#[cfg(all(test, not(target_arch = "wasm32")))] +mod http_builder_tls_test; + +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use typesense::NodeConfig; + +/// Verify that the `http_builder` closure is actually invoked when constructing the client. +/// +/// Uses an atomic flag as a side-effect observable. This test should also work on WASM +/// since it doesn't depend on TCP or TLS. +async fn test_http_builder_sideeffect() { + let builder_called = Arc::new(AtomicBool::new(false)); + let client = typesense::Client::builder() + .nodes(vec![NodeConfig::new("http://localhost:9001").http_builder( + { + let builder_called = builder_called.clone(); + move |b| { + builder_called.store(true, Ordering::SeqCst); + b + } + }, + )]) + .api_key("xyz") + .build() + .expect("Failed to create Typesense client"); + + // call the health endpoint, this will fail (no server), but the builder should have been called + client.operations().health().await.unwrap_err(); + + assert!(builder_called.load(Ordering::SeqCst)); +} + +/// Verify that per-node http_builder works independently. +async fn test_per_node_http_builder() { + let node1_called = Arc::new(AtomicBool::new(false)); + let node2_called = Arc::new(AtomicBool::new(false)); + + let _client = typesense::Client::builder() + .nodes(vec![ + NodeConfig::new("http://localhost:9001").http_builder({ + let flag = node1_called.clone(); + move |b| { + flag.store(true, Ordering::SeqCst); + b + } + }), + NodeConfig::new("http://localhost:9002").http_builder({ + let flag = node2_called.clone(); + move |b| { + flag.store(true, Ordering::SeqCst); + b + } + }), + ]) + .api_key("xyz") + .build() + .expect("Failed to create Typesense client"); + + assert!(node1_called.load(Ordering::SeqCst)); + assert!(node2_called.load(Ordering::SeqCst)); +} + +/// Verify that plain string URLs still work (backward compatibility). +async fn test_plain_string_nodes() { + let _client = typesense::Client::builder() + .nodes(vec!["http://localhost:9001"]) + .api_key("xyz") + .build() + .expect("Failed to create Typesense client with plain string nodes"); +} + +/// Verify that mixing NodeConfig and string nodes works via the iterator API. +async fn test_mixed_node_configs() { + let _client = typesense::Client::builder() + .nodes(vec![ + NodeConfig::new("http://localhost:9001"), + NodeConfig::new("http://localhost:9002").http_builder(|b| b), + ]) + .api_key("xyz") + .build() + .expect("Failed to create Typesense client with mixed node configs"); +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tokio_test { + #[tokio::test] + async fn test_http_builder_sideeffect() { + super::test_http_builder_sideeffect().await; + } + + #[tokio::test] + async fn test_per_node_http_builder() { + super::test_per_node_http_builder().await; + } + + #[tokio::test] + async fn test_plain_string_nodes() { + super::test_plain_string_nodes().await; + } + + #[tokio::test] + async fn test_mixed_node_configs() { + super::test_mixed_node_configs().await; + } + + #[tokio::test] + async fn test_http_builder_tls() { + super::http_builder_tls_test::test_http_builder_tls().await; + } +} + +#[cfg(all(test, target_arch = "wasm32"))] +mod wasm_test { + use wasm_bindgen_test::wasm_bindgen_test; + + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + + #[wasm_bindgen_test] + async fn test_http_builder_sideeffect() { + console_error_panic_hook::set_once(); + super::test_http_builder_sideeffect().await; + } + + #[wasm_bindgen_test] + async fn test_plain_string_nodes() { + console_error_panic_hook::set_once(); + super::test_plain_string_nodes().await; + } +} diff --git a/typesense/tests/client/mod.rs b/typesense/tests/client/mod.rs index ecafdee6..f2d37e9b 100644 --- a/typesense/tests/client/mod.rs +++ b/typesense/tests/client/mod.rs @@ -6,6 +6,7 @@ mod conversation_models_test; mod curation_sets_test; mod derive_integration_test; mod documents_test; +mod http_builder_test; mod keys_test; mod multi_search_test; mod operations_test; @@ -25,7 +26,6 @@ pub fn get_client() -> Client { .api_key("xyz") .healthcheck_interval(Duration::from_secs(5)) .retry_policy(ExponentialBackoff::builder().build_with_max_retries(0)) - .connection_timeout(Duration::from_secs(3)) .build() .expect("Failed to create Typesense client") } diff --git a/xtask/src/test_clean.rs b/xtask/src/test_clean.rs index a527b92b..e5d75155 100644 --- a/xtask/src/test_clean.rs +++ b/xtask/src/test_clean.rs @@ -7,7 +7,6 @@ async fn clean_test_artifacts() { .api_key("xyz") .healthcheck_interval(Duration::from_secs(5)) .retry_policy(ExponentialBackoff::builder().build_with_max_retries(3)) - .connection_timeout(Duration::from_secs(3)) .build() .expect("Failed to create Typesense client"); From 0f3bb4a825961eacae7eeb85687727d41dd28f69 Mon Sep 17 00:00:00 2001 From: Luis Araujo <7045685+luis-araujo-dev@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:02:05 +0000 Subject: [PATCH 2/4] chore: Change from Fn to FnOnce --- typesense/src/client/mod.rs | 47 +++++++++++++++++-- .../http_builder_tls_test.rs | 6 +-- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/typesense/src/client/mod.rs b/typesense/src/client/mod.rs index 80561a88..6bc5506a 100644 --- a/typesense/src/client/mod.rs +++ b/typesense/src/client/mod.rs @@ -207,7 +207,21 @@ macro_rules! execute_wrapper { /// ``` pub struct NodeConfig { url: String, - http_builder: Option reqwest::ClientBuilder>>, + http_builder: Option>, +} + +/// Internal helper to allow storing and calling a boxed `FnOnce`. +trait HttpBuilderFn: Send { + fn call_once(self: Box, builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder; +} + +impl HttpBuilderFn for F +where + F: FnOnce(reqwest::ClientBuilder) -> reqwest::ClientBuilder + Send, +{ + fn call_once(self: Box, builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder { + (*self)(builder) + } } impl std::fmt::Debug for NodeConfig { @@ -250,16 +264,39 @@ impl NodeConfig { /// // portable across native and WASM): /// // /// // builder - /// // .add_root_certificate(cert.clone()) + /// // .add_root_certificate(cert) /// // .connect_timeout(std::time::Duration::from_secs(10)) /// // /// // For this doctest, we just return the builder unchanged. /// builder /// }); /// ``` + /// + /// # Multiple nodes with the same configuration + /// + /// The closure is `FnOnce`, so it is consumed when the HTTP client for that node + /// is built. To use the same configuration (e.g. the same TLS certificate) for + /// multiple nodes, clone the value once per node when building the configs: + /// + /// ```no_run + /// use typesense::{Client, NodeConfig}; + /// + /// # fn cert() -> reqwest::Certificate { unimplemented!() } + /// let cert = cert(); + /// let nodes = ["https://node1:8108", "https://node2:8108"] + /// .into_iter() + /// .map(|url| { + /// let cert_for_node = cert.clone(); + /// NodeConfig::new(url).http_builder(move |b| { + /// b.add_root_certificate(cert_for_node) // reqwest takes ownership + /// }) + /// }) + /// .collect::>(); + /// let _client = Client::builder().nodes(nodes).api_key("key").build(); + /// ``` pub fn http_builder( mut self, - f: impl Fn(reqwest::ClientBuilder) -> reqwest::ClientBuilder + 'static, + f: impl FnOnce(reqwest::ClientBuilder) -> reqwest::ClientBuilder + Send + 'static, ) -> Self { self.http_builder = Some(Box::new(f)); self @@ -321,7 +358,7 @@ impl Client { /// - **nearest_node**: None. /// - **healthcheck_interval**: 60 seconds. /// - **retry_policy**: Exponential backoff with a maximum of 3 retries. (disabled on WASM) - /// - **http_builder**: An `Fn(reqwest::ClientBuilder) -> reqwest::ClientBuilder` closure + /// - **http_builder**: An `FnOnce(reqwest::ClientBuilder) -> reqwest::ClientBuilder` closure /// for per-node HTTP client customization (optional, via [`NodeConfig`]). /// /// When no custom `http_builder` is configured, a default `reqwest::ClientBuilder` with @@ -359,7 +396,7 @@ impl Client { .chain(nearest_node) .map(|node_config| { let builder = match node_config.http_builder { - Some(f) => f(reqwest::Client::builder()), + Some(f) => f.call_once(reqwest::Client::builder()), None => { let b = reqwest::Client::builder(); #[cfg(not(target_arch = "wasm32"))] diff --git a/typesense/tests/client/http_builder_test/http_builder_tls_test.rs b/typesense/tests/client/http_builder_test/http_builder_tls_test.rs index 307e1733..ac9578b8 100644 --- a/typesense/tests/client/http_builder_test/http_builder_tls_test.rs +++ b/typesense/tests/client/http_builder_test/http_builder_tls_test.rs @@ -49,11 +49,7 @@ pub(super) async fn test_http_builder_tls() { let client = typesense::Client::builder() .nodes(vec![ NodeConfig::new(format!("https://localhost:{}", server_addr.port())).http_builder( - move |builder| { - builder - .add_root_certificate(client_cert.clone()) - .https_only(true) - }, + move |builder| builder.add_root_certificate(client_cert).https_only(true), ), ]) .api_key(api_key) From 16a3793feebe3c5a60b379c1973e0f591cb8dbc0 Mon Sep 17 00:00:00 2001 From: RoDmitry Date: Fri, 6 Mar 2026 15:17:10 +0000 Subject: [PATCH 3/4] Rm not needed HttpBuilderFn --- typesense/src/client/mod.rs | 37 +++++++++++-------------------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/typesense/src/client/mod.rs b/typesense/src/client/mod.rs index 6bc5506a..be03bd30 100644 --- a/typesense/src/client/mod.rs +++ b/typesense/src/client/mod.rs @@ -207,21 +207,7 @@ macro_rules! execute_wrapper { /// ``` pub struct NodeConfig { url: String, - http_builder: Option>, -} - -/// Internal helper to allow storing and calling a boxed `FnOnce`. -trait HttpBuilderFn: Send { - fn call_once(self: Box, builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder; -} - -impl HttpBuilderFn for F -where - F: FnOnce(reqwest::ClientBuilder) -> reqwest::ClientBuilder + Send, -{ - fn call_once(self: Box, builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder { - (*self)(builder) - } + http_builder: Option reqwest::ClientBuilder>>, } impl std::fmt::Debug for NodeConfig { @@ -260,14 +246,11 @@ impl NodeConfig { /// // and apply it to the `reqwest::ClientBuilder` on platforms that support it. /// let node = NodeConfig::new("https://secure.example.com") /// .http_builder(move |builder| { - /// // Example (native-only, not shown here to keep the example - /// // portable across native and WASM): - /// // - /// // builder - /// // .add_root_certificate(cert) - /// // .connect_timeout(std::time::Duration::from_secs(10)) - /// // - /// // For this doctest, we just return the builder unchanged. + /// #[cfg(not(target_family = "wasm"))] + /// let builder = builder + /// .add_root_certificate(cert) + /// .connect_timeout(std::time::Duration::from_secs(10)); + /// /// builder /// }); /// ``` @@ -288,7 +271,9 @@ impl NodeConfig { /// .map(|url| { /// let cert_for_node = cert.clone(); /// NodeConfig::new(url).http_builder(move |b| { - /// b.add_root_certificate(cert_for_node) // reqwest takes ownership + /// #[cfg(not(target_family = "wasm"))] + /// let b = b.add_root_certificate(cert_for_node); + /// b /// }) /// }) /// .collect::>(); @@ -296,7 +281,7 @@ impl NodeConfig { /// ``` pub fn http_builder( mut self, - f: impl FnOnce(reqwest::ClientBuilder) -> reqwest::ClientBuilder + Send + 'static, + f: impl FnOnce(reqwest::ClientBuilder) -> reqwest::ClientBuilder + 'static, ) -> Self { self.http_builder = Some(Box::new(f)); self @@ -396,7 +381,7 @@ impl Client { .chain(nearest_node) .map(|node_config| { let builder = match node_config.http_builder { - Some(f) => f.call_once(reqwest::Client::builder()), + Some(f) => f(reqwest::Client::builder()), None => { let b = reqwest::Client::builder(); #[cfg(not(target_arch = "wasm32"))] From 6d67c0e08d3ecb3d02a377a21504fced89e58f11 Mon Sep 17 00:00:00 2001 From: RoDmitry Date: Fri, 6 Mar 2026 15:28:49 +0000 Subject: [PATCH 4/4] Fix tests --- typesense/src/client/mod.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/typesense/src/client/mod.rs b/typesense/src/client/mod.rs index be03bd30..df9b70df 100644 --- a/typesense/src/client/mod.rs +++ b/typesense/src/client/mod.rs @@ -240,19 +240,21 @@ impl NodeConfig { /// # Examples /// /// ```no_run + /// #[cfg(not(target_family = "wasm"))] + /// { /// use typesense::NodeConfig; /// + /// # fn cert() -> reqwest::Certificate { unimplemented!() } + /// let cert = cert(); /// // You can capture arbitrary configuration here (certs, proxies, etc.) /// // and apply it to the `reqwest::ClientBuilder` on platforms that support it. /// let node = NodeConfig::new("https://secure.example.com") /// .http_builder(move |builder| { - /// #[cfg(not(target_family = "wasm"))] - /// let builder = builder - /// .add_root_certificate(cert) - /// .connect_timeout(std::time::Duration::from_secs(10)); - /// /// builder + /// .add_root_certificate(cert) + /// .connect_timeout(std::time::Duration::from_secs(10)) /// }); + /// } /// ``` /// /// # Multiple nodes with the same configuration @@ -262,6 +264,8 @@ impl NodeConfig { /// multiple nodes, clone the value once per node when building the configs: /// /// ```no_run + /// #[cfg(not(target_family = "wasm"))] + /// { /// use typesense::{Client, NodeConfig}; /// /// # fn cert() -> reqwest::Certificate { unimplemented!() } @@ -271,13 +275,12 @@ impl NodeConfig { /// .map(|url| { /// let cert_for_node = cert.clone(); /// NodeConfig::new(url).http_builder(move |b| { - /// #[cfg(not(target_family = "wasm"))] - /// let b = b.add_root_certificate(cert_for_node); - /// b + /// b.add_root_certificate(cert_for_node) /// }) /// }) /// .collect::>(); /// let _client = Client::builder().nodes(nodes).api_key("key").build(); + /// } /// ``` pub fn http_builder( mut self,