From 4be74fa17a999bdd0e8ca6036c16f54a2ca0c637 Mon Sep 17 00:00:00 2001 From: Tymon Tobolski Date: Mon, 29 Oct 2018 13:20:47 +0100 Subject: [PATCH 01/11] Cache middleware --- .formatter.exs | 7 +- lib/tesla/middleware/cache.ex | 325 +++++++++++++++++ mix.exs | 1 + mix.lock | 2 + test/tesla/middleware/cache_test.exs | 523 +++++++++++++++++++++++++++ 5 files changed, 857 insertions(+), 1 deletion(-) create mode 100644 lib/tesla/middleware/cache.ex create mode 100644 test/tesla/middleware/cache_test.exs diff --git a/.formatter.exs b/.formatter.exs index dd2daac1..5a99fffe 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -6,12 +6,17 @@ export_locals_without_parens = [ adapter: 2 ] +internal = [ + assert_cached: 1, + refute_cached: 1 +] + [ inputs: [ "lib/**/*.{ex,exs}", "test/**/*.{ex,exs}", "mix.exs" ], - locals_without_parens: export_locals_without_parens, + locals_without_parens: export_locals_without_parens ++ internal, export: [locals_without_parens: export_locals_without_parens] ] diff --git a/lib/tesla/middleware/cache.ex b/lib/tesla/middleware/cache.ex new file mode 100644 index 00000000..84621795 --- /dev/null +++ b/lib/tesla/middleware/cache.ex @@ -0,0 +1,325 @@ +defmodule Tesla.Middleware.Cache do + @moduledoc """ + + plug Tesla.Middleware.Cache, store: MyStore + + Rewrite of https://github.com/plataformatec/faraday-http-cache + """ + + @behaviour Tesla.Middleware + + defmodule Store do + @type key :: binary + @type response :: {Tesla.Env.status(), Tesla.Env.headers(), Tesla.Env.body()} + @type vary :: binary + @type data :: response | vary + + @callback get(key) :: {:ok, data} | :not_found + @callback put(key, data) :: :ok + @callback delete(key) :: :ok + end + + defmodule CacheControl do + @moduledoc false + + defstruct public?: false, + private?: false, + no_cache?: false, + no_store?: false, + must_revalidate?: false, + proxy_revalidate?: false, + max_age: nil, + s_max_age: nil + + def new(nil), do: %__MODULE__{} + def new(%Tesla.Env{} = env), do: new(Tesla.get_header(env, "cache-control")) + def new(header) when is_binary(header), do: parse(header) + + defp parse(header) do + header + |> String.trim() + |> String.split(",") + |> Enum.reduce(%__MODULE__{}, fn part, cc -> + part + |> String.split("=", parts: 2) + |> Enum.map(&String.trim/1) + |> case do + [] -> :ignore + [key] -> parse(key, "") + [key, val] -> parse(key, val) + end + |> case do + :ignore -> cc + {key, val} -> Map.put(cc, key, val) + end + end) + end + + # boolean flags + defp parse("no-cache", _), do: {:no_cache?, true} + defp parse("no-store", _), do: {:no_store?, true} + defp parse("must-revalidate", _), do: {:must_revalidate?, true} + defp parse("proxy-revalidate", _), do: {:proxy_revalidate?, true} + defp parse("public", _), do: {:public?, true} + defp parse("private", _), do: {:private?, true} + + # integers + defp parse("max-age", val), do: {:max_age, int(val)} + defp parse("s-maxage", val), do: {:s_max_age, int(val)} + + # others + defp parse(_, _), do: :ignore + + defp int(bin) do + case Integer.parse(bin) do + {int, ""} -> int + _ -> nil + end + end + end + + defmodule Request do + def new(env), do: {env, CacheControl.new(env)} + + def cacheable?({%{method: method}, _cc}) when method not in [:get, :head], do: false + def cacheable?({_env, %{no_store?: true}}), do: false + def cacheable?({_env, _cc}), do: true + + def skip_cache?({_env, %{no_cache?: true}}), do: true + def skip_cache?(_), do: false + end + + defmodule Response do + def new(env), do: {env, CacheControl.new(env)} + + @cacheable_status [200, 203, 300, 301, 302, 307, 404, 410] + def cacheable?({_env, %{no_store?: true}}, _), do: false + def cacheable?({_env, %{private?: true}}, false), do: false + def cacheable?({%{status: status}, _cc}, _) when status in @cacheable_status, do: true + def cacheable?({_env, _cc}, _), do: false + + def fresh?({env, cc}) do + cond do + cc.must_revalidate? -> false + cc.no_cache? -> false + true -> ttl({env, cc}) > 0 + end + end + + defp ttl({env, cc}) do + with {:ok, max_age} <- max_age({env, cc}), + {:ok, age} <- age(env) do + max_age - age + else + _ -> 0 + end + end + + defp max_age({env, cc}) do + with nil <- cc.s_max_age, + nil <- cc.max_age do + expires(env) + else + max when is_integer(max) -> {:ok, max} + end + end + + defp expires(env) do + with header when not is_nil(header) <- Tesla.get_header(env, "expires"), + {:ok, date} <- Calendar.DateTime.Parse.httpdate(header), + {:ok, seconds, _, :after} <- Calendar.DateTime.diff(date, DateTime.utc_now()) do + {:ok, seconds} + else + _ -> :error + end + end + + defp age(env) do + with :error <- age_by_age_header(env) do + age_by_date_header(env) + end + end + + defp age_by_age_header(env) do + with bin when not is_nil(bin) <- Tesla.get_header(env, "age"), + {age, ""} <- Integer.parse(bin) do + {:ok, age} + else + _ -> :error + end + end + + defp age_by_date_header(env) do + with bin when not is_nil(bin) <- Tesla.get_header(env, "date"), + {:ok, date} <- Calendar.DateTime.Parse.httpdate(bin), + {:ok, seconds, _, :after} <- Calendar.DateTime.diff(DateTime.utc_now(), date) do + {:ok, seconds} + else + _ -> :error + end + end + end + + defmodule Storage do + def get(store, req) do + key = cache_key(req) + + with {:ok, {req0, res}} <- store.get(key) do + if valid?(req, req0, res) do + {:ok, %{req | status: res.status, headers: res.headers, body: res.body}} + else + :not_found + end + end + end + + def put(store, req, res) do + key = cache_key(req) + store.put(key, {req, res}) + end + + def delete(store, res) do + key = cache_key(res) + store.delete(key) + end + + defp cache_key(env) do + :crypto.hash(:sha256, [ + Tesla.build_url(env.url, env.query) + # Enum.map(env.headers, fn {k, v} -> "#{k}:#{v}" end) + ]) + |> Base.encode16() + end + + defp valid?(req, req0, res) do + case Tesla.get_header(res, "vary") do + nil -> true + "*" -> false + vary -> vary_matches?(req, req0, vary) + end + end + + defp vary_matches?(req, req0, vary) do + vary + |> String.downcase() + |> String.split(~r/[\s,]+/) + |> Enum.all?(fn header -> + Tesla.get_headers(req, header) == Tesla.get_headers(req0, header) + end) + end + end + + @impl true + def call(env, next, opts) do + store = Keyword.fetch!(opts, :store) + private = Keyword.get(opts, :cache_private, false) + request = Request.new(env) + + with {:ok, {env, _}} <- process(request, next, store, private) do + cleanup(env, store) + {:ok, env} + end + end + + defp process(request, next, store, private) do + if Request.cacheable?(request) do + if Request.skip_cache?(request) do + run_and_store(request, next, store, private) + else + case fetch(request, store) do + {:ok, response} -> + if Response.fresh?(response) do + {:ok, response} + else + with {:ok, response} <- validate(request, response, next) do + store(request, response, store, private) + end + end + + :not_found -> + run_and_store(request, next, store, private) + end + end + else + run(request, next) + end + end + + defp run({env, _} = _request, next) do + with {:ok, env} <- Tesla.run(env, next) do + {:ok, Response.new(env)} + end + end + + defp run_and_store(request, next, store, private) do + with {:ok, response} <- run(request, next) do + store(request, response, store, private) + end + end + + defp fetch({env, _}, store) do + case Storage.get(store, env) do + {:ok, res} -> {:ok, Response.new(res)} + :not_found -> :not_found + end + end + + defp store({req, _} = _request, {res, _} = response, store, private) do + if Response.cacheable?(response, private) do + Storage.put(store, req, ensure_date_header(res)) + end + + {:ok, response} + end + + defp ensure_date_header(env) do + case Tesla.get_header(env, "date") do + nil -> Tesla.put_header(env, "date", Calendar.DateTime.Format.httpdate(DateTime.utc_now())) + _ -> env + end + end + + defp validate({env, _}, {res, _}, next) do + env = + env + |> maybe_put_header("if-modified-since", Tesla.get_header(res, "last-modified")) + |> maybe_put_header("if-none-match", Tesla.get_header(res, "etag")) + + case Tesla.run(env, next) do + {:ok, %{status: 304, headers: headers}} -> + res = + Enum.reduce(headers, res, fn + {k, _}, env when k in ["content-type", "content-length"] -> env + {k, v}, env -> Tesla.put_header(env, k, v) + end) + + {:ok, Response.new(res)} + + {:ok, env} -> + {:ok, Response.new(env)} + + error -> + error + end + end + + defp maybe_put_header(env, _, nil), do: env + defp maybe_put_header(env, name, value), do: Tesla.put_header(env, name, value) + + @delete_headers ["location", "content-location"] + defp cleanup(env, store) do + if delete?(env) do + for header <- @delete_headers do + if location = Tesla.get_header(env, header) do + Storage.delete(store, %{env | url: location}) + end + end + + Storage.delete(store, env) + end + end + + defp delete?(%{method: method}) when method in [:head, :get, :trace, :options], do: false + defp delete?(%{status: status}) when status in 400..499, do: false + defp delete?(_env), do: true +end diff --git a/mix.exs b/mix.exs index 68407717..62693749 100644 --- a/mix.exs +++ b/mix.exs @@ -68,6 +68,7 @@ defmodule Tesla.Mixfile do {:exjsx, ">= 3.0.0", optional: true}, # other + {:calendar, "~> 1.0", optional: true}, {:fuse, "~> 2.4", optional: true}, {:telemetry, "~> 0.4 or ~> 1.0", optional: true}, diff --git a/mix.lock b/mix.lock index 263f9d34..c12df15e 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,6 @@ %{ "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, + "calendar": {:hex, :calendar, "1.0.0", "f52073a708528482ec33d0a171954ca610fe2bd28f1e871f247dc7f1565fa807", [:mix], [{:tzdata, "~> 0.5.20 or ~> 0.1.201603 or ~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "990e9581920c82912a5ee50e62ff5ef96da6b15949a2ee4734f935fdef0f0a6f"}, "castore": {:hex, :castore, "0.1.11", "c0665858e0e1c3e8c27178e73dffea699a5b28eb72239a3b2642d208e8594914", [:mix], [], "hexpm", "91b009ba61973b532b84f7c09ce441cba7aa15cb8b006cf06c6f4bba18220081"}, "certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"}, "con_cache": {:hex, :con_cache, "0.14.0", "863acb90fa08017be3129074993af944cf7a4b6c3ee7c06c5cd0ed6b94fbc223", [:mix], [], "hexpm", "50887a8949377d0b707a3c6653b7610de06074751b52d0f267f52135f391aece"}, @@ -39,5 +40,6 @@ "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, + "tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, } diff --git a/test/tesla/middleware/cache_test.exs b/test/tesla/middleware/cache_test.exs new file mode 100644 index 00000000..1c67b80a --- /dev/null +++ b/test/tesla/middleware/cache_test.exs @@ -0,0 +1,523 @@ +defmodule Tesla.Middleware.CacheTest do + use ExUnit.Case + + defmodule TestStore do + @behaviour Tesla.Middleware.Cache.Store + + def get(key) do + case Process.get(key) do + nil -> :not_found + data -> {:ok, data} + end + end + + def put(key, data) do + Process.put(key, data) + end + + def delete(key) do + Process.delete(key) + end + end + + defmodule TestAdapter do + # source: https://github.com/plataformatec/faraday-http-cache/blob/master/spec/support/test_app.rb + + alias Calendar.DateTime + + def call(env, opts) do + {status, headers, body} = handle(env.method, env.url, env) + send(opts[:pid], {env.method, env.url, status}) + {:ok, %{env | status: status, headers: headers, body: body}} + end + + @yesterday DateTime.now_utc() + |> DateTime.subtract!(60 * 60 * 24) + |> DateTime.Format.httpdate() + + defp handle(:post, "/post", _) do + {200, [{"cache-control", "max-age=400"}], ""} + end + + defp handle(:get, "/broken", _) do + {500, [{"cache-control", "max-age=400"}], ""} + end + + defp handle(:get, "/counter", _) do + {200, [{"cache-control", "max-age=200"}], ""} + end + + defp handle(:post, "/counter", _) do + {200, [], ""} + end + + defp handle(:put, "/counter", _) do + {200, [], ""} + end + + defp handle(:delete, "/counter", _) do + {200, [], ""} + end + + defp handle(:patch, "/counter", _) do + {200, [], ""} + end + + defp handle(:get, "/get", _) do + date = DateTime.now_utc() |> DateTime.Format.httpdate() + {200, [{"cache-control", "max-age=200"}, {"date", date}], ""} + end + + defp handle(:post, "/delete-with-location", _) do + {200, [{"location", "/get"}], ""} + end + + defp handle(:post, "/delete-with-content-location", _) do + {200, [{"content-location", "/get"}], ""} + end + + defp handle(:post, "/get", _) do + {405, [], ""} + end + + defp handle(:get, "/private", _) do + {200, [{"cache-control", "private, max-age=100"}], ""} + end + + defp handle(:get, "/dontstore", _) do + {200, [{"cache-control", "no-store"}], ""} + end + + defp handle(:get, "/expires", _) do + expires = DateTime.now_utc() |> DateTime.add!(10) |> DateTime.Format.httpdate() + {200, [{"expires", expires}], ""} + end + + defp handle(:get, "/yesterday", _) do + {200, [{"date", @yesterday}, {"expires", @yesterday}], ""} + end + + defp handle(:get, "/timestamped", env) do + case Tesla.get_header(env, "if-modified-since") do + "1" -> + {304, [], ""} + + nil -> + increment_counter() + {200, [{"last-modified", to_string(counter())}], to_string(counter())} + end + end + + defp handle(:get, "/etag", env) do + case Tesla.get_header(env, "if-none-match") do + "1" -> + date = DateTime.now_utc() + expires = DateTime.now_utc() |> DateTime.add!(10) + + headers = [ + {"etag", "2"}, + {"cache-control", "max-age=200"}, + {"date", DateTime.Format.httpdate(date)}, + {"expires", DateTime.Format.httpdate(expires)}, + {"vary", "*"} + ] + + {304, headers, ""} + + nil -> + increment_counter() + expires = DateTime.now_utc() + + headers = [ + {"etag", "1"}, + {"cache-control", "max-age=0"}, + {"date", @yesterday}, + {"expires", DateTime.Format.httpdate(expires)}, + {"vary", "Accept"} + ] + + {200, headers, to_string(counter())} + end + end + + defp handle(:get, "/no_cache", _) do + increment_counter() + {200, [{"cache-control", "max-age=200, no-cache"}, {"ETag", to_string(counter())}], ""} + end + + defp handle(:get, "/vary", _) do + {200, [{"cache-control", "max-age=50"}, {"vary", "user-agent"}], ""} + end + + defp handle(:get, "/vary-wildcard", _) do + {200, [{"cache-control", "max-age=50"}, {"vary", "*"}], ""} + end + + defp handle(:get, "/image", _) do + data = :crypto.strong_rand_bytes(100) + + headers = [ + {"cache-control", "max-age=400"}, + {"content-type", "application/octet-stream"} + ] + + {200, headers, data} + end + + defp counter, do: Process.get(:counter) || 0 + + defp increment_counter do + next = counter() + 1 + Process.put(:counter, next) + to_string(next) + end + end + + setup do + middleware = [ + {Tesla.Middleware.Cache, store: TestStore} + ] + + adapter = {TestAdapter, pid: self()} + client = Tesla.client(middleware, adapter) + + {:ok, client: client, adapter: adapter} + end + + # source: https://github.com/plataformatec/faraday-http-cache/blob/master/spec/http_cache_spec.rb + + test "does not cache POST requests", %{client: client} do + refute_cached Tesla.post(client, "/post", "hello") + refute_cached Tesla.post(client, "/post", "world") + end + + test "does not cache responses with 500 status code", %{client: client} do + refute_cached Tesla.get(client, "/broken") + refute_cached Tesla.get(client, "/broken") + end + + describe "cache invalidation" do + test "expires POST requests", %{client: client} do + refute_cached Tesla.get(client, "/counter") + refute_cached Tesla.post(client, "/counter", "") + refute_cached Tesla.get(client, "/counter") + end + + test "does not expires POST requests that failed", %{client: client} do + refute_cached Tesla.get(client, "/get") + refute_cached Tesla.post(client, "/get", "") + assert_cached Tesla.get(client, "/get") + end + + test "expires PUT requests", %{client: client} do + refute_cached Tesla.get(client, "/counter") + refute_cached Tesla.put(client, "/counter", "") + refute_cached Tesla.get(client, "/counter") + end + + test "expires DELETE requests", %{client: client} do + refute_cached Tesla.get(client, "/counter") + refute_cached Tesla.delete(client, "/counter") + refute_cached Tesla.get(client, "/counter") + end + + test "expires PATCH requests", %{client: client} do + refute_cached Tesla.get(client, "/counter") + refute_cached Tesla.patch(client, "/counter", "") + refute_cached Tesla.get(client, "/counter") + end + + test "expires entries for the 'Location' header", %{client: client} do + refute_cached Tesla.get(client, "/get") + refute_cached Tesla.post(client, "/delete-with-location", "") + refute_cached Tesla.get(client, "/get") + end + + test "expires entries for the 'Content-Location' header", %{client: client} do + refute_cached Tesla.get(client, "/get") + refute_cached Tesla.post(client, "/delete-with-content-location", "") + refute_cached Tesla.get(client, "/get") + end + end + + describe "when acting as a shared cache (the default)" do + test "does not cache requests with a private cache control", %{client: client} do + refute_cached Tesla.get(client, "/private") + refute_cached Tesla.get(client, "/private") + end + end + + describe "when acting as a private cache" do + test "does cache requests with a private cache control", %{adapter: adapter} do + middleware = [ + {Tesla.Middleware.Cache, store: TestStore, cache_private: true} + ] + + client = Tesla.client(middleware, adapter) + + refute_cached Tesla.get(client, "/private") + assert_cached Tesla.get(client, "/private") + end + end + + test "does not cache responses with a explicit no-store directive", %{client: client} do + refute_cached Tesla.get(client, "/dontstore") + refute_cached Tesla.get(client, "/dontstore") + end + + test "does not caches multiple responses when the headers differ", %{client: client} do + refute_cached Tesla.get(client, "/get", headers: [{"accept", "text/html"}]) + assert_cached Tesla.get(client, "/get", headers: [{"accept", "text/html"}]) + + # TODO: This one fails - the reqeust IS cached. + # I think faraday-http-cache specs migh have a bug + # refute_cached Tesla.get(client, "/get", headers: [{"accept", "application/json"}]) + end + + test "caches multiples responses based on the 'Vary' header", %{client: client} do + refute_cached Tesla.get(client, "/vary", headers: [{"user-agent", "Agent/1.0"}]) + assert_cached Tesla.get(client, "/vary", headers: [{"user-agent", "Agent/1.0"}]) + refute_cached Tesla.get(client, "/vary", headers: [{"user-agent", "Agent/2.0"}]) + refute_cached Tesla.get(client, "/vary", headers: [{"user-agent", "Agent/3.0"}]) + end + + test "never caches responses with the wildcard 'Vary' header", %{client: client} do + refute_cached Tesla.get(client, "/vary-wildcard") + refute_cached Tesla.get(client, "/vary-wildcard") + end + + test "caches requests with the 'Expires' header", %{client: client} do + refute_cached Tesla.get(client, "/expires") + assert_cached Tesla.get(client, "/expires") + end + + test "caches GET responses", %{client: client} do + refute_cached Tesla.get(client, "/get") + assert_cached Tesla.get(client, "/get") + end + + describe "when the request has a 'no-cache' directive" do + test "by-passes the cache", %{client: client} do + refute_cached Tesla.get(client, "/get", headers: [{"cache-control", "no-cache"}]) + refute_cached Tesla.get(client, "/get", headers: [{"cache-control", "no-cache"}]) + end + + test "caches the response", %{client: client} do + refute_cached Tesla.get(client, "/get", headers: [{"cache-control", "no-cache"}]) + assert_cached Tesla.get(client, "/get") + end + end + + describe "when the response has a 'no-cache' directive" do + test "always revalidate the cached response", %{client: client} do + refute_cached Tesla.get(client, "/no_cache") + refute_cached Tesla.get(client, "/no_cache") + refute_cached Tesla.get(client, "/no_cache") + end + end + + test "differs requests with different query strings", %{client: client} do + refute_cached Tesla.get(client, "/get") + refute_cached Tesla.get(client, "/get", query: [q: "what"]) + assert_cached Tesla.get(client, "/get", query: [q: "what"]) + refute_cached Tesla.get(client, "/get", query: [q: "wat"]) + end + + test "sends the 'Last-Modified' header on response validation", %{client: client} do + refute_cached Tesla.get(client, "/timestamped") + + assert_validated({:ok, env} = Tesla.get(client, "/timestamped")) + assert env.body == "1" + end + + test "sends the 'If-None-Match' header on response validation", %{client: client} do + refute_cached Tesla.get(client, "/etag") + + assert_validated({:ok, env} = Tesla.get(client, "/etag")) + assert env.body == "1" + end + + test "maintains the 'Date' header for cached responses", %{client: client} do + refute_cached({:ok, env0} = Tesla.get(client, "/get")) + assert_cached({:ok, env1} = Tesla.get(client, "/get")) + + date0 = Tesla.get_header(env0, "date") + date1 = Tesla.get_header(env1, "date") + + assert date0 != nil + assert date0 == date1 + end + + test "preserves an old 'Date' header if present", %{client: client} do + refute_cached({:ok, env} = Tesla.get(client, "/yesterday")) + date = Tesla.get_header(env, "date") + assert date =~ ~r/^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/ + end + + test "updates the 'Cache-Control' header when a response is validated", %{client: client} do + {:ok, env0} = Tesla.get(client, "/etag") + {:ok, env1} = Tesla.get(client, "/etag") + + cc0 = Tesla.get_header(env0, "cache-control") + cc1 = Tesla.get_header(env1, "cache-control") + + assert cc0 != nil + assert cc0 != cc1 + end + + test "updates the 'Date' header when a response is validated", %{client: client} do + {:ok, env0} = Tesla.get(client, "/etag") + {:ok, env1} = Tesla.get(client, "/etag") + + date0 = Tesla.get_header(env0, "date") + date1 = Tesla.get_header(env1, "date") + + assert date0 != nil + assert date0 != date1 + end + + test "updates the 'Expires' header when a response is validated", %{client: client} do + {:ok, env0} = Tesla.get(client, "/etag") + {:ok, env1} = Tesla.get(client, "/etag") + + expires0 = Tesla.get_header(env0, "expires") + expires1 = Tesla.get_header(env1, "expires") + + assert expires0 != nil + assert expires0 != expires1 + end + + test "updates the 'Vary' header when a response is validated", %{client: client} do + {:ok, env0} = Tesla.get(client, "/etag") + {:ok, env1} = Tesla.get(client, "/etag") + + vary0 = Tesla.get_header(env0, "vary") + vary1 = Tesla.get_header(env1, "vary") + + assert vary0 != nil + assert vary0 != vary1 + end + + describe "CacheControl" do + # Source: https://github.com/plataformatec/faraday-http-cache/blob/master/spec/cache_control_spec.rb + + alias Tesla.Middleware.Cache.CacheControl + + test "takes a String with multiple name=value pairs" do + cache_control = CacheControl.new("max-age=600, max-stale=300, min-fresh=570") + assert cache_control.max_age == 600 + end + + test "takes a String with a single flag value" do + cache_control = CacheControl.new("no-cache") + assert cache_control.no_cache? == true + end + + test "takes a String with a bunch of all kinds of stuff" do + cache_control = CacheControl.new("max-age=600,must-revalidate,min-fresh=3000,foo=bar,baz") + + assert cache_control.max_age == 600 + assert cache_control.must_revalidate? == true + end + + test "strips leading and trailing spaces" do + cache_control = CacheControl.new(" public, max-age = 600 ") + assert cache_control.public? == true + assert cache_control.max_age == 600 + end + + test "ignores blank segments" do + cache_control = CacheControl.new("max-age=600,,s-maxage=300") + assert cache_control.max_age == 600 + assert cache_control.s_max_age == 300 + end + + test "responds to #max_age with an integer when max-age directive present" do + cache_control = CacheControl.new("public, max-age=600") + assert cache_control.max_age == 600 + end + + test "responds to #max_age with nil when no max-age directive present" do + cache_control = CacheControl.new("public") + assert cache_control.max_age == nil + end + + test "responds to #shared_max_age with an integer when s-maxage directive present" do + cache_control = CacheControl.new("public, s-maxage=600") + assert cache_control.s_max_age == 600 + end + + test "responds to #shared_max_age with nil when no s-maxage directive present" do + cache_control = CacheControl.new("public") + assert cache_control.s_max_age == nil + end + + test "responds to #public? truthfully when public directive present" do + cache_control = CacheControl.new("public") + assert cache_control.public? == true + end + + test "responds to #public? non-truthfully when no public directive present" do + cache_control = CacheControl.new("private") + assert cache_control.public? == false + end + + test "responds to #private? truthfully when private directive present" do + cache_control = CacheControl.new("private") + assert cache_control.private? == true + end + + test "responds to #private? non-truthfully when no private directive present" do + cache_control = CacheControl.new("public") + assert cache_control.private? == false + end + + test "responds to #no_cache? truthfully when no-cache directive present" do + cache_control = CacheControl.new("no-cache") + assert cache_control.no_cache? == true + end + + test "responds to #no_cache? non-truthfully when no no-cache directive present" do + cache_control = CacheControl.new("max-age=600") + assert cache_control.no_cache? == false + end + + test "responds to #must_revalidate? truthfully when must-revalidate directive present" do + cache_control = CacheControl.new("must-revalidate") + assert cache_control.must_revalidate? == true + end + + test "responds to #must_revalidate? non-truthfully when no must-revalidate directive present" do + cache_control = CacheControl.new("max-age=600") + assert cache_control.must_revalidate? == false + end + + test "responds to #proxy_revalidate? truthfully when proxy-revalidate directive present" do + cache_control = CacheControl.new("proxy-revalidate") + assert cache_control.proxy_revalidate? == true + end + + test "responds to #proxy_revalidate? non-truthfully when no proxy-revalidate directive present" do + cache_control = CacheControl.new("max-age=600") + assert cache_control.proxy_revalidate? == false + end + end + + describe "Binary Data" do + # Source: https://github.com/plataformatec/faraday-http-cache/blob/master/spec/binary_spec.rb + + test "works fine with binary data", %{client: client} do + refute_cached({:ok, env0} = Tesla.get(client, "/image")) + assert_cached({:ok, env1} = Tesla.get(client, "/image")) + + assert env0.body != nil + assert env0.body == env1.body + end + end + + defp assert_cached({:ok, %{method: method, url: url}}), do: refute_receive({^method, ^url, _}) + defp refute_cached({:ok, %{method: method, url: url}}), do: assert_receive({^method, ^url, _}) + + defp assert_validated({:ok, %{method: method, url: url}}), + do: assert_receive({^method, ^url, 304}) +end From b84663881136aac32ed0e73dcd4e0904fbc21125 Mon Sep 17 00:00:00 2001 From: Tymon Tobolski Date: Sun, 18 Nov 2018 13:20:56 +0100 Subject: [PATCH 02/11] Reorder tests --- test/tesla/middleware/cache_test.exs | 228 ++++++++++++++------------- 1 file changed, 117 insertions(+), 111 deletions(-) diff --git a/test/tesla/middleware/cache_test.exs b/test/tesla/middleware/cache_test.exs index 1c67b80a..da8d1977 100644 --- a/test/tesla/middleware/cache_test.exs +++ b/test/tesla/middleware/cache_test.exs @@ -186,14 +186,92 @@ defmodule Tesla.Middleware.CacheTest do # source: https://github.com/plataformatec/faraday-http-cache/blob/master/spec/http_cache_spec.rb - test "does not cache POST requests", %{client: client} do - refute_cached Tesla.post(client, "/post", "hello") - refute_cached Tesla.post(client, "/post", "world") + describe "basics" do + test "caches GET responses", %{client: client} do + refute_cached Tesla.get(client, "/get") + assert_cached Tesla.get(client, "/get") + end + + test "does not cache POST requests", %{client: client} do + refute_cached Tesla.post(client, "/post", "hello") + refute_cached Tesla.post(client, "/post", "world") + end + + test "does not cache responses with 500 status code", %{client: client} do + refute_cached Tesla.get(client, "/broken") + refute_cached Tesla.get(client, "/broken") + end + + test "differs requests with different query strings", %{client: client} do + refute_cached Tesla.get(client, "/get") + refute_cached Tesla.get(client, "/get", query: [q: "what"]) + assert_cached Tesla.get(client, "/get", query: [q: "what"]) + refute_cached Tesla.get(client, "/get", query: [q: "wat"]) + end end - test "does not cache responses with 500 status code", %{client: client} do - refute_cached Tesla.get(client, "/broken") - refute_cached Tesla.get(client, "/broken") + describe "headers handling" do + test "does not cache responses with a explicit no-store directive", %{client: client} do + refute_cached Tesla.get(client, "/dontstore") + refute_cached Tesla.get(client, "/dontstore") + end + + test "does not caches multiple responses when the headers differ", %{client: client} do + refute_cached Tesla.get(client, "/get", headers: [{"accept", "text/html"}]) + assert_cached Tesla.get(client, "/get", headers: [{"accept", "text/html"}]) + + # TODO: This one fails - the reqeust IS cached. + # I think faraday-http-cache specs migh have a bug + # refute_cached Tesla.get(client, "/get", headers: [{"accept", "application/json"}]) + end + + test "caches multiples responses based on the 'Vary' header", %{client: client} do + refute_cached Tesla.get(client, "/vary", headers: [{"user-agent", "Agent/1.0"}]) + assert_cached Tesla.get(client, "/vary", headers: [{"user-agent", "Agent/1.0"}]) + refute_cached Tesla.get(client, "/vary", headers: [{"user-agent", "Agent/2.0"}]) + refute_cached Tesla.get(client, "/vary", headers: [{"user-agent", "Agent/3.0"}]) + end + + test "never caches responses with the wildcard 'Vary' header", %{client: client} do + refute_cached Tesla.get(client, "/vary-wildcard") + refute_cached Tesla.get(client, "/vary-wildcard") + end + + test "caches requests with the 'Expires' header", %{client: client} do + refute_cached Tesla.get(client, "/expires") + assert_cached Tesla.get(client, "/expires") + end + + test "sends the 'Last-Modified' header on response validation", %{client: client} do + refute_cached Tesla.get(client, "/timestamped") + + assert_validated({:ok, env} = Tesla.get(client, "/timestamped")) + assert env.body == "1" + end + + test "sends the 'If-None-Match' header on response validation", %{client: client} do + refute_cached Tesla.get(client, "/etag") + + assert_validated({:ok, env} = Tesla.get(client, "/etag")) + assert env.body == "1" + end + + test "maintains the 'Date' header for cached responses", %{client: client} do + refute_cached({:ok, env0} = Tesla.get(client, "/get")) + assert_cached({:ok, env1} = Tesla.get(client, "/get")) + + date0 = Tesla.get_header(env0, "date") + date1 = Tesla.get_header(env1, "date") + + assert date0 != nil + assert date0 == date1 + end + + test "preserves an old 'Date' header if present", %{client: client} do + refute_cached({:ok, env} = Tesla.get(client, "/yesterday")) + date = Tesla.get_header(env, "date") + assert date =~ ~r/^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/ + end end describe "cache invalidation" do @@ -260,42 +338,6 @@ defmodule Tesla.Middleware.CacheTest do end end - test "does not cache responses with a explicit no-store directive", %{client: client} do - refute_cached Tesla.get(client, "/dontstore") - refute_cached Tesla.get(client, "/dontstore") - end - - test "does not caches multiple responses when the headers differ", %{client: client} do - refute_cached Tesla.get(client, "/get", headers: [{"accept", "text/html"}]) - assert_cached Tesla.get(client, "/get", headers: [{"accept", "text/html"}]) - - # TODO: This one fails - the reqeust IS cached. - # I think faraday-http-cache specs migh have a bug - # refute_cached Tesla.get(client, "/get", headers: [{"accept", "application/json"}]) - end - - test "caches multiples responses based on the 'Vary' header", %{client: client} do - refute_cached Tesla.get(client, "/vary", headers: [{"user-agent", "Agent/1.0"}]) - assert_cached Tesla.get(client, "/vary", headers: [{"user-agent", "Agent/1.0"}]) - refute_cached Tesla.get(client, "/vary", headers: [{"user-agent", "Agent/2.0"}]) - refute_cached Tesla.get(client, "/vary", headers: [{"user-agent", "Agent/3.0"}]) - end - - test "never caches responses with the wildcard 'Vary' header", %{client: client} do - refute_cached Tesla.get(client, "/vary-wildcard") - refute_cached Tesla.get(client, "/vary-wildcard") - end - - test "caches requests with the 'Expires' header", %{client: client} do - refute_cached Tesla.get(client, "/expires") - assert_cached Tesla.get(client, "/expires") - end - - test "caches GET responses", %{client: client} do - refute_cached Tesla.get(client, "/get") - assert_cached Tesla.get(client, "/get") - end - describe "when the request has a 'no-cache' directive" do test "by-passes the cache", %{client: client} do refute_cached Tesla.get(client, "/get", headers: [{"cache-control", "no-cache"}]) @@ -316,86 +358,50 @@ defmodule Tesla.Middleware.CacheTest do end end - test "differs requests with different query strings", %{client: client} do - refute_cached Tesla.get(client, "/get") - refute_cached Tesla.get(client, "/get", query: [q: "what"]) - assert_cached Tesla.get(client, "/get", query: [q: "what"]) - refute_cached Tesla.get(client, "/get", query: [q: "wat"]) - end - - test "sends the 'Last-Modified' header on response validation", %{client: client} do - refute_cached Tesla.get(client, "/timestamped") - - assert_validated({:ok, env} = Tesla.get(client, "/timestamped")) - assert env.body == "1" - end + describe "validation" do + test "updates the 'Cache-Control' header when a response is validated", %{client: client} do + {:ok, env0} = Tesla.get(client, "/etag") + {:ok, env1} = Tesla.get(client, "/etag") - test "sends the 'If-None-Match' header on response validation", %{client: client} do - refute_cached Tesla.get(client, "/etag") + cc0 = Tesla.get_header(env0, "cache-control") + cc1 = Tesla.get_header(env1, "cache-control") - assert_validated({:ok, env} = Tesla.get(client, "/etag")) - assert env.body == "1" - end - - test "maintains the 'Date' header for cached responses", %{client: client} do - refute_cached({:ok, env0} = Tesla.get(client, "/get")) - assert_cached({:ok, env1} = Tesla.get(client, "/get")) - - date0 = Tesla.get_header(env0, "date") - date1 = Tesla.get_header(env1, "date") - - assert date0 != nil - assert date0 == date1 - end - - test "preserves an old 'Date' header if present", %{client: client} do - refute_cached({:ok, env} = Tesla.get(client, "/yesterday")) - date = Tesla.get_header(env, "date") - assert date =~ ~r/^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/ - end - - test "updates the 'Cache-Control' header when a response is validated", %{client: client} do - {:ok, env0} = Tesla.get(client, "/etag") - {:ok, env1} = Tesla.get(client, "/etag") - - cc0 = Tesla.get_header(env0, "cache-control") - cc1 = Tesla.get_header(env1, "cache-control") - - assert cc0 != nil - assert cc0 != cc1 - end + assert cc0 != nil + assert cc0 != cc1 + end - test "updates the 'Date' header when a response is validated", %{client: client} do - {:ok, env0} = Tesla.get(client, "/etag") - {:ok, env1} = Tesla.get(client, "/etag") + test "updates the 'Date' header when a response is validated", %{client: client} do + {:ok, env0} = Tesla.get(client, "/etag") + {:ok, env1} = Tesla.get(client, "/etag") - date0 = Tesla.get_header(env0, "date") - date1 = Tesla.get_header(env1, "date") + date0 = Tesla.get_header(env0, "date") + date1 = Tesla.get_header(env1, "date") - assert date0 != nil - assert date0 != date1 - end + assert date0 != nil + assert date0 != date1 + end - test "updates the 'Expires' header when a response is validated", %{client: client} do - {:ok, env0} = Tesla.get(client, "/etag") - {:ok, env1} = Tesla.get(client, "/etag") + test "updates the 'Expires' header when a response is validated", %{client: client} do + {:ok, env0} = Tesla.get(client, "/etag") + {:ok, env1} = Tesla.get(client, "/etag") - expires0 = Tesla.get_header(env0, "expires") - expires1 = Tesla.get_header(env1, "expires") + expires0 = Tesla.get_header(env0, "expires") + expires1 = Tesla.get_header(env1, "expires") - assert expires0 != nil - assert expires0 != expires1 - end + assert expires0 != nil + assert expires0 != expires1 + end - test "updates the 'Vary' header when a response is validated", %{client: client} do - {:ok, env0} = Tesla.get(client, "/etag") - {:ok, env1} = Tesla.get(client, "/etag") + test "updates the 'Vary' header when a response is validated", %{client: client} do + {:ok, env0} = Tesla.get(client, "/etag") + {:ok, env1} = Tesla.get(client, "/etag") - vary0 = Tesla.get_header(env0, "vary") - vary1 = Tesla.get_header(env1, "vary") + vary0 = Tesla.get_header(env0, "vary") + vary1 = Tesla.get_header(env1, "vary") - assert vary0 != nil - assert vary0 != vary1 + assert vary0 != nil + assert vary0 != vary1 + end end describe "CacheControl" do From 8066d5cd27b41582fcca3642962eb337a0fd50ec Mon Sep 17 00:00:00 2001 From: Tymon Tobolski Date: Sun, 18 Nov 2018 13:25:42 +0100 Subject: [PATCH 03/11] Cache Request tests --- test/tesla/middleware/cache_test.exs | 49 +++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/test/tesla/middleware/cache_test.exs b/test/tesla/middleware/cache_test.exs index da8d1977..4d6b05cb 100644 --- a/test/tesla/middleware/cache_test.exs +++ b/test/tesla/middleware/cache_test.exs @@ -509,7 +509,54 @@ defmodule Tesla.Middleware.CacheTest do end end - describe "Binary Data" do + describe "Request" do + alias Tesla.Middleware.Cache.Request + + test "GET request should be cacheable" do + request = Request.new(%Tesla.Env{method: :get}) + assert Request.cacheable?(request) == true + end + + test "HEAD request should be cacheable" do + request = Request.new(%Tesla.Env{method: :head}) + assert Request.cacheable?(request) == true + end + + test "POST request should not be cacheable" do + request = Request.new(%Tesla.Env{method: :post}) + assert Request.cacheable?(request) == false + end + + test "PUT request should not be cacheable" do + request = Request.new(%Tesla.Env{method: :put}) + assert Request.cacheable?(request) == false + end + + test "OPTIONS request should not be cacheable" do + request = Request.new(%Tesla.Env{method: :options}) + assert Request.cacheable?(request) == false + end + + test "DELETE request should not be cacheable" do + request = Request.new(%Tesla.Env{method: :delete}) + assert Request.cacheable?(request) == false + end + + test "TRACE request should not be cacheable" do + request = Request.new(%Tesla.Env{method: :trace}) + assert Request.cacheable?(request) == false + end + + test "no-store request should not be cacheable" do + request = Request.new(%Tesla.Env{method: :get, headers: [{"cache-control", "no-store"}]}) + assert Request.cacheable?(request) == false + end + end + + describe "Response" do + end + + describe "binary data" do # Source: https://github.com/plataformatec/faraday-http-cache/blob/master/spec/binary_spec.rb test "works fine with binary data", %{client: client} do From 5ba38fdbc1e66e4298af39014149c575ca5f3b5f Mon Sep 17 00:00:00 2001 From: Tymon Tobolski Date: Sun, 18 Nov 2018 13:59:54 +0100 Subject: [PATCH 04/11] Cache Response tests --- lib/tesla/middleware/cache.ex | 42 ++----- test/tesla/middleware/cache_test.exs | 166 ++++++++++++++++++++++++++- 2 files changed, 172 insertions(+), 36 deletions(-) diff --git a/lib/tesla/middleware/cache.ex b/lib/tesla/middleware/cache.ex index 84621795..7d0711db 100644 --- a/lib/tesla/middleware/cache.ex +++ b/lib/tesla/middleware/cache.ex @@ -106,56 +106,36 @@ defmodule Tesla.Middleware.Cache do end end - defp ttl({env, cc}) do - with {:ok, max_age} <- max_age({env, cc}), - {:ok, age} <- age(env) do - max_age - age - else - _ -> 0 - end - end - - defp max_age({env, cc}) do - with nil <- cc.s_max_age, - nil <- cc.max_age do - expires(env) - else - max when is_integer(max) -> {:ok, max} - end - end + defp ttl({env, cc}), do: max_age({env, cc}) - age(env) + defp max_age({env, cc}), do: cc.s_max_age || cc.max_age || expires(env) || 0 + defp age(env), do: age_header(env) || date_header(env) || 0 defp expires(env) do with header when not is_nil(header) <- Tesla.get_header(env, "expires"), {:ok, date} <- Calendar.DateTime.Parse.httpdate(header), {:ok, seconds, _, :after} <- Calendar.DateTime.diff(date, DateTime.utc_now()) do - {:ok, seconds} + seconds else - _ -> :error - end - end - - defp age(env) do - with :error <- age_by_age_header(env) do - age_by_date_header(env) + _ -> nil end end - defp age_by_age_header(env) do + defp age_header(env) do with bin when not is_nil(bin) <- Tesla.get_header(env, "age"), {age, ""} <- Integer.parse(bin) do - {:ok, age} + age else - _ -> :error + _ -> nil end end - defp age_by_date_header(env) do + defp date_header(env) do with bin when not is_nil(bin) <- Tesla.get_header(env, "date"), {:ok, date} <- Calendar.DateTime.Parse.httpdate(bin), {:ok, seconds, _, :after} <- Calendar.DateTime.diff(DateTime.utc_now(), date) do - {:ok, seconds} + seconds else - _ -> :error + _ -> nil end end end diff --git a/test/tesla/middleware/cache_test.exs b/test/tesla/middleware/cache_test.exs index 4d6b05cb..24a24a0e 100644 --- a/test/tesla/middleware/cache_test.exs +++ b/test/tesla/middleware/cache_test.exs @@ -173,6 +173,12 @@ defmodule Tesla.Middleware.CacheTest do end end + alias Tesla.Middleware.Cache.CacheControl + alias Tesla.Middleware.Cache.Request + alias Tesla.Middleware.Cache.Response + + alias Calendar.DateTime + setup do middleware = [ {Tesla.Middleware.Cache, store: TestStore} @@ -407,8 +413,6 @@ defmodule Tesla.Middleware.CacheTest do describe "CacheControl" do # Source: https://github.com/plataformatec/faraday-http-cache/blob/master/spec/cache_control_spec.rb - alias Tesla.Middleware.Cache.CacheControl - test "takes a String with multiple name=value pairs" do cache_control = CacheControl.new("max-age=600, max-stale=300, min-fresh=570") assert cache_control.max_age == 600 @@ -510,8 +514,6 @@ defmodule Tesla.Middleware.CacheTest do end describe "Request" do - alias Tesla.Middleware.Cache.Request - test "GET request should be cacheable" do request = Request.new(%Tesla.Env{method: :get}) assert Request.cacheable?(request) == true @@ -553,7 +555,161 @@ defmodule Tesla.Middleware.CacheTest do end end - describe "Response" do + describe "Response: in shared cache" do + test "the response is not cacheable if the response is marked as private" do + headers = [{"cache-control", "private, max-age=400"}] + response = Response.new(%Tesla.Env{status: 200, headers: headers}) + + assert Response.cacheable?(response, false) == false + end + + test "the response is not cacheable if it should not be stored" do + headers = [{"cache-control", "no-store, max-age=400"}] + response = Response.new(%Tesla.Env{status: 200, headers: headers}) + + assert Response.cacheable?(response, false) == false + end + + test "the response is not cacheable when the status code is not acceptable" do + headers = [{"cache-control", "max-age=400"}] + response = Response.new(%Tesla.Env{status: 503, headers: headers}) + assert Response.cacheable?(response, false) == false + end + + test "the response is cacheable if the status code is 200 and the response is fresh" do + headers = [{"cache-control", "max-age=400"}] + response = Response.new(%Tesla.Env{status: 200, headers: headers}) + + assert Response.cacheable?(response, false) == true + end + end + + describe "Response: in private cache" do + test "the response is cacheable if the response is marked as private" do + headers = [{"cache-control", "private, max-age=400"}] + response = Response.new(%Tesla.Env{status: 200, headers: headers}) + + assert Response.cacheable?(response, true) == true + end + + test "the response is not cacheable if it should not be stored" do + headers = [{"cache-control", "no-store, max-age=400"}] + response = Response.new(%Tesla.Env{status: 200, headers: headers}) + + assert Response.cacheable?(response, true) == false + end + + test "the response is not cacheable when the status code is not acceptable" do + headers = [{"cache-control", "max-age=400"}] + response = Response.new(%Tesla.Env{status: 503, headers: headers}) + assert Response.cacheable?(response, true) == false + end + + test "the response is cacheable if the status code is 200 and the response is fresh" do + headers = [{"cache-control", "max-age=400"}] + response = Response.new(%Tesla.Env{status: 200, headers: headers}) + + assert Response.cacheable?(response, true) == true + end + end + + describe "Response: freshness" do + test "is fresh if the response still has some time to live" do + date = DateTime.now_utc() |> DateTime.subtract!(200) |> DateTime.Format.httpdate() + headers = [{"cache-control", "max-age=400"}, {"date", date}] + response = Response.new(%Tesla.Env{headers: headers}) + + assert Response.fresh?(response) == true + end + + test "is not fresh if the ttl has expired" do + date = DateTime.now_utc() |> DateTime.subtract!(500) |> DateTime.Format.httpdate() + headers = [{"cache-control", "max-age=400"}, {"date", date}] + response = Response.new(%Tesla.Env{headers: headers}) + + assert Response.fresh?(response) == false + end + + test "is not fresh if Cache-Control has 'no-cache'" do + date = DateTime.now_utc() |> DateTime.subtract!(200) |> DateTime.Format.httpdate() + headers = [{"cache-control", "max-age=400, no-cache"}, {"date", date}] + response = Response.new(%Tesla.Env{headers: headers}) + + assert Response.fresh?(response) == false + end + + test "is not fresh if Cache-Control has 'must-revalidate'" do + date = DateTime.now_utc() |> DateTime.subtract!(200) |> DateTime.Format.httpdate() + headers = [{"cache-control", "max-age=400, must-revalidate"}, {"date", date}] + response = Response.new(%Tesla.Env{headers: headers}) + + assert Response.fresh?(response) == false + end + + test "uses the s-maxage directive when present" do + headers = [{"age", "100"}, {"cache-control", "s-maxage=200, max-age=0"}] + response = Response.new(%Tesla.Env{headers: headers}) + assert Response.fresh?(response) == true + + headers = [{"age", "300"}, {"cache-control", "s-maxage=200, max-age=0"}] + response = Response.new(%Tesla.Env{headers: headers}) + assert Response.fresh?(response) == false + end + + test "uses the max-age directive when present" do + headers = [{"age", "50"}, {"cache-control", "max-age=100"}] + response = Response.new(%Tesla.Env{headers: headers}) + assert Response.fresh?(response) == true + + headers = [{"age", "150"}, {"cache-control", "max-age=100"}] + response = Response.new(%Tesla.Env{headers: headers}) + assert Response.fresh?(response) == false + end + + test "fallsback to the expiration date leftovers" do + past = DateTime.now_utc() |> DateTime.subtract!(100) |> DateTime.Format.httpdate() + now = DateTime.now_utc() |> DateTime.Format.httpdate() + future = DateTime.now_utc() |> DateTime.add!(100) |> DateTime.Format.httpdate() + + headers = [{"expires", future}, {"date", now}] + response = Response.new(%Tesla.Env{headers: headers}) + assert Response.fresh?(response) == true + + headers = [{"expires", past}, {"date", now}] + response = Response.new(%Tesla.Env{headers: headers}) + assert Response.fresh?(response) == false + end + + test "calculates the time from the 'Date' header" do + past = DateTime.now_utc() |> DateTime.subtract!(100) |> DateTime.Format.httpdate() + now = DateTime.now_utc() |> DateTime.Format.httpdate() + + headers = [{"date", now}, {"cache-control", "max-age=1"}] + response = Response.new(%Tesla.Env{headers: headers}) + assert Response.fresh?(response) == true + + headers = [{"date", past}, {"cache-control", "max-age=10"}] + response = Response.new(%Tesla.Env{headers: headers}) + assert Response.fresh?(response) == false + end + + # describe 'remove age before caching and normalize max-age if non-zero age present' do + # it 'is fresh if the response still has some time to live' do + # headers = { + # 'Age' => 6, + # 'Cache-Control' => 'public, max-age=40', + # 'Date' => (Time.now - 38).httpdate, + # 'Expires' => (Time.now - 37).httpdate, + # 'Last-Modified' => (Time.now - 300).httpdate + # } + # response = Faraday::HttpCache::Response.new(response_headers: headers) + # expect(response).to be_fresh + # + # response.serializable_hash + # expect(response.max_age).to eq(34) + # expect(response).not_to be_fresh + # end + # end end describe "binary data" do From 86a59f27ecbe5c8208f652bb114be97df8b01955 Mon Sep 17 00:00:00 2001 From: Tymon Tobolski Date: Sun, 18 Nov 2018 14:25:25 +0100 Subject: [PATCH 05/11] Naive implementation of cache by Vary header --- lib/tesla/middleware/cache.ex | 9 +++--- test/tesla/middleware/cache_test.exs | 47 +++++++++++++++++++++++----- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/lib/tesla/middleware/cache.ex b/lib/tesla/middleware/cache.ex index 7d0711db..656f46da 100644 --- a/lib/tesla/middleware/cache.ex +++ b/lib/tesla/middleware/cache.ex @@ -144,11 +144,10 @@ defmodule Tesla.Middleware.Cache do def get(store, req) do key = cache_key(req) - with {:ok, {req0, res}} <- store.get(key) do - if valid?(req, req0, res) do - {:ok, %{req | status: res.status, headers: res.headers, body: res.body}} - else - :not_found + with {:ok, list} <- store.get(key) do + case Enum.find(list, fn {req0, res} -> valid?(req, req0, res) end) do + {_, res} -> {:ok, %{req | status: res.status, headers: res.headers, body: res.body}} + nil -> :not_found end end end diff --git a/test/tesla/middleware/cache_test.exs b/test/tesla/middleware/cache_test.exs index 24a24a0e..e1a0f64c 100644 --- a/test/tesla/middleware/cache_test.exs +++ b/test/tesla/middleware/cache_test.exs @@ -12,7 +12,10 @@ defmodule Tesla.Middleware.CacheTest do end def put(key, data) do - Process.put(key, data) + case get(key) do + {:ok, list} -> Process.put(key, [data | list]) + :not_found -> Process.put(key, [data]) + end end def delete(key) do @@ -153,6 +156,15 @@ defmodule Tesla.Middleware.CacheTest do {200, [{"cache-control", "max-age=50"}, {"vary", "*"}], ""} end + defp handle(:get, "/user", env) do + body = case Tesla.get_header(env, "authorization") do + "x" -> "X" + "y" -> "Y" + end + + {200, [{"cache-control", "private, max-age=100"}, {"vary", "authorization"}], body} + end + defp handle(:get, "/image", _) do data = :crypto.strong_rand_bytes(100) @@ -332,16 +344,29 @@ defmodule Tesla.Middleware.CacheTest do end describe "when acting as a private cache" do - test "does cache requests with a private cache control", %{adapter: adapter} do - middleware = [ - {Tesla.Middleware.Cache, store: TestStore, cache_private: true} - ] - - client = Tesla.client(middleware, adapter) + setup :setup_private_cache + test "does cache requests with a private cache control", %{client: client} do refute_cached Tesla.get(client, "/private") assert_cached Tesla.get(client, "/private") end + + test "cache multiple responses with different headers according to Vary", %{client: client} do + refute_cached {:ok, env_x0} = Tesla.get(client, "/user", headers: [{"authorization", "x"}]) + assert_cached {:ok, env_x1} = Tesla.get(client, "/user", headers: [{"authorization", "x"}]) + + assert env_x0.body == "X" + assert env_x0.body == env_x1.body + + refute_cached {:ok, env_y0} = Tesla.get(client, "/user", headers: [{"authorization", "y"}]) + assert_cached {:ok, env_y1} = Tesla.get(client, "/user", headers: [{"authorization", "y"}]) + + assert env_y0.body == "Y" + assert env_y0.body == env_y1.body + + assert_cached {:ok, env_x2} = Tesla.get(client, "/user", headers: [{"authorization", "x"}]) + assert env_x0.body == env_x2.body + end end describe "when the request has a 'no-cache' directive" do @@ -724,6 +749,14 @@ defmodule Tesla.Middleware.CacheTest do end end + defp setup_private_cache(%{adapter: adapter}) do + middleware = [ + {Tesla.Middleware.Cache, store: TestStore, cache_private: true} + ] + + %{client: Tesla.client(middleware, adapter)} + end + defp assert_cached({:ok, %{method: method, url: url}}), do: refute_receive({^method, ^url, _}) defp refute_cached({:ok, %{method: method, url: url}}), do: assert_receive({^method, ^url, _}) From 22779facf16ed33355d214a49805b122e2a45ab1 Mon Sep 17 00:00:00 2001 From: Tymon Tobolski Date: Sun, 18 Nov 2018 15:36:40 +0100 Subject: [PATCH 06/11] More efficient Vary cache --- lib/tesla/middleware/cache.ex | 106 +++++++++++++++++++-------- test/tesla/middleware/cache_test.exs | 14 ++-- 2 files changed, 80 insertions(+), 40 deletions(-) diff --git a/lib/tesla/middleware/cache.ex b/lib/tesla/middleware/cache.ex index 656f46da..730ff7de 100644 --- a/lib/tesla/middleware/cache.ex +++ b/lib/tesla/middleware/cache.ex @@ -10,9 +10,10 @@ defmodule Tesla.Middleware.Cache do defmodule Store do @type key :: binary - @type response :: {Tesla.Env.status(), Tesla.Env.headers(), Tesla.Env.body()} - @type vary :: binary - @type data :: response | vary + @type entry :: + {Tesla.Env.status(), Tesla.Env.headers(), Tesla.Env.body(), Tesla.Env.headers()} + @type vary :: [binary] + @type data :: entry | vary @callback get(key) :: {:ok, data} | :not_found @callback put(key, data) :: :ok @@ -142,49 +143,90 @@ defmodule Tesla.Middleware.Cache do defmodule Storage do def get(store, req) do - key = cache_key(req) - - with {:ok, list} <- store.get(key) do - case Enum.find(list, fn {req0, res} -> valid?(req, req0, res) end) do - {_, res} -> {:ok, %{req | status: res.status, headers: res.headers, body: res.body}} - nil -> :not_found + with {:ok, {status, res_headers, body, orig_req_headers}} <- get_by_vary(store, req) do + if valid?(req.headers, orig_req_headers, res_headers) do + {:ok, %{req | status: status, headers: res_headers, body: body}} + else + :not_found end end end + defp get_by_vary(store, req) do + case store.get(key(:vary, req)) do + {:ok, [_ | _] = vary} -> store.get(key(:entry, req, vary)) + _ -> store.get(key(:entry, req)) + end + end + def put(store, req, res) do - key = cache_key(req) - store.put(key, {req, res}) + case vary(res.headers) do + nil -> + # no Vary, store under URL key + store.put(key(:entry, req), entry(req, res)) + + :wildcard -> + # * Vary, store under URL key + store.put(key(:entry, req), entry(req, res)) + + vary -> + # with Vary, store under URL key + store.put(key(:vary, req), vary) + store.put(key(:entry, req, vary), entry(req, res)) + end end - def delete(store, res) do - key = cache_key(res) - store.delete(key) + def delete(store, req) do + # check if there is stored vary for this URL + case store.get(key(:vary, req)) do + {:ok, [_ | _] = vary} -> store.delete(key(:entry, req, vary)) + _ -> store.delete(key(:entry, req)) + end end - defp cache_key(env) do - :crypto.hash(:sha256, [ - Tesla.build_url(env.url, env.query) - # Enum.map(env.headers, fn {k, v} -> "#{k}:#{v}" end) - ]) - |> Base.encode16() + defp key(:entry, env), do: key(env) <> ":entry" + + defp key(:vary, env), do: key(env) <> ":vary" + + defp key(:entry, env, vary) do + headers = vary |> Enum.map(&Tesla.get_header(env, &1)) |> Enum.filter(& &1) + key(env) <> ":entry:" <> key(headers) end - defp valid?(req, req0, res) do - case Tesla.get_header(res, "vary") do - nil -> true - "*" -> false - vary -> vary_matches?(req, req0, vary) + defp key(%{url: url, query: query}), do: key([Tesla.build_url(url, query)]) + + defp key(iodata), do: :crypto.hash(:sha256, iodata) |> Base.encode16() + + defp entry(req, res), do: {res.status, res.headers, res.body, req.headers} + + defp valid?(req_headers, orig_req_headers, res_headers) do + case vary(res_headers) do + nil -> + true + + :wildcard -> + false + + vary -> + Enum.all?(vary, fn header -> + List.keyfind(req_headers, header, 0) == List.keyfind(orig_req_headers, header, 0) + end) end end - defp vary_matches?(req, req0, vary) do - vary - |> String.downcase() - |> String.split(~r/[\s,]+/) - |> Enum.all?(fn header -> - Tesla.get_headers(req, header) == Tesla.get_headers(req0, header) - end) + defp vary(headers) do + case List.keyfind(headers, "vary", 0) do + {_, "*"} -> + :wildcard + + {_, vary} -> + vary + |> String.downcase() + |> String.split(~r/[\s,]+/) + + _ -> + nil + end end end diff --git a/test/tesla/middleware/cache_test.exs b/test/tesla/middleware/cache_test.exs index e1a0f64c..37ccc256 100644 --- a/test/tesla/middleware/cache_test.exs +++ b/test/tesla/middleware/cache_test.exs @@ -12,10 +12,7 @@ defmodule Tesla.Middleware.CacheTest do end def put(key, data) do - case get(key) do - {:ok, list} -> Process.put(key, [data | list]) - :not_found -> Process.put(key, [data]) - end + Process.put(key, data) end def delete(key) do @@ -157,10 +154,11 @@ defmodule Tesla.Middleware.CacheTest do end defp handle(:get, "/user", env) do - body = case Tesla.get_header(env, "authorization") do - "x" -> "X" - "y" -> "Y" - end + body = + case Tesla.get_header(env, "authorization") do + "x" -> "X" + "y" -> "Y" + end {200, [{"cache-control", "private, max-age=100"}, {"vary", "authorization"}], body} end From 9dea90fa510c638c95c19640d00b6186ffa3dc9d Mon Sep 17 00:00:00 2001 From: Tymon Tobolski Date: Mon, 19 Nov 2018 10:29:24 +0100 Subject: [PATCH 07/11] Some docs & cleanup --- lib/tesla/middleware/cache.ex | 43 +++++--- test/tesla/middleware/cache_test.exs | 140 +++++++++++++-------------- 2 files changed, 98 insertions(+), 85 deletions(-) diff --git a/lib/tesla/middleware/cache.ex b/lib/tesla/middleware/cache.ex index 730ff7de..bebebde3 100644 --- a/lib/tesla/middleware/cache.ex +++ b/lib/tesla/middleware/cache.ex @@ -1,17 +1,30 @@ defmodule Tesla.Middleware.Cache do @moduledoc """ - - plug Tesla.Middleware.Cache, store: MyStore + Implementation of HTTP cache Rewrite of https://github.com/plataformatec/faraday-http-cache + + ### Example + ``` + defmodule MyClient do + use Tesla + + plug Tesla.Middleware.Cache, store: MyStore + end + ``` + + ### Options + - `:store` - cache store, possible options: `Tesla.Middleware.Cache.Store.Redis` + - `:mode` - `:shared` (default) or `:private` (do cache when `Cache-Control: private`) """ @behaviour Tesla.Middleware defmodule Store do + alias Tesla.Env + @type key :: binary - @type entry :: - {Tesla.Env.status(), Tesla.Env.headers(), Tesla.Env.body(), Tesla.Env.headers()} + @type entry :: {Env.status(), Env.headers(), Env.body(), Env.headers()} @type vary :: [binary] @type data :: entry | vary @@ -95,7 +108,7 @@ defmodule Tesla.Middleware.Cache do @cacheable_status [200, 203, 300, 301, 302, 307, 404, 410] def cacheable?({_env, %{no_store?: true}}, _), do: false - def cacheable?({_env, %{private?: true}}, false), do: false + def cacheable?({_env, %{private?: true}}, :shared), do: false def cacheable?({%{status: status}, _cc}, _) when status in @cacheable_status, do: true def cacheable?({_env, _cc}, _), do: false @@ -233,19 +246,19 @@ defmodule Tesla.Middleware.Cache do @impl true def call(env, next, opts) do store = Keyword.fetch!(opts, :store) - private = Keyword.get(opts, :cache_private, false) + mode = Keyword.get(opts, :mode, :shared) request = Request.new(env) - with {:ok, {env, _}} <- process(request, next, store, private) do + with {:ok, {env, _}} <- process(request, next, store, mode) do cleanup(env, store) {:ok, env} end end - defp process(request, next, store, private) do + defp process(request, next, store, mode) do if Request.cacheable?(request) do if Request.skip_cache?(request) do - run_and_store(request, next, store, private) + run_and_store(request, next, store, mode) else case fetch(request, store) do {:ok, response} -> @@ -253,12 +266,12 @@ defmodule Tesla.Middleware.Cache do {:ok, response} else with {:ok, response} <- validate(request, response, next) do - store(request, response, store, private) + store(request, response, store, mode) end end :not_found -> - run_and_store(request, next, store, private) + run_and_store(request, next, store, mode) end end else @@ -272,9 +285,9 @@ defmodule Tesla.Middleware.Cache do end end - defp run_and_store(request, next, store, private) do + defp run_and_store(request, next, store, mode) do with {:ok, response} <- run(request, next) do - store(request, response, store, private) + store(request, response, store, mode) end end @@ -285,8 +298,8 @@ defmodule Tesla.Middleware.Cache do end end - defp store({req, _} = _request, {res, _} = response, store, private) do - if Response.cacheable?(response, private) do + defp store({req, _} = _request, {res, _} = response, store, mode) do + if Response.cacheable?(response, mode) do Storage.put(store, req, ensure_date_header(res)) end diff --git a/test/tesla/middleware/cache_test.exs b/test/tesla/middleware/cache_test.exs index 37ccc256..3639815e 100644 --- a/test/tesla/middleware/cache_test.exs +++ b/test/tesla/middleware/cache_test.exs @@ -204,37 +204,37 @@ defmodule Tesla.Middleware.CacheTest do describe "basics" do test "caches GET responses", %{client: client} do - refute_cached Tesla.get(client, "/get") - assert_cached Tesla.get(client, "/get") + refute_cached(Tesla.get(client, "/get")) + assert_cached(Tesla.get(client, "/get")) end test "does not cache POST requests", %{client: client} do - refute_cached Tesla.post(client, "/post", "hello") - refute_cached Tesla.post(client, "/post", "world") + refute_cached(Tesla.post(client, "/post", "hello")) + refute_cached(Tesla.post(client, "/post", "world")) end test "does not cache responses with 500 status code", %{client: client} do - refute_cached Tesla.get(client, "/broken") - refute_cached Tesla.get(client, "/broken") + refute_cached(Tesla.get(client, "/broken")) + refute_cached(Tesla.get(client, "/broken")) end test "differs requests with different query strings", %{client: client} do - refute_cached Tesla.get(client, "/get") - refute_cached Tesla.get(client, "/get", query: [q: "what"]) - assert_cached Tesla.get(client, "/get", query: [q: "what"]) - refute_cached Tesla.get(client, "/get", query: [q: "wat"]) + refute_cached(Tesla.get(client, "/get")) + refute_cached(Tesla.get(client, "/get", query: [q: "what"])) + assert_cached(Tesla.get(client, "/get", query: [q: "what"])) + refute_cached(Tesla.get(client, "/get", query: [q: "wat"])) end end describe "headers handling" do test "does not cache responses with a explicit no-store directive", %{client: client} do - refute_cached Tesla.get(client, "/dontstore") - refute_cached Tesla.get(client, "/dontstore") + refute_cached(Tesla.get(client, "/dontstore")) + refute_cached(Tesla.get(client, "/dontstore")) end test "does not caches multiple responses when the headers differ", %{client: client} do - refute_cached Tesla.get(client, "/get", headers: [{"accept", "text/html"}]) - assert_cached Tesla.get(client, "/get", headers: [{"accept", "text/html"}]) + refute_cached(Tesla.get(client, "/get", headers: [{"accept", "text/html"}])) + assert_cached(Tesla.get(client, "/get", headers: [{"accept", "text/html"}])) # TODO: This one fails - the reqeust IS cached. # I think faraday-http-cache specs migh have a bug @@ -242,31 +242,31 @@ defmodule Tesla.Middleware.CacheTest do end test "caches multiples responses based on the 'Vary' header", %{client: client} do - refute_cached Tesla.get(client, "/vary", headers: [{"user-agent", "Agent/1.0"}]) - assert_cached Tesla.get(client, "/vary", headers: [{"user-agent", "Agent/1.0"}]) - refute_cached Tesla.get(client, "/vary", headers: [{"user-agent", "Agent/2.0"}]) - refute_cached Tesla.get(client, "/vary", headers: [{"user-agent", "Agent/3.0"}]) + refute_cached(Tesla.get(client, "/vary", headers: [{"user-agent", "Agent/1.0"}])) + assert_cached(Tesla.get(client, "/vary", headers: [{"user-agent", "Agent/1.0"}])) + refute_cached(Tesla.get(client, "/vary", headers: [{"user-agent", "Agent/2.0"}])) + refute_cached(Tesla.get(client, "/vary", headers: [{"user-agent", "Agent/3.0"}])) end test "never caches responses with the wildcard 'Vary' header", %{client: client} do - refute_cached Tesla.get(client, "/vary-wildcard") - refute_cached Tesla.get(client, "/vary-wildcard") + refute_cached(Tesla.get(client, "/vary-wildcard")) + refute_cached(Tesla.get(client, "/vary-wildcard")) end test "caches requests with the 'Expires' header", %{client: client} do - refute_cached Tesla.get(client, "/expires") - assert_cached Tesla.get(client, "/expires") + refute_cached(Tesla.get(client, "/expires")) + assert_cached(Tesla.get(client, "/expires")) end test "sends the 'Last-Modified' header on response validation", %{client: client} do - refute_cached Tesla.get(client, "/timestamped") + refute_cached(Tesla.get(client, "/timestamped")) assert_validated({:ok, env} = Tesla.get(client, "/timestamped")) assert env.body == "1" end test "sends the 'If-None-Match' header on response validation", %{client: client} do - refute_cached Tesla.get(client, "/etag") + refute_cached(Tesla.get(client, "/etag")) assert_validated({:ok, env} = Tesla.get(client, "/etag")) assert env.body == "1" @@ -292,52 +292,52 @@ defmodule Tesla.Middleware.CacheTest do describe "cache invalidation" do test "expires POST requests", %{client: client} do - refute_cached Tesla.get(client, "/counter") - refute_cached Tesla.post(client, "/counter", "") - refute_cached Tesla.get(client, "/counter") + refute_cached(Tesla.get(client, "/counter")) + refute_cached(Tesla.post(client, "/counter", "")) + refute_cached(Tesla.get(client, "/counter")) end test "does not expires POST requests that failed", %{client: client} do - refute_cached Tesla.get(client, "/get") - refute_cached Tesla.post(client, "/get", "") - assert_cached Tesla.get(client, "/get") + refute_cached(Tesla.get(client, "/get")) + refute_cached(Tesla.post(client, "/get", "")) + assert_cached(Tesla.get(client, "/get")) end test "expires PUT requests", %{client: client} do - refute_cached Tesla.get(client, "/counter") - refute_cached Tesla.put(client, "/counter", "") - refute_cached Tesla.get(client, "/counter") + refute_cached(Tesla.get(client, "/counter")) + refute_cached(Tesla.put(client, "/counter", "")) + refute_cached(Tesla.get(client, "/counter")) end test "expires DELETE requests", %{client: client} do - refute_cached Tesla.get(client, "/counter") - refute_cached Tesla.delete(client, "/counter") - refute_cached Tesla.get(client, "/counter") + refute_cached(Tesla.get(client, "/counter")) + refute_cached(Tesla.delete(client, "/counter")) + refute_cached(Tesla.get(client, "/counter")) end test "expires PATCH requests", %{client: client} do - refute_cached Tesla.get(client, "/counter") - refute_cached Tesla.patch(client, "/counter", "") - refute_cached Tesla.get(client, "/counter") + refute_cached(Tesla.get(client, "/counter")) + refute_cached(Tesla.patch(client, "/counter", "")) + refute_cached(Tesla.get(client, "/counter")) end test "expires entries for the 'Location' header", %{client: client} do - refute_cached Tesla.get(client, "/get") - refute_cached Tesla.post(client, "/delete-with-location", "") - refute_cached Tesla.get(client, "/get") + refute_cached(Tesla.get(client, "/get")) + refute_cached(Tesla.post(client, "/delete-with-location", "")) + refute_cached(Tesla.get(client, "/get")) end test "expires entries for the 'Content-Location' header", %{client: client} do - refute_cached Tesla.get(client, "/get") - refute_cached Tesla.post(client, "/delete-with-content-location", "") - refute_cached Tesla.get(client, "/get") + refute_cached(Tesla.get(client, "/get")) + refute_cached(Tesla.post(client, "/delete-with-content-location", "")) + refute_cached(Tesla.get(client, "/get")) end end describe "when acting as a shared cache (the default)" do test "does not cache requests with a private cache control", %{client: client} do - refute_cached Tesla.get(client, "/private") - refute_cached Tesla.get(client, "/private") + refute_cached(Tesla.get(client, "/private")) + refute_cached(Tesla.get(client, "/private")) end end @@ -345,45 +345,45 @@ defmodule Tesla.Middleware.CacheTest do setup :setup_private_cache test "does cache requests with a private cache control", %{client: client} do - refute_cached Tesla.get(client, "/private") - assert_cached Tesla.get(client, "/private") + refute_cached(Tesla.get(client, "/private")) + assert_cached(Tesla.get(client, "/private")) end test "cache multiple responses with different headers according to Vary", %{client: client} do - refute_cached {:ok, env_x0} = Tesla.get(client, "/user", headers: [{"authorization", "x"}]) - assert_cached {:ok, env_x1} = Tesla.get(client, "/user", headers: [{"authorization", "x"}]) + refute_cached({:ok, env_x0} = Tesla.get(client, "/user", headers: [{"authorization", "x"}])) + assert_cached({:ok, env_x1} = Tesla.get(client, "/user", headers: [{"authorization", "x"}])) assert env_x0.body == "X" assert env_x0.body == env_x1.body - refute_cached {:ok, env_y0} = Tesla.get(client, "/user", headers: [{"authorization", "y"}]) - assert_cached {:ok, env_y1} = Tesla.get(client, "/user", headers: [{"authorization", "y"}]) + refute_cached({:ok, env_y0} = Tesla.get(client, "/user", headers: [{"authorization", "y"}])) + assert_cached({:ok, env_y1} = Tesla.get(client, "/user", headers: [{"authorization", "y"}])) assert env_y0.body == "Y" assert env_y0.body == env_y1.body - assert_cached {:ok, env_x2} = Tesla.get(client, "/user", headers: [{"authorization", "x"}]) + assert_cached({:ok, env_x2} = Tesla.get(client, "/user", headers: [{"authorization", "x"}])) assert env_x0.body == env_x2.body end end describe "when the request has a 'no-cache' directive" do test "by-passes the cache", %{client: client} do - refute_cached Tesla.get(client, "/get", headers: [{"cache-control", "no-cache"}]) - refute_cached Tesla.get(client, "/get", headers: [{"cache-control", "no-cache"}]) + refute_cached(Tesla.get(client, "/get", headers: [{"cache-control", "no-cache"}])) + refute_cached(Tesla.get(client, "/get", headers: [{"cache-control", "no-cache"}])) end test "caches the response", %{client: client} do - refute_cached Tesla.get(client, "/get", headers: [{"cache-control", "no-cache"}]) - assert_cached Tesla.get(client, "/get") + refute_cached(Tesla.get(client, "/get", headers: [{"cache-control", "no-cache"}])) + assert_cached(Tesla.get(client, "/get")) end end describe "when the response has a 'no-cache' directive" do test "always revalidate the cached response", %{client: client} do - refute_cached Tesla.get(client, "/no_cache") - refute_cached Tesla.get(client, "/no_cache") - refute_cached Tesla.get(client, "/no_cache") + refute_cached(Tesla.get(client, "/no_cache")) + refute_cached(Tesla.get(client, "/no_cache")) + refute_cached(Tesla.get(client, "/no_cache")) end end @@ -583,27 +583,27 @@ defmodule Tesla.Middleware.CacheTest do headers = [{"cache-control", "private, max-age=400"}] response = Response.new(%Tesla.Env{status: 200, headers: headers}) - assert Response.cacheable?(response, false) == false + assert Response.cacheable?(response, :shared) == false end test "the response is not cacheable if it should not be stored" do headers = [{"cache-control", "no-store, max-age=400"}] response = Response.new(%Tesla.Env{status: 200, headers: headers}) - assert Response.cacheable?(response, false) == false + assert Response.cacheable?(response, :shared) == false end test "the response is not cacheable when the status code is not acceptable" do headers = [{"cache-control", "max-age=400"}] response = Response.new(%Tesla.Env{status: 503, headers: headers}) - assert Response.cacheable?(response, false) == false + assert Response.cacheable?(response, :shared) == false end test "the response is cacheable if the status code is 200 and the response is fresh" do headers = [{"cache-control", "max-age=400"}] response = Response.new(%Tesla.Env{status: 200, headers: headers}) - assert Response.cacheable?(response, false) == true + assert Response.cacheable?(response, :shared) == true end end @@ -612,27 +612,27 @@ defmodule Tesla.Middleware.CacheTest do headers = [{"cache-control", "private, max-age=400"}] response = Response.new(%Tesla.Env{status: 200, headers: headers}) - assert Response.cacheable?(response, true) == true + assert Response.cacheable?(response, :private) == true end test "the response is not cacheable if it should not be stored" do headers = [{"cache-control", "no-store, max-age=400"}] response = Response.new(%Tesla.Env{status: 200, headers: headers}) - assert Response.cacheable?(response, true) == false + assert Response.cacheable?(response, :private) == false end test "the response is not cacheable when the status code is not acceptable" do headers = [{"cache-control", "max-age=400"}] response = Response.new(%Tesla.Env{status: 503, headers: headers}) - assert Response.cacheable?(response, true) == false + assert Response.cacheable?(response, :private) == false end test "the response is cacheable if the status code is 200 and the response is fresh" do headers = [{"cache-control", "max-age=400"}] response = Response.new(%Tesla.Env{status: 200, headers: headers}) - assert Response.cacheable?(response, true) == true + assert Response.cacheable?(response, :private) == true end end @@ -749,7 +749,7 @@ defmodule Tesla.Middleware.CacheTest do defp setup_private_cache(%{adapter: adapter}) do middleware = [ - {Tesla.Middleware.Cache, store: TestStore, cache_private: true} + {Tesla.Middleware.Cache, store: TestStore, mode: :private} ] %{client: Tesla.client(middleware, adapter)} From 0494d66dcd481b4b6527bcaac3cca5d1e0b89a0b Mon Sep 17 00:00:00 2001 From: Andrew Rosa Date: Wed, 3 Nov 2021 15:51:14 -0300 Subject: [PATCH 08/11] Add basic Store tests Extracted from original work done for Redis. --- test/tesla/middleware/cache_test.exs | 42 ++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/test/tesla/middleware/cache_test.exs b/test/tesla/middleware/cache_test.exs index 3639815e..bfe296b9 100644 --- a/test/tesla/middleware/cache_test.exs +++ b/test/tesla/middleware/cache_test.exs @@ -1,3 +1,39 @@ +defmodule Tesla.Middleware.Cache.StoreTest do + defmacro __using__(store) do + quote location: :keep do + @store unquote(store) + + @entry { + 200, + [{"vary", "user-agent"}, {"date", "Sun, 18 Nov 2018 14:40:44 GMT"}], + "Agent 1.0", + [{"user-agent", "Agent/1.0"}] + } + + test "return :not_found when empty" do + assert @store.get("KEY0:vary") == :not_found + assert @store.get("KEY0:entry") == :not_found + end + + test "put & get vary" do + @store.put("KEY0:vary", ["user-agent", "accept"]) + assert @store.get("KEY0:vary") == {:ok, ["user-agent", "accept"]} + end + + test "put & get entry" do + @store.put("KEY0:entry:VARY0", @entry) + assert @store.get("KEY0:entry:VARY0") == {:ok, @entry} + end + + test "delete" do + @store.put("KEY0:entry:VARY0", @entry) + @store.delete("KEY0:entry:VARY0") + assert @store.get("KEY0:entry:VARY0") == :not_found + end + end + end +end + defmodule Tesla.Middleware.CacheTest do use ExUnit.Case @@ -187,6 +223,8 @@ defmodule Tesla.Middleware.CacheTest do alias Tesla.Middleware.Cache.Request alias Tesla.Middleware.Cache.Response + alias Tesla.Middleware.Cache.StoreTest + alias Calendar.DateTime setup do @@ -747,6 +785,10 @@ defmodule Tesla.Middleware.CacheTest do end end + describe "TestStore" do + use StoreTest, TestStore + end + defp setup_private_cache(%{adapter: adapter}) do middleware = [ {Tesla.Middleware.Cache, store: TestStore, mode: :private} From 1e9431032a2cf3b8e802c7bdd1117ec6b90c9f6c Mon Sep 17 00:00:00 2001 From: Andrew Rosa Date: Sat, 6 Nov 2021 21:36:27 -0300 Subject: [PATCH 09/11] Pass TTL to Store put calls Pass TTL values as-is to `Store.put/3` calls. This allow implementations to be smart, like a Redis-based use the datastore TTL. --- lib/tesla/middleware/cache.ex | 17 ++++++++++------- test/tesla/middleware/cache_test.exs | 8 ++++---- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/lib/tesla/middleware/cache.ex b/lib/tesla/middleware/cache.ex index bebebde3..42c86b66 100644 --- a/lib/tesla/middleware/cache.ex +++ b/lib/tesla/middleware/cache.ex @@ -27,9 +27,10 @@ defmodule Tesla.Middleware.Cache do @type entry :: {Env.status(), Env.headers(), Env.body(), Env.headers()} @type vary :: [binary] @type data :: entry | vary + @type ttl :: integer() @callback get(key) :: {:ok, data} | :not_found - @callback put(key, data) :: :ok + @callback put(key, data, ttl) :: :ok @callback delete(key) :: :ok end @@ -120,6 +121,8 @@ defmodule Tesla.Middleware.Cache do end end + def ttl_ms({env, cc}), do: ttl({env, cc}) * 1_000 + defp ttl({env, cc}), do: max_age({env, cc}) - age(env) defp max_age({env, cc}), do: cc.s_max_age || cc.max_age || expires(env) || 0 defp age(env), do: age_header(env) || date_header(env) || 0 @@ -172,20 +175,20 @@ defmodule Tesla.Middleware.Cache do end end - def put(store, req, res) do + def put(store, req, res, ttl) do case vary(res.headers) do nil -> # no Vary, store under URL key - store.put(key(:entry, req), entry(req, res)) + store.put(key(:entry, req), entry(req, res), ttl) :wildcard -> # * Vary, store under URL key - store.put(key(:entry, req), entry(req, res)) + store.put(key(:entry, req), entry(req, res), ttl) vary -> # with Vary, store under URL key - store.put(key(:vary, req), vary) - store.put(key(:entry, req, vary), entry(req, res)) + store.put(key(:vary, req), vary, ttl) + store.put(key(:entry, req, vary), entry(req, res), ttl) end end @@ -300,7 +303,7 @@ defmodule Tesla.Middleware.Cache do defp store({req, _} = _request, {res, _} = response, store, mode) do if Response.cacheable?(response, mode) do - Storage.put(store, req, ensure_date_header(res)) + Storage.put(store, req, ensure_date_header(res), Response.ttl_ms(response)) end {:ok, response} diff --git a/test/tesla/middleware/cache_test.exs b/test/tesla/middleware/cache_test.exs index bfe296b9..ea0cd8d7 100644 --- a/test/tesla/middleware/cache_test.exs +++ b/test/tesla/middleware/cache_test.exs @@ -16,17 +16,17 @@ defmodule Tesla.Middleware.Cache.StoreTest do end test "put & get vary" do - @store.put("KEY0:vary", ["user-agent", "accept"]) + @store.put("KEY0:vary", ["user-agent", "accept"], 42) assert @store.get("KEY0:vary") == {:ok, ["user-agent", "accept"]} end test "put & get entry" do - @store.put("KEY0:entry:VARY0", @entry) + @store.put("KEY0:entry:VARY0", @entry, 42) assert @store.get("KEY0:entry:VARY0") == {:ok, @entry} end test "delete" do - @store.put("KEY0:entry:VARY0", @entry) + @store.put("KEY0:entry:VARY0", @entry, 42) @store.delete("KEY0:entry:VARY0") assert @store.get("KEY0:entry:VARY0") == :not_found end @@ -47,7 +47,7 @@ defmodule Tesla.Middleware.CacheTest do end end - def put(key, data) do + def put(key, data, _ttl) do Process.put(key, data) end From 43af2f00869ac5df7efef40bcc938fe22ba281ab Mon Sep 17 00:00:00 2001 From: Andrew Rosa Date: Sat, 6 Nov 2021 21:03:26 -0300 Subject: [PATCH 10/11] Add naive ETS cache store Simple TTL logic, inspired from ConCache - but way less robust. It's here just so items don't linger forever at the ETS. --- lib/tesla/middleware/cache.ex | 62 ++++++++++++++++++++++++++++ test/tesla/middleware/cache_test.exs | 10 +++++ 2 files changed, 72 insertions(+) diff --git a/lib/tesla/middleware/cache.ex b/lib/tesla/middleware/cache.ex index 42c86b66..1715afeb 100644 --- a/lib/tesla/middleware/cache.ex +++ b/lib/tesla/middleware/cache.ex @@ -34,6 +34,68 @@ defmodule Tesla.Middleware.Cache do @callback delete(key) :: :ok end + defmodule Store.ETS do + use GenServer + + @behaviour Store + + @ttl_interval :timer.seconds(5) + + def start_link(opts) do + opts = Keyword.put_new(opts, :name, __MODULE__) + GenServer.start_link(__MODULE__, opts, opts) + end + + @impl Store + def get(key) do + case :ets.lookup(__MODULE__, key) do + [{^key, {_exp, data}} | _rest] -> {:ok, data} + [] -> :not_found + end + end + + @impl Store + def put(key, data, ttl) do + GenServer.call(__MODULE__, {:put, key, data, ttl}) + end + + @impl Store + def delete(key) do + GenServer.call(__MODULE__, {:delete, key}) + end + + @impl GenServer + def init(_opts) do + :ets.new(__MODULE__, [:named_table]) + Process.send_after(self(), :cleanup, @ttl_interval) + {:ok, %{current_time: 0}} + end + + @impl GenServer + def handle_call({:put, key, data, ttl}, _from, state) do + steps = :erlang.ceil(ttl / @ttl_interval) + exp = state.current_time + steps + :ets.insert(__MODULE__, {key, {exp, data}}) + {:reply, :ok, state} + end + + def handle_call({:delete, key}, _from, state) do + :ets.delete(__MODULE__, key) + {:reply, :ok, state} + end + + @impl GenServer + def handle_info(:cleanup, %{current_time: current_time} = state) do + :ets.tab2list(__MODULE__) + |> Enum.each(fn {key, {exp, _data}} -> + if current_time > exp, do: :ets.delete(__MODULE__, key) + end) + + Process.send_after(self(), :cleanup, @ttl_interval) + {:noreply, %{state | current_time: current_time + 1}} + end + end + defmodule CacheControl do @moduledoc false diff --git a/test/tesla/middleware/cache_test.exs b/test/tesla/middleware/cache_test.exs index ea0cd8d7..41538722 100644 --- a/test/tesla/middleware/cache_test.exs +++ b/test/tesla/middleware/cache_test.exs @@ -789,6 +789,11 @@ defmodule Tesla.Middleware.CacheTest do use StoreTest, TestStore end + describe "Store.ETS" do + setup :setup_ets_store + use StoreTest, Tesla.Middleware.Cache.Store.ETS + end + defp setup_private_cache(%{adapter: adapter}) do middleware = [ {Tesla.Middleware.Cache, store: TestStore, mode: :private} @@ -797,6 +802,11 @@ defmodule Tesla.Middleware.CacheTest do %{client: Tesla.client(middleware, adapter)} end + defp setup_ets_store(_) do + _pid = start_supervised!({Tesla.Middleware.Cache.Store.ETS, []}) + :ok + end + defp assert_cached({:ok, %{method: method, url: url}}), do: refute_receive({^method, ^url, _}) defp refute_cached({:ok, %{method: method, url: url}}), do: assert_receive({^method, ^url, _}) From e9fa7d55a3e2476ebacc0cfcf9f29e516b7a53c5 Mon Sep 17 00:00:00 2001 From: Andrew Rosa Date: Mon, 8 Nov 2021 01:25:49 -0300 Subject: [PATCH 11/11] Add `store_opts` to allow multi-tenant caches Implementation is a bit hacky, passing forward {store, opts} tuples around, but enought to work and see how the API looks like. Not entirely happey with `Store` behaviour (it's getting too wide), but not much that can be done. One potential is to work via some sort of Registry, but that may overcomplicate the middleware as impose some avoidable overhead. --- lib/tesla/middleware/cache.ex | 67 +++++++++++++++------------- test/tesla/middleware/cache_test.exs | 42 +++++++++-------- 2 files changed, 60 insertions(+), 49 deletions(-) diff --git a/lib/tesla/middleware/cache.ex b/lib/tesla/middleware/cache.ex index 1715afeb..1a4e7355 100644 --- a/lib/tesla/middleware/cache.ex +++ b/lib/tesla/middleware/cache.ex @@ -28,10 +28,11 @@ defmodule Tesla.Middleware.Cache do @type vary :: [binary] @type data :: entry | vary @type ttl :: integer() + @type opts :: Keyword.t() - @callback get(key) :: {:ok, data} | :not_found - @callback put(key, data, ttl) :: :ok - @callback delete(key) :: :ok + @callback get(key, opts) :: {:ok, data} | :not_found + @callback put(key, data, ttl, opts) :: :ok + @callback delete(key, opts) :: :ok end defmodule Store.ETS do @@ -47,46 +48,51 @@ defmodule Tesla.Middleware.Cache do end @impl Store - def get(key) do - case :ets.lookup(__MODULE__, key) do + def get(key, opts \\ []) do + case :ets.lookup(table_name(opts), key) do [{^key, {_exp, data}} | _rest] -> {:ok, data} [] -> :not_found end end @impl Store - def put(key, data, ttl) do - GenServer.call(__MODULE__, {:put, key, data, ttl}) + def put(key, data, ttl, opts \\ []) do + server = Keyword.get(opts, :name, __MODULE__) + GenServer.call(server, {:put, key, data, ttl}) end @impl Store - def delete(key) do - GenServer.call(__MODULE__, {:delete, key}) + def delete(key, opts \\ []) do + server = Keyword.get(opts, :name, __MODULE__) + GenServer.call(server, {:delete, key}) end + defp table_name(opts), do: Keyword.get(opts, :name, __MODULE__) + @impl GenServer - def init(_opts) do - :ets.new(__MODULE__, [:named_table]) + def init(opts) do + table_name = table_name(opts) + :ets.new(table_name, [:named_table]) Process.send_after(self(), :cleanup, @ttl_interval) - {:ok, %{current_time: 0}} + {:ok, %{current_time: 0, table_name: table_name}} end @impl GenServer def handle_call({:put, key, data, ttl}, _from, state) do steps = :erlang.ceil(ttl / @ttl_interval) exp = state.current_time + steps - :ets.insert(__MODULE__, {key, {exp, data}}) + :ets.insert(state.table_name, {key, {exp, data}}) {:reply, :ok, state} end def handle_call({:delete, key}, _from, state) do - :ets.delete(__MODULE__, key) + :ets.delete(state.table_name, key) {:reply, :ok, state} end @impl GenServer def handle_info(:cleanup, %{current_time: current_time} = state) do - :ets.tab2list(__MODULE__) + :ets.tab2list(state.table_name) |> Enum.each(fn {key, {exp, _data}} -> if current_time > exp, do: :ets.delete(__MODULE__, key) end) @@ -230,35 +236,35 @@ defmodule Tesla.Middleware.Cache do end end - defp get_by_vary(store, req) do - case store.get(key(:vary, req)) do - {:ok, [_ | _] = vary} -> store.get(key(:entry, req, vary)) - _ -> store.get(key(:entry, req)) + defp get_by_vary({store, store_opts}, req) do + case store.get(key(:vary, req), store_opts) do + {:ok, [_ | _] = vary} -> store.get(key(:entry, req, vary), store_opts) + _ -> store.get(key(:entry, req), store_opts) end end - def put(store, req, res, ttl) do + def put({store, store_opts}, req, res, ttl) do case vary(res.headers) do nil -> # no Vary, store under URL key - store.put(key(:entry, req), entry(req, res), ttl) + store.put(key(:entry, req), entry(req, res), ttl, store_opts) :wildcard -> # * Vary, store under URL key - store.put(key(:entry, req), entry(req, res), ttl) + store.put(key(:entry, req), entry(req, res), ttl, store_opts) vary -> # with Vary, store under URL key - store.put(key(:vary, req), vary, ttl) - store.put(key(:entry, req, vary), entry(req, res), ttl) + store.put(key(:vary, req), vary, ttl, store_opts) + store.put(key(:entry, req, vary), entry(req, res), ttl, store_opts) end end - def delete(store, req) do + def delete({store, store_opts}, req) do # check if there is stored vary for this URL - case store.get(key(:vary, req)) do - {:ok, [_ | _] = vary} -> store.delete(key(:entry, req, vary)) - _ -> store.delete(key(:entry, req)) + case store.get(key(:vary, req), store_opts) do + {:ok, [_ | _] = vary} -> store.delete(key(:entry, req, vary), store_opts) + _ -> store.delete(key(:entry, req), store_opts) end end @@ -311,11 +317,12 @@ defmodule Tesla.Middleware.Cache do @impl true def call(env, next, opts) do store = Keyword.fetch!(opts, :store) + store_opts = Keyword.get(opts, :store_opts, []) mode = Keyword.get(opts, :mode, :shared) request = Request.new(env) - with {:ok, {env, _}} <- process(request, next, store, mode) do - cleanup(env, store) + with {:ok, {env, _}} <- process(request, next, {store, store_opts}, mode) do + cleanup(env, {store, store_opts}) {:ok, env} end end diff --git a/test/tesla/middleware/cache_test.exs b/test/tesla/middleware/cache_test.exs index 41538722..8d46496c 100644 --- a/test/tesla/middleware/cache_test.exs +++ b/test/tesla/middleware/cache_test.exs @@ -10,25 +10,27 @@ defmodule Tesla.Middleware.Cache.StoreTest do [{"user-agent", "Agent/1.0"}] } - test "return :not_found when empty" do - assert @store.get("KEY0:vary") == :not_found - assert @store.get("KEY0:entry") == :not_found + setup :ensure_store_opts + + test "return :not_found when empty", %{store_opts: store_opts} do + assert @store.get("KEY0:vary", store_opts) == :not_found + assert @store.get("KEY0:entry", store_opts) == :not_found end - test "put & get vary" do - @store.put("KEY0:vary", ["user-agent", "accept"], 42) - assert @store.get("KEY0:vary") == {:ok, ["user-agent", "accept"]} + test "put & get vary", %{store_opts: store_opts} do + @store.put("KEY0:vary", ["user-agent", "accept"], 42, store_opts) + assert @store.get("KEY0:vary", store_opts) == {:ok, ["user-agent", "accept"]} end - test "put & get entry" do - @store.put("KEY0:entry:VARY0", @entry, 42) - assert @store.get("KEY0:entry:VARY0") == {:ok, @entry} + test "put & get entry", %{store_opts: store_opts} do + @store.put("KEY0:entry:VARY0", @entry, 42, store_opts) + assert @store.get("KEY0:entry:VARY0", store_opts) == {:ok, @entry} end - test "delete" do - @store.put("KEY0:entry:VARY0", @entry, 42) - @store.delete("KEY0:entry:VARY0") - assert @store.get("KEY0:entry:VARY0") == :not_found + test "delete", %{store_opts: store_opts} do + @store.put("KEY0:entry:VARY0", @entry, 42, store_opts) + @store.delete("KEY0:entry:VARY0", store_opts) + assert @store.get("KEY0:entry:VARY0", store_opts) == :not_found end end end @@ -40,18 +42,18 @@ defmodule Tesla.Middleware.CacheTest do defmodule TestStore do @behaviour Tesla.Middleware.Cache.Store - def get(key) do + def get(key, _opts) do case Process.get(key) do nil -> :not_found data -> {:ok, data} end end - def put(key, data, _ttl) do + def put(key, data, _ttl, _opts) do Process.put(key, data) end - def delete(key) do + def delete(key, _opts) do Process.delete(key) end end @@ -794,6 +796,8 @@ defmodule Tesla.Middleware.CacheTest do use StoreTest, Tesla.Middleware.Cache.Store.ETS end + defp ensure_store_opts(state), do: Map.put_new(state, :store_opts, []) + defp setup_private_cache(%{adapter: adapter}) do middleware = [ {Tesla.Middleware.Cache, store: TestStore, mode: :private} @@ -802,9 +806,9 @@ defmodule Tesla.Middleware.CacheTest do %{client: Tesla.client(middleware, adapter)} end - defp setup_ets_store(_) do - _pid = start_supervised!({Tesla.Middleware.Cache.Store.ETS, []}) - :ok + defp setup_ets_store(%{test: test}) do + _pid = start_supervised!({Tesla.Middleware.Cache.Store.ETS, [name: test]}) + %{store_opts: [name: test]} end defp assert_cached({:ok, %{method: method, url: url}}), do: refute_receive({^method, ^url, _})