origincloud_core/consistency.rs
1//! Where a [`Client`](crate::Client) keeps the consistency token it sends
2//! with its calls and replaces with the one each answer carries.
3//!
4//! By default each client has its own, a [`ConsistencyTokenCell`], shared
5//! by its clones. A program that wants several clients, or code outside the
6//! SDK, to share one token gives each the same store
7//! ([`ClientBuilder::consistency_token_store`](crate::ClientBuilder::consistency_token_store)),
8//! such as one [`ConsistencyTokenCell`] in an [`Arc`](std::sync::Arc), or
9//! an implementation of its own, such as one a browser tab's state holds.
10
11use std::fmt;
12use std::sync::Mutex;
13
14/// Holds the consistency token a client presents. The client reads it
15/// before each attempt of an authenticated call, replaces it with the token
16/// an answer carries, and [`remove`](Self::remove)s one the API refused.
17///
18/// It must be `Send + Sync` on every target, as [`Client`](crate::Client)
19/// is, so that a client can live in a browser app's reactive state. In a
20/// browser a store therefore holds the token in something `Send + Sync`,
21/// such as a `Mutex` or a reactive signal, and copies it to
22/// `web_sys::Storage` from there if it is to outlive the page, rather than
23/// wrapping the `Storage` itself, which isn't `Send`. Its methods are
24/// synchronous, and are called with no lock of the client's held.
25pub trait ConsistencyTokenStore: Send + Sync + fmt::Debug {
26 /// The token held; empty when none is.
27 fn get(&self) -> String;
28
29 /// Replaces the token held with `token`.
30 fn set(&self, token: String);
31
32 /// Replaces the token held with `answered`, the token the answer to a
33 /// call that presented `presented` (empty for none) carried. A store
34 /// that holds a token other than `presented` by then was written by a
35 /// concurrent call in between, and may keep one or the other as it
36 /// sees fit; the default [`set`](Self::set)s `answered`, so the token
37 /// answered last is kept.
38 fn replace(&self, presented: &str, answered: String) {
39 let _ = presented;
40 self.set(answered);
41 }
42
43 /// Forgets `refused`, a token the API refused to read, if it is still
44 /// the one held; a token that replaced it in the meantime stays. The
45 /// default reads and then sets the token, so it can lose a token set
46 /// in between; a store that can do it at once should.
47 fn remove(&self, refused: &str) {
48 if self.get() == refused {
49 self.set(String::new());
50 }
51 }
52}
53
54/// A consistency token in memory: what a client holds by default. Give one
55/// in an [`Arc`](std::sync::Arc) to several clients for them to share it.
56#[derive(Default)]
57pub struct ConsistencyTokenCell(Mutex<String>);
58
59impl ConsistencyTokenCell {
60 /// A cell holding no token.
61 pub fn new() -> Self {
62 Self::default()
63 }
64
65 fn lock(&self) -> std::sync::MutexGuard<'_, String> {
66 // A token is replaced whole, so one a panic left behind is whole.
67 self.0.lock().unwrap_or_else(|e| e.into_inner())
68 }
69}
70
71impl ConsistencyTokenStore for ConsistencyTokenCell {
72 fn get(&self) -> String {
73 self.lock().clone()
74 }
75
76 fn set(&self, token: String) {
77 *self.lock() = token;
78 }
79
80 fn remove(&self, refused: &str) {
81 let mut held = self.lock();
82 if *held == refused {
83 held.clear();
84 }
85 }
86}
87
88/// The token itself is left out: it is a credential of sorts, naming what
89/// the client wrote.
90impl fmt::Debug for ConsistencyTokenCell {
91 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92 f.debug_struct("ConsistencyTokenCell")
93 .field("holds_token", &!self.lock().is_empty())
94 .finish()
95 }
96}
97
98/// Why the API refuses a request whose `Origin-Consistency-Token` header
99/// isn't a token it issued (`MALFORMED_TOKEN` in the API's
100/// `oc-lib-http-api`), with status 400. The refusal comes before the
101/// request does anything, so the request is sent again without it.
102pub(crate) const MALFORMED_TOKEN: &str =
103 "the Origin-Consistency-Token header must be a token a response of this API carried";
104
105/// Whether `err` is the API refusing the consistency token presented, and
106/// nothing else: a 400 with exactly [`MALFORMED_TOKEN`] as its message.
107pub(crate) fn is_malformed_token(err: &crate::Error) -> bool {
108 err.api()
109 .is_some_and(|e| e.http_status == Some(400) && e.message == MALFORMED_TOKEN)
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115 use crate::Error;
116 use crate::error::ApiError;
117
118 #[test]
119 fn a_cell_forgets_a_refused_token_only_while_it_holds_it() {
120 let cell = ConsistencyTokenCell::new();
121 assert_eq!(cell.get(), "");
122 cell.set("v1.a".to_owned());
123 cell.remove("v1.b");
124 assert_eq!(cell.get(), "v1.a");
125 cell.remove("v1.a");
126 assert_eq!(cell.get(), "");
127 assert_eq!(
128 format!("{cell:?}"),
129 "ConsistencyTokenCell { holds_token: false }"
130 );
131 }
132
133 #[test]
134 fn the_default_remove_forgets_only_the_refused_token() {
135 #[derive(Debug, Default)]
136 struct Plain(Mutex<String>);
137 impl ConsistencyTokenStore for Plain {
138 fn get(&self) -> String {
139 self.0.lock().unwrap().clone()
140 }
141 fn set(&self, token: String) {
142 *self.0.lock().unwrap() = token;
143 }
144 }
145 let store = Plain::default();
146 store.set("v1.a".to_owned());
147 store.remove("v1.b");
148 assert_eq!(store.get(), "v1.a");
149 store.remove("v1.a");
150 assert_eq!(store.get(), "");
151 }
152
153 #[test]
154 fn only_the_malformed_token_refusal_is_one() {
155 let refusal = |status: u16, message: &str| {
156 Error::from(ApiError::from_response(
157 status,
158 serde_json::json!({"error": {"code": status, "status": "INVALID_ARGUMENT", "message": message}})
159 .to_string()
160 .as_bytes(),
161 ))
162 };
163 assert!(is_malformed_token(&refusal(400, MALFORMED_TOKEN)));
164 assert!(!is_malformed_token(&refusal(
165 400,
166 "the request body must be at most 1 bytes"
167 )));
168 assert!(!is_malformed_token(&refusal(
169 400,
170 &format!("{MALFORMED_TOKEN}.")
171 )));
172 assert!(!is_malformed_token(&refusal(409, MALFORMED_TOKEN)));
173 assert!(!is_malformed_token(&Error::NoCredentials));
174 }
175}