From b543f06f2c1d60137e33032f21a5a09f47d9ce6d Mon Sep 17 00:00:00 2001 From: wiseaidev <oss@wiseai.dev> Date: Thu, 10 Apr 2025 08:37:01 +0300 Subject: [PATCH] feat: impl cache storage --- crates/storage/Cargo.toml | 4 +- crates/storage/src/cache_storage.rs | 145 ++++++++++++++++++++++++++ crates/storage/src/lib.rs | 30 ++++++ crates/storage/tests/cache_storage.rs | 103 ++++++++++++++++++ 4 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 crates/storage/src/cache_storage.rs create mode 100644 crates/storage/tests/cache_storage.rs diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 56172162..d63594e2 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -18,9 +18,11 @@ serde_json = "1.0" thiserror = "1.0" js-sys = "0.3" gloo-utils = { version = "0.2", path = "../utils" } +wasm-bindgen-futures = "0.4.50" + [dependencies.web-sys] version = "0.3" -features = ["Storage", "Window"] +features = ["Storage", "Window", "CacheStorage", "Cache", "Request", "Response"] [dev-dependencies] wasm-bindgen-test = "0.3" diff --git a/crates/storage/src/cache_storage.rs b/crates/storage/src/cache_storage.rs new file mode 100644 index 00000000..bffd20e0 --- /dev/null +++ b/crates/storage/src/cache_storage.rs @@ -0,0 +1,145 @@ +use crate::AsyncStorage; +use js_sys::Array; +use serde::de::Error; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::future::Future; +use wasm_bindgen::JsCast; +use wasm_bindgen::UnwrapThrowExt; +use wasm_bindgen_futures::JsFuture; +use web_sys::window; +use web_sys::{Cache, CacheStorage as WebCacheStorage, Request, Response}; + +use crate::errors::StorageError; +use crate::Result; + +/// Provides API to deal with `CacheStorage` +#[derive(Debug)] +pub struct CacheStorage; + +impl CacheStorage { + fn raw() -> WebCacheStorage { + window().expect_throw("no window").caches().unwrap_throw() + } + + fn make_request(url: &str) -> Result<Request> { + Request::new_with_str(&url) + .map_err(|e| StorageError::JsError(js_sys::Error::from(e).into())) + } + + async fn open_cache() -> Result<Cache> { + let promise = Self::raw().open("gloo-cache"); + let cache = JsFuture::from(promise) + .await + .map_err(|e| StorageError::JsError(js_sys::Error::from(e).into()))?; + Ok(Cache::from(cache)) + } + + async fn all_keys() -> Result<Vec<String>> { + let promise = Self::raw().keys(); + let js_value = JsFuture::from(promise) + .await + .map_err(|e| StorageError::JsError(js_sys::Error::from(e).into()))?; + let array: Array = js_value.dyn_into().unwrap_throw(); + Ok(array.iter().filter_map(|v| v.as_string()).collect()) + } +} + +impl AsyncStorage for CacheStorage { + fn get<T>(key: &str) -> impl Future<Output = Result<T>> + where + T: for<'de> Deserialize<'de> + 'static, + { + let key = key.to_string(); + async move { + let cache = Self::open_cache().await?; + let req = Self::make_request(&key)?; + + let match_promise = cache.match_with_request(&req); + let res_val = JsFuture::from(match_promise) + .await + .map_err(|e| StorageError::JsError(js_sys::Error::from(e).into()))?; + + if res_val.is_undefined() { + return Err(StorageError::KeyNotFound(key)); + } + + let response: Response = res_val.dyn_into().unwrap_throw(); + let text_promise = response + .text() + .map_err(|e| StorageError::JsError(js_sys::Error::from(e).into()))?; + let text = JsFuture::from(text_promise) + .await + .map_err(|e| StorageError::JsError(js_sys::Error::from(e).into()))? + .as_string() + .ok_or_else(|| { + StorageError::SerdeError(serde_json::Error::custom("Expected response text")) + })?; + + Ok(serde_json::from_str(&text)?) + } + } + + fn get_all<T>() -> impl Future<Output = Result<T>> + where + T: for<'de> Deserialize<'de> + 'static, + { + async move { + let keys = Self::all_keys().await?; + let mut map = Map::with_capacity(keys.len()); + for key in keys { + let val: Value = Self::get(&key).await?; + map.insert(key, val); + } + Ok(serde_json::from_value(Value::Object(map))?) + } + } + + fn set<T>(key: &str, value: T) -> impl Future<Output = Result<()>> + where + T: Serialize + 'static, + { + let key = key.to_string(); + async move { + let cache = Self::open_cache().await?; + let req = Self::make_request(&key)?; + let json = serde_json::to_string(&value)?; + let res = Response::new_with_opt_str(Some(&json)) + .map_err(|e| StorageError::JsError(js_sys::Error::from(e).into()))?; + + let put_promise = cache.put_with_request(&req, &res); + JsFuture::from(put_promise) + .await + .map_err(|e| StorageError::JsError(js_sys::Error::from(e).into()))?; + + Ok(()) + } + } + + fn delete(key: &str) -> impl Future<Output = Result<()>> { + let key = key.to_string(); + async move { + let cache = Self::open_cache().await?; + let req = Self::make_request(&key)?; + let delete_promise = cache.delete_with_request(&req); + JsFuture::from(delete_promise) + .await + .map_err(|e| StorageError::JsError(js_sys::Error::from(e).into()))?; + Ok(()) + } + } + + fn clear() -> impl Future<Output = Result<()>> { + async move { + let delete_promise = Self::raw().delete("gloo-cache"); + JsFuture::from(delete_promise) + .await + .map_err(|e| StorageError::JsError(js_sys::Error::from(e).into()))?; + Ok(()) + } + } + + fn length() -> impl Future<Output = Result<u32>> { + async move { Ok(Self::all_keys().await?.len() as u32) } + } +} diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index f5713f67..e2cfac3e 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -7,15 +7,18 @@ #![deny(missing_docs, missing_debug_implementations)] use serde::{Deserialize, Serialize}; +use std::future::Future; use wasm_bindgen::prelude::*; use crate::errors::js_to_error; use errors::StorageError; use serde_json::{Map, Value}; +mod cache_storage; pub mod errors; mod local_storage; mod session_storage; +pub use cache_storage::CacheStorage; pub use local_storage::LocalStorage; pub use session_storage::SessionStorage; @@ -95,3 +98,30 @@ pub trait Storage { .expect_throw("unreachable: length does not throw an exception") } } + +/// Trait for async cache-like storage +pub trait AsyncStorage { + /// Get a value by key + fn get<T>(key: &str) -> impl Future<Output = Result<T>> + where + T: for<'de> Deserialize<'de> + 'static; + + /// Get all keys/values as a deserialized map or struct + fn get_all<T>() -> impl Future<Output = Result<T>> + where + T: for<'de> Deserialize<'de> + 'static; + + /// Set a value by key + fn set<T>(key: &str, value: T) -> impl Future<Output = Result<()>> + where + T: Serialize + 'static; + + /// Delete a key + fn delete(key: &str) -> impl Future<Output = Result<()>>; + + /// Clear all keys + fn clear() -> impl Future<Output = Result<()>>; + + /// Get number of stored items + fn length() -> impl Future<Output = Result<u32>>; +} diff --git a/crates/storage/tests/cache_storage.rs b/crates/storage/tests/cache_storage.rs new file mode 100644 index 00000000..fa865161 --- /dev/null +++ b/crates/storage/tests/cache_storage.rs @@ -0,0 +1,103 @@ +use gloo_storage::AsyncStorage; +use gloo_storage::CacheStorage; +use serde::{Deserialize, Serialize}; +use wasm_bindgen_test::*; + +wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen_test] +async fn get_and_set() { + let key = "https://rustacean.net/assets/cuddlyferris.png"; + let value = "Ferris is cute 🦀"; + + CacheStorage::set(key, value).await.unwrap(); + let obtained: String = CacheStorage::get(key).await.unwrap(); + + assert_eq!(obtained, value); +} + +#[derive(Serialize, Deserialize)] +struct FerrisFacts { + cuteness: String, + power: String, +} + +#[wasm_bindgen_test] +async fn get_all() { + CacheStorage::set( + "https://rustacean.net/assets/cuddlyferris.png", + "Ferris is cute 🦀", + ) + .await + .unwrap(); + CacheStorage::set("power", "Ferris is King 👑") + .await + .unwrap(); + + let facts: serde_json::Value = CacheStorage::get_all().await.unwrap(); + + assert_eq!( + facts["https://rustacean.net/assets/cuddlyferris.png"], + "Ferris is cute 🦀" + ); + assert_eq!(facts["power"], "Ferris is King 👑"); +} + +#[wasm_bindgen_test] +async fn set_and_length() { + CacheStorage::clear().await.unwrap(); + + let len = CacheStorage::length().await.unwrap(); + assert_eq!(len, 0); + + CacheStorage::set( + "https://rustacean.net/assets/cuddlyferris.png", + "Trust the compiler, no cap 🧠", + ) + .await + .unwrap(); + let len = CacheStorage::length().await.unwrap(); + assert_eq!(len, 1); + + CacheStorage::clear().await.unwrap(); + let len = CacheStorage::length().await.unwrap(); + assert_eq!(len, 0); +} + +#[wasm_bindgen_test] +async fn delete_key() { + let key = "https://rustacean.net/assets/cuddlyferris.png"; + CacheStorage::set(key, "Goodbye, Ferris, see you tomorrow 😢") + .await + .unwrap(); + assert!(CacheStorage::get::<String>(key).await.is_ok()); + + CacheStorage::delete(key).await.unwrap(); + let result = CacheStorage::get::<String>(key).await; + + assert!(result.is_err()); +} + +#[wasm_bindgen_test] +async fn clear_storage() { + CacheStorage::set( + "https://rustacean.net/assets/cuddlyferris.png", + "Ferris remembers everything 🧠", + ) + .await + .unwrap(); + CacheStorage::set( + "https://rustacean.net/assets/cuddlyferris.png", + "Except when cleared, no cap 😅", + ) + .await + .unwrap(); + + let len_before = CacheStorage::length().await.unwrap(); + assert_eq!(len_before, 2); + + CacheStorage::clear().await.unwrap(); + + let len_after = CacheStorage::length().await.unwrap(); + assert_eq!(len_after, 0); +}