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..1a4e7355 --- /dev/null +++ b/lib/tesla/middleware/cache.ex @@ -0,0 +1,431 @@ +defmodule Tesla.Middleware.Cache do + @moduledoc """ + 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 :: {Env.status(), Env.headers(), Env.body(), Env.headers()} + @type vary :: [binary] + @type data :: entry | vary + @type ttl :: integer() + @type opts :: Keyword.t() + + @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 + 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, 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, opts \\ []) do + server = Keyword.get(opts, :name, __MODULE__) + GenServer.call(server, {:put, key, data, ttl}) + end + + @impl Store + 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 + table_name = table_name(opts) + :ets.new(table_name, [:named_table]) + Process.send_after(self(), :cleanup, @ttl_interval) + {: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(state.table_name, {key, {exp, data}}) + {:reply, :ok, state} + end + + def handle_call({:delete, key}, _from, state) do + :ets.delete(state.table_name, key) + {:reply, :ok, state} + end + + @impl GenServer + def handle_info(:cleanup, %{current_time: current_time} = state) do + :ets.tab2list(state.table_name) + |> 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 + + 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}}, :shared), 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 + + 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 + + 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 + seconds + else + _ -> nil + end + end + + defp age_header(env) do + with bin when not is_nil(bin) <- Tesla.get_header(env, "age"), + {age, ""} <- Integer.parse(bin) do + age + else + _ -> nil + end + end + + 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 + seconds + else + _ -> nil + end + end + end + + defmodule Storage do + def get(store, req) do + 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, 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, 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_opts) + + :wildcard -> + # * Vary, store under URL key + 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_opts) + store.put(key(:entry, req, vary), entry(req, res), ttl, store_opts) + end + end + + def delete({store, store_opts}, req) do + # check if there is stored vary for this URL + 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 + + 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 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(headers) do + case List.keyfind(headers, "vary", 0) do + {_, "*"} -> + :wildcard + + {_, vary} -> + vary + |> String.downcase() + |> String.split(~r/[\s,]+/) + + _ -> + nil + end + end + end + + @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, store_opts}, mode) do + cleanup(env, {store, store_opts}) + {:ok, env} + end + end + + defp process(request, next, store, mode) do + if Request.cacheable?(request) do + if Request.skip_cache?(request) do + run_and_store(request, next, store, mode) + 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, mode) + end + end + + :not_found -> + run_and_store(request, next, store, mode) + 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, mode) do + with {:ok, response} <- run(request, next) do + store(request, response, store, mode) + 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, mode) do + if Response.cacheable?(response, mode) do + Storage.put(store, req, ensure_date_header(res), Response.ttl_ms(response)) + 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..8d46496c --- /dev/null +++ b/test/tesla/middleware/cache_test.exs @@ -0,0 +1,819 @@ +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"}] + } + + 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", %{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", %{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", %{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 +end + +defmodule Tesla.Middleware.CacheTest do + use ExUnit.Case + + defmodule TestStore do + @behaviour Tesla.Middleware.Cache.Store + + def get(key, _opts) do + case Process.get(key) do + nil -> :not_found + data -> {:ok, data} + end + end + + def put(key, data, _ttl, _opts) do + Process.put(key, data) + end + + def delete(key, _opts) 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, "/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) + + 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 + + alias Tesla.Middleware.Cache.CacheControl + alias Tesla.Middleware.Cache.Request + alias Tesla.Middleware.Cache.Response + + alias Tesla.Middleware.Cache.StoreTest + + alias Calendar.DateTime + + 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 + + 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 + + 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 + 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 + 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 + 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 + + 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") + + 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 + end + + describe "CacheControl" do + # Source: https://github.com/plataformatec/faraday-http-cache/blob/master/spec/cache_control_spec.rb + + 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 "Request" do + 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: 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, :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, :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, :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, :shared) == 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, :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, :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, :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, :private) == 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 + # 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 + + describe "TestStore" do + use StoreTest, TestStore + end + + describe "Store.ETS" do + setup :setup_ets_store + 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} + ] + + %{client: Tesla.client(middleware, adapter)} + end + + 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, _}) + 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