use std::{borrow::Cow, marker::PhantomData};
use serde::{Serialize, de::DeserializeOwned};
use crate::{Cookie, Cookies};
pub struct CookieStore<T, J> {
jar: J,
key: Cow<'static, str>,
value: T,
}
impl<T, J> CookieStore<T, J>
where
T: Serialize + DeserializeOwned,
J: Cookies,
{
pub fn new(jar: J, key: impl Into<Cow<'static, str>>, value: T) -> Self {
Self {
jar,
key: key.into(),
value,
}
}
pub fn read(&self) -> &T {
&self.value
}
pub fn get(&self) -> T
where
T: Clone,
{
self.value.clone()
}
#[must_use]
pub fn set(mut self, value: T) -> Self {
self.value = value;
self
}
#[must_use]
pub fn update<F>(mut self, f: F) -> Self
where
F: FnOnce(&mut T),
{
f(&mut self.value);
self
}
pub fn commit(self) -> Result<T, serde_json::Error> {
let serialized = serde_json::to_string(&self.value)?;
self.jar.add(Cookie::new(self.key, serialized));
Ok(self.value)
}
pub fn rollback(self) {}
pub fn remove(self) {
self.jar.remove(Cookie::new(self.key, ""));
}
}
pub struct UnparsedCookieStore<T, J> {
jar: J,
key: Cow<'static, str>,
_marker: PhantomData<fn() -> T>,
}
impl<T, J> UnparsedCookieStore<T, J>
where
T: Serialize + DeserializeOwned,
J: Cookies,
{
pub fn new(jar: J, key: impl Into<Cow<'static, str>>) -> Self {
Self {
jar,
key: key.into(),
_marker: PhantomData,
}
}
pub fn set(self, value: T) -> CookieStore<T, J> {
CookieStore::new(self.jar, self.key, value)
}
pub fn remove(self) {
self.jar.remove(Cookie::new(self.key, ""));
}
pub fn parse(self) -> Result<Option<CookieStore<T, J>>, serde_json::Error> {
match self.jar.get(&self.key) {
Some(cookie) => {
let value = serde_json::from_str(cookie.value())?;
Ok(Some(CookieStore::new(self.jar, self.key, value)))
}
None => Ok(None),
}
}
pub fn parse_or(self, default: T) -> CookieStore<T, J> {
self.parse_or_else(move || default)
}
pub fn parse_or_else<F>(self, f: F) -> CookieStore<T, J>
where
F: FnOnce() -> T,
{
let value = match self.jar.get(&self.key) {
Some(cookie) => serde_json::from_str(cookie.value()).unwrap_or_else(|_| f()),
None => f(),
};
CookieStore::new(self.jar, self.key, value)
}
pub fn parse_or_default(self) -> CookieStore<T, J>
where
T: Default,
{
self.parse_or_else(T::default)
}
}
pub fn cookie_store<T, J>(jar: J, key: impl Into<Cow<'static, str>>) -> UnparsedCookieStore<T, J>
where
T: Serialize + DeserializeOwned,
J: Cookies,
{
UnparsedCookieStore::new(jar, key)
}
#[cfg(test)]
mod tests {
use std::{cell::RefCell, collections::HashMap};
use http::{HeaderMap, Request, header};
use serde::{Deserialize, Serialize};
use topcoat_core::context::{Cx, CxTestBuilder};
use super::*;
use crate::{CookieJarCell, Key, cookies, write_cookies};
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
struct Cart {
items: Vec<String>,
}
#[derive(Default)]
struct MockJar {
incoming: HashMap<String, String>,
added: RefCell<Vec<Cookie<'static>>>,
removed: RefCell<Vec<Cookie<'static>>>,
}
impl MockJar {
fn with(pairs: &[(&str, &str)]) -> Self {
Self {
incoming: pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect(),
..Self::default()
}
}
fn added(&self) -> Vec<Cookie<'static>> {
self.added.borrow().clone()
}
fn removed(&self) -> Vec<Cookie<'static>> {
self.removed.borrow().clone()
}
}
impl Cookies for &MockJar {
fn get(&self, name: &str) -> Option<Cookie<'static>> {
self.incoming
.get(name)
.map(|value| Cookie::new(name.to_owned(), value.clone()))
}
fn add<C: Into<Cookie<'static>>>(&self, cookie: C) {
self.added.borrow_mut().push(cookie.into());
}
fn remove<C: Into<Cookie<'static>>>(&self, cookie: C) {
self.removed.borrow_mut().push(cookie.into());
}
}
#[test]
fn parse_reads_existing_value() {
let jar = MockJar::with(&[("cart", r#"{"items":["a","b"]}"#)]);
let store = cookie_store::<Cart, _>(&jar, "cart")
.parse()
.unwrap()
.expect("cookie is present");
assert_eq!(store.read().items, ["a", "b"]);
}
#[test]
fn parse_returns_none_when_absent() {
let jar = MockJar::with(&[]);
assert!(
cookie_store::<Cart, _>(&jar, "cart")
.parse()
.unwrap()
.is_none()
);
}
#[test]
fn parse_errors_on_malformed() {
let jar = MockJar::with(&[("cart", "not json")]);
assert!(cookie_store::<Cart, _>(&jar, "cart").parse().is_err());
}
#[test]
fn parse_or_default_falls_back_when_absent_or_malformed() {
let absent = MockJar::with(&[]);
assert_eq!(
cookie_store::<Cart, _>(&absent, "cart")
.parse_or_default()
.get(),
Cart::default()
);
let malformed = MockJar::with(&[("cart", "not json")]);
assert_eq!(
cookie_store::<Cart, _>(&malformed, "cart")
.parse_or_default()
.get(),
Cart::default()
);
}
#[test]
fn parse_or_and_parse_or_else_use_their_fallbacks() {
let jar = MockJar::with(&[]);
let from_value = cookie_store::<Cart, _>(&jar, "cart").parse_or(Cart {
items: vec!["x".to_owned()],
});
assert_eq!(from_value.read().items, ["x"]);
let from_closure = cookie_store::<Cart, _>(&jar, "cart").parse_or_else(|| Cart {
items: vec!["y".to_owned()],
});
assert_eq!(from_closure.read().items, ["y"]);
}
#[test]
fn commit_writes_serialized_cookie_and_returns_value() {
let jar = MockJar::with(&[]);
let cart = cookie_store::<Cart, _>(&jar, "cart")
.parse_or_default()
.update(|cart| cart.items.push("widget".to_owned()))
.commit()
.unwrap();
assert_eq!(cart.items, ["widget"]);
let added = jar.added();
assert_eq!(added.len(), 1);
assert_eq!(added[0].name(), "cart");
assert_eq!(added[0].value(), r#"{"items":["widget"]}"#);
}
#[test]
fn get_clones_and_read_borrows() {
let jar = MockJar::with(&[("cart", r#"{"items":["a"]}"#)]);
let store = cookie_store::<Cart, _>(&jar, "cart").parse_or_default();
assert_eq!(store.read().items, ["a"]);
assert_eq!(store.get(), *store.read());
}
#[test]
fn nothing_is_written_without_commit() {
let jar = MockJar::with(&[]);
let _ = cookie_store::<Cart, _>(&jar, "cart")
.parse_or_default()
.update(|cart| cart.items.push("z".to_owned()));
assert!(jar.added().is_empty());
}
#[test]
fn rollback_writes_nothing() {
let jar = MockJar::with(&[]);
cookie_store::<Cart, _>(&jar, "cart")
.parse_or_default()
.update(|cart| cart.items.push("z".to_owned()))
.rollback();
assert!(jar.added().is_empty());
}
#[test]
fn unparsed_set_overwrites_without_reading() {
let jar = MockJar::with(&[("cart", r#"{"items":["old"]}"#)]);
cookie_store::<Cart, _>(&jar, "cart")
.set(Cart {
items: vec!["new".to_owned()],
})
.commit()
.unwrap();
assert_eq!(jar.added()[0].value(), r#"{"items":["new"]}"#);
}
#[test]
fn remove_queues_a_removal() {
let parsed = MockJar::with(&[("cart", r#"{"items":[]}"#)]);
cookie_store::<Cart, _>(&parsed, "cart")
.parse_or_default()
.remove();
let removed = parsed.removed();
assert_eq!(removed.len(), 1);
assert_eq!(removed[0].name(), "cart");
assert!(parsed.added().is_empty());
let unparsed = MockJar::with(&[]);
cookie_store::<Cart, _>(&unparsed, "cart").remove();
assert_eq!(unparsed.removed()[0].name(), "cart");
}
fn cx_with(cookie_headers: &[&str]) -> Cx {
let mut builder = Request::builder();
for value in cookie_headers {
builder = builder.header(header::COOKIE, *value);
}
let (parts, ()) = builder.body(()).unwrap().into_parts();
CxTestBuilder::new()
.request_context(parts)
.request_context(CookieJarCell::new())
.build()
}
fn echoed_pair(cx: &Cx) -> String {
let mut headers = HeaderMap::new();
write_cookies(cx, &mut headers);
let set = headers
.get(header::SET_COOKIE)
.unwrap()
.to_str()
.unwrap()
.to_owned();
set.split(';').next().unwrap().to_owned()
}
#[test]
fn round_trips_through_a_signed_jar() {
let key = Key::generate();
let writer = cx_with(&[]);
cookie_store::<Cart, _>(cookies(&writer).signed(&key), "cart")
.set(Cart {
items: vec!["widget".to_owned()],
})
.commit()
.unwrap();
let echoed = echoed_pair(&writer);
let reader = cx_with(&[&echoed]);
let cart = cookie_store::<Cart, _>(cookies(&reader).signed(&key), "cart")
.parse()
.unwrap()
.expect("the signed cookie should verify and deserialize");
assert_eq!(cart.read().items, ["widget"]);
}
}