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}