mod address;
mod credentials;
mod options;
use std::time::Duration;
pub use address::ServerAddress;
pub use credentials::{AdapterSet, Credentials};
pub use options::{ConnectionOptions, RetryPolicy, Transport};
pub const MAX_TIMING: Duration = Duration::from_secs(365 * 24 * 60 * 60);
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ConfigError {
#[error("server address is empty")]
EmptyServerAddress,
#[error(
"server address `{address}` has no scheme: expected ws://, wss://, http:// or https://"
)]
MissingScheme {
address: String,
},
#[error("unsupported scheme `{scheme}`: expected ws, wss, http or https")]
UnsupportedScheme {
scheme: String,
},
#[error("server address `{address}` has no host")]
MissingHost {
address: String,
},
#[error("server address must not contain credentials; use Credentials instead")]
AddressHasUserinfo,
#[error(
"`{host}` is not a host: an IPv6 literal must be bracketed, and no host may contain whitespace"
)]
InvalidHost {
host: String,
},
#[error("`{port}` is not a port: expected a number from 1 to 65535")]
InvalidPort {
port: String,
},
#[error("`{path}` is not a base path: no query, fragment or `..` segment is allowed")]
InvalidAddressPath {
path: String,
},
#[error("adapter set name is empty; leave it unset to use the server's DEFAULT")]
EmptyAdapterSet,
#[error("adapter set name contains a control character")]
AdapterSetControlCharacter,
#[error("subscription names no items")]
EmptyItemGroup,
#[error("item group contains a control character")]
ItemGroupControlCharacter,
#[error("item name `{name}` contains whitespace; item names are separated by spaces")]
ItemNameHasWhitespace {
name: String,
},
#[error("subscription names no fields")]
EmptyFieldSchema,
#[error("field schema contains a control character")]
FieldSchemaControlCharacter,
#[error("field name `{name}` contains whitespace; field names are separated by spaces")]
FieldNameHasWhitespace {
name: String,
},
#[error("`{name}` is not a sequence name: expected letters, digits and underscores")]
InvalidSequenceName {
name: String,
},
#[error("`{value}` is not a frequency: expected a decimal number such as `2` or `0.5`")]
InvalidFrequency {
value: String,
},
#[error("a snapshot length is admitted only with SubscriptionMode::Distinct, not {mode}")]
SnapshotLengthNeedsDistinct {
mode: &'static str,
},
#[error("a snapshot is not available with SubscriptionMode::Raw")]
SnapshotNeedsAStatefulMode,
#[error("a maximum frequency is not applied with SubscriptionMode::Raw")]
FrequencyNeedsAFilteredMode,
#[error("a buffer size applies only to SubscriptionMode::Merge or ::Distinct, not {mode}")]
BufferSizeNeedsMergeOrDistinct {
mode: &'static str,
},
#[error("a buffer size is ignored with MaxFrequency::Unfiltered; set one or the other")]
BufferSizeWithUnfilteredFrequency,
#[error("a fire-and-forget message cannot be placed in a sequence: it carries no progressive")]
SequencedMessageNeedsAProgressive,
#[error("a maximum wait applies only to a message in a sequence")]
MaxWaitNeedsASequence,
#[error("`{field}` must be greater than zero")]
ZeroDuration {
field: &'static str,
},
#[error("`{field}` is longer than the supported maximum of {MAX_TIMING:?}")]
DurationTooLarge {
field: &'static str,
},
#[error("retry max_delay ({max:?}) is below initial_delay ({initial:?})")]
RetryCeilingBelowInitial {
initial: Duration,
max: Duration,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientConfig {
address: ServerAddress,
adapter_set: Option<AdapterSet>,
credentials: Credentials,
transport: Transport,
options: ConnectionOptions,
}
impl ClientConfig {
#[must_use = "builders do nothing unless .build() is called"]
pub fn builder(address: ServerAddress) -> ClientConfigBuilder {
ClientConfigBuilder {
address,
adapter_set: None,
credentials: Credentials::anonymous(),
transport: Transport::default(),
options: ConnectionOptions::default(),
}
}
#[must_use]
#[inline]
pub const fn address(&self) -> &ServerAddress {
&self.address
}
#[must_use]
#[inline]
pub const fn adapter_set(&self) -> Option<&AdapterSet> {
self.adapter_set.as_ref()
}
#[must_use]
#[inline]
pub const fn credentials(&self) -> &Credentials {
&self.credentials
}
#[must_use]
#[inline]
pub const fn transport(&self) -> Transport {
self.transport
}
#[must_use]
#[inline]
pub const fn options(&self) -> &ConnectionOptions {
&self.options
}
pub(crate) fn into_parts(self) -> (Option<AdapterSet>, Credentials, ConnectionOptions) {
(self.adapter_set, self.credentials, self.options)
}
}
#[derive(Debug, Clone)]
#[must_use = "builders do nothing unless .build() is called"]
pub struct ClientConfigBuilder {
address: ServerAddress,
adapter_set: Option<AdapterSet>,
credentials: Credentials,
transport: Transport,
options: ConnectionOptions,
}
impl ClientConfigBuilder {
#[must_use = "builders do nothing unless .build() is called"]
pub fn with_adapter_set(mut self, adapter_set: AdapterSet) -> Self {
self.adapter_set = Some(adapter_set);
self
}
#[must_use = "builders do nothing unless .build() is called"]
pub fn with_credentials(mut self, credentials: Credentials) -> Self {
self.credentials = credentials;
self
}
#[must_use = "builders do nothing unless .build() is called"]
pub const fn with_transport(mut self, transport: Transport) -> Self {
self.transport = transport;
self
}
#[must_use = "builders do nothing unless .build() is called"]
pub fn with_options(mut self, options: ConnectionOptions) -> Self {
self.options = options;
self
}
pub fn build(self) -> Result<ClientConfig, ConfigError> {
require_positive("open_timeout", self.options.open_timeout())?;
require_within_maximum("open_timeout", self.options.open_timeout())?;
require_within_maximum("keepalive_slack", self.options.keepalive_slack())?;
if let Some(hint) = self.options.keepalive_hint() {
require_positive("keepalive_hint", hint)?;
require_within_maximum("keepalive_hint", hint)?;
require_millis("keepalive_hint", hint)?;
}
if let Some(commitment) = self.options.inactivity_commitment() {
require_positive("inactivity_commitment", commitment)?;
require_within_maximum("inactivity_commitment", commitment)?;
require_millis("inactivity_commitment", commitment)?;
}
let retry = self.options.retry();
require_positive("retry.initial_delay", retry.initial_delay())?;
require_within_maximum("retry.initial_delay", retry.initial_delay())?;
require_within_maximum("retry.max_delay", retry.max_delay())?;
if retry.max_delay() < retry.initial_delay() {
return Err(ConfigError::RetryCeilingBelowInitial {
initial: retry.initial_delay(),
max: retry.max_delay(),
});
}
Ok(ClientConfig {
address: self.address,
adapter_set: self.adapter_set,
credentials: self.credentials,
transport: self.transport,
options: self.options,
})
}
}
#[cold]
#[inline(never)]
fn zero_duration(field: &'static str) -> ConfigError {
ConfigError::ZeroDuration { field }
}
fn require_positive(field: &'static str, value: Duration) -> Result<(), ConfigError> {
if value.is_zero() {
return Err(zero_duration(field));
}
Ok(())
}
fn require_within_maximum(field: &'static str, value: Duration) -> Result<(), ConfigError> {
if value > MAX_TIMING {
return Err(ConfigError::DurationTooLarge { field });
}
Ok(())
}
fn require_millis(field: &'static str, value: Duration) -> Result<(), ConfigError> {
u64::try_from(value.as_millis())
.map(|_| ())
.map_err(|_| ConfigError::DurationTooLarge { field })
}
#[cfg(test)]
mod tests {
use super::*;
fn address() -> ServerAddress {
match ServerAddress::try_new("wss://push.example.com") {
Ok(address) => address,
Err(error) => unreachable!("the fixture address is valid: {error}"),
}
}
#[test]
fn test_config_builds_with_only_an_address() {
match ClientConfig::builder(address()).build() {
Ok(config) => {
assert_eq!(config.adapter_set(), None);
assert_eq!(config.transport(), Transport::WebSocket);
assert_eq!(config.credentials().user(), None);
}
Err(error) => panic!("a minimal configuration was rejected: {error}"),
}
}
#[test]
fn test_config_keeps_what_it_was_given() {
let adapters = match AdapterSet::try_new("DEMO") {
Ok(set) => set,
Err(error) => unreachable!("the fixture adapter set is valid: {error}"),
};
let built = ClientConfig::builder(address())
.with_adapter_set(adapters)
.with_credentials(Credentials::new("alice", "hunter2"))
.with_options(ConnectionOptions::default().with_send_sync(false))
.build();
match built {
Ok(config) => {
assert_eq!(config.adapter_set().map(AdapterSet::as_str), Some("DEMO"));
assert_eq!(config.credentials().user(), Some("alice"));
assert!(config.credentials().has_password());
assert!(!config.options().send_sync());
}
Err(error) => panic!("rejected: {error}"),
}
}
#[test]
fn test_config_rejects_a_zero_open_timeout() {
let built = ClientConfig::builder(address())
.with_options(ConnectionOptions::default().with_open_timeout(Duration::ZERO))
.build();
assert!(matches!(
built,
Err(ConfigError::ZeroDuration {
field: "open_timeout"
})
));
}
#[test]
fn test_config_rejects_a_zero_keepalive_hint() {
let built = ClientConfig::builder(address())
.with_options(ConnectionOptions::default().with_keepalive_hint(Some(Duration::ZERO)))
.build();
assert!(matches!(
built,
Err(ConfigError::ZeroDuration {
field: "keepalive_hint"
})
));
}
#[test]
fn test_config_rejects_a_zero_inactivity_commitment() {
let built = ClientConfig::builder(address())
.with_options(
ConnectionOptions::default().with_inactivity_commitment(Some(Duration::ZERO)),
)
.build();
assert!(matches!(
built,
Err(ConfigError::ZeroDuration {
field: "inactivity_commitment"
})
));
}
#[test]
fn test_config_rejects_a_duration_that_cannot_be_expressed_in_millis() {
let built = ClientConfig::builder(address())
.with_options(ConnectionOptions::default().with_keepalive_hint(Some(Duration::MAX)))
.build();
assert!(matches!(
built,
Err(ConfigError::DurationTooLarge {
field: "keepalive_hint"
})
));
}
#[test]
fn test_config_rejects_a_zero_first_retry_delay() {
let retry = RetryPolicy::default().with_initial_delay(Duration::ZERO);
let built = ClientConfig::builder(address())
.with_options(ConnectionOptions::default().with_retry(retry))
.build();
assert!(matches!(
built,
Err(ConfigError::ZeroDuration {
field: "retry.initial_delay"
})
));
}
#[test]
fn test_config_rejects_a_retry_ceiling_below_its_first_delay() {
let retry = RetryPolicy::default()
.with_initial_delay(Duration::from_secs(10))
.with_max_delay(Duration::from_secs(1));
let built = ClientConfig::builder(address())
.with_options(ConnectionOptions::default().with_retry(retry))
.build();
assert!(matches!(
built,
Err(ConfigError::RetryCeilingBelowInitial { .. })
));
}
#[test]
fn test_config_accepts_an_equal_retry_ceiling_and_first_delay() {
let retry = RetryPolicy::default()
.with_initial_delay(Duration::from_secs(2))
.with_max_delay(Duration::from_secs(2));
let built = ClientConfig::builder(address())
.with_options(ConnectionOptions::default().with_retry(retry))
.build();
assert!(built.is_ok());
}
fn with_each_timing(value: Duration) -> Vec<(&'static str, Result<ClientConfig, ConfigError>)> {
let retry_initial = RetryPolicy::default()
.with_initial_delay(value)
.with_max_delay(value);
let retry_max = RetryPolicy::default()
.with_initial_delay(Duration::from_nanos(1))
.with_max_delay(value);
vec![
(
"open_timeout",
ClientConfig::builder(address())
.with_options(ConnectionOptions::default().with_open_timeout(value))
.build(),
),
(
"keepalive_slack",
ClientConfig::builder(address())
.with_options(ConnectionOptions::default().with_keepalive_slack(value))
.build(),
),
(
"keepalive_hint",
ClientConfig::builder(address())
.with_options(ConnectionOptions::default().with_keepalive_hint(Some(value)))
.build(),
),
(
"inactivity_commitment",
ClientConfig::builder(address())
.with_options(
ConnectionOptions::default().with_inactivity_commitment(Some(value)),
)
.build(),
),
(
"retry.initial_delay",
ClientConfig::builder(address())
.with_options(ConnectionOptions::default().with_retry(retry_initial))
.build(),
),
(
"retry.max_delay",
ClientConfig::builder(address())
.with_options(ConnectionOptions::default().with_retry(retry_max))
.build(),
),
]
}
#[test]
fn test_config_accepts_every_timing_at_the_supported_maximum() {
for (field, built) in with_each_timing(MAX_TIMING) {
assert!(built.is_ok(), "{field} was rejected at the maximum");
}
}
#[test]
fn test_config_rejects_every_timing_one_step_past_the_maximum() {
let over = match MAX_TIMING.checked_add(Duration::from_nanos(1)) {
Some(over) => over,
None => Duration::MAX,
};
for (field, built) in with_each_timing(over) {
assert!(
matches!(built, Err(ConfigError::DurationTooLarge { field: named }) if named == field),
"{field} was accepted one step past the maximum"
);
}
}
#[test]
fn test_config_rejects_every_timing_at_duration_max() {
for (field, built) in with_each_timing(Duration::MAX) {
assert!(
matches!(built, Err(ConfigError::DurationTooLarge { field: named }) if named == field),
"{field} accepted Duration::MAX"
);
}
}
#[test]
fn test_config_accepts_every_timing_at_its_smallest_usable_value() {
for (field, built) in with_each_timing(Duration::from_nanos(1)) {
assert!(built.is_ok(), "{field} was rejected at one nanosecond");
}
}
#[test]
fn test_config_accepts_a_zero_keepalive_slack() {
let built = ClientConfig::builder(address())
.with_options(ConnectionOptions::default().with_keepalive_slack(Duration::ZERO))
.build();
assert!(built.is_ok());
}
#[test]
fn test_config_accepts_unlimited_retries() {
let retry = RetryPolicy::default().with_max_attempts(None);
let built = ClientConfig::builder(address())
.with_options(ConnectionOptions::default().with_retry(retry))
.build();
assert!(built.is_ok());
}
}