Introduction

http-cache is a library that acts as a middleware for caching HTTP responses. It is intended to be used by other libraries to support multiple HTTP clients and backend cache managers, though it does come with two optional manager implementations out of the box. http-cache is built on top of http-cache-semantics which parses HTTP headers to correctly compute cacheability of responses.

Cache Modes

When constructing a new instance of HttpCache, you must specify a cache mode. The cache mode determines how the cache will behave in certain situations. These modes are similar to make-fetch-happen cache options. The available cache modes are:

  • Default: This mode will inspect the HTTP cache on the way to the network. If there is a fresh response it will be used. If there is a stale response a conditional request will be created, and a normal request otherwise. It then updates the HTTP cache with the response. If the revalidation request fails (for example, on a 500 or if you're offline), the stale response will be returned.

  • NoStore: This mode will ignore the HTTP cache on the way to the network. It will always create a normal request, and will never cache the response.

  • Reload: This mode will ignore the HTTP cache on the way to the network. It will always create a normal request, and will update the HTTP cache with the response.

  • NoCache: This mode will create a conditional request if there is a response in the HTTP cache and a normal request otherwise. It then updates the HTTP cache with the response.

  • ForceCache: This mode will inspect the HTTP cache on the way to the network. If there is a cached response it will be used regardless of freshness. If there is no cached response it will create a normal request, and will update the cache with the response.

  • OnlyIfCached: This mode will inspect the HTTP cache on the way to the network. If there is a cached response it will be used regardless of freshness. If there is no cached response it will return a 504 Gateway Timeout error.

  • IgnoreRules: This mode will ignore the HTTP headers and always store a response given it was a 200 status code. It will also ignore the staleness when retrieving a response from the cache, so expiration of the cached response will need to be handled manually. If there was no cached response it will create a normal request, and will update the cache with the response.

Development

http-cache is meant to be extended to support multiple HTTP clients and backend cache managers. A CacheManager trait has been provided to help ease support for new backend cache managers. Similarly, a Middleware trait has been provided to help ease supporting new HTTP clients.

Supporting a Backend Cache Manager

This section is intended for those looking to implement a custom backend cache manager, or understand how the CacheManager trait works.

Supporting an HTTP Client

This section is intended for those looking to implement a custom HTTP client, or understand how the Middleware trait works.

Supporting a Backend Cache Manager

This section is intended for those looking to implement a custom backend cache manager, or understand how the CacheManager trait works.

The CacheManager trait

The CacheManager trait is the main trait that needs to be implemented to support a new backend cache manager. It has three methods that it requires:

  • get: retrieve a cached response given the provided cache key
  • put: store a response and related policy object in the cache associated with the provided cache key
  • delete: remove a cached response from the cache associated with the provided cache key

Because the methods are asynchronous, they currently require async_trait to be derived. This may change in the future.

The get method

The get method is used to retrieve a cached response given the provided cache key. It returns an Result<Option<(HttpResponse, CachePolicy)>, BoxError> where HttpResponse is the cached response and CachePolicy is the associated cache policy object that provides us helpful metadata. If the cache key does not exist in the cache, Ok(None) is returned.

The put method

The put method is used to store a response and related policy object in the cache associated with the provided cache key. It returns an Result<HttpResponse, BoxError> where HttpResponse is the passed response.

The delete method

The delete method is used to remove a cached response from the cache associated with the provided cache key. It returns an Result<(), BoxError>.

How to implement a custom backend cache manager

This guide will use the cacache backend cache manager as an example. The full source can be found here. There are several ways to accomplish this, so feel free to experiment!

Part One: The base structs

The first step is to create a struct that will hold the cache manager's configuration or potentially the cache itself. This struct will implement the CacheManager trait. In this case, we'll call it CACacheManager and it will have a field to store the path for the cache directory.

#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
pub struct CACacheManager {
    /// Directory where the cache will be stored.
    pub path: PathBuf,
}
}

Next we will create a struct to store the response and accompanying policy object. This struct will be used to store the response and policy object in the cache. We'll call it Store. This isn't strictly necessary, but I find this easier to work with.

#![allow(unused)]
fn main() {
#[derive(Debug, Deserialize, Serialize)]
struct Store {
    response: HttpResponse,
    policy: CachePolicy,
}
}

This struct will also derive serde Deserialize and Serialize to ease the serialization and deserialization with bincode.

Part Two: Implementing the CacheManager trait

Now that we have our base structs, we can implement the CacheManager trait for our CACacheManager struct. We'll start with the get method, but first we must make sure we derive async_trait.

#![allow(unused)]
fn main() {
#[async_trait::async_trait]
impl CacheManager for CACacheManager {
    ...
}

The get method accepts a &str as the cache key and returns an Result<Option<(HttpResponse, CachePolicy)>, BoxError>. We will read function from cacache to lookup the cache key in the cache directory. If the cache key does not exist, we'll return Ok(None). The object we will be serializing and deserializing is our Store struct.

#![allow(unused)]
fn main() {
...
async fn get(
    &self,
    cache_key: &str,
) -> Result<Option<(HttpResponse, CachePolicy)>> {
    let store: Store = match cacache::read(&self.path, cache_key).await {
        Ok(d) => bincode::deserialize(&d)?,
        Err(_e) => {
            return Ok(None);
        }
    };
    Ok(Some((store.response, store.policy)))
}
...
}

Next we'll implement the put method. This method accepts a String as the cache key, a HttpResponse as the response, and a CachePolicy as the policy object. It returns an Result<HttpResponse, BoxError>. We will clone the response during our construction of the Store struct, then serialize the Store struct using serialize and write it to the cache directory using write from cacache.

#![allow(unused)]
fn main() {
...
async fn put(
    &self,
    cache_key: String,
    response: HttpResponse,
    policy: CachePolicy,
) -> Result<HttpResponse> {
    let data = Store { response: response.clone(), policy };
    let bytes = bincode::serialize(&data)?;
    cacache::write(&self.path, cache_key, bytes).await?;
    Ok(response)
}
...
}

Finally we'll implement the delete method. This method accepts a &str as the cache key and returns an Result<(), BoxError>. We will use remove from cacache to remove the object from the cache directory.

#![allow(unused)]
fn main() {
...
async fn delete(&self, cache_key: &str) -> Result<()> {
    Ok(cacache::remove(&self.path, cache_key).await?)
}
...
}

Our CACacheManager struct now meets the requirements of the CacheManager trait and is ready for use!

Supporting an HTTP Client

This section is intended for those who wish to add support for a new HTTP client to http-cache, or understand how the Middleware trait works. If you are looking to use http-cache with an HTTP client that is already supported, please see the Client Implementations section.

The Middleware trait

The Middleware trait is the main trait that needs to be implemented to add support for a new HTTP client. It has nine methods that it requires:

  • is_method_get_head: returns true if the method of the request is GET or HEAD, false otherwise
  • policy: returns a CachePolicy with default options for the given HttpResponse
  • policy_with_options: returns a CachePolicy with the provided CacheOptions for the given HttpResponse
  • update_headers: updates the request headers with the provided http::request::Parts
  • force_no_cache: overrides the Cache-Control header to 'no-cache' derective
  • parts: returns the http::request::Parts from the request
  • url: returns the requested Url
  • method: returns the method of the request as a String
  • remote_fetch: performs the request and returns the HttpResponse

Because the remote_fetch method is asynchronous, it currently requires async_trait to be derived. This may change in the future.

The is_method_get_head method

The is_method_get_head method is used to determine if the method of the request is GET or HEAD. It returns a bool where true indicates the method is GET or HEAD, and false if otherwise.

The policy and policy_with_options methods

The policy method is used to generate the cache policy for the given HttpResponse. It returns a CachePolicy with default options.

The policy_with_options method is used to generate the cache policy for the given HttpResponse with the provided CacheOptions. It returns a CachePolicy.

The update_headers method

The update_headers method is used to update the request headers with the provided http::request::Parts.

The force_no_cache method

The force_no_cache method is used to override the Cache-Control header to 'no-cache' derective. This is used to allow caching but force revalidation before resuse.

The parts method

The parts method is used to return the http::request::Parts from the request which eases working with the http_cache_semantics crate.

The url method

The url method is used to return the requested Url in a standard format.

The method method

The method method is used to return the HTTP method of the request as a String to standardize the format.

The remote_fetch method

The remote_fetch method is used to perform the request and return the HttpResponse. This goal here is to abstract away the HTTP client implementation and return a more generic response type.

How to implement a custom HTTP client

This guide will use the surf HTTP client as an example. The full source can be found here. There are several ways to accomplish this, so feel free to experiment!

Part One: The base structs

First we will create a wrapper for the HttpCache struct. This is required because we cannot implement a trait for a type declared in another crate, see docs for more info. We will call it Cache in this case.

#![allow(unused)]
fn main() {
#[derive(Debug)]
pub struct Cache<T: CacheManager>(pub HttpCache<T>);
}

Next we will create a struct to store the request and anything else we will need for our surf::Middleware implementation (more on that later). This struct will also implement the http-cache Middleware trait. We'll call it SurfMiddleware in this case.

#![allow(unused)]
fn main() {
pub(crate) struct SurfMiddleware<'a> {
    pub req: Request,
    pub client: Client,
    pub next: Next<'a>,
}
}

Part Two: Implementing the Middleware trait

Now that we have our base structs, we can implement the Middleware trait for our SurfMiddleware struct. We'll start with the is_method_get_head method, but first we must make sure we derive async_trait.

#![allow(unused)]
fn main() {
#[async_trait::async_trait]
impl Middleware for SurfMiddleware<'_> {
    ...
}

The is_method_get_head will check the request stored in our SurfMiddleware struct and return true if the method is GET or HEAD, false otherwise.

#![allow(unused)]
fn main() {
fn is_method_get_head(&self) -> bool {
    self.req.method() == Method::Get || self.req.method() == Method::Head
}
}

Next we'll implement the policy method. This method accepts a reference to the HttpResponse and returns a CachePolicy with default options. We'll use the http_cache_semantics::CachePolicy::new method to generate the policy.

#![allow(unused)]
fn main() {
fn policy(&self, response: &HttpResponse) -> Result<CachePolicy> {
    Ok(CachePolicy::new(&self.parts()?, &response.parts()?))
}
}

The policy_with_options method is similar to the policy method, but accepts a CacheOptions struct to override the default options. We'll use the http_cache_semantics::CachePolicy::new_options method to generate the policy.

#![allow(unused)]
fn main() {
fn policy_with_options(
    &self,
    response: &HttpResponse,
    options: CacheOptions,
) -> Result<CachePolicy> {
    Ok(CachePolicy::new_options(
        &self.parts()?,
        &response.parts()?,
        SystemTime::now(),
        options,
    ))
}
}

Next we'll implement the update_headers method. This method accepts a reference to the http::request::Parts and updates the request headers. We will iterate over the part headers and attempt to convert the header value to a HeaderValue and set the header on the request. If the conversion fails, we will return an error.

#![allow(unused)]
fn main() {
fn update_headers(&mut self, parts: &Parts) -> Result<()> {
    for header in parts.headers.iter() {
        let value = match HeaderValue::from_str(header.1.to_str()?) {
            Ok(v) => v,
            Err(_e) => return Err(Box::new(BadHeader)),
        };
        self.req.set_header(header.0.as_str(), value);
    }
    Ok(())
}
}

The force_no_cache method is used to override the Cache-Control header in the request to 'no-cache' derective. This is used to allow caching but force revalidation before resuse.

#![allow(unused)]
fn main() {
fn force_no_cache(&mut self) -> Result<()> {
    self.req.insert_header(CACHE_CONTROL.as_str(), "no-cache");
    Ok(())
}
}

The parts method is used to return the http::request::Parts from the request which eases working with the http_cache_semantics crate.

#![allow(unused)]
fn main() {
fn parts(&self) -> Result<Parts> {
    let mut converted = request::Builder::new()
        .method(self.req.method().as_ref())
        .uri(self.req.url().as_str())
        .body(())?;
    {
        let headers = converted.headers_mut();
        for header in self.req.iter() {
            headers.insert(
                http::header::HeaderName::from_str(header.0.as_str())?,
                http::HeaderValue::from_str(header.1.as_str())?,
            );
        }
    }
    Ok(converted.into_parts().0)
}
}

The url method is used to return the requested Url in a standard format.

#![allow(unused)]
fn main() {
fn url(&self) -> Result<Url> {
    Ok(self.req.url().clone())
}
}

The method method is used to return the HTTP method of the request as a String to standardize the format.

#![allow(unused)]
fn main() {
fn method(&self) -> Result<String> {
    Ok(self.req.method().as_ref().to_string())
}
}

Finally, the remote_fetch method is used to perform the request and return the HttpResponse.

#![allow(unused)]
fn main() {
async fn remote_fetch(&mut self) -> Result<HttpResponse> {
    let url = self.req.url().clone();
    let mut res =
        self.next.run(self.req.clone(), self.client.clone()).await?;
    let mut headers = HashMap::new();
    for header in res.iter() {
        headers.insert(
            header.0.as_str().to_owned(),
            header.1.as_str().to_owned(),
        );
    }
    let status = res.status().into();
    let version = res.version().unwrap_or(Version::Http1_1);
    let body: Vec<u8> = res.body_bytes().await?;
    Ok(HttpResponse {
        body,
        headers,
        status,
        url,
        version: version.try_into()?,
    })
}
}

Our SurfMiddleware struct now meets the requirements of the Middleware trait. We can now implement the surf::middleware::Middleware trait for our Cache struct.

Part Three: Implementing the surf::middleware::Middleware trait

We have our Cache struct that wraps our HttpCache struct, but we need to implement the surf::middleware::Middleware trait for it. This is required to use our Cache struct as a middleware with surf. This part may differ depending on the HTTP client you are supporting.

#![allow(unused)]
fn main() {
#[surf::utils::async_trait]
impl<T: CacheManager> surf::middleware::Middleware for Cache<T> {
    async fn handle(
        &self,
        req: Request,
        client: Client,
        next: Next<'_>,
    ) -> std::result::Result<surf::Response, http_types::Error> {
        let middleware = SurfMiddleware { req, client, next };
        let res = self.0.run(middleware).await.map_err(to_http_types_error)?;
        let mut converted = Response::new(StatusCode::Ok);
        for header in &res.headers {
            let val = HeaderValue::from_bytes(header.1.as_bytes().to_vec())?;
            converted.insert_header(header.0.as_str(), val);
        }
        converted.set_status(res.status.try_into()?);
        converted.set_version(Some(res.version.try_into()?));
        converted.set_body(res.body);
        Ok(surf::Response::from(converted))
    }
}
}

First we create a SurfMiddleware struct with the provided req, client, and next arguments. Then we call the run method on our HttpCache struct with our SurfMiddleware struct as the argument. This will perform the request and return the HttpResponse. We then convert the HttpResponse to a surf::Response and return it.

Client Implementations

The following client implementations are provided by this crate:

reqwest

The http-cache-reqwest crate provides a Middleware implementation for the reqwest HTTP client.

surf

The http-cache-surf crate provides a Middleware implementation for the surf HTTP client.

reqwest

The http-cache-reqwest crate provides a Middleware implementation for the reqwest HTTP client. It accomplishes this by utilizing reqwest_middleware.

Getting Started

cargo add http-cache-reqwest

Features

  • manager-cacache: (default) Enables the CACacheManager backend cache manager.
  • manager-moka: Enables the MokaManager backend cache manager.

Usage

In the following example we will construct our client using the builder provided by reqwest_middleware with our cache struct from http-cache-reqwest. This example will use the default mode, default cacache manager, and default http cache options.

After constructing our client, we will make a request to the MDN Caching Docs which should result in an object stored in cache on disk.

use reqwest::Client;
use reqwest_middleware::{ClientBuilder, Result};
use http_cache_reqwest::{Cache, CacheMode, CACacheManager, HttpCache, HttpCacheOptions};

#[tokio::main]
async fn main() -> Result<()> {
    let client = ClientBuilder::new(Client::new())
        .with(Cache(HttpCache {
          mode: CacheMode::Default,
          manager: CACacheManager::default(),
          options: HttpCacheOptions::default(),
        }))
        .build();
    client
        .get("https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching")
        .send()
        .await?;
    Ok(())
}

surf

The http-cache-surf crate provides a Middleware implementation for the surf HTTP client.

Getting Started

cargo add http-cache-surf

Features

  • manager-cacache: (default) Enables the CACacheManager backend cache manager.
  • manager-moka: Enables the MokaManager backend cache manager.

Usage

In the following example we will construct our client with our cache struct from http-cache-surf. This example will use the default mode, default cacache manager, and default http cache options.

After constructing our client, we will make a request to the MDN Caching Docs which should result in an object stored in cache on disk.

use http_cache_surf::{Cache, CacheMode, CACacheManager, HttpCache, HttpCacheOptions};

#[async_std::main]
async fn main() -> surf::Result<()> {
    let req = surf::get("https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching");
    surf::client()
        .with(Cache(HttpCache {
          mode: CacheMode::Default,
          manager: CACacheManager::default(),
          options: HttpCacheOptions::default(),
        }))
        .send(req)
        .await?;
    Ok(())
}

Backend Cache Manager Implementations

The following backend cache manager implementations are provided by this crate:

cacache

cacache is a high-performance, concurrent, content-addressable disk cache, optimized for async APIs.

moka

moka is a fast, concurrent cache library inspired by the Caffeine library for Java.

quick_cache

quick_cache is a lightweight and high performance concurrent cache optimized for low cache overhead.

cacache

cacache is a high-performance, concurrent, content-addressable disk cache, optimized for async APIs.

Getting Started

The cacache backend cache manager is provided by the http-cache crate and is enabled by default. Both the http-cache-reqwest and http-cache-surf crates expose the types so no need to pull in the http-cache directly unless you need to implement your own client.

reqwest

cargo add http-cache-reqwest

surf

cargo add http-cache-surf

Working with the manager directly

First construct your manager instance. This example will use the default cache directory.

#![allow(unused)]
fn main() {
let manager = CACacheManager::default();
}

You can also specify the cache directory.

#![allow(unused)]
fn main() {
let manager = CACacheManager {
    path: "./my-cache".into(),
};
}

You can attempt to retrieve a record from the cache using the get method. This method accepts a &str as the cache key and returns an Result<Option<(HttpResponse, CachePolicy)>, BoxError>.

#![allow(unused)]
fn main() {
let response = manager.get("my-cache-key").await?;
}

You can store a record in the cache using the put method. This method accepts a String as the cache key, a HttpResponse as the response, and a CachePolicy as the policy object. It returns an Result<HttpResponse, BoxError>. The below example constructs the response and policy manually, normally this would be handled by the middleware.

#![allow(unused)]
fn main() {
let url = Url::parse("http://example.com")?;
let response = HttpResponse {
    body: TEST_BODY.to_vec(),
    headers: Default::default(),
    status: 200,
    url: url.clone(),
    version: HttpVersion::Http11,
};
let req = http::Request::get("http://example.com").body(())?;
let res = http::Response::builder()
    .status(200)
    .body(TEST_BODY.to_vec())?;
let policy = CachePolicy::new(&req, &res);
let response = manager.put("my-cache-key".into(), response, policy).await?;
}

You can remove a record from the cache using the delete method. This method accepts a &str as the cache key and returns an Result<(), BoxError>.

#![allow(unused)]
fn main() {
manager.delete("my-cache-key").await?;
}

You can also clear the entire cache using the clear method. This method accepts no arguments and returns an Result<(), BoxError>.

#![allow(unused)]
fn main() {
manager.clear().await?;
}

moka

moka is a fast, concurrent cache library inspired by the Caffeine library for Java.

Getting Started

The moka backend cache manager is provided by the http-cache crate but is not enabled by default. Both the http-cache-reqwest and http-cache-surf crates expose the types so no need to pull in the http-cache directly unless you need to implement your own client.

reqwest

cargo add http-cache-reqwest --no-default-features -F manager-moka

surf

cargo add http-cache-surf --no-default-features -F manager-moka

Working with the manager directly

First construct your manager instance. This example will use the default cache configuration (42).

#![allow(unused)]
fn main() {
let manager = Arc::new(MokaManager::default());
}

You can also specify other configuration options. This uses the new methods on both MokaManager and moka::future::Cache to construct a cache with a maximum capacity of 100 items.

#![allow(unused)]
fn main() {
let manager = Arc::new(MokaManager::new(moka::future::Cache::new(100)));
}

You can attempt to retrieve a record from the cache using the get method. This method accepts a &str as the cache key and returns an Result<Option<(HttpResponse, CachePolicy)>, BoxError>.

#![allow(unused)]
fn main() {
let response = manager.get("my-cache-key").await?;
}

You can store a record in the cache using the put method. This method accepts a String as the cache key, a HttpResponse as the response, and a CachePolicy as the policy object. It returns an Result<HttpResponse, BoxError>. The below example constructs the response and policy manually, normally this would be handled by the middleware.

#![allow(unused)]
fn main() {
let url = Url::parse("http://example.com")?;
let response = HttpResponse {
    body: TEST_BODY.to_vec(),
    headers: Default::default(),
    status: 200,
    url: url.clone(),
    version: HttpVersion::Http11,
};
let req = http::Request::get("http://example.com").body(())?;
let res = http::Response::builder()
    .status(200)
    .body(TEST_BODY.to_vec())?;
let policy = CachePolicy::new(&req, &res);
let response = manager.put("my-cache-key".into(), response, policy).await?;
}

You can remove a record from the cache using the delete method. This method accepts a &str as the cache key and returns an Result<(), BoxError>.

#![allow(unused)]
fn main() {
manager.delete("my-cache-key").await?;
}

You can also clear the entire cache using the clear method. This method accepts no arguments and returns an Result<(), BoxError>.

#![allow(unused)]
fn main() {
manager.clear().await?;
}

quick_cache

quick_cache is a lightweight and high performance concurrent cache optimized for low cache overhead.

Getting Started

The quick_cache backend cache manager is provided by the http-cache-quickcache crate.

cargo add http-cache-quickcache

Working with the manager directly

First construct your manager instance. This example will use the default cache configuration (42).

#![allow(unused)]
fn main() {
let manager = Arc::new(QuickManager::default());
}

You can also specify other configuration options. This uses the new methods on both QuickManager and quick_cache::sync::Cache to construct a cache with a maximum capacity of 100 items.

#![allow(unused)]
fn main() {
let manager = Arc::new(QuickManager::new(quick_cache::sync::Cache::new(100)));
}

You can attempt to retrieve a record from the cache using the get method. This method accepts a &str as the cache key and returns an Result<Option<(HttpResponse, CachePolicy)>, BoxError>.

#![allow(unused)]
fn main() {
let response = manager.get("my-cache-key").await?;
}

You can store a record in the cache using the put method. This method accepts a String as the cache key, a HttpResponse as the response, and a CachePolicy as the policy object. It returns an Result<HttpResponse, BoxError>. The below example constructs the response and policy manually, normally this would be handled by the middleware.

#![allow(unused)]
fn main() {
let url = Url::parse("http://example.com")?;
let response = HttpResponse {
    body: TEST_BODY.to_vec(),
    headers: Default::default(),
    status: 200,
    url: url.clone(),
    version: HttpVersion::Http11,
};
let req = http::Request::get("http://example.com").body(())?;
let res = http::Response::builder()
    .status(200)
    .body(TEST_BODY.to_vec())?;
let policy = CachePolicy::new(&req, &res);
let response = manager.put("my-cache-key".into(), response, policy).await?;
}

You can remove a record from the cache using the delete method. This method accepts a &str as the cache key and returns an Result<(), BoxError>.

#![allow(unused)]
fn main() {
manager.delete("my-cache-key").await?;
}