use crate::namespace::Namespace;
use tokio::sync::RwLock;
use cache::Cache;
use client_config::ClientConfig;
use log::{error, trace};
use std::{collections::HashMap, sync::Arc};
use wasm_bindgen::prelude::wasm_bindgen;
#[cfg(all(feature = "native-tls", feature = "rustls", not(target_arch = "wasm32")))]
compile_error!(
"Features 'native-tls' and 'rustls' are mutually exclusive on non-WASM targets. \
Please disable default features and enable only one."
);
#[cfg(all(feature = "rustls", target_arch = "wasm32"))]
compile_error!("Feature 'rustls' is not supported on WASM targets. Only native-tls (browser) is supported.");
cfg_if::cfg_if! {
if #[cfg(not(target_arch = "wasm32"))] {
use tokio::spawn as spawn;
}
}
mod cache;
pub mod client_config;
pub mod namespace;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Client is already running")]
AlreadyRunning,
#[error("Namespace error: {0}")]
Namespace(#[from] namespace::Error),
#[error("Cache error: {0}")]
Cache(#[from] cache::Error),
}
impl From<Error> for wasm_bindgen::JsValue {
fn from(error: Error) -> Self {
cfg_if::cfg_if! {
if #[cfg(target_arch = "wasm32")] {
js_sys::Error::new(&error.to_string()).into()
} else {
error.to_string().into()
}
}
}
}
cfg_if::cfg_if! {
if #[cfg(target_arch = "wasm32")] {
pub type EventListener = Arc<dyn Fn(Result<Namespace, Error>)>;
} else {
pub type EventListener = Arc<dyn Fn(Result<Namespace, Error>) + Send + Sync>;
}
}
#[wasm_bindgen]
pub struct Client {
config: ClientConfig,
namespaces: Arc<RwLock<HashMap<String, Arc<Cache>>>>,
handle: Option<tokio::task::JoinHandle<()>>,
running: Arc<RwLock<bool>>,
http_client: reqwest::Client,
}
impl Client {
pub(crate) async fn cache(&self, namespace: &str) -> Arc<Cache> {
let mut namespaces = self.namespaces.write().await;
let cache = namespaces.entry(namespace.to_string()).or_insert_with(|| {
trace!("Cache miss, creating cache for namespace {namespace}");
Arc::new(Cache::new(
self.config.clone(),
namespace,
self.http_client.clone(),
))
});
cache.clone()
}
pub async fn add_listener(&self, namespace: &str, listener: EventListener) {
let mut namespaces = self.namespaces.write().await;
let cache = namespaces.entry(namespace.to_string()).or_insert_with(|| {
trace!("Cache miss, creating cache for namespace {namespace}");
Arc::new(Cache::new(
self.config.clone(),
namespace,
self.http_client.clone(),
))
});
cache.add_listener(listener).await;
}
pub async fn namespace(&self, namespace: &str) -> Result<namespace::Namespace, Error> {
let cache = self.cache(namespace).await;
let value = cache.get_value().await?;
Ok(namespace::get_namespace(namespace, value)?)
}
pub async fn start(&mut self) -> Result<(), Error> {
let mut running = self.running.write().await;
if *running {
return Err(Error::AlreadyRunning);
}
*running = true;
cfg_if::cfg_if! {
if #[cfg(target_arch = "wasm32")] {
self.handle = None;
} else {
let running = self.running.clone();
let namespaces = self.namespaces.clone();
let refresh_interval = {
let v = self.config.refresh_interval.unwrap_or(30);
let min_val = if cfg!(test) { 1 } else { 30 };
if v < min_val { min_val } else { v }
};
let handle = spawn(async move {
loop {
let running = running.read().await;
if !*running {
break;
}
let cache_refs: Vec<_> = {
let namespaces = namespaces.read().await;
namespaces.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
};
for (namespace, cache) in cache_refs {
if let Err(err) = cache.refresh().await {
error!("Failed to refresh cache for namespace {namespace}: {err:?}");
} else {
log::debug!("Successfully refreshed cache for namespace {namespace}");
}
}
tokio::time::sleep(std::time::Duration::from_secs(refresh_interval)).await;
}
});
self.handle = Some(handle);
}
}
Ok(())
}
pub async fn stop(&mut self) {
let mut running = self.running.write().await;
*running = false;
cfg_if::cfg_if! {
if #[cfg(not(target_arch = "wasm32"))] {
if let Some(handle) = self.handle.take() {
handle.abort();
}
}
}
}
pub async fn preload(&self, namespaces: &[impl AsRef<str>]) -> Result<(), Error> {
#[cfg(not(target_arch = "wasm32"))]
let mut tasks = Vec::new();
#[cfg(target_arch = "wasm32")]
{
for namespace in namespaces {
let cache = self.cache(namespace.as_ref()).await;
cache.get_value().await?;
}
}
#[cfg(not(target_arch = "wasm32"))]
{
for namespace in namespaces {
let cache = self.cache(namespace.as_ref()).await;
let task = tokio::spawn(async move { cache.get_value().await });
tasks.push(task);
}
for task in tasks {
let result = task.await.map_err(|e| {
Error::Cache(cache::Error::Io(std::io::Error::other(format!(
"Preload task failed: {e}"
))))
})?;
result?;
}
}
Ok(())
}
}
#[wasm_bindgen]
impl Client {
#[wasm_bindgen(constructor)]
#[must_use]
pub fn new(config: ClientConfig) -> Self {
let http_client = {
cfg_if::cfg_if! {
if #[cfg(not(target_arch = "wasm32"))] {
if let Some(custom_client) = config.http_client.clone() {
custom_client
} else if config.allow_insecure_https.unwrap_or(false) {
reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(true)
.build()
.unwrap_or_else(|_| reqwest::Client::new())
} else {
reqwest::Client::new()
}
} else {
if config.allow_insecure_https.unwrap_or(false) {
log::warn!(
"allow_insecure_https is silently ignored on wasm32 targets \
because SSL/TLS cert validation is strictly controlled by the browser sandbox environment."
);
}
reqwest::Client::new()
}
}
};
Self {
config,
namespaces: Arc::new(RwLock::new(HashMap::new())),
handle: None,
running: Arc::new(RwLock::new(false)),
http_client,
}
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen(js_name = "add_listener")]
pub async fn add_listener_wasm(&self, namespace: &str, js_listener: js_sys::Function) {
let js_listener_clone = js_listener.clone();
let event_listener: EventListener = Arc::new(move |result: Result<Namespace, Error>| {
let err_js_val: wasm_bindgen::JsValue;
let data_js_val: wasm_bindgen::JsValue;
match result {
Ok(value) => {
data_js_val = value.into();
err_js_val = wasm_bindgen::JsValue::UNDEFINED;
}
Err(cache_error) => {
err_js_val = cache_error.into();
data_js_val = wasm_bindgen::JsValue::UNDEFINED;
}
};
match js_listener_clone.call2(
&wasm_bindgen::JsValue::UNDEFINED,
&data_js_val,
&err_js_val,
) {
Ok(_) => {
}
Err(e) => {
log::error!("JavaScript listener threw an error: {:?}", e);
}
}
});
self.add_listener(namespace, event_listener).await; }
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen(js_name = "namespace")]
pub async fn namespace_wasm(&self, namespace: &str) -> Result<wasm_bindgen::JsValue, Error> {
let cache = self.cache(namespace).await;
let value = cache.get_value().await?;
Ok(namespace::get_namespace(namespace, value)?.into())
}
}
#[cfg(test)]
pub(crate) struct TempDir {
path: std::path::PathBuf,
}
#[cfg(test)]
impl TempDir {
pub(crate) fn new(name: &str) -> Self {
let path = std::env::temp_dir().join(name);
let _ = std::fs::create_dir_all(&path);
Self { path }
}
pub(crate) fn path(&self) -> &std::path::Path {
&self.path
}
}
#[cfg(test)]
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
#[cfg(test)]
pub(crate) fn setup() {
cfg_if::cfg_if! {
if #[cfg(target_arch = "wasm32")] {
let _ = wasm_logger::init(wasm_logger::Config::default());
console_error_panic_hook::set_once();
} else {
let _ = env_logger::builder().is_test(true).try_init();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
fn test_server_url() -> String {
std::env::var("APOLLO_TEST_SERVER").unwrap_or_else(|_| String::from("http://localhost:8080"))
}
fn test_cache_dir() -> String {
std::env::temp_dir().join("apollo").to_string_lossy().to_string()
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) static CLIENT_NO_SECRET: std::sync::LazyLock<Client> =
std::sync::LazyLock::new(|| {
let config = ClientConfig {
app_id: String::from("101010101"),
cluster: String::from("default"),
config_server: test_server_url(),
label: None,
secret: None,
cache_dir: Some(test_cache_dir()),
ip: None,
allow_insecure_https: None,
#[cfg(not(target_arch = "wasm32"))]
cache_ttl: None,
#[cfg(not(target_arch = "wasm32"))]
refresh_interval: None,
#[cfg(not(target_arch = "wasm32"))]
http_client: None,
};
Client::new(config)
});
#[cfg(not(target_arch = "wasm32"))]
pub(crate) static CLIENT_WITH_SECRET: std::sync::LazyLock<Client> =
std::sync::LazyLock::new(|| {
let config = ClientConfig {
app_id: String::from("101010102"),
cluster: String::from("default"),
config_server: test_server_url(),
label: None,
secret: Some(String::from("53bf47631db540ac9700f0020d2192c8")),
cache_dir: Some(test_cache_dir()),
ip: None,
allow_insecure_https: None,
#[cfg(not(target_arch = "wasm32"))]
cache_ttl: None,
#[cfg(not(target_arch = "wasm32"))]
refresh_interval: None,
#[cfg(not(target_arch = "wasm32"))]
http_client: None,
};
Client::new(config)
});
#[cfg(not(target_arch = "wasm32"))]
pub(crate) static CLIENT_WITH_GRAYSCALE_IP: std::sync::LazyLock<Client> =
std::sync::LazyLock::new(|| {
let config = ClientConfig {
app_id: String::from("101010101"),
cluster: String::from("default"),
config_server: test_server_url(),
label: None,
secret: None,
cache_dir: Some(test_cache_dir()),
ip: Some(String::from("1.2.3.4")),
allow_insecure_https: None,
#[cfg(not(target_arch = "wasm32"))]
cache_ttl: None,
#[cfg(not(target_arch = "wasm32"))]
refresh_interval: None,
#[cfg(not(target_arch = "wasm32"))]
http_client: None,
};
Client::new(config)
});
#[cfg(not(target_arch = "wasm32"))]
pub(crate) static CLIENT_WITH_GRAYSCALE_LABEL: std::sync::LazyLock<Client> =
std::sync::LazyLock::new(|| {
let config = ClientConfig {
app_id: String::from("101010101"),
cluster: String::from("default"),
config_server: test_server_url(),
label: Some(String::from("GrayScale")),
secret: None,
cache_dir: Some(test_cache_dir()),
ip: None,
allow_insecure_https: None,
#[cfg(not(target_arch = "wasm32"))]
cache_ttl: None,
#[cfg(not(target_arch = "wasm32"))]
refresh_interval: None,
#[cfg(not(target_arch = "wasm32"))]
http_client: None,
};
Client::new(config)
});
#[cfg(not(target_arch = "wasm32"))]
#[tokio::test]
async fn test_missing_value() {
setup();
let namespace::Namespace::Properties(properties) =
CLIENT_NO_SECRET.namespace("application").await.unwrap()
else {
panic!("Expected Properties namespace");
};
assert_eq!(properties.get_property::<String>("missingValue"), None);
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen_test::wasm_bindgen_test]
#[allow(dead_code)]
async fn test_missing_value_wasm() {
setup();
let client = create_client_no_secret();
let namespace = client.namespace("application").await;
match namespace {
Ok(namespace) => match namespace {
namespace::Namespace::Properties(properties) => {
assert_eq!(properties.get_string("missingValue"), None);
}
_ => panic!("Expected Properties namespace"),
},
Err(e) => panic!("Expected Properties namespace, got error: {e:?}"),
}
}
#[cfg(not(target_arch = "wasm32"))]
#[tokio::test]
async fn test_string_value() {
setup();
let namespace::Namespace::Properties(properties) =
CLIENT_NO_SECRET.namespace("application").await.unwrap()
else {
panic!("Expected Properties namespace");
};
assert_eq!(
properties.get_property::<String>("stringValue"),
Some("string value".to_string())
);
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen_test::wasm_bindgen_test]
#[allow(dead_code)]
async fn test_string_value_wasm() {
setup();
let client = create_client_no_secret();
let namespace = client.namespace("application").await;
match namespace {
Ok(namespace) => match namespace {
namespace::Namespace::Properties(properties) => {
assert_eq!(
properties.get_string("stringValue"),
Some("string value".to_string())
);
}
_ => panic!("Expected Properties namespace"),
},
Err(e) => panic!("Expected Properties namespace, got error: {e:?}"),
}
}
#[cfg(not(target_arch = "wasm32"))]
#[tokio::test]
async fn test_string_value_with_secret() {
setup();
let namespace::Namespace::Properties(properties) =
CLIENT_WITH_SECRET.namespace("application").await.unwrap()
else {
panic!("Expected Properties namespace");
};
assert_eq!(
properties.get_property::<String>("stringValue"),
Some("string value".to_string())
);
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen_test::wasm_bindgen_test]
#[allow(dead_code)]
async fn test_string_value_with_secret_wasm() {
setup();
let client = create_client_with_secret();
let namespace = client.namespace("application").await;
match namespace {
Ok(namespace) => match namespace {
namespace::Namespace::Properties(properties) => {
assert_eq!(
properties.get_string("stringValue"),
Some("string value".to_string())
);
}
_ => panic!("Expected Properties namespace"),
},
Err(e) => panic!("Expected Properties namespace, got error: {e:?}"),
}
}
#[cfg(not(target_arch = "wasm32"))]
#[tokio::test]
async fn test_int_value() {
setup();
let namespace::Namespace::Properties(properties) =
CLIENT_NO_SECRET.namespace("application").await.unwrap()
else {
panic!("Expected Properties namespace");
};
assert_eq!(properties.get_property::<i32>("intValue"), Some(42));
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen_test::wasm_bindgen_test]
#[allow(dead_code)]
async fn test_int_value_wasm() {
setup();
let client = create_client_no_secret();
let namespace = client.namespace("application").await;
match namespace {
Ok(namespace) => match namespace {
namespace::Namespace::Properties(properties) => {
assert_eq!(properties.get_int("intValue"), Some(42));
}
_ => panic!("Expected Properties namespace"),
},
Err(e) => panic!("Expected Properties namespace, got error: {e:?}"),
}
}
#[cfg(not(target_arch = "wasm32"))]
#[tokio::test]
async fn test_int_value_with_secret() {
setup();
let namespace::Namespace::Properties(properties) =
CLIENT_WITH_SECRET.namespace("application").await.unwrap()
else {
panic!("Expected Properties namespace");
};
assert_eq!(properties.get_property::<i32>("intValue"), Some(42));
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen_test::wasm_bindgen_test]
#[allow(dead_code)]
async fn test_int_value_with_secret_wasm() {
setup();
let client = create_client_with_secret();
let namespace = client.namespace("application").await;
match namespace {
Ok(namespace) => match namespace {
namespace::Namespace::Properties(properties) => {
assert_eq!(properties.get_int("intValue"), Some(42));
}
_ => panic!("Expected Properties namespace"),
},
Err(e) => panic!("Expected Properties namespace, got error: {e:?}"),
}
}
#[cfg(not(target_arch = "wasm32"))]
#[tokio::test]
async fn test_float_value() {
setup();
let namespace::Namespace::Properties(properties) =
CLIENT_NO_SECRET.namespace("application").await.unwrap()
else {
panic!("Expected Properties namespace");
};
assert_eq!(properties.get_property::<f64>("floatValue"), Some(4.20));
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen_test::wasm_bindgen_test]
#[allow(dead_code)]
async fn test_float_value_wasm() {
setup();
let client = create_client_no_secret();
let namespace = client.namespace("application").await;
match namespace {
Ok(namespace) => match namespace {
namespace::Namespace::Properties(properties) => {
assert_eq!(properties.get_float("floatValue"), Some(4.20));
}
_ => panic!("Expected Properties namespace"),
},
Err(e) => panic!("Expected Properties namespace, got error: {e:?}"),
}
}
#[cfg(not(target_arch = "wasm32"))]
#[tokio::test]
async fn test_float_value_with_secret() {
setup();
let namespace::Namespace::Properties(properties) =
CLIENT_WITH_SECRET.namespace("application").await.unwrap()
else {
panic!("Expected Properties namespace");
};
assert_eq!(properties.get_property::<f64>("floatValue"), Some(4.20));
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen_test::wasm_bindgen_test]
#[allow(dead_code)]
async fn test_float_value_with_secret_wasm() {
setup();
let client = create_client_with_secret();
let namespace = client.namespace("application").await;
match namespace {
Ok(namespace) => match namespace {
namespace::Namespace::Properties(properties) => {
assert_eq!(properties.get_float("floatValue"), Some(4.20));
}
_ => panic!("Expected Properties namespace"),
},
Err(e) => panic!("Expected Properties namespace, got error: {e:?}"),
}
}
#[cfg(not(target_arch = "wasm32"))]
#[tokio::test]
async fn test_bool_value() {
setup();
let namespace::Namespace::Properties(properties) =
CLIENT_NO_SECRET.namespace("application").await.unwrap()
else {
panic!("Expected Properties namespace");
};
assert_eq!(properties.get_property::<bool>("boolValue"), Some(false));
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen_test::wasm_bindgen_test]
#[allow(dead_code)]
async fn test_bool_value_wasm() {
setup();
let client = create_client_no_secret();
let namespace = client.namespace("application").await;
match namespace {
Ok(namespace) => match namespace {
namespace::Namespace::Properties(properties) => {
assert_eq!(properties.get_bool("boolValue"), Some(false));
}
_ => panic!("Expected Properties namespace"),
},
Err(e) => panic!("Expected Properties namespace, got error: {e:?}"),
}
}
#[cfg(not(target_arch = "wasm32"))]
#[tokio::test]
async fn test_bool_value_with_secret() {
setup();
let namespace::Namespace::Properties(properties) =
CLIENT_WITH_SECRET.namespace("application").await.unwrap()
else {
panic!("Expected Properties namespace");
};
assert_eq!(properties.get_property::<bool>("boolValue"), Some(false));
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen_test::wasm_bindgen_test]
#[allow(dead_code)]
async fn test_bool_value_with_secret_wasm() {
setup();
let client = create_client_with_secret();
let namespace = client.namespace("application").await;
match namespace {
Ok(namespace) => match namespace {
namespace::Namespace::Properties(properties) => {
assert_eq!(properties.get_bool("boolValue"), Some(false));
}
_ => panic!("Expected Properties namespace"),
},
Err(e) => panic!("Expected Properties namespace, got error: {e:?}"),
}
}
#[cfg(not(target_arch = "wasm32"))]
#[tokio::test]
async fn test_bool_value_with_grayscale_ip() {
setup();
let namespace::Namespace::Properties(properties) = CLIENT_WITH_GRAYSCALE_IP
.namespace("application")
.await
.unwrap()
else {
panic!("Expected Properties namespace");
};
assert_eq!(
properties.get_property::<bool>("grayScaleValue"),
Some(true)
);
let namespace::Namespace::Properties(properties) =
CLIENT_NO_SECRET.namespace("application").await.unwrap()
else {
panic!("Expected Properties namespace");
};
assert_eq!(
properties.get_property::<bool>("grayScaleValue"),
Some(false)
);
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen_test::wasm_bindgen_test]
#[allow(dead_code)]
async fn test_bool_value_with_grayscale_ip_wasm() {
setup();
let client1 = create_client_with_grayscale_ip();
let namespace = client1.namespace("application").await;
match namespace {
Ok(namespace) => match namespace {
namespace::Namespace::Properties(properties) => {
assert_eq!(properties.get_bool("grayScaleValue"), Some(true));
}
_ => panic!("Expected Properties namespace"),
},
Err(e) => panic!("Expected Properties namespace, got error: {:?}", e),
}
let client2 = create_client_no_secret();
let namespace = client2.namespace("application").await;
match namespace {
Ok(namespace) => match namespace {
namespace::Namespace::Properties(properties) => {
assert_eq!(properties.get_bool("grayScaleValue"), Some(false));
}
_ => panic!("Expected Properties namespace"),
},
Err(e) => panic!("Expected Properties namespace, got error: {:?}", e),
}
}
#[cfg(not(target_arch = "wasm32"))]
#[tokio::test]
async fn test_bool_value_with_grayscale_label() {
setup();
let namespace::Namespace::Properties(properties) = CLIENT_WITH_GRAYSCALE_LABEL
.namespace("application")
.await
.unwrap()
else {
panic!("Expected Properties namespace");
};
assert_eq!(
properties.get_property::<bool>("grayScaleValue"),
Some(true)
);
let namespace::Namespace::Properties(properties) =
CLIENT_NO_SECRET.namespace("application").await.unwrap()
else {
panic!("Expected Properties namespace");
};
assert_eq!(
properties.get_property::<bool>("grayScaleValue"),
Some(false)
);
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen_test::wasm_bindgen_test]
#[allow(dead_code)]
async fn test_bool_value_with_grayscale_label_wasm() {
setup();
let client1 = create_client_with_grayscale_label();
let namespace = client1.namespace("application").await;
match namespace {
Ok(namespace) => match namespace {
namespace::Namespace::Properties(properties) => {
assert_eq!(properties.get_bool("grayScaleValue"), Some(true));
}
_ => panic!("Expected Properties namespace"),
},
Err(e) => panic!("Expected Properties namespace, got error: {:?}", e),
}
let client2 = create_client_no_secret();
let namespace = client2.namespace("application").await;
match namespace {
Ok(namespace) => match namespace {
namespace::Namespace::Properties(properties) => {
assert_eq!(properties.get_bool("grayScaleValue"), Some(false));
}
_ => panic!("Expected Properties namespace"),
},
Err(e) => panic!("Expected Properties namespace, got error: {:?}", e),
}
}
#[cfg(target_arch = "wasm32")]
fn create_client_no_secret() -> Client {
let config = ClientConfig {
app_id: String::from("101010101"),
cluster: String::from("default"),
config_server: test_server_url(),
label: None,
secret: None,
cache_dir: None,
ip: None,
allow_insecure_https: None,
};
Client::new(config)
}
#[cfg(target_arch = "wasm32")]
fn create_client_with_secret() -> Client {
let config = ClientConfig {
app_id: String::from("101010102"),
cluster: String::from("default"),
config_server: test_server_url(),
label: None,
secret: Some(String::from("53bf47631db540ac9700f0020d2192c8")),
cache_dir: None,
ip: None,
allow_insecure_https: None,
};
Client::new(config)
}
#[cfg(target_arch = "wasm32")]
fn create_client_with_grayscale_ip() -> Client {
let config = ClientConfig {
app_id: String::from("101010101"),
cluster: String::from("default"),
config_server: test_server_url(),
label: None,
secret: None,
cache_dir: None,
ip: Some(String::from("1.2.3.4")),
allow_insecure_https: None,
};
Client::new(config)
}
#[cfg(target_arch = "wasm32")]
fn create_client_with_grayscale_label() -> Client {
let config = ClientConfig {
app_id: String::from("101010101"),
cluster: String::from("default"),
config_server: test_server_url(),
label: Some(String::from("GrayScale")),
secret: None,
cache_dir: None,
ip: None,
allow_insecure_https: None,
};
Client::new(config)
}
#[cfg(not(target_arch = "wasm32"))]
#[tokio::test] async fn test_add_listener_and_notify_on_refresh() {
setup();
let listener_called_flag = Arc::new(Mutex::new(false));
let received_config_data = Arc::new(Mutex::new(None::<Namespace>));
let temp_dir = TempDir::new("apollo_listener_test");
let config = ClientConfig {
config_server: test_server_url(), app_id: "101010101".to_string(), cluster: "default".to_string(),
cache_dir: Some(temp_dir.path().to_str().unwrap().to_string()), secret: None,
label: None,
ip: None,
allow_insecure_https: None,
#[cfg(not(target_arch = "wasm32"))]
cache_ttl: None,
#[cfg(not(target_arch = "wasm32"))]
refresh_interval: None,
#[cfg(not(target_arch = "wasm32"))]
http_client: None,
};
let client = Client::new(config);
let flag_clone = listener_called_flag.clone();
let data_clone = received_config_data.clone();
let listener: EventListener = Arc::new(move |result| {
let mut called_guard = flag_clone.lock().unwrap();
*called_guard = true;
if let Ok(config_value) = result {
match config_value {
Namespace::Properties(_) => {
let mut data_guard = data_clone.lock().unwrap();
*data_guard = Some(config_value.clone());
}
_ => {
panic!("Expected Properties namespace, got {config_value:?}");
}
}
}
});
client.add_listener("application", listener).await;
let cache = client.cache("application").await;
match cache.refresh().await {
Ok(()) => log::debug!("Refresh successful for test_add_listener_and_notify_on_refresh"),
Err(e) => panic!("Cache refresh failed during test: {e:?}"),
}
cfg_if::cfg_if! {
if #[cfg(target_arch = "wasm32")] {
} else {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
}
let called = *listener_called_flag.lock().unwrap();
assert!(called, "Listener was not called.");
let config_data_guard = received_config_data.lock().unwrap();
assert!(
config_data_guard.is_some(),
"Listener did not receive config data."
);
if let Some(value) = config_data_guard.as_ref() {
match value {
Namespace::Properties(properties) => {
assert_eq!(
properties.get_string("stringValue"),
Some(String::from("string value")),
"Received config data does not match expected content for stringValue."
);
}
_ => {
panic!("Expected Properties namespace, got {value:?}");
}
}
}
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen_test::wasm_bindgen_test]
async fn test_add_listener_wasm_and_notify() {
setup();
let listener_called_flag = Arc::new(Mutex::new(false));
let received_config_data = Arc::new(Mutex::new(None::<Namespace>));
let flag_clone = listener_called_flag.clone();
let data_clone = received_config_data.clone();
let js_listener_func_body = format!(
r#"
(data, error) => {{
// We can't use window in Node.js, so we'll use a different approach
// The Rust closure will handle the verification
console.log('JS Listener called with error:', error);
console.log('JS Listener called with data:', data);
}}
"#
);
let js_listener = js_sys::Function::new_with_args("data, error", &js_listener_func_body);
let client = create_client_no_secret();
let rust_listener: EventListener = Arc::new(move |result| {
let mut called_guard = flag_clone.lock().unwrap();
*called_guard = true;
if let Ok(config_value) = result {
let mut data_guard = data_clone.lock().unwrap();
*data_guard = Some(config_value);
}
});
client.add_listener("application", rust_listener).await;
client.add_listener_wasm("application", js_listener).await;
let cache = client.cache("application").await;
match cache.refresh().await {
Ok(_) => web_sys::console::log_1(&"WASM Test: Refresh successful".into()), Err(e) => panic!("WASM Test: Cache refresh failed: {:?}", e),
}
cfg_if::cfg_if! {
if #[cfg(target_arch = "wasm32")] {
} else {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
}
let called = *listener_called_flag.lock().unwrap();
assert!(called, "Listener was not called.");
let config_data_guard = received_config_data.lock().unwrap();
assert!(
config_data_guard.is_some(),
"Listener did not receive config data."
);
if let Some(value) = config_data_guard.as_ref() {
match value {
namespace::Namespace::Properties(properties) => {
assert_eq!(
properties.get_string("stringValue"),
Some("string value".to_string())
);
}
_ => panic!("Expected Properties namespace"),
}
}
}
#[cfg(not(target_arch = "wasm32"))]
#[tokio::test]
async fn test_concurrent_namespace_hang_repro() {
setup();
let temp_dir = TempDir::new("apollo_hang_test");
let config = ClientConfig {
app_id: String::from("101010101"),
cluster: String::from("default"),
config_server: test_server_url(),
secret: None,
cache_dir: Some(temp_dir.path().to_str().unwrap().to_string()),
label: None,
ip: None,
allow_insecure_https: None,
cache_ttl: None,
refresh_interval: None,
http_client: None,
};
let client = Arc::new(Client::new(config));
let client_in_listener = client.clone();
let listener_triggered = Arc::new(Mutex::new(false));
let listener_triggered_in_listener = listener_triggered.clone();
let listener: EventListener = Arc::new(move |_| {
let client_in_listener = client_in_listener.clone();
let listener_triggered_in_listener = listener_triggered_in_listener.clone();
tokio::spawn(async move {
{
let mut triggered = listener_triggered_in_listener.lock().unwrap();
if *triggered {
return;
}
*triggered = true;
}
let _ = client_in_listener.namespace("application").await;
});
});
client.add_listener("application", listener).await;
let test_body = async {
let _ = client.namespace("application").await;
};
let res = tokio::time::timeout(std::time::Duration::from_secs(10), test_body).await;
assert!(res.is_ok(), "Test timed out, which indicates a deadlock.");
}
#[cfg(not(target_arch = "wasm32"))]
#[tokio::test]
async fn test_custom_refresh_interval() {
setup();
let temp_dir = TempDir::new("apollo_custom_refresh_interval");
let config = ClientConfig {
app_id: String::from("101010101"),
cluster: String::from("default"),
config_server: test_server_url(),
secret: None,
cache_dir: Some(temp_dir.path().to_str().unwrap().to_string()),
label: None,
ip: None,
allow_insecure_https: None,
cache_ttl: None,
refresh_interval: Some(1), http_client: None,
};
let mut client = Client::new(config);
let _ = client.namespace("application").await;
let res = client.start().await;
assert!(res.is_ok(), "Failed to start client background task");
tokio::time::sleep(std::time::Duration::from_millis(2500)).await;
client.stop().await;
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_refresh_interval_clamping() {
unsafe {
std::env::set_var("APP_ID", "101010101");
std::env::set_var("APOLLO_CONFIG_SERVICE", "http://localhost:8080");
std::env::set_var("APOLLO_REFRESH_INTERVAL", "0");
}
let config = ClientConfig::from_env().unwrap();
assert_eq!(config.refresh_interval, Some(1));
unsafe {
std::env::set_var("APOLLO_REFRESH_INTERVAL", "15");
}
let config2 = ClientConfig::from_env().unwrap();
assert_eq!(config2.refresh_interval, Some(15));
unsafe {
std::env::remove_var("APP_ID");
std::env::remove_var("APOLLO_CONFIG_SERVICE");
std::env::remove_var("APOLLO_REFRESH_INTERVAL");
}
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen_test::wasm_bindgen_test]
async fn test_wasm_local_storage_caching() {
use wasm_bindgen::prelude::Closure;
setup();
let store = Arc::new(Mutex::new(HashMap::<String, String>::new()));
let store_clone1 = store.clone();
let get_item = Closure::wrap(Box::new(move |key: String| -> wasm_bindgen::JsValue {
let map = store_clone1.lock().unwrap();
if let Some(val) = map.get(&key) {
wasm_bindgen::JsValue::from_str(val)
} else {
wasm_bindgen::JsValue::NULL
}
}) as Box<dyn Fn(String) -> wasm_bindgen::JsValue>);
let store_clone2 = store.clone();
let set_item = Closure::wrap(Box::new(move |key: String, value: String| {
let mut map = store_clone2.lock().unwrap();
map.insert(key, value);
}) as Box<dyn Fn(String, String)>);
let mock_storage = js_sys::Object::new();
js_sys::Reflect::set(&mock_storage, &wasm_bindgen::JsValue::from_str("getItem"), get_item.as_ref()).unwrap();
js_sys::Reflect::set(&mock_storage, &wasm_bindgen::JsValue::from_str("setItem"), set_item.as_ref()).unwrap();
let global = js_sys::global();
js_sys::Reflect::set(&global, &wasm_bindgen::JsValue::from_str("localStorage"), &mock_storage).unwrap();
let config = ClientConfig {
app_id: "101010101".to_string(),
cluster: "default".to_string(),
config_server: "http://localhost:8080".to_string(),
secret: None,
cache_dir: None,
label: None,
ip: None,
allow_insecure_https: None,
};
let cache_item = serde_json::json!({
"timestamp": chrono::Utc::now().timestamp(),
"config": {
"stringValue": "localstorage value"
}
});
let cache_content = serde_json::to_string(&cache_item).unwrap();
let cache_key = "apollo_cache_101010101_default_application";
{
let mut map = store.lock().unwrap();
map.insert(cache_key.to_string(), cache_content);
}
let cache = cache::Cache::new(
config,
"application",
reqwest::Client::new(),
);
let value = cache.get_value().await.unwrap();
assert_eq!(
value.get("stringValue").and_then(|v| v.as_str()),
Some("localstorage value"),
"Cache failed to load configuration from mocked local storage"
);
get_item.into_js_value();
set_item.into_js_value();
let _ = js_sys::Reflect::delete_property(&global, &wasm_bindgen::JsValue::from_str("localStorage"));
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen_test::wasm_bindgen_test]
fn test_wasm_cache_key_isolation() {
setup();
let config1 = ClientConfig {
app_id: "app1".to_string(),
cluster: "default".to_string(),
config_server: "http://localhost:8080".to_string(),
secret: None,
cache_dir: None,
label: None,
ip: None,
allow_insecure_https: None,
};
let cache1 = cache::Cache::new(config1, "application", reqwest::Client::new());
assert_eq!(cache1.wasm_cache_key(), "apollo_cache_app1_default_application");
let config2 = ClientConfig {
app_id: "app1".to_string(),
cluster: "prod".to_string(),
config_server: "http://localhost:8080".to_string(),
secret: None,
cache_dir: None,
label: None,
ip: None,
allow_insecure_https: None,
};
let cache2 = cache::Cache::new(config2, "application", reqwest::Client::new());
assert_eq!(cache2.wasm_cache_key(), "apollo_cache_app1_prod_application");
let config3 = ClientConfig {
app_id: "app1".to_string(),
cluster: "default".to_string(),
config_server: "http://localhost:8080".to_string(),
secret: None,
cache_dir: None,
label: None,
ip: None,
allow_insecure_https: None,
};
let cache3 = cache::Cache::new(config3, "other_namespace", reqwest::Client::new());
assert_eq!(cache3.wasm_cache_key(), "apollo_cache_app1_default_other_namespace");
let config4 = ClientConfig {
app_id: "app1".to_string(),
cluster: "default".to_string(),
config_server: "http://localhost:8080".to_string(),
secret: None,
cache_dir: None,
label: Some("gray".to_string()),
ip: Some("192.168.1.1".to_string()),
allow_insecure_https: None,
};
let cache4 = cache::Cache::new(config4, "application", reqwest::Client::new());
assert_eq!(cache4.wasm_cache_key(), "apollo_cache_app1_default_application_192.168.1.1_gray");
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen_test::wasm_bindgen_test]
fn test_wasm_allow_insecure_https_warning() {
setup();
let config = ClientConfig {
app_id: "101010101".to_string(),
cluster: "default".to_string(),
config_server: "http://localhost:8080".to_string(),
secret: None,
cache_dir: None,
label: None,
ip: None,
allow_insecure_https: Some(true),
};
let _client = Client::new(config);
}
#[cfg(not(target_arch = "wasm32"))]
#[tokio::test]
async fn test_custom_http_client_injection() {
setup();
let custom_client = reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(1))
.build()
.unwrap();
let temp_dir = TempDir::new("apollo_custom_http_test");
let config = ClientConfig {
config_server: test_server_url(),
app_id: "101010101".to_string(),
cluster: "default".to_string(),
cache_dir: Some(temp_dir.path().to_str().unwrap().to_string()),
secret: None,
label: None,
ip: None,
allow_insecure_https: None,
cache_ttl: None,
refresh_interval: None,
http_client: Some(custom_client),
};
let client = Client::new(config);
let result = client.namespace("application").await;
assert!(result.is_err(), "Expected request to fail due to custom injected HTTP client timeout");
let err_str = result.err().unwrap().to_string();
assert!(err_str.contains("timeout") || err_str.contains("error") || err_str.contains("reqwest"), "Expected error to mention timeout or request failure, got: {err_str}");
}
}