Skip to main content

topcoat_cookie/
store.rs

1use std::{borrow::Cow, marker::PhantomData};
2
3use serde::{Serialize, de::DeserializeOwned};
4
5use crate::{Cookie, Cookies};
6
7/// A typed value backed by a single cookie.
8///
9/// Holds a value of type `T` in memory. [`commit`](Self::commit) serializes it
10/// as JSON and writes it through the configured [`Cookies`] jar.
11///
12/// Reads and mutations affect only the in-memory value until you commit.
13/// Dropping the store or calling [`rollback`](Self::rollback) discards those
14/// changes. Call [`remove`](Self::remove) to delete the cookie instead.
15///
16/// Obtain one by reading the incoming cookie through [`cookie_store`]:
17///
18/// ```rust
19/// // src/app/api/cart.rs
20/// use serde::{Deserialize, Serialize};
21/// use topcoat::{
22///     Result,
23///     context::Cx,
24///     cookie::{cookie_store, private_cookies},
25///     router::route,
26/// };
27///
28/// #[derive(Default, Serialize, Deserialize)]
29/// struct Cart {
30///     items: Vec<String>,
31/// }
32///
33/// #[route(POST)]
34/// async fn add_item(cx: &Cx) -> Result<String> {
35///     // `commit` writes the cookie and hands the value back; without it the
36///     // change is discarded.
37///     let cart = cookie_store::<Cart, _>(private_cookies(cx), "cart")
38///         .parse_or_default()
39///         .update(|cart| cart.items.push("widget".to_owned()))
40///         .commit()?;
41///
42///     Ok(format!("{} items in cart", cart.items.len()))
43/// }
44/// ```
45pub struct CookieStore<T, J> {
46    jar: J,
47    key: Cow<'static, str>,
48    value: T,
49}
50
51impl<T, J> CookieStore<T, J>
52where
53    T: Serialize + DeserializeOwned,
54    J: Cookies,
55{
56    /// Builds a store around an already-known `value`.
57    ///
58    /// Most code instead goes through [`cookie_store`] and one of the `parse*`
59    /// methods, which read the existing cookie first.
60    pub fn new(jar: J, key: impl Into<Cow<'static, str>>, value: T) -> Self {
61        Self {
62            jar,
63            key: key.into(),
64            value,
65        }
66    }
67
68    /// Returns a reference to the in-memory value.
69    pub fn read(&self) -> &T {
70        &self.value
71    }
72
73    /// Returns a clone of the in-memory value.
74    pub fn get(&self) -> T
75    where
76        T: Clone,
77    {
78        self.value.clone()
79    }
80
81    /// Replaces the in-memory value, returning the store so calls can be chained.
82    ///
83    /// Like every mutation, this is not persisted until [`commit`](Self::commit).
84    #[must_use]
85    pub fn set(mut self, value: T) -> Self {
86        self.value = value;
87        self
88    }
89
90    /// Mutates the in-memory value in place, returning the store so calls can be
91    /// chained.
92    ///
93    /// Like every mutation, this is not persisted until [`commit`](Self::commit).
94    ///
95    /// ```rust
96    /// # use serde::{Deserialize, Serialize};
97    /// # use topcoat::{
98    /// #     Result,
99    /// #     context::Cx,
100    /// #     cookie::{cookie_store, private_cookies},
101    /// # };
102    /// # #[derive(Default, Serialize, Deserialize)]
103    /// # struct Cart {
104    /// #     items: Vec<String>,
105    /// # }
106    /// # fn example(cx: &Cx) -> Result<(), topcoat::Error> {
107    /// let cart = cookie_store::<Cart, _>(private_cookies(cx), "cart")
108    ///     .parse_or_default()
109    ///     .update(|cart| cart.items.push("widget".to_owned()))
110    ///     .commit()?;
111    /// #     Ok(())
112    /// # }
113    /// ```
114    #[must_use]
115    pub fn update<F>(mut self, f: F) -> Self
116    where
117        F: FnOnce(&mut T),
118    {
119        f(&mut self.value);
120        self
121    }
122
123    /// Serializes the current value, queues it on the backing jar as a
124    /// `Set-Cookie`, and returns the value.
125    ///
126    /// Call this after any work that must succeed before saving the value.
127    ///
128    /// # Errors
129    ///
130    /// Returns `Err` if serializing `T` to JSON fails.
131    pub fn commit(self) -> Result<T, serde_json::Error> {
132        let serialized = serde_json::to_string(&self.value)?;
133        self.jar.add(Cookie::new(self.key, serialized));
134        Ok(self.value)
135    }
136
137    /// Discards the store along with any uncommitted changes.
138    ///
139    /// Equivalent to dropping the store without calling [`commit`](Self::commit);
140    /// it exists to make that intent explicit at the call site.
141    pub fn rollback(self) {}
142
143    /// Queues a removal of the backing cookie, expiring it on the client.
144    ///
145    /// Drops the in-memory value and removes the cookie through the backing
146    /// jar. Use the same jar configuration as when writing so the browser can
147    /// match the cookie's name, path, and domain.
148    pub fn remove(self) {
149        self.jar.remove(Cookie::new(self.key, ""));
150    }
151}
152
153/// A [`CookieStore`] that has not yet read its backing cookie.
154///
155/// Created by [`cookie_store`]. Call one of the `parse*` methods to read and
156/// deserialize the cookie, yielding a [`CookieStore`] you can read and mutate.
157pub struct UnparsedCookieStore<T, J> {
158    jar: J,
159    key: Cow<'static, str>,
160    _marker: PhantomData<fn() -> T>,
161}
162
163impl<T, J> UnparsedCookieStore<T, J>
164where
165    T: Serialize + DeserializeOwned,
166    J: Cookies,
167{
168    /// Builds an unparsed store for the cookie named `key`, backed by `jar`.
169    ///
170    /// Usually called through [`cookie_store`].
171    pub fn new(jar: J, key: impl Into<Cow<'static, str>>) -> Self {
172        Self {
173            jar,
174            key: key.into(),
175            _marker: PhantomData,
176        }
177    }
178
179    /// Seeds a [`CookieStore`] with `value` without reading the existing cookie.
180    ///
181    /// Use this when the old value is not needed. Call
182    /// [`commit`](CookieStore::commit) on the returned store to save it.
183    ///
184    /// ```rust
185    /// # use serde::{Deserialize, Serialize};
186    /// # use topcoat::{
187    /// #     Result,
188    /// #     context::Cx,
189    /// #     cookie::{cookie_store, private_cookies},
190    /// # };
191    /// # #[derive(Default, Serialize, Deserialize)]
192    /// # struct Cart {
193    /// #     items: Vec<String>,
194    /// # }
195    /// # fn example(cx: &Cx) -> Result<(), topcoat::Error> {
196    /// cookie_store::<Cart, _>(private_cookies(cx), "cart")
197    ///     .set(Cart::default())
198    ///     .commit()?;
199    /// #     Ok(())
200    /// # }
201    /// ```
202    pub fn set(self, value: T) -> CookieStore<T, J> {
203        CookieStore::new(self.jar, self.key, value)
204    }
205
206    /// Queues a removal of the backing cookie without reading it first.
207    ///
208    /// Use this to delete the cookie regardless of its current contents.
209    pub fn remove(self) {
210        self.jar.remove(Cookie::new(self.key, ""));
211    }
212
213    /// Reads and deserializes the backing cookie.
214    ///
215    /// Returns `Ok(None)` when the cookie is absent, and `Err` when it is
216    /// present but cannot be deserialized.
217    ///
218    /// # Errors
219    ///
220    /// Returns `Err` if the cookie is present but its value cannot be
221    /// deserialized into `T`.
222    pub fn parse(self) -> Result<Option<CookieStore<T, J>>, serde_json::Error> {
223        match self.jar.get(&self.key) {
224            Some(cookie) => {
225                let value = serde_json::from_str(cookie.value())?;
226                Ok(Some(CookieStore::new(self.jar, self.key, value)))
227            }
228            None => Ok(None),
229        }
230    }
231
232    /// Parses the backing cookie, falling back to `default` when it is absent or
233    /// malformed.
234    pub fn parse_or(self, default: T) -> CookieStore<T, J> {
235        self.parse_or_else(move || default)
236    }
237
238    /// Parses the backing cookie, falling back to `f()` when it is absent or
239    /// malformed.
240    pub fn parse_or_else<F>(self, f: F) -> CookieStore<T, J>
241    where
242        F: FnOnce() -> T,
243    {
244        let value = match self.jar.get(&self.key) {
245            Some(cookie) => serde_json::from_str(cookie.value()).unwrap_or_else(|_| f()),
246            None => f(),
247        };
248        CookieStore::new(self.jar, self.key, value)
249    }
250
251    /// Parses the backing cookie, falling back to `T::default()` when it is
252    /// absent or malformed.
253    pub fn parse_or_default(self) -> CookieStore<T, J>
254    where
255        T: Default,
256    {
257        self.parse_or_else(T::default)
258    }
259}
260
261/// Builds an [`UnparsedCookieStore`] for the cookie named `key`, backed by `jar`.
262///
263/// The store uses the jar's configuration for every operation. Specify the
264/// stored type as `T`:
265///
266/// ```rust
267/// # use serde::{Deserialize, Serialize};
268/// # use topcoat::{
269/// #     Result,
270/// #     context::Cx,
271/// #     cookie::{cookie_store, private_cookies},
272/// # };
273/// # #[derive(Default, Serialize, Deserialize)]
274/// # struct Cart {
275/// #     items: Vec<String>,
276/// # }
277/// # fn example(cx: &Cx) -> Result<(), topcoat::Error> {
278/// let cart = cookie_store::<Cart, _>(private_cookies(cx), "cart").parse_or_default();
279/// #     Ok(())
280/// # }
281/// ```
282pub fn cookie_store<T, J>(jar: J, key: impl Into<Cow<'static, str>>) -> UnparsedCookieStore<T, J>
283where
284    T: Serialize + DeserializeOwned,
285    J: Cookies,
286{
287    UnparsedCookieStore::new(jar, key)
288}
289
290#[cfg(test)]
291mod tests {
292    use std::{cell::RefCell, collections::HashMap};
293
294    use http::{HeaderMap, Request, header};
295    use serde::{Deserialize, Serialize};
296    use topcoat_core::context::{Cx, CxTestBuilder};
297
298    use super::*;
299    use crate::{CookieJarCell, Key, cookies, write_cookies};
300
301    #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
302    struct Cart {
303        items: Vec<String>,
304    }
305
306    /// A minimal [`Cookies`] jar for testing the store in isolation: it serves
307    /// preset incoming cookies and records the cookies written through it.
308    #[derive(Default)]
309    struct MockJar {
310        incoming: HashMap<String, String>,
311        added: RefCell<Vec<Cookie<'static>>>,
312        removed: RefCell<Vec<Cookie<'static>>>,
313    }
314
315    impl MockJar {
316        fn with(pairs: &[(&str, &str)]) -> Self {
317            Self {
318                incoming: pairs
319                    .iter()
320                    .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
321                    .collect(),
322                ..Self::default()
323            }
324        }
325
326        fn added(&self) -> Vec<Cookie<'static>> {
327            self.added.borrow().clone()
328        }
329
330        fn removed(&self) -> Vec<Cookie<'static>> {
331            self.removed.borrow().clone()
332        }
333    }
334
335    impl Cookies for &MockJar {
336        fn get(&self, name: &str) -> Option<Cookie<'static>> {
337            self.incoming
338                .get(name)
339                .map(|value| Cookie::new(name.to_owned(), value.clone()))
340        }
341
342        fn add<C: Into<Cookie<'static>>>(&self, cookie: C) {
343            self.added.borrow_mut().push(cookie.into());
344        }
345
346        fn remove<C: Into<Cookie<'static>>>(&self, cookie: C) {
347            self.removed.borrow_mut().push(cookie.into());
348        }
349    }
350
351    #[test]
352    fn parse_reads_existing_value() {
353        let jar = MockJar::with(&[("cart", r#"{"items":["a","b"]}"#)]);
354        let store = cookie_store::<Cart, _>(&jar, "cart")
355            .parse()
356            .unwrap()
357            .expect("cookie is present");
358
359        assert_eq!(store.read().items, ["a", "b"]);
360    }
361
362    #[test]
363    fn parse_returns_none_when_absent() {
364        let jar = MockJar::with(&[]);
365        assert!(
366            cookie_store::<Cart, _>(&jar, "cart")
367                .parse()
368                .unwrap()
369                .is_none()
370        );
371    }
372
373    #[test]
374    fn parse_errors_on_malformed() {
375        let jar = MockJar::with(&[("cart", "not json")]);
376        assert!(cookie_store::<Cart, _>(&jar, "cart").parse().is_err());
377    }
378
379    #[test]
380    fn parse_or_default_falls_back_when_absent_or_malformed() {
381        let absent = MockJar::with(&[]);
382        assert_eq!(
383            cookie_store::<Cart, _>(&absent, "cart")
384                .parse_or_default()
385                .get(),
386            Cart::default()
387        );
388
389        // A malformed cookie is treated like a missing one, not an error.
390        let malformed = MockJar::with(&[("cart", "not json")]);
391        assert_eq!(
392            cookie_store::<Cart, _>(&malformed, "cart")
393                .parse_or_default()
394                .get(),
395            Cart::default()
396        );
397    }
398
399    #[test]
400    fn parse_or_and_parse_or_else_use_their_fallbacks() {
401        let jar = MockJar::with(&[]);
402        let from_value = cookie_store::<Cart, _>(&jar, "cart").parse_or(Cart {
403            items: vec!["x".to_owned()],
404        });
405        assert_eq!(from_value.read().items, ["x"]);
406
407        let from_closure = cookie_store::<Cart, _>(&jar, "cart").parse_or_else(|| Cart {
408            items: vec!["y".to_owned()],
409        });
410        assert_eq!(from_closure.read().items, ["y"]);
411    }
412
413    #[test]
414    fn commit_writes_serialized_cookie_and_returns_value() {
415        let jar = MockJar::with(&[]);
416        let cart = cookie_store::<Cart, _>(&jar, "cart")
417            .parse_or_default()
418            .update(|cart| cart.items.push("widget".to_owned()))
419            .commit()
420            .unwrap();
421
422        // The value is handed back.
423        assert_eq!(cart.items, ["widget"]);
424
425        // And serialized into the jar under the right name.
426        let added = jar.added();
427        assert_eq!(added.len(), 1);
428        assert_eq!(added[0].name(), "cart");
429        assert_eq!(added[0].value(), r#"{"items":["widget"]}"#);
430    }
431
432    #[test]
433    fn get_clones_and_read_borrows() {
434        let jar = MockJar::with(&[("cart", r#"{"items":["a"]}"#)]);
435        let store = cookie_store::<Cart, _>(&jar, "cart").parse_or_default();
436
437        assert_eq!(store.read().items, ["a"]);
438        assert_eq!(store.get(), *store.read());
439    }
440
441    #[test]
442    fn nothing_is_written_without_commit() {
443        let jar = MockJar::with(&[]);
444        let _ = cookie_store::<Cart, _>(&jar, "cart")
445            .parse_or_default()
446            .update(|cart| cart.items.push("z".to_owned()));
447
448        assert_eq!(jar.added(), Vec::<Cookie<'_>>::new());
449    }
450
451    #[test]
452    fn rollback_writes_nothing() {
453        let jar = MockJar::with(&[]);
454        cookie_store::<Cart, _>(&jar, "cart")
455            .parse_or_default()
456            .update(|cart| cart.items.push("z".to_owned()))
457            .rollback();
458
459        assert_eq!(jar.added(), Vec::<Cookie<'_>>::new());
460    }
461
462    #[test]
463    fn unparsed_set_overwrites_without_reading() {
464        let jar = MockJar::with(&[("cart", r#"{"items":["old"]}"#)]);
465        cookie_store::<Cart, _>(&jar, "cart")
466            .set(Cart {
467                items: vec!["new".to_owned()],
468            })
469            .commit()
470            .unwrap();
471
472        assert_eq!(jar.added()[0].value(), r#"{"items":["new"]}"#);
473    }
474
475    #[test]
476    fn remove_queues_a_removal() {
477        // On a parsed store.
478        let parsed = MockJar::with(&[("cart", r#"{"items":[]}"#)]);
479        cookie_store::<Cart, _>(&parsed, "cart")
480            .parse_or_default()
481            .remove();
482        let removed = parsed.removed();
483        assert_eq!(removed.len(), 1);
484        assert_eq!(removed[0].name(), "cart");
485        assert_eq!(parsed.added(), Vec::<Cookie<'_>>::new());
486
487        // And directly on the unparsed store.
488        let unparsed = MockJar::with(&[]);
489        cookie_store::<Cart, _>(&unparsed, "cart").remove();
490        assert_eq!(unparsed.removed()[0].name(), "cart");
491    }
492
493    /// Builds a `Cx` whose request carries the given raw `Cookie` header values.
494    fn cx_with(cookie_headers: &[&str]) -> Cx {
495        let mut builder = Request::builder();
496        for value in cookie_headers {
497            builder = builder.header(header::COOKIE, *value);
498        }
499        let (parts, ()) = builder.body(()).unwrap().into_parts();
500
501        CxTestBuilder::new()
502            .request_context(parts)
503            .request_context(CookieJarCell::new())
504            .build()
505    }
506
507    /// The leading `name=value` pair of the request's first `Set-Cookie` value.
508    fn echoed_pair(cx: &Cx) -> String {
509        let mut headers = HeaderMap::new();
510        write_cookies(cx, &mut headers);
511        let set = headers
512            .get(header::SET_COOKIE)
513            .unwrap()
514            .to_str()
515            .unwrap()
516            .to_owned();
517        set.split(';').next().unwrap().to_owned()
518    }
519
520    #[test]
521    fn round_trips_through_a_signed_jar() {
522        // Proves the store's serialization composes with the jar's layers: write
523        // through a signed jar, then read the echoed cookie back through one.
524        let key = Key::generate();
525
526        let writer = cx_with(&[]);
527        cookie_store::<Cart, _>(cookies(&writer).signed(&key), "cart")
528            .set(Cart {
529                items: vec!["widget".to_owned()],
530            })
531            .commit()
532            .unwrap();
533        let echoed = echoed_pair(&writer);
534
535        let reader = cx_with(&[&echoed]);
536        let cart = cookie_store::<Cart, _>(cookies(&reader).signed(&key), "cart")
537            .parse()
538            .unwrap()
539            .expect("the signed cookie should verify and deserialize");
540
541        assert_eq!(cart.read().items, ["widget"]);
542    }
543}