use crate::{
EventListener,
client_config::ClientConfig,
namespace::{self, get_namespace},
};
use tokio::sync::RwLock;
use base64::display::Base64Display;
use cfg_if::cfg_if;
use chrono::Utc;
use hmac::{Hmac, KeyInit, Mac};
use log::{debug, trace};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha1::Sha1;
use std::{fmt::Write, sync::Arc};
use tokio::sync::Notify;
use url::{ParseError, Url};
#[derive(Serialize, Deserialize)]
struct CacheItem {
timestamp: i64,
config: Value,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Io error: {0}")]
Io(#[from] std::io::Error),
#[error("Namespace error: {0}")]
Namespace(namespace::Error),
#[error("Serde error: {0}")]
Serde(#[from] serde_json::Error),
#[error("Namespace not found: {0}")]
NamespaceNotFound(String),
#[error("Reqwest error: {0}")]
Reqwest(#[from] reqwest::Error),
#[error("Url parse error: {0}")]
UrlParse(#[from] url::ParseError),
}
#[derive(Clone)]
pub(crate) struct Cache {
client_config: ClientConfig,
namespace: String,
memory: Arc<RwLock<Option<Value>>>,
listeners: Arc<RwLock<Vec<EventListener>>>,
#[cfg(target_arch = "wasm32")]
wasm_cache_key: String,
loading: Arc<RwLock<bool>>,
loading_complete: Arc<Notify>,
#[cfg(not(target_arch = "wasm32"))]
file_path: std::path::PathBuf,
http_client: reqwest::Client,
}
impl Cache {
pub(crate) fn new(
client_config: ClientConfig,
namespace: &str,
http_client: reqwest::Client,
) -> Self {
let mut file_name = namespace.to_string();
if let Some(ip) = &client_config.ip {
let _ = write!(file_name, "_{ip}");
}
if let Some(label) = &client_config.label {
let _ = write!(file_name, "_{label}");
}
#[cfg(not(target_arch = "wasm32"))]
let file_path = client_config
.get_cache_dir()
.join(format!("{file_name}.cache.json"));
#[cfg(target_arch = "wasm32")]
let mut wasm_cache_key = format!(
"apollo_cache_{}_{}_{}",
client_config.app_id,
client_config.cluster,
namespace
);
#[cfg(target_arch = "wasm32")]
{
if let Some(ip) = &client_config.ip {
let _ = write!(wasm_cache_key, "_{ip}");
}
if let Some(label) = &client_config.label {
let _ = write!(wasm_cache_key, "_{label}");
}
}
Self {
client_config,
namespace: namespace.to_string(),
memory: Arc::new(RwLock::new(None)),
listeners: Arc::new(RwLock::new(Vec::new())),
loading: Arc::new(RwLock::new(false)),
loading_complete: Arc::new(Notify::new()),
#[cfg(not(target_arch = "wasm32"))]
file_path,
#[cfg(target_arch = "wasm32")]
wasm_cache_key,
http_client,
}
}
pub(crate) async fn get_value(&self) -> Result<Value, Error> {
if let Some(value) = self.memory.read().await.as_ref() {
return Ok(value.clone());
}
let should_load = loop {
if let Some(value) = self.memory.read().await.as_ref() {
return Ok(value.clone());
}
let mut loading = self.loading.write().await;
if *loading {
drop(loading);
self.loading_complete.notified().await;
} else {
*loading = true;
break true;
}
};
if should_load {
let result = self.load_and_cache().await;
{
let mut loading = self.loading.write().await;
*loading = false;
}
self.loading_complete.notify_waiters();
result
} else {
Err(Error::NamespaceNotFound(self.namespace.clone()))
}
}
async fn load_and_cache(&self) -> Result<Value, Error> {
let mut w_lock = self.memory.write().await;
if let Some(value) = w_lock.as_ref() {
return Ok(value.clone());
}
cfg_if! {
if #[cfg(not(target_arch = "wasm32"))] {
let file_path = self.file_path.clone();
if file_path.exists()
&& let Ok(file) = std::fs::File::open(&file_path)
&& let Ok(cache_item) = serde_json::from_reader::<_, CacheItem>(file) {
let mut is_stale = false;
if let Some(ttl) = self.client_config.cache_ttl {
let age = Utc::now().timestamp() - cache_item.timestamp;
#[allow(clippy::cast_possible_wrap)]
if age > ttl as i64 {
is_stale = true;
}
}
if !is_stale {
w_lock.replace(cache_item.config.clone());
let config = cache_item.config;
let listeners = self.listeners.read().await.clone();
drop(w_lock); self.notify_listeners(&config, &listeners);
return Ok(config);
}
}
} else {
if let Some(cached_str) = load_from_local_storage(&self.wasm_cache_key) {
if let Ok(cache_item) = serde_json::from_str::<CacheItem>(&cached_str) {
w_lock.replace(cache_item.config.clone());
let config = cache_item.config;
let listeners = self.listeners.read().await.clone();
drop(w_lock); self.notify_listeners(&config, &listeners);
return Ok(config);
}
}
}
}
let config = self.fetch_remote_config().await?;
w_lock.replace(config.clone());
let listeners = self.listeners.read().await.clone();
drop(w_lock); self.notify_listeners(&config, &listeners);
Ok(config)
}
pub(crate) async fn refresh(&self) -> Result<(), Error> {
let (config, listeners) = {
let mut w_lock = self.memory.write().await;
let config = self.fetch_remote_config().await?;
w_lock.replace(config.clone());
let listeners = self.listeners.read().await.clone();
(config, listeners)
};
self.notify_listeners(&config, &listeners);
Ok(())
}
fn notify_listeners(&self, config: &Value, listeners: &[EventListener]) {
for listener in listeners {
let listener = listener.clone(); let config = config.clone();
let namespace = self.namespace.clone();
cfg_if::cfg_if! {
if #[cfg(target_arch = "wasm32")] {
listener(
get_namespace(&namespace, config).map_err(crate::Error::Namespace),
);
} else {
tokio::spawn(async move {
listener(
get_namespace(&namespace, config).map_err(crate::Error::Namespace),
);
});
}
}
}
}
async fn fetch_remote_config(&self) -> Result<Value, Error> {
let url = self.build_request_url()?;
let client = self.build_http_request(&url)?;
#[cfg(target_arch = "wasm32")]
let response = self.execute_request(client).await?;
#[cfg(not(target_arch = "wasm32"))]
let response = {
let timeout_duration = std::time::Duration::from_secs(10);
tokio::time::timeout(timeout_duration, self.execute_request(client))
.await
.map_err(|_| {
let io_error = std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Request timeout after 10 seconds",
);
Error::Io(io_error)
})??
};
let config = self.parse_response(response).await?;
cfg_if! {
if #[cfg(not(target_arch = "wasm32"))] {
self.write_to_file_cache(&config)?;
} else {
let cache_item = CacheItem {
timestamp: chrono::Utc::now().timestamp(),
config: config.clone(),
};
if let Ok(cache_content) = serde_json::to_string(&cache_item) {
let _ = save_to_local_storage(&self.wasm_cache_key, &cache_content);
}
}
}
Ok(config)
}
fn build_request_url(&self) -> Result<Url, Error> {
let url = format!(
"{}/configfiles/json/{}/{}/{}",
self.client_config.config_server,
self.client_config.app_id,
self.client_config.cluster,
self.namespace
);
let mut url = match Url::parse(&url) {
Ok(u) => u,
Err(e) => return Err(Error::UrlParse(e)),
};
if let Some(ip) = &self.client_config.ip {
url.query_pairs_mut().append_pair("ip", ip);
}
if let Some(label) = &self.client_config.label {
url.query_pairs_mut().append_pair("label", label);
}
Ok(url)
}
fn build_http_request(&self, url: &Url) -> Result<reqwest::RequestBuilder, Error> {
let mut client = self.http_client.get(url.as_str());
if let Some(secret) = &self.client_config.secret {
let timestamp = Utc::now().timestamp_millis();
let signature = sign(timestamp, url.as_str(), secret)?;
client = client.header("timestamp", timestamp.to_string());
client = client.header(
"Authorization",
format!("Apollo {}:{}", &self.client_config.app_id, signature),
);
}
Ok(client)
}
async fn execute_request(
&self,
client: reqwest::RequestBuilder,
) -> Result<reqwest::Response, Error> {
match client.send().await {
Ok(r) => Ok(r),
Err(e) => Err(Error::Reqwest(e)),
}
}
async fn parse_response(&self, response: reqwest::Response) -> Result<Value, Error> {
let body: String = match response.text().await {
Ok(b) => b,
Err(e) => return Err(Error::Reqwest(e)),
};
trace!("Response body {} for namespace {}", body, self.namespace);
match serde_json::from_str(&body) {
Ok(c) => Ok(c),
Err(e) => {
debug!("error parsing config: {e}");
Err(Error::Serde(e))
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn write_to_file_cache(&self, config: &Value) -> Result<(), Error> {
debug!("writing cache file {}", self.file_path.display());
if let Some(parent) = self.file_path.parent() {
std::fs::create_dir_all(parent)?;
}
let cache_item = CacheItem {
timestamp: Utc::now().timestamp(),
config: config.clone(),
};
let cache_content = serde_json::to_string(&cache_item)?;
std::fs::write(&self.file_path, cache_content)?;
trace!(
"Wrote cache file {} for namespace {}",
self.file_path.display(),
self.namespace
);
Ok(())
}
pub async fn add_listener(&self, listener: EventListener) {
let mut listeners = self.listeners.write().await;
listeners.push(listener);
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn wasm_cache_key(&self) -> &str {
&self.wasm_cache_key
}
}
type HmacSha1 = Hmac<Sha1>;
pub(crate) fn sign(timestamp: i64, url: &str, secret: &str) -> Result<String, Error> {
let u = match Url::parse(url) {
Ok(u) => u,
Err(e) => match e {
ParseError::RelativeUrlWithoutBase => {
let base_url = Url::parse("http://localhost:8080").unwrap();
base_url.join(url).unwrap()
}
_ => {
return Err(Error::UrlParse(e));
}
},
};
let mut path_and_query = String::from(u.path());
if let Some(query) = u.query() {
let _ = write!(path_and_query, "?{query}");
}
let input = format!("{timestamp}\n{path_and_query}");
trace!("input for signing: {input}");
let mut mac = HmacSha1::new_from_slice(secret.as_bytes()).unwrap();
mac.update(input.as_bytes());
let result: [u8; 20] = mac.finalize().into_bytes().into();
let code = Base64Display::new(&result, &base64::engine::general_purpose::STANDARD);
Ok(code.to_string())
}
#[cfg(target_arch = "wasm32")]
fn load_from_local_storage(key: &str) -> Option<String> {
let global = js_sys::global();
let storage = js_sys::Reflect::get(&global, &wasm_bindgen::JsValue::from_str("localStorage")).ok()?;
if storage.is_undefined() || storage.is_null() {
return None;
}
let get_item_fn = js_sys::Reflect::get(&storage, &wasm_bindgen::JsValue::from_str("getItem")).ok()?;
if get_item_fn.is_function() {
let args = js_sys::Array::of1(&wasm_bindgen::JsValue::from_str(key));
let result = js_sys::Reflect::apply(&get_item_fn.into(), &storage, &args).ok()?;
if !result.is_null() && !result.is_undefined() {
return result.as_string();
}
}
None
}
#[cfg(target_arch = "wasm32")]
fn save_to_local_storage(key: &str, value: &str) -> Option<()> {
let global = js_sys::global();
let storage = js_sys::Reflect::get(&global, &wasm_bindgen::JsValue::from_str("localStorage")).ok()?;
if storage.is_undefined() || storage.is_null() {
return None;
}
let set_item_fn = js_sys::Reflect::get(&storage, &wasm_bindgen::JsValue::from_str("setItem")).ok()?;
if set_item_fn.is_function() {
let args = js_sys::Array::of2(&wasm_bindgen::JsValue::from_str(key), &wasm_bindgen::JsValue::from_str(value));
let _ = js_sys::Reflect::apply(&set_item_fn.into(), &storage, &args).ok()?;
}
Some(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{TempDir, client_config::ClientConfig, setup};
use std::sync::Arc;
#[cfg(not(target_arch = "wasm32"))]
#[tokio::test]
async fn test_concurrent_get_value() {
setup();
let temp_dir = TempDir::new("apollo_concurrent_get_test");
let config = ClientConfig {
app_id: String::from("101010101"),
cluster: String::from("default"),
config_server: std::env::var("APOLLO_TEST_SERVER").unwrap_or_else(|_| String::from("http://localhost:8080")),
secret: None,
cache_dir: Some(temp_dir.path().to_str().unwrap().to_string()),
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 cache = Arc::new(Cache::new(
config,
"application",
reqwest::Client::new(),
));
let mut handles = Vec::new();
for _ in 0..10 {
let cache = cache.clone();
let handle = tokio::spawn(async move { cache.get_value().await });
handles.push(handle);
}
let results = futures::future::join_all(handles).await;
let first_result = results[0].as_ref().unwrap().as_ref().unwrap();
for result in &results {
let result = result.as_ref().unwrap().as_ref().unwrap();
assert_eq!(result, first_result);
}
}
#[test]
fn test_sign_with_path() {
let url = "/configs/100004458/default/application?ip=10.0.0.1";
let secret = "df23df3f59884980844ff3dada30fa97";
let signature = sign(1_576_478_257_344, url, secret).unwrap();
assert_eq!(signature, "EoKyziXvKqzHgwx+ijDJwgVTDgE=");
}
#[test]
fn test_sign_url() {
setup();
let url = "http://localhost:8080/configs/100004458/default/application?ip=10.0.0.1";
let secret = "df23df3f59884980844ff3dada30fa97";
let signature = sign(1_576_478_257_344, url, secret).unwrap();
assert_eq!(signature, "EoKyziXvKqzHgwx+ijDJwgVTDgE=");
}
}