use std::marker::PhantomData;
use std::sync::Arc;
use std::time::Duration;
use crate::auth::Credentials;
use crate::config::{CacheConfig, DegradationConfig, RetryConfig, TlsConfig};
#[cfg(feature = "grpc")]
use crate::transport::GrpcTransport;
#[cfg(feature = "rest")]
use crate::transport::RestTransport;
use crate::transport::{PoolConfig, TransportStrategy};
use crate::{Client, Error};
use super::inner::ClientInner;
pub struct NoUrl;
pub struct HasUrl;
pub struct NoCredentials;
pub struct HasCredentials;
pub struct ClientBuilder<UrlState, CredentialsState> {
url: Option<String>,
credentials: Option<Credentials>,
retry_config: RetryConfig,
cache_config: CacheConfig,
tls_config: TlsConfig,
degradation_config: DegradationConfig,
timeout: Option<Duration>,
transport_strategy: TransportStrategy,
pool_config: PoolConfig,
_url_state: PhantomData<UrlState>,
_credentials_state: PhantomData<CredentialsState>,
}
impl ClientBuilder<NoUrl, NoCredentials> {
pub fn new() -> Self {
Self {
url: None,
credentials: None,
retry_config: RetryConfig::default(),
cache_config: CacheConfig::default(),
tls_config: TlsConfig::default(),
degradation_config: DegradationConfig::default(),
timeout: None,
transport_strategy: TransportStrategy::default(),
pool_config: PoolConfig::default(),
_url_state: PhantomData,
_credentials_state: PhantomData,
}
}
}
impl Default for ClientBuilder<NoUrl, NoCredentials> {
fn default() -> Self {
Self::new()
}
}
impl<C> ClientBuilder<NoUrl, C> {
pub fn url(self, url: impl Into<String>) -> ClientBuilder<HasUrl, C> {
ClientBuilder {
url: Some(url.into()),
credentials: self.credentials,
retry_config: self.retry_config,
cache_config: self.cache_config,
tls_config: self.tls_config,
degradation_config: self.degradation_config,
timeout: self.timeout,
transport_strategy: self.transport_strategy,
pool_config: self.pool_config,
_url_state: PhantomData,
_credentials_state: PhantomData,
}
}
}
impl<U> ClientBuilder<U, NoCredentials> {
pub fn credentials(
self,
credentials: impl Into<Credentials>,
) -> ClientBuilder<U, HasCredentials> {
ClientBuilder {
url: self.url,
credentials: Some(credentials.into()),
retry_config: self.retry_config,
cache_config: self.cache_config,
tls_config: self.tls_config,
degradation_config: self.degradation_config,
timeout: self.timeout,
transport_strategy: self.transport_strategy,
pool_config: self.pool_config,
_url_state: PhantomData,
_credentials_state: PhantomData,
}
}
}
impl<U, C> ClientBuilder<U, C> {
#[must_use]
pub fn retry_config(mut self, config: RetryConfig) -> Self {
self.retry_config = config;
self
}
#[must_use]
pub fn cache_config(mut self, config: CacheConfig) -> Self {
self.cache_config = config;
self
}
#[must_use]
pub fn tls_config(mut self, config: TlsConfig) -> Self {
self.tls_config = config;
self
}
#[must_use]
pub fn insecure(mut self) -> Self {
self.tls_config.skip_verification = true;
self
}
#[must_use]
pub fn degradation_config(mut self, config: DegradationConfig) -> Self {
self.degradation_config = config;
self
}
#[must_use]
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
#[must_use]
pub fn transport_strategy(mut self, strategy: TransportStrategy) -> Self {
self.transport_strategy = strategy;
self
}
#[must_use]
pub fn pool_config(mut self, config: PoolConfig) -> Self {
self.pool_config = config;
self
}
}
impl<U, C> ClientBuilder<U, C> {
#[allow(unused_variables)]
async fn create_transport(
&self,
url: &url::Url,
timeout: Duration,
initial_token: Option<&String>,
) -> Result<Option<Arc<dyn crate::transport::TransportClient + Send + Sync>>, Error> {
match &self.transport_strategy {
#[cfg(feature = "grpc")]
TransportStrategy::GrpcOnly => {
let grpc = GrpcTransport::new(
url.clone(),
&self.tls_config,
&self.pool_config,
self.retry_config.clone(),
timeout,
)
.await?;
Ok(Some(Arc::new(grpc)))
}
#[cfg(not(feature = "grpc"))]
TransportStrategy::GrpcOnly => Err(Error::configuration(
"gRPC transport requested but 'grpc' feature is not enabled",
)),
#[cfg(feature = "rest")]
TransportStrategy::RestOnly => {
let rest = RestTransport::new(
url.clone(),
&self.tls_config,
&self.pool_config,
self.retry_config.clone(),
timeout,
)?;
if let Some(token) = initial_token {
rest.set_auth_token(token.clone());
}
Ok(Some(Arc::new(rest)))
}
#[cfg(not(feature = "rest"))]
TransportStrategy::RestOnly => Err(Error::configuration(
"REST transport requested but 'rest' feature is not enabled",
)),
#[cfg(all(feature = "grpc", feature = "rest"))]
TransportStrategy::PreferGrpc { .. } => {
match GrpcTransport::new(
url.clone(),
&self.tls_config,
&self.pool_config,
self.retry_config.clone(),
timeout,
)
.await
{
Ok(grpc) => Ok(Some(Arc::new(grpc))),
Err(_) => {
let rest = RestTransport::new(
url.clone(),
&self.tls_config,
&self.pool_config,
self.retry_config.clone(),
timeout,
)?;
if let Some(token) = initial_token {
rest.set_auth_token(token.clone());
}
Ok(Some(Arc::new(rest)))
}
}
}
#[cfg(all(feature = "grpc", not(feature = "rest")))]
TransportStrategy::PreferGrpc { .. } => {
let grpc = GrpcTransport::new(
url.clone(),
&self.tls_config,
&self.pool_config,
self.retry_config.clone(),
timeout,
)
.await?;
Ok(Some(Arc::new(grpc)))
}
#[cfg(all(not(feature = "grpc"), feature = "rest"))]
TransportStrategy::PreferGrpc { .. } => {
let rest = RestTransport::new(
url.clone(),
&self.tls_config,
&self.pool_config,
self.retry_config.clone(),
timeout,
)?;
if let Some(token) = initial_token {
rest.set_auth_token(token.clone());
}
Ok(Some(Arc::new(rest)))
}
#[cfg(all(feature = "grpc", feature = "rest"))]
TransportStrategy::PreferRest { .. } => {
match RestTransport::new(
url.clone(),
&self.tls_config,
&self.pool_config,
self.retry_config.clone(),
timeout,
) {
Ok(rest) => {
if let Some(token) = initial_token {
rest.set_auth_token(token.clone());
}
Ok(Some(Arc::new(rest)))
}
Err(_) => {
let grpc = GrpcTransport::new(
url.clone(),
&self.tls_config,
&self.pool_config,
self.retry_config.clone(),
timeout,
)
.await?;
Ok(Some(Arc::new(grpc)))
}
}
}
#[cfg(all(feature = "rest", not(feature = "grpc")))]
TransportStrategy::PreferRest { .. } => {
let rest = RestTransport::new(
url.clone(),
&self.tls_config,
&self.pool_config,
self.retry_config.clone(),
timeout,
)?;
if let Some(token) = initial_token {
rest.set_auth_token(token.clone());
}
Ok(Some(Arc::new(rest)))
}
#[cfg(all(not(feature = "rest"), feature = "grpc"))]
TransportStrategy::PreferRest { .. } => {
let grpc = GrpcTransport::new(
url.clone(),
&self.tls_config,
&self.pool_config,
self.retry_config.clone(),
timeout,
)
.await?;
Ok(Some(Arc::new(grpc)))
}
#[cfg(not(any(feature = "grpc", feature = "rest")))]
_ => Ok(None),
}
}
}
impl ClientBuilder<HasUrl, HasCredentials> {
#[cfg(test)]
pub async fn build_with_transport(
self,
transport: Arc<dyn crate::transport::TransportClient + Send + Sync>,
) -> Result<Client, Error> {
let url = self
.url
.ok_or_else(|| Error::configuration("URL is required"))?;
let credentials = self
.credentials
.ok_or_else(|| Error::configuration("credentials are required"))?;
let timeout = self.timeout.unwrap_or(Duration::from_secs(30));
let inner = ClientInner {
url,
credentials,
retry_config: self.retry_config,
cache_config: self.cache_config,
tls_config: self.tls_config,
degradation_config: self.degradation_config,
timeout,
#[cfg(any(feature = "grpc", feature = "rest"))]
transport: Some(transport),
#[cfg(feature = "rest")]
http_client: None,
#[cfg(feature = "rest")]
auth_token: parking_lot::RwLock::new(None),
shutdown_guard: None,
};
Ok(Client::from_inner(inner))
}
pub async fn build(self) -> Result<Client, Error> {
let url = self
.url
.clone()
.ok_or_else(|| Error::configuration("URL is required"))?;
let parsed_url = url::Url::parse(&url)
.map_err(|e| Error::configuration(format!("invalid URL: {}", e)))?;
if parsed_url.scheme() != "https" && !self.tls_config.skip_verification {
return Err(Error::configuration(
"HTTPS is required. Use .insecure() for development with HTTP.",
));
}
let timeout = self.timeout.unwrap_or(Duration::from_secs(30));
#[cfg(feature = "rest")]
let initial_token = self
.credentials
.as_ref()
.and_then(|c| c.as_bearer())
.map(|b| b.token().to_string());
#[cfg(not(feature = "rest"))]
let initial_token: Option<String> = None;
let transport = self
.create_transport(&parsed_url, timeout, initial_token.as_ref())
.await?;
let credentials = self
.credentials
.ok_or_else(|| Error::configuration("credentials are required"))?;
#[cfg(feature = "rest")]
let http_client = {
let mut builder = reqwest::Client::builder()
.timeout(timeout)
.connect_timeout(timeout);
#[cfg(feature = "rustls")]
if parsed_url.scheme() == "https" {
builder = builder.use_rustls_tls();
}
if self.tls_config.skip_verification {
builder = builder.danger_accept_invalid_certs(true);
}
Some(builder.build().map_err(|e| {
Error::configuration(format!("Failed to create HTTP client: {}", e))
})?)
};
let inner = ClientInner {
url,
credentials,
retry_config: self.retry_config,
cache_config: self.cache_config,
tls_config: self.tls_config,
degradation_config: self.degradation_config,
timeout,
#[cfg(any(feature = "grpc", feature = "rest"))]
transport,
#[cfg(feature = "rest")]
http_client,
#[cfg(feature = "rest")]
auth_token: parking_lot::RwLock::new(initial_token),
shutdown_guard: None,
};
Ok(Client::from_inner(inner))
}
pub async fn build_with_shutdown(
self,
) -> Result<(Client, super::health::ShutdownHandle), Error> {
let url = self
.url
.clone()
.ok_or_else(|| Error::configuration("URL is required"))?;
let parsed_url = url::Url::parse(&url)
.map_err(|e| Error::configuration(format!("invalid URL: {}", e)))?;
if parsed_url.scheme() != "https" && !self.tls_config.skip_verification {
return Err(Error::configuration(
"HTTPS is required. Use .insecure() for development with HTTP.",
));
}
let timeout = self.timeout.unwrap_or(Duration::from_secs(30));
#[cfg(feature = "rest")]
let initial_token = self
.credentials
.as_ref()
.and_then(|c| c.as_bearer())
.map(|b| b.token().to_string());
#[cfg(not(feature = "rest"))]
let initial_token: Option<String> = None;
let transport = self
.create_transport(&parsed_url, timeout, initial_token.as_ref())
.await?;
let credentials = self
.credentials
.ok_or_else(|| Error::configuration("credentials are required"))?;
#[cfg(feature = "rest")]
let http_client = {
let mut builder = reqwest::Client::builder()
.timeout(timeout)
.connect_timeout(timeout);
#[cfg(feature = "rustls")]
if parsed_url.scheme() == "https" {
builder = builder.use_rustls_tls();
}
if self.tls_config.skip_verification {
builder = builder.danger_accept_invalid_certs(true);
}
Some(builder.build().map_err(|e| {
Error::configuration(format!("Failed to create HTTP client: {}", e))
})?)
};
let (shutdown_handle, shutdown_guard) = super::health::ShutdownHandle::new();
let inner = ClientInner {
url,
credentials,
retry_config: self.retry_config,
cache_config: self.cache_config,
tls_config: self.tls_config,
degradation_config: self.degradation_config,
timeout,
#[cfg(any(feature = "grpc", feature = "rest"))]
transport,
#[cfg(feature = "rest")]
http_client,
#[cfg(feature = "rest")]
auth_token: parking_lot::RwLock::new(initial_token),
shutdown_guard: Some(shutdown_guard),
};
Ok((Client::from_inner(inner), shutdown_handle))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::{BearerCredentialsConfig, ClientCredentialsConfig, Ed25519PrivateKey};
use crate::transport::mock::MockTransport;
#[test]
fn test_builder_typestate() {
let _builder = ClientBuilder::new()
.url("https://api.example.com")
.credentials(BearerCredentialsConfig::new("token"));
}
#[tokio::test]
async fn test_build_invalid_url() {
let result = ClientBuilder::new()
.url("not-a-valid-url")
.credentials(BearerCredentialsConfig::new("token"))
.build()
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_build_requires_https() {
let result = ClientBuilder::new()
.url("http://api.example.com")
.credentials(BearerCredentialsConfig::new("token"))
.build()
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("HTTPS"));
}
#[tokio::test]
async fn test_build_allows_http_when_insecure() {
let result = ClientBuilder::new()
.url("http://api.example.com")
.credentials(BearerCredentialsConfig::new("token"))
.insecure()
.build()
.await;
assert!(result.is_ok() || !result.unwrap_err().to_string().contains("HTTPS"));
}
#[tokio::test]
async fn test_build_with_client_credentials() {
let key = Ed25519PrivateKey::generate();
let mock_transport = Arc::new(MockTransport::new());
let result = ClientBuilder::new()
.url("https://api.example.com")
.credentials(ClientCredentialsConfig::new("client_id", key))
.build_with_transport(mock_transport)
.await;
assert!(result.is_ok());
}
#[test]
fn test_optional_configs() {
let builder = ClientBuilder::new()
.url("https://api.example.com")
.credentials(BearerCredentialsConfig::new("token"))
.retry_config(RetryConfig::disabled())
.cache_config(CacheConfig::enabled())
.timeout(Duration::from_secs(60));
assert_eq!(builder.retry_config.max_retries, 0);
assert!(builder.cache_config.enabled);
assert_eq!(builder.timeout, Some(Duration::from_secs(60)));
}
#[cfg(feature = "rest")]
#[tokio::test]
async fn test_build_with_shutdown() {
let result = ClientBuilder::new()
.url("https://api.example.com")
.credentials(BearerCredentialsConfig::new("token"))
.build_with_shutdown()
.await;
assert!(result.is_ok());
let (client, shutdown_handle) = result.unwrap();
assert!(!client.is_shutting_down());
assert!(!shutdown_handle.is_shutting_down());
shutdown_handle.shutdown().await;
assert!(client.is_shutting_down());
}
#[test]
fn test_builder_tls_config() {
let builder = ClientBuilder::new()
.url("https://api.example.com")
.credentials(BearerCredentialsConfig::new("token"))
.tls_config(TlsConfig::default());
assert!(builder.tls_config.ca_cert_file.is_none());
assert!(builder.tls_config.ca_cert_pem.is_none());
assert!(builder.tls_config.client_cert_file.is_none());
assert!(builder.tls_config.client_key_file.is_none());
}
#[test]
fn test_builder_degradation_config() {
let builder = ClientBuilder::new()
.url("https://api.example.com")
.credentials(BearerCredentialsConfig::new("token"))
.degradation_config(DegradationConfig::fail_open());
assert_eq!(
builder.degradation_config.failure_mode,
crate::config::FailureMode::FailOpen
);
}
#[test]
fn test_builder_transport_strategy() {
let builder = ClientBuilder::new()
.url("https://api.example.com")
.credentials(BearerCredentialsConfig::new("token"))
.transport_strategy(crate::transport::TransportStrategy::RestOnly);
assert_eq!(
builder.transport_strategy.preferred_transport(),
crate::transport::Transport::Http
);
}
#[test]
fn test_builder_pool_config() {
let builder = ClientBuilder::new()
.url("https://api.example.com")
.credentials(BearerCredentialsConfig::new("token"))
.pool_config(PoolConfig::default());
assert_eq!(builder.pool_config.max_connections, 100);
}
#[test]
fn test_builder_default() {
let builder: ClientBuilder<NoUrl, NoCredentials> = ClientBuilder::default();
assert!(builder.url.is_none());
assert!(builder.credentials.is_none());
}
#[test]
fn test_builder_all_configs_combined() {
let mut cache_config = CacheConfig::new();
cache_config.enabled = false;
let builder = ClientBuilder::new()
.url("https://api.example.com")
.credentials(BearerCredentialsConfig::new("token"))
.retry_config(RetryConfig::new().with_max_retries(10))
.cache_config(cache_config)
.tls_config(TlsConfig::default())
.degradation_config(DegradationConfig::fail_closed())
.timeout(Duration::from_secs(30))
.transport_strategy(crate::transport::TransportStrategy::RestOnly)
.pool_config(PoolConfig::default());
assert_eq!(builder.retry_config.max_retries, 10);
assert!(!builder.cache_config.enabled);
assert_eq!(builder.timeout, Some(Duration::from_secs(30)));
}
}