Skip to main content

hpr_net/
client.rs

1//! The client: the cache in front of a transport, and the rule for when the transport is called.
2
3use std::cell::Cell;
4use std::collections::BTreeMap;
5use std::path::PathBuf;
6
7use serde::{Deserialize, Serialize};
8
9use crate::{Cache, NetError};
10
11/// Whether the client may use the network.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13pub enum Mode {
14    /// Fetch when the cache holds no fresh copy.
15    Online,
16    /// Never call the transport; answer from the cache or fail with [`NetError::NotCached`].
17    Offline,
18}
19
20/// Something that fetches a URL's bytes: `Http` with the `http` feature, [`Replay`]'s recorded
21/// responses in tests.
22pub trait Transport {
23    /// The body at `url`.
24    ///
25    /// # Errors
26    /// A short reason, which the client wraps in [`NetError::Transport`].
27    fn get(&self, url: &str) -> Result<Vec<u8>, String>;
28}
29
30/// One online data source: its name, the credit its terms ask for, and how long a copy stays fresh.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct Source {
33    /// A short name, such as `"Open-Meteo"`.
34    pub name: String,
35    /// The attribution to show wherever its data is shown, such as `"Weather data by Open-Meteo.com
36    /// (CC BY 4.0)"`.
37    pub attribution: String,
38    /// How long a cached copy counts as fresh, in seconds.
39    pub ttl_s: u64,
40}
41
42/// How a [`Fetched`] body relates to its source.
43#[non_exhaustive]
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45pub enum Freshness {
46    /// Fetched by this call.
47    Fetched,
48    /// From the cache, younger than the source's TTL.
49    Cached,
50    /// From the cache, older than the TTL: offline, or the fetch failed or its answer was refused.
51    Stale,
52}
53
54/// A body and where it came from.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct Fetched {
57    /// The response body.
58    pub body: Vec<u8>,
59    /// When it was fetched, in seconds since the Unix epoch.
60    pub fetched_at_s: u64,
61    /// Fetched now, cached and fresh, or cached and stale.
62    pub freshness: Freshness,
63    /// The source's attribution, to show with the data.
64    pub attribution: String,
65    /// Online, why the fetch failed, or why its answer was refused, when a stale copy was returned
66    /// in its place.
67    pub stale_reason: Option<String>,
68}
69
70/// A cache in front of a transport.
71#[derive(Debug)]
72pub struct Client<T> {
73    transport: T,
74    cache: Cache,
75    mode: Mode,
76}
77
78impl<T: Transport> Client<T> {
79    /// A client over `transport` and `cache`, in `mode`.
80    pub fn new(transport: T, cache: Cache, mode: Mode) -> Self {
81        Self {
82            transport,
83            cache,
84            mode,
85        }
86    }
87
88    /// The mode the client is in.
89    pub fn mode(&self) -> Mode {
90        self.mode
91    }
92
93    /// The body at `url` from `source`, at time `now_s` (seconds since the Unix epoch).
94    ///
95    /// A cached copy younger than `source.ttl_s` is returned as [`Freshness::Cached`] without a
96    /// fetch. Otherwise, online, the transport is called and its body cached; if it fails, a stale
97    /// copy is returned as [`Freshness::Stale`] when there is one. Offline, the transport is never
98    /// called: any cached copy is returned, [`Freshness::Stale`] if it is past its TTL.
99    ///
100    /// A copy dated after `now_s` is never fresh. Online, a cache entry that cannot be read is
101    /// treated as missing and overwritten by the fetch.
102    ///
103    /// # Errors
104    /// [`NetError::NotCached`] offline with no copy; [`NetError::Transport`] online when the fetch
105    /// fails with no copy; cache errors as [`Cache::get`] and [`Cache::put`] give them.
106    pub fn fetch(&self, source: &Source, url: &str, now_s: u64) -> Result<Fetched, NetError> {
107        self.fetch_checked(source, url, now_s, |_| Ok(()))
108    }
109
110    /// As [`Client::fetch`], but only a body that `check` accepts is cached or returned: a data
111    /// source passes its parser, so an answer it can't read (an error page served as 200, a
112    /// forecast with its hours still empty) is never kept in place of a good copy.
113    ///
114    /// Online, a fetched body the check refuses is not cached; a stale copy is returned in its
115    /// place, with the reason, when there is one. A cached copy the check refuses counts as
116    /// missing online, and is the error offline.
117    ///
118    /// # Errors
119    /// As [`Client::fetch`], and [`NetError::Refused`] when the check refuses the only body there
120    /// is.
121    pub fn fetch_checked(
122        &self,
123        source: &Source,
124        url: &str,
125        now_s: u64,
126        check: impl Fn(&[u8]) -> Result<(), String>,
127    ) -> Result<Fetched, NetError> {
128        let refused = |reason: String| NetError::Refused {
129            url: url.to_owned(),
130            reason,
131        };
132        // Online, an unreadable entry is a miss: the fetch below overwrites it. Offline it is the
133        // error, since there is nothing else to answer with. The same goes for a copy the check
134        // refuses.
135        let cached = match (self.cache.get(url), self.mode) {
136            (Ok(Some(entry)), mode) => match (check(&entry.body), mode) {
137                (Ok(()), _) => Some(entry),
138                (Err(_), Mode::Online) => None,
139                (Err(reason), Mode::Offline) => return Err(refused(reason)),
140            },
141            (Ok(None), _) | (Err(_), Mode::Online) => None,
142            (Err(e), Mode::Offline) => return Err(e),
143        };
144        let answer =
145            |entry: crate::CacheEntry, fresh: bool, stale_reason: Option<String>| Fetched {
146                body: entry.body,
147                fetched_at_s: entry.fetched_at_s,
148                freshness: if fresh {
149                    Freshness::Cached
150                } else {
151                    Freshness::Stale
152                },
153                attribution: source.attribution.clone(),
154                stale_reason,
155            };
156        // A copy dated after `now_s` (saved while the clock ran fast) is not fresh.
157        let is_fresh = |entry: &crate::CacheEntry| {
158            entry.fetched_at_s <= now_s && now_s - entry.fetched_at_s < source.ttl_s
159        };
160        if let Some(entry) = cached {
161            if is_fresh(&entry) {
162                return Ok(answer(entry, true, None));
163            }
164            if self.mode == Mode::Offline {
165                return Ok(answer(entry, false, None));
166            }
167            return match self.transport.get(url).map(|body| (check(&body), body)) {
168                Ok((Ok(()), body)) => self.store(source, url, body, now_s),
169                Ok((Err(reason), _)) => Ok(answer(
170                    entry,
171                    false,
172                    Some(format!("the answer was refused: {reason}")),
173                )),
174                Err(reason) => Ok(answer(entry, false, Some(reason))),
175            };
176        }
177        if self.mode == Mode::Offline {
178            return Err(NetError::NotCached {
179                url: url.to_owned(),
180            });
181        }
182        let body = self
183            .transport
184            .get(url)
185            .map_err(|reason| NetError::Transport {
186                url: url.to_owned(),
187                reason,
188            })?;
189        check(&body).map_err(refused)?;
190        self.store(source, url, body, now_s)
191    }
192
193    fn store(
194        &self,
195        source: &Source,
196        url: &str,
197        body: Vec<u8>,
198        now_s: u64,
199    ) -> Result<Fetched, NetError> {
200        self.cache.put(url, &body, now_s)?;
201        Ok(Fetched {
202            body,
203            fetched_at_s: now_s,
204            freshness: Freshness::Fetched,
205            attribution: source.attribution.clone(),
206            stale_reason: None,
207        })
208    }
209}
210
211/// A transport that replays recorded responses from a directory, for tests and offline demos.
212///
213/// The directory holds `index.json`, an object from URL to a file name in the same directory. A
214/// URL not in the index fails like a network error. It counts its calls, so a test can assert
215/// that none were made.
216#[derive(Debug)]
217pub struct Replay {
218    dir: PathBuf,
219    index: BTreeMap<String, String>,
220    calls: Cell<usize>,
221}
222
223impl Replay {
224    /// Reads `dir/index.json`.
225    ///
226    /// # Errors
227    /// [`NetError::Cache`] if the index cannot be read; [`NetError::CorruptEntry`] if it does not
228    /// parse.
229    pub fn open(dir: impl Into<PathBuf>) -> Result<Self, NetError> {
230        let dir = dir.into();
231        let path = dir.join("index.json");
232        let bytes = std::fs::read(&path).map_err(|source| NetError::Cache {
233            path: path.clone(),
234            source,
235        })?;
236        let index = serde_json::from_slice(&bytes).map_err(|e| NetError::CorruptEntry {
237            path,
238            reason: e.to_string(),
239        })?;
240        Ok(Self {
241            dir,
242            index,
243            calls: Cell::new(0),
244        })
245    }
246
247    /// How many times [`Transport::get`] has been called.
248    pub fn calls(&self) -> usize {
249        self.calls.get()
250    }
251}
252
253impl Transport for Replay {
254    fn get(&self, url: &str) -> Result<Vec<u8>, String> {
255        self.calls.set(self.calls.get() + 1);
256        let file = self
257            .index
258            .get(url)
259            .ok_or_else(|| format!("no recording of {url}"))?;
260        std::fs::read(self.dir.join(file)).map_err(|e| e.to_string())
261    }
262}
263
264impl<T: Transport + ?Sized> Transport for &T {
265    fn get(&self, url: &str) -> Result<Vec<u8>, String> {
266        (**self).get(url)
267    }
268}