Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion typesense/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -64,4 +66,4 @@ required-features = ["derive"]

[[test]]
name = "client"
path = "tests/client/mod.rs"
path = "tests/client/mod.rs"
19 changes: 17 additions & 2 deletions typesense/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@
/// - **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.
Expand All @@ -231,25 +232,39 @@
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,

Check warning on line 235 in typesense/src/client/mod.rs

View workflow job for this annotation

GitHub Actions / tests (ubuntu, wasm32-unknown-unknown)

unused variable: `retry_policy`

Check warning on line 235 in typesense/src/client/mod.rs

View workflow job for this annotation

GitHub Actions / tests (ubuntu, wasm32-unknown-unknown)

unused variable: `retry_policy`

Check warning on line 235 in typesense/src/client/mod.rs

View workflow job for this annotation

GitHub Actions / tests (ubuntu, wasm32-unknown-unknown)

unused variable: `retry_policy`
#[builder(default = Duration::from_secs(5))]
/// The timeout for each individual network request.
connection_timeout: Duration,

Check warning on line 238 in typesense/src/client/mod.rs

View workflow job for this annotation

GitHub Actions / tests (ubuntu, wasm32-unknown-unknown)

unused variable: `connection_timeout`

Check warning on line 238 in typesense/src/client/mod.rs

View workflow job for this annotation

GitHub Actions / tests (ubuntu, wasm32-unknown-unknown)

unused variable: `connection_timeout`

Check warning on line 238 in typesense/src/client/mod.rs

View workflow job for this annotation

GitHub Actions / tests (ubuntu, wasm32-unknown-unknown)

unused variable: `connection_timeout`

/// 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<Box<dyn Fn() -> reqwest::ClientBuilder>>,
) -> Result<Self, &'static str> {
let is_nearest_node_set = nearest_node.is_some();

let nodes: Vec<_> = nodes
.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"),
Expand Down
116 changes: 116 additions & 0 deletions typesense/tests/client/http_builder_test/http_builder_tls_test.rs
Original file line number Diff line number Diff line change
@@ -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");
}
63 changes: 63 additions & 0 deletions typesense/tests/client/http_builder_test/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
}
}
1 change: 1 addition & 0 deletions typesense/tests/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading