From b3598e87f3761c8444d4aae30405ee3bab22396c Mon Sep 17 00:00:00 2001 From: Vojtech Kral Date: Thu, 27 Nov 2025 22:09:39 +0100 Subject: [PATCH] feat: support customizing the reqwest client in the Client builder --- typesense/Cargo.toml | 4 +- typesense/src/client/mod.rs | 19 ++- .../http_builder_tls_test.rs | 116 ++++++++++++++++++ .../tests/client/http_builder_test/mod.rs | 63 ++++++++++ typesense/tests/client/mod.rs | 1 + 5 files changed, 200 insertions(+), 3 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..5f8fc248 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 @@ -64,4 +66,4 @@ required-features = ["derive"] [[test]] name = "client" -path = "tests/client/mod.rs" \ No newline at end of file +path = "tests/client/mod.rs" diff --git a/typesense/src/client/mod.rs b/typesense/src/client/mod.rs index f42cfb71..2724feb4 100644 --- a/typesense/src/client/mod.rs +++ b/typesense/src/client/mod.rs @@ -210,6 +210,7 @@ impl Client { /// - **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()` closure returning a `reqwest::ClientBuilder` instance (optional). #[builder] pub fn new( /// The Typesense API key used for authentication. @@ -235,6 +236,16 @@ impl Client { #[builder(default = Duration::from_secs(5))] /// The timeout for each individual network request. connection_timeout: Duration, + + /// An optional custom builder for the HTTP client. + /// + /// This is useful if you need to configure custom settings on the HTTP client. + /// The value should be a closure that returns a `reqwest::ClientBuilder` instance. + /// + /// Note that this library may apply its own settings before building the client (eg. `connection_timeout`), + /// so not all custom settings may be preserved. + #[builder(with = |f: impl Fn() -> reqwest::ClientBuilder + 'static| Box::new(f))] + http_builder: Option reqwest::ClientBuilder>>, ) -> Result { let is_nearest_node_set = nearest_node.is_some(); @@ -242,14 +253,18 @@ impl Client { .into_iter() .chain(nearest_node) .map(|mut url| { + let http_buidler = http_builder + .as_ref() + .map(|f| f()) + .unwrap_or_else(reqwest::Client::builder); #[cfg(target_arch = "wasm32")] - let http_client = reqwest::Client::builder() + let http_client = http_buidler .build() .expect("Failed to build reqwest client"); #[cfg(not(target_arch = "wasm32"))] let http_client = ReqwestMiddlewareClientBuilder::new( - reqwest::Client::builder() + http_buidler .timeout(connection_timeout) .build() .expect("Failed to build reqwest 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..c2c9c9e8 --- /dev/null +++ b/typesense/tests/client/http_builder_test/http_builder_tls_test.rs @@ -0,0 +1,116 @@ +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; + +/// Reqwest custom builder test. +/// +/// In this test we exercise the `reqwest_builder` option by setting up a custom root TLS certificate. +/// If the cusomization doesn't work, reqwest would be unable to connect to the mocked Typesense node. +/// +/// 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"; + + // generate a self-signed key pair and build TLS config out of it + 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"); + + // spawn a handler which handles one /health request over a TLS connection + let handler = tokio::spawn(mock_node_handler(listener, tls_config, api_key)); + + // create the client, configuring the certificate with reqwest + let client_cert = reqwest::Certificate::from_der(&cert) + .expect("Failed to convert certificate to Certificate"); + let client = typesense::Client::builder() + .nodes(vec![format!("https://localhost:{}", server_addr.port())]) + .api_key(api_key) + .http_builder(move || { + reqwest::Client::builder() + .add_root_certificate(client_cert.clone()) + .https_only(true) + }) + .healthcheck_interval(Duration::from_secs(9001)) // we'll do a healthcheck manually + .retry_policy(ExponentialBackoff::builder().build_with_max_retries(0)) // no retries + .connection_timeout(Duration::from_secs(1)) // short + .build() + .expect("Failed to create Typesense client"); + + // request /health + 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)); + + // mock a /health response + let response = r#"HTTP/1.1 200 OK\r\n\ +Content-Type: application/json;\r\n\ +Connection: close\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..2e893eee --- /dev/null +++ b/typesense/tests/client/http_builder_test/mod.rs @@ -0,0 +1,63 @@ +#[cfg(all(test, not(target_arch = "wasm32")))] +mod http_builder_tls_test; + +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +/// Test that the `http_builder` option can be used to set up a custom DNS resolver. +/// +/// In this test we exercise the `http_builder` option by setting a flag when the builder is called. +/// This test should run on WASM as well; due to the constrains of WASM we can't really do a better test than that. +async fn test_http_builder_sideeffect() { + let builder_called = Arc::new(AtomicBool::new(false)); + let client = typesense::Client::builder() + .nodes(vec!["http://localhost:9001"]) // does not exist + .api_key("xyz") + .http_builder({ + let builder_called = builder_called.clone(); + move || { + builder_called.store(true, Ordering::SeqCst); + reqwest::Client::builder() + } + }) + .connection_timeout(Duration::from_millis(10)) + .build() + .expect("Failed to create Typesense client"); + + // call the health endpoint, this will fail + client.operations().health().await.unwrap_err(); + + // make sure the builder was called + assert!(builder_called.load(Ordering::SeqCst)); +} + +#[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_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; + } +} diff --git a/typesense/tests/client/mod.rs b/typesense/tests/client/mod.rs index 41882a93..a7d46318 100644 --- a/typesense/tests/client/mod.rs +++ b/typesense/tests/client/mod.rs @@ -4,6 +4,7 @@ mod collections_test; mod conversation_models_test; mod derive_integration_test; mod documents_test; +mod http_builder_test; mod keys_test; mod multi_search_test; mod operations_test;