Skip to content
Closed
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
2 changes: 2 additions & 0 deletions 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 Down
143 changes: 122 additions & 21 deletions typesense/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
//!
Expand Down Expand Up @@ -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:
//!
Expand All @@ -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();
//!
Expand Down Expand Up @@ -182,6 +179,100 @@
};
}

/// 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
/// let node = NodeConfig::new("https://node2.example.com")
/// .http_builder(|builder| {
/// builder.connect_timeout(std::time::Duration::from_secs(10))
/// });

Check failure on line 202 in typesense/src/client/mod.rs

View workflow job for this annotation

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

no method named `connect_timeout` found for struct `reqwest::wasm::client::ClientBuilder` in the current scope
/// ```
pub struct NodeConfig {
url: String,
http_builder: Option<Box<dyn Fn(reqwest::ClientBuilder) -> 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<String>) -> 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;
///
/// let cert = reqwest::Certificate::from_pem(b"...").unwrap();
/// let node = NodeConfig::new("https://secure.example.com")

Check failure on line 242 in typesense/src/client/mod.rs

View workflow job for this annotation

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

failed to resolve: could not find `Certificate` in `reqwest`
/// .http_builder(move |builder| {
/// builder
/// .add_root_certificate(cert.clone())
/// .connect_timeout(std::time::Duration::from_secs(10))

Check failure on line 246 in typesense/src/client/mod.rs

View workflow job for this annotation

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

no method named `add_root_certificate` found for struct `ClientBuilder` in the current scope
/// });
/// ```
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<String> 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<reqwest::Url> 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 {
Expand Down Expand Up @@ -219,54 +310,64 @@
/// - **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<Item = impl Into<String>>|
iter.into_iter().map(Into::into).collect::<Vec<String>>()
with = |iter: impl IntoIterator<Item = impl Into<NodeConfig>>|
iter.into_iter().map(Into::into).collect::<Vec<NodeConfig>>()
)]
nodes: Vec<String>,
nodes: Vec<NodeConfig>,
#[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<String>,
nearest_node: Option<NodeConfig>,
#[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,

Check warning on line 342 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 342 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 342 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,
) -> 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| {
.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();
}
Expand Down
9 changes: 3 additions & 6 deletions typesense/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")]
Expand Down Expand Up @@ -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();
//!
Expand All @@ -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;
Expand Down
2 changes: 0 additions & 2 deletions typesense/tests/client/client_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ fn get_client(nodes: Vec<String>, nearest_node: Option<String>) -> 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")
}
Expand Down Expand Up @@ -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");

Expand Down
1 change: 0 additions & 1 deletion typesense/tests/client/conversation_models_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
114 changes: 114 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,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");
}
Loading
Loading