static_http_cache/
lib.rs

1#![doc(html_root_url = "https://docs.rs/static_http_cache/0.3.0")]
2//! Introduction
3//! ============
4//!
5//! `static_http_cache` is a local cache for static HTTP resources.
6//!
7//! This library maintains a cache of HTTP resources
8//! in a local directory you specify.
9//! Whenever you ask it for the contents of a URL,
10//! it will re-use a previously-downloaded copy
11//! if the resource has not changed on the server.
12//! Otherwise,
13//! it will download the new version and use that instead.
14//!
15//! Because it only supports static resources,
16//! `static_http_cache` only sends HTTP `GET` requests.
17//!
18//! `static_http_cache` uses the [Reqwest][rq] crate for HTTP operations,
19//! so it should properly handle HTTPS negotiation
20//! and use the operating-system's certificate store.
21//!
22//! Currently,
23//! `static_http_cache` only uses the `Last-Modified` and `ETag` HTTP headers
24//! to determine when its cached data is out of date.
25//! Therefore,
26//! it's not suitable for general-purpose HTTP caching;
27//! it's best suited for static content like Amazon S3 data,
28//! or Apache or nginx serving up a filesystem directory.
29//!
30//! [rq]: https://crates.io/crates/reqwest
31//!
32//! First Example
33//! =============
34//!
35//! To use this crate, you need to construct a [`Cache`]
36//! then call its [`get`] method:
37//!
38//!     extern crate reqwest;
39//!     extern crate static_http_cache;
40//!
41//!     use std::error::Error;
42//!     use std::fs::File;
43//!     use std::path::PathBuf;
44//!
45//!     fn get_my_resource() -> Result<File, Box<dyn Error>> {
46//!         let mut cache = static_http_cache::Cache::new(
47//!             PathBuf::from("my_cache_directory"),
48//!             reqwest::blocking::Client::new(),
49//!         )?;
50//!
51//!         cache.get(reqwest::Url::parse("http://example.com/some-resource")?)
52//!     }
53//!
54//! For repeated queries in the same program,
55//! you'd probably want to create the `Cache` once
56//! and call `get` repeatedly,
57//! of course.
58//!
59//! [`Cache`]: struct.Cache.html
60//! [`get`]: struct.Cache.html#method.get
61//!
62//! For a complete, minimal example of how to use `static_http_cache`,
63//! see the included [simple example][ex].
64//!
65//! [ex]: https://gitlab.com/Screwtapello/static_http_cache/blob/master/examples/simple.rs
66//!
67//! Capabilities
68//! ============
69//!
70//! Alternative HTTP backends
71//! -------------------------
72//!
73//! Although `static_http_cache` is designed to work with the `reqwest` library,
74//! it will accept any type that implements
75//! the traits in the [`reqwest_mock`] module.
76//! If you want to use it with an alternative HTTP backend,
77//! or if you need to stub out network access for testing purposes,
78//! you can do that.
79//!
80//! [`reqwest_mock`]: reqwest_mock/index.html
81//!
82//! Concurrent cache sharing
83//! ------------------------
84//!
85//! Cache metadata is stored in a SQLite database,
86//! so it's safe to give different threads
87//! (or even different processes)
88//! their own [`Cache`] instance
89//! backed by the same filesystem path.
90//!
91//! Note that while it's *safe* to have multiple things
92//! managing the same cache,
93//! it's not necessarily performant:
94//! a [`Cache`] instance that's downloading a new or updated file
95//! is likely to stall other cache reads or writes
96//! until it's complete.
97
98use std::error;
99use std::fs;
100use std::io;
101use std::path;
102
103use log::{debug, info, warn};
104use rand::distributions::Alphanumeric;
105use rand::{thread_rng, Rng};
106use reqwest::header as rh;
107
108pub mod reqwest_mock;
109
110mod db;
111
112fn make_random_file<P: AsRef<path::Path>>(
113    parent: P,
114) -> Result<(fs::File, path::PathBuf), Box<dyn error::Error>> {
115    let mut rng = thread_rng();
116
117    loop {
118        let new_path = parent.as_ref().join(
119            (0..20)
120                .map(|_| rng.sample(Alphanumeric) as char)
121                .collect::<String>(),
122        );
123
124        match fs::OpenOptions::new()
125            .create_new(true)
126            .write(true)
127            .open(&new_path)
128        {
129            Ok(handle) => return Ok((handle, new_path)),
130            Err(e) => {
131                if e.kind() != io::ErrorKind::AlreadyExists {
132                    // An actual error, we'd better report it!
133                    return Err(e.into());
134                }
135
136                // Otherwise, we just picked a bad name. Let's go back
137                // around the loop and try again.
138            }
139        };
140    }
141}
142
143fn header_as_string(
144    headers: &rh::HeaderMap,
145    key: &rh::HeaderName,
146) -> Option<String> {
147    headers.get(key).and_then(|value| match value.to_str() {
148        Ok(s) => Some(s.into()),
149        Err(err) => {
150            warn!("Header {} contained weird value: {}", key, err);
151            None
152        }
153    })
154}
155
156/// Represents a local cache of HTTP resources.
157///
158/// Whenever you ask it for the contents of a URL,
159/// it will re-use a previously-downloaded copy
160/// if the resource has not changed on the server.
161/// Otherwise,
162/// it will download the new version and use that instead.
163///
164/// See [an example](index.html#first-example).
165///
166/// [`reqwest_mock::Client`]: reqwest_mock/trait.Client.html
167/// [`Cache`]: struct.Cache.html
168#[derive(Debug, PartialEq, Eq)]
169pub struct Cache<C: reqwest_mock::Client> {
170    root: path::PathBuf,
171    db: db::CacheDB,
172    client: C,
173}
174
175impl<C: reqwest_mock::Client> Cache<C> {
176    /// Returns a Cache that wraps `client` and caches data in `root`.
177    ///
178    /// If the directory `root` does not exist, it will be created.
179    /// If multiple instances share the same `root`
180    /// (concurrently or in series),
181    /// each instance will be able to re-use resources downloaded by
182    /// the others.
183    ///
184    /// For best results,
185    /// choose a `root` that is directly attached to
186    /// the computer running your program,
187    /// such as somewhere inside the `%LOCALAPPDATA%` directory on Windows,
188    /// or the `$XDG_CACHE_HOME` directory on POSIX systems.
189    ///
190    /// `client` should almost certainly be a `reqwest::Client`,
191    /// but you can use any type that implements [`reqwest_mock::Client`]
192    /// if you want to use a different HTTP client library
193    /// or a test double of some kind.
194    ///
195    ///     # extern crate reqwest;
196    ///     # extern crate static_http_cache;
197    ///     # use std::error::Error;
198    ///     # use std::fs::File;
199    ///     # use std::path::PathBuf;
200    ///     # fn get_my_resource() -> Result<(), Box<dyn Error>> {
201    ///     let mut cache = static_http_cache::Cache::new(
202    ///         PathBuf::from("my_cache_directory"),
203    ///         reqwest::blocking::Client::new(),
204    ///     )?;
205    ///     # Ok(())
206    ///     # }
207    ///
208    /// [`reqwest_mock::Client`]: reqwest_mock/trait.Client.html
209    ///
210    /// Errors
211    /// ======
212    ///
213    /// This method may return an error:
214    ///
215    ///   - if `root` cannot be created, or cannot be written to
216    ///   - if the metadata database cannot be created or cannot be written to
217    ///   - if the metadata database is corrupt
218    ///
219    /// In all cases, it should be safe to blow away the entire directory
220    /// and start from scratch.
221    /// It's only cached data, after all.
222    pub fn new(
223        root: path::PathBuf,
224        client: C,
225    ) -> Result<Cache<C>, Box<dyn error::Error>> {
226        fs::DirBuilder::new().recursive(true).create(&root)?;
227
228        let db = db::CacheDB::new(root.join("cache.db"))?;
229
230        Ok(Cache { root, db, client })
231    }
232
233    fn record_response(
234        &mut self,
235        url: reqwest::Url,
236        response: &C::Response,
237    ) -> Result<(fs::File, path::PathBuf, db::Transaction), Box<dyn error::Error>>
238    {
239        use reqwest_mock::HttpResponse;
240
241        let content_dir = self.root.join("content");
242        fs::DirBuilder::new().recursive(true).create(&content_dir)?;
243
244        let (handle, path) = make_random_file(&content_dir)?;
245        let trans = {
246            // We can be sure the relative path is valid UTF-8, because
247            // make_random_file() just generated it from ASCII.
248            let path = path.strip_prefix(&self.root)?.to_str().unwrap().into();
249
250            let last_modified =
251                header_as_string(response.headers(), &rh::LAST_MODIFIED);
252
253            let etag = header_as_string(response.headers(), &rh::ETAG);
254
255            self.db.set(
256                url,
257                db::CacheRecord {
258                    path,
259                    last_modified,
260                    etag,
261                },
262            )?
263        };
264
265        Ok((handle, path, trans))
266    }
267
268    /// Retrieve the content of the given URL.
269    ///
270    /// If we've never seen this URL before,
271    /// we will try to retrieve it
272    /// (with a `GET` request)
273    /// and store its data locally.
274    ///
275    /// If we have seen this URL before, we will ask the server
276    /// whether our cached data is stale.
277    /// If our data is stale,
278    /// we'll download the new version
279    /// and store it locally.
280    /// If our data is fresh,
281    /// we'll re-use the local copy we already have.
282    ///
283    /// If we can't talk to the server to see if our cached data is stale,
284    /// we'll silently re-use the data we have.
285    ///
286    /// Returns a file-handle to the local copy of the data, open for
287    /// reading.
288    ///
289    ///     # extern crate reqwest;
290    ///     # extern crate static_http_cache;
291    ///     # use std::error::Error;
292    ///     # use std::fs::File;
293    ///     # use std::path::PathBuf;
294    ///     # fn get_my_resource() -> Result<(), Box<dyn Error>> {
295    ///     # let mut cache = static_http_cache::Cache::new(
296    ///     #     PathBuf::from("my_cache_directory"),
297    ///     #     reqwest::blocking::Client::new(),
298    ///     # )?;
299    ///     let file = cache.get(reqwest::Url::parse("http://example.com/some-resource")?)?;
300    ///     # Ok(())
301    ///     # }
302    ///
303    /// Errors
304    /// ======
305    ///
306    /// This method may return an error:
307    ///
308    ///   - if the cache metadata is corrupt
309    ///   - if the requested resource is not cached,
310    ///     and we can't connect to/download it
311    ///   - if we can't update the cache metadata
312    ///   - if the cache metadata points to a local file that no longer exists
313    ///
314    /// After returning a network-related or disk I/O-related error,
315    /// this `Cache` instance should be OK and you may keep using it.
316    /// If it returns a database-related error,
317    /// the on-disk storage *should* be OK,
318    /// so you might want to destroy this `Cache` instance
319    /// and create a new one pointing at the same location.
320    pub fn get(
321        &mut self,
322        mut url: reqwest::Url,
323    ) -> Result<fs::File, Box<dyn error::Error>> {
324        use reqwest::StatusCode;
325        use reqwest_mock::HttpResponse;
326
327        url.set_fragment(None);
328
329        let mut response = match self.db.get(url.clone()) {
330            Ok(db::CacheRecord {
331                path: p,
332                last_modified: lm,
333                etag: et,
334            }) => {
335                // We have a locally-cached copy, let's check whether the
336                // copy on the server has changed.
337                let mut request = reqwest::blocking::Request::new(
338                    reqwest::Method::GET,
339                    url.clone(),
340                );
341                if let Some(timestamp) = lm {
342                    request.headers_mut().append(
343                        rh::IF_MODIFIED_SINCE,
344                        rh::HeaderValue::from_str(&timestamp)?,
345                    );
346                }
347                if let Some(etag) = et {
348                    request.headers_mut().append(
349                        rh::IF_NONE_MATCH,
350                        rh::HeaderValue::from_str(&etag)?,
351                    );
352                }
353
354                info!("Sending HTTP request: {:?}", request);
355
356                let maybe_validation = self
357                    .client
358                    .execute(request)
359                    .and_then(|resp| resp.error_for_status());
360
361                match maybe_validation {
362                    Ok(new_response) => {
363                        info!("Got HTTP response: {:?}", new_response);
364
365                        // If our existing cached data is still fresh...
366                        if new_response.status() == StatusCode::NOT_MODIFIED {
367                            // ... let's use it as is.
368                            return Ok(fs::File::open(self.root.join(p))?);
369                        }
370
371                        // Otherwise, we got a new response we need to cache.
372                        new_response
373                    }
374                    Err(e) => {
375                        warn!("Could not validate cached response: {}", e);
376
377                        // Let's just use the existing data we have.
378                        return Ok(fs::File::open(self.root.join(p))?);
379                    }
380                }
381            }
382            Err(_) => {
383                // This URL isn't in the cache, or we otherwise can't find it.
384                self.client
385                    .execute(reqwest::blocking::Request::new(
386                        reqwest::Method::GET,
387                        url.clone(),
388                    ))?
389                    .error_for_status()?
390            }
391        };
392
393        let (mut handle, path, trans) =
394            self.record_response(url.clone(), &response)?;
395
396        let count = io::copy(&mut response, &mut handle)?;
397
398        debug!("Downloaded {} bytes", count);
399
400        trans.commit()?;
401
402        Ok(fs::File::open(path)?)
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    extern crate env_logger;
409    extern crate tempdir;
410
411    
412    use reqwest::header as rh;
413
414    use std::io;
415
416    use std::io::Read;
417
418    use super::reqwest_mock::tests as rmt;
419
420    const DATE_ZERO: &str = "Thu, 01 Jan 1970 00:00:00 GMT";
421    const DATE_ONE: &str = "Thu, 01 Jan 1970 00:00:00 GMT";
422
423    fn make_test_cache(
424        client: rmt::FakeClient,
425    ) -> super::Cache<rmt::FakeClient> {
426        super::Cache::new(
427            tempdir::TempDir::new("http-cache-test")
428                .unwrap()
429                .into_path(),
430            client,
431        )
432        .unwrap()
433    }
434
435    #[test]
436    fn initial_request_success() {
437        let _ = env_logger::try_init();
438
439        let url_text = "http://example.com/";
440        let url: reqwest::Url = url_text.parse().unwrap();
441
442        let body = b"hello world";
443
444        let mut c = make_test_cache(rmt::FakeClient::new(
445            url.clone(),
446            rh::HeaderMap::new(),
447            rmt::FakeResponse {
448                status: reqwest::StatusCode::OK,
449                headers: rh::HeaderMap::new(),
450                body: io::Cursor::new(body.as_ref().into()),
451            },
452        ));
453
454        // We should get a file-handle containing the body bytes.
455        let mut res = c.get(url).unwrap();
456        let mut buf = vec![];
457        res.read_to_end(&mut buf).unwrap();
458        assert_eq!(&buf, body);
459        c.client.assert_called();
460    }
461
462    #[test]
463    fn initial_request_failure() {
464        let _ = env_logger::try_init();
465
466        let url: reqwest::Url = "http://example.com/".parse().unwrap();
467        let mut c = make_test_cache(rmt::FakeClient::new(
468            url.clone(),
469            rh::HeaderMap::new(),
470            rmt::FakeResponse {
471                status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
472                headers: rh::HeaderMap::new(),
473                body: io::Cursor::new(vec![]),
474            },
475        ));
476
477        let err = c.get(url).expect_err("Got a response??");
478        assert_eq!(format!("{}", err), "FakeError");
479        c.client.assert_called();
480    }
481
482    #[test]
483    fn ignore_fragment_in_url() {
484        let _ = env_logger::try_init();
485
486        let url_fragment: reqwest::Url =
487            "http://example.com/#frag".parse().unwrap();
488
489        let mut network_url = url_fragment.clone();
490        network_url.set_fragment(None);
491
492        let mut c = make_test_cache(rmt::FakeClient::new(
493            // We expect the cache to request the URL without the fragment.
494            network_url,
495            rh::HeaderMap::new(),
496            rmt::FakeResponse {
497                status: reqwest::StatusCode::OK,
498                headers: rh::HeaderMap::new(),
499                body: io::Cursor::new(b"hello world"[..].into()),
500            },
501        ));
502
503        // Ask for the URL with the fragment.
504        c.get(url_fragment).unwrap();
505    }
506
507    #[test]
508    fn use_cache_data_if_not_modified_since() {
509        let _ = env_logger::try_init();
510
511        let url: reqwest::Url = "http://example.com/".parse().unwrap();
512        let body = b"hello world";
513
514        // We send a request, and the server responds with the data,
515        // and a "Last-Modified" header.
516        let mut response_headers = rh::HeaderMap::new();
517        response_headers
518            .append(rh::LAST_MODIFIED, rh::HeaderValue::from_static(DATE_ZERO));
519
520        let mut c = make_test_cache(rmt::FakeClient::new(
521            url.clone(),
522            rh::HeaderMap::new(),
523            rmt::FakeResponse {
524                status: reqwest::StatusCode::OK,
525                headers: response_headers.clone(),
526                body: io::Cursor::new(body.as_ref().into()),
527            },
528        ));
529
530        // The response and its last-modified date should now be recorded
531        // in the cache.
532        c.get(url.clone()).unwrap();
533        c.client.assert_called();
534
535        // For the next request, we expect the request to include the
536        // modified date in the "if modified since" header, and we'll give
537        // the "no, it hasn't been modified" response.
538        let mut second_request = rh::HeaderMap::new();
539        second_request.append(
540            rh::IF_MODIFIED_SINCE,
541            rh::HeaderValue::from_static(DATE_ZERO),
542        );
543
544        c.client = rmt::FakeClient::new(
545            url.clone(),
546            second_request,
547            rmt::FakeResponse {
548                status: reqwest::StatusCode::NOT_MODIFIED,
549                headers: response_headers,
550                body: io::Cursor::new(b""[..].into()),
551            },
552        );
553
554        // Now when we make the request, even though the actual response
555        // did not include a body, we should get the complete body from
556        // the local cache.
557        let mut res = c.get(url).unwrap();
558        let mut buf = vec![];
559        res.read_to_end(&mut buf).unwrap();
560        assert_eq!(&buf, body);
561        c.client.assert_called();
562    }
563
564    #[test]
565    fn update_cache_if_modified_since() {
566        let _ = env_logger::try_init();
567
568        let url: reqwest::Url = "http://example.com/".parse().unwrap();
569
570        // We send a request, and the server responds with the data,
571        // and a "Last-Modified" header.
572        let request_1_headers = rh::HeaderMap::new();
573        let mut response_1_headers = rh::HeaderMap::new();
574        response_1_headers
575            .append(rh::LAST_MODIFIED, rh::HeaderValue::from_static(DATE_ZERO));
576
577        let mut c = make_test_cache(rmt::FakeClient::new(
578            url.clone(),
579            request_1_headers,
580            rmt::FakeResponse {
581                status: reqwest::StatusCode::OK,
582                headers: response_1_headers,
583                body: io::Cursor::new(b"hello".as_ref().into()),
584            },
585        ));
586
587        // The response and its last-modified date should now be recorded
588        // in the cache.
589        c.get(url.clone()).unwrap();
590        c.client.assert_called();
591
592        // For the next request, we expect the request to include the
593        // modified date in the "if modified since" header, and we'll give
594        // the "yes, it has been modified" response with a new Last-Modified.
595        let mut request_2_headers = rh::HeaderMap::new();
596        request_2_headers.append(
597            rh::IF_MODIFIED_SINCE,
598            rh::HeaderValue::from_static(DATE_ZERO),
599        );
600        let mut response_2_headers = rh::HeaderMap::new();
601        response_2_headers
602            .append(rh::LAST_MODIFIED, rh::HeaderValue::from_static(DATE_ONE));
603
604        c.client = rmt::FakeClient::new(
605            url.clone(),
606            request_2_headers,
607            rmt::FakeResponse {
608                status: reqwest::StatusCode::OK,
609                headers: response_2_headers,
610                body: io::Cursor::new(b"world".as_ref().into()),
611            },
612        );
613
614        // Now when we make the request, we should get the new body and
615        // ignore what's in the cache.
616        let mut res = c.get(url.clone()).unwrap();
617        let mut buf = vec![];
618        res.read_to_end(&mut buf).unwrap();
619        assert_eq!(&buf, b"world");
620        c.client.assert_called();
621
622        // If we make another request, we should set If-Modified-Since
623        // to match the second response, and be able to return the data from
624        // the second response.
625        let mut request_3_headers = rh::HeaderMap::new();
626        request_3_headers.append(
627            rh::IF_MODIFIED_SINCE,
628            rh::HeaderValue::from_static(DATE_ONE),
629        );
630        let response_3_headers = rh::HeaderMap::new();
631
632        c.client = rmt::FakeClient::new(
633            url.clone(),
634            request_3_headers,
635            rmt::FakeResponse {
636                status: reqwest::StatusCode::NOT_MODIFIED,
637                headers: response_3_headers,
638                body: io::Cursor::new(b"".as_ref().into()),
639            },
640        );
641
642        // Now when we make the request, we should get updated info from the
643        // cache.
644        let mut res = c.get(url).unwrap();
645        let mut buf = vec![];
646        res.read_to_end(&mut buf).unwrap();
647        assert_eq!(&buf, b"world");
648        c.client.assert_called();
649    }
650
651    #[test]
652    fn return_existing_data_on_connection_refused() {
653        let _ = env_logger::try_init();
654
655        let temp_path = tempdir::TempDir::new("http-cache-test")
656            .unwrap()
657            .into_path();
658
659        let url: reqwest::Url = "http://example.com/".parse().unwrap();
660
661        // We send a request, and the server responds with the data,
662        // and a "Last-Modified" header.
663        let request_1_headers = rh::HeaderMap::new();
664        let mut response_1_headers = rh::HeaderMap::new();
665        response_1_headers
666            .append(rh::LAST_MODIFIED, rh::HeaderValue::from_static(DATE_ZERO));
667
668        let mut c = super::Cache::new(
669            temp_path.clone(),
670            rmt::FakeClient::new(
671                url.clone(),
672                request_1_headers,
673                rmt::FakeResponse {
674                    status: reqwest::StatusCode::OK,
675                    headers: response_1_headers,
676                    body: io::Cursor::new(b"hello".as_ref().into()),
677                },
678            ),
679        )
680        .unwrap();
681
682        // The response and its last-modified date should now be recorded
683        // in the cache.
684        c.get(url.clone()).unwrap();
685        c.client.assert_called();
686
687        // If we make second request, we should set If-Modified-Since
688        // to match the first response's Last-Modified.
689        let mut request_2_headers = rh::HeaderMap::new();
690        request_2_headers.append(
691            rh::IF_MODIFIED_SINCE,
692            rh::HeaderValue::from_static(DATE_ZERO),
693        );
694
695        // This time, however, the request will return an error.
696        let mut c = super::Cache::new(
697            temp_path,
698            rmt::BrokenClient::new(url.clone(), request_2_headers, || {
699                rmt::FakeError.into()
700            }),
701        )
702        .unwrap();
703
704        // Now when we request a URL, we should get the cached result.
705        let mut res = c.get(url).unwrap();
706        let mut buf = vec![];
707        res.read_to_end(&mut buf).unwrap();
708        assert_eq!(&buf, b"hello");
709        c.client.assert_called();
710    }
711
712    #[test]
713    fn use_cache_data_if_some_match() {
714        let _ = env_logger::try_init();
715
716        let url: reqwest::Url = "http://example.com/".parse().unwrap();
717        let body = b"hello world";
718
719        // We send a request, and the server responds with the data,
720        // and an "Etag" header.
721        let mut response_headers = rh::HeaderMap::new();
722        response_headers.append(rh::ETAG, rh::HeaderValue::from_static("abcd"));
723
724        let mut c = make_test_cache(rmt::FakeClient::new(
725            url.clone(),
726            rh::HeaderMap::new(),
727            rmt::FakeResponse {
728                status: reqwest::StatusCode::OK,
729                headers: response_headers.clone(),
730                body: io::Cursor::new(body.as_ref().into()),
731            },
732        ));
733
734        // The response and its etag should now be recorded
735        // in the cache.
736        c.get(url.clone()).unwrap();
737        c.client.assert_called();
738
739        // For the next request, we expect the request to include the
740        // etag in the "if none match" header, and we'll give
741        // the "no, it hasn't been modified" response.
742        let mut second_request = rh::HeaderMap::new();
743        second_request
744            .append(rh::IF_NONE_MATCH, rh::HeaderValue::from_static("abcd"));
745
746        c.client = rmt::FakeClient::new(
747            url.clone(),
748            second_request,
749            rmt::FakeResponse {
750                status: reqwest::StatusCode::NOT_MODIFIED,
751                headers: response_headers,
752                body: io::Cursor::new(b""[..].into()),
753            },
754        );
755
756        // Now when we make the request, even though the actual response
757        // did not include a body, we should get the complete body from
758        // the local cache.
759        let mut res = c.get(url).unwrap();
760        let mut buf = vec![];
761        res.read_to_end(&mut buf).unwrap();
762        assert_eq!(&buf, body);
763        c.client.assert_called();
764    }
765
766    #[test]
767    fn update_cache_if_none_match() {
768        let _ = env_logger::try_init();
769
770        let url: reqwest::Url = "http://example.com/".parse().unwrap();
771
772        // We send a request, and the server responds with the data,
773        // and an "ETag" header.
774        let request_1_headers = rh::HeaderMap::new();
775        let mut response_1_headers = rh::HeaderMap::new();
776        response_1_headers
777            .append(rh::ETAG, rh::HeaderValue::from_static("abcd"));
778
779        let mut c = make_test_cache(rmt::FakeClient::new(
780            url.clone(),
781            request_1_headers,
782            rmt::FakeResponse {
783                status: reqwest::StatusCode::OK,
784                headers: response_1_headers,
785                body: io::Cursor::new(b"hello".as_ref().into()),
786            },
787        ));
788
789        // The response and its etag should now be recorded in the cache.
790        c.get(url.clone()).unwrap();
791        c.client.assert_called();
792
793        // For the next request, we expect the request to include the
794        // etag in the "if none match" header, and we'll give
795        // the "yes, it has been modified" response with a new etag.
796        let mut request_2_headers = rh::HeaderMap::new();
797        request_2_headers
798            .append(rh::IF_NONE_MATCH, rh::HeaderValue::from_static("abcd"));
799        let mut response_2_headers = rh::HeaderMap::new();
800        response_2_headers
801            .append(rh::ETAG, rh::HeaderValue::from_static("efgh"));
802
803        c.client = rmt::FakeClient::new(
804            url.clone(),
805            request_2_headers,
806            rmt::FakeResponse {
807                status: reqwest::StatusCode::OK,
808                headers: response_2_headers,
809                body: io::Cursor::new(b"world".as_ref().into()),
810            },
811        );
812
813        // Now when we make the request, we should get the new body and
814        // ignore what's in the cache.
815        let mut res = c.get(url.clone()).unwrap();
816        let mut buf = vec![];
817        res.read_to_end(&mut buf).unwrap();
818        assert_eq!(&buf, b"world");
819        c.client.assert_called();
820
821        // If we make another request, we should set If-None-Match
822        // to match the second response, and be able to return the data from
823        // the second response.
824        let mut request_3_headers = rh::HeaderMap::new();
825        request_3_headers
826            .append(rh::IF_NONE_MATCH, rh::HeaderValue::from_static("efgh"));
827        let response_3_headers = rh::HeaderMap::new();
828
829        c.client = rmt::FakeClient::new(
830            url.clone(),
831            request_3_headers,
832            rmt::FakeResponse {
833                status: reqwest::StatusCode::NOT_MODIFIED,
834                headers: response_3_headers,
835                body: io::Cursor::new(b"".as_ref().into()),
836            },
837        );
838
839        // Now when we make the request, we should get updated info from the
840        // cache.
841        let mut res = c.get(url).unwrap();
842        let mut buf = vec![];
843        res.read_to_end(&mut buf).unwrap();
844        assert_eq!(&buf, b"world");
845        c.client.assert_called();
846    }
847
848    // See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching
849}