From f5d6cae2068d7a91696bf76a1af46823d971afa9 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 a2a9fc0b..0ec16b78 100644 --- a/mix.exs +++ b/mix.exs @@ -72,6 +72,7 @@ defmodule Tesla.Mixfile do # other {:fuse, "~> 2.4", optional: true}, {:telemetry, "~> 0.3", optional: true}, + {:calendar, "~> 0.17", optional: true}, # testing & docs {:excoveralls, "~> 0.8", only: :test}, diff --git a/mix.lock b/mix.lock index d2e1a14c..c7f1bf11 100644 --- a/mix.lock +++ b/mix.lock @@ -1,4 +1,5 @@ %{ + "calendar": {:hex, :calendar, "0.18.0", "3379e03694b9faab085fa2b96fc033644395c23170248c8ce0db78e11c88056a", [:mix], [{:tzdata, "~> 0.5.20 or ~> 0.1.201603 or ~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, "castore": {:hex, :castore, "0.1.2", "81adb0683c4ec8ebb97ad777ec1b050405282d55453df14567a3c73ae25932a6", [:mix], [], "hexpm"}, "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, "con_cache": {:hex, :con_cache, "0.13.1", "047e097ab2a8c6876e12d0c29e29a86d487b592df97b98e3e2abedad574e215d", [:mix], [], "hexpm"}, @@ -33,5 +34,6 @@ "ranch": {:hex, :ranch, "1.6.2", "6db93c78f411ee033dbb18ba8234c5574883acb9a75af0fb90a9b82ea46afa00", [:rebar3], [], "hexpm"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, "telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"}, + "tzdata": {:hex, :tzdata, "1.0.1", "f6027a331af7d837471248e62733c6ebee86a72e57c613aa071ebb1f750fc71a", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, } 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 be7a7c66e07e155a6f808bb802caf23d53cb5ac2 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 c9fc585b72f37fc6e13702d0ec8a1f4a6a41a6ae 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 b8f68b6a0e971fb2866652c55b8912fb25693496 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 e57a2642466fa0c905a15c0cd391a452ecf7067e 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 0f54ffd329c219ca01ac4605d97bb6b2efbcb86c 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 0500f9b238c38fa67a17e6f1d9cd141215b6cc68 Mon Sep 17 00:00:00 2001 From: Tymon Tobolski Date: Sun, 18 Nov 2018 16:10:35 +0100 Subject: [PATCH 07/11] Dummy redis store --- lib/tesla/middleware/cache.ex | 34 ++++++++++++++++++ mix.exs | 1 + mix.lock | 10 +++--- test/tesla/middleware/cache_test.exs | 53 ++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 5 deletions(-) diff --git a/lib/tesla/middleware/cache.ex b/lib/tesla/middleware/cache.ex index 730ff7de..bf7e1607 100644 --- a/lib/tesla/middleware/cache.ex +++ b/lib/tesla/middleware/cache.ex @@ -20,6 +20,40 @@ defmodule Tesla.Middleware.Cache do @callback delete(key) :: :ok end + defmodule Store.Redis do + @behaviour Store + + @redis :redis + + def get(key) do + case command(["GET", key]) do + {:ok, data} -> + case decode(data) do + nil -> :not_found + data -> {:ok, data} + end + + _ -> + :not_found + end + end + + def put(key, data) do + command!(["SET", key, encode(data)]) + end + + def delete(key) do + command!(["DEL", key]) + end + + defp encode(data), do: :erlang.term_to_binary(data) + defp decode(nil), do: nil + defp decode(bin), do: :erlang.binary_to_term(bin, [:safe]) + + defp command(args), do: Redix.command(@redis, args) + defp command!(args), do: Redix.command!(@redis, args) + end + defmodule CacheControl do @moduledoc false diff --git a/mix.exs b/mix.exs index 0ec16b78..5687bc66 100644 --- a/mix.exs +++ b/mix.exs @@ -73,6 +73,7 @@ defmodule Tesla.Mixfile do {:fuse, "~> 2.4", optional: true}, {:telemetry, "~> 0.3", optional: true}, {:calendar, "~> 0.17", optional: true}, + {:redix, "~> 0.7", optional: true}, # testing & docs {:excoveralls, "~> 0.8", only: :test}, diff --git a/mix.lock b/mix.lock index c7f1bf11..33a622db 100644 --- a/mix.lock +++ b/mix.lock @@ -31,9 +31,9 @@ "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm"}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, - "ranch": {:hex, :ranch, "1.6.2", "6db93c78f411ee033dbb18ba8234c5574883acb9a75af0fb90a9b82ea46afa00", [:rebar3], [], "hexpm"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, - "telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"}, - "tzdata": {:hex, :tzdata, "1.0.1", "f6027a331af7d837471248e62733c6ebee86a72e57c613aa071ebb1f750fc71a", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, + "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], [], "hexpm"}, + "redix": {:hex, :redix, "0.8.2", "c25158f905bcf8842e9a11411d65b9257ac70057c4330521d1a4d2a44b4f7ecf", [:mix], [], "hexpm"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"}, + "tzdata": {:hex, :tzdata, "0.5.19", "7962a3997bf06303b7d1772988ede22260f3dae1bf897408ebdac2b4435f4e6a", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"}, } diff --git a/test/tesla/middleware/cache_test.exs b/test/tesla/middleware/cache_test.exs index 37ccc256..3c2120f2 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,15 @@ defmodule Tesla.Middleware.CacheTest do end end + describe "TestStore" do + use StoreTest, TestStore + end + + describe "Store.Redis" do + setup :setup_redis_store + use StoreTest, Tesla.Middleware.Cache.Store.Redis + end + defp setup_private_cache(%{adapter: adapter}) do middleware = [ {Tesla.Middleware.Cache, store: TestStore, cache_private: true} @@ -755,6 +802,12 @@ defmodule Tesla.Middleware.CacheTest do %{client: Tesla.client(middleware, adapter)} end + defp setup_redis_store(_) do + {:ok, _conn} = Redix.start_link("redis://localhost:6379/15", name: :redis) + Redix.command!(:redis, ["FLUSHALL"]) + :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 8eb0dc78aa030448548ae5bcee2214707f0b0f87 Mon Sep 17 00:00:00 2001 From: Tymon Tobolski Date: Mon, 19 Nov 2018 10:29:24 +0100 Subject: [PATCH 08/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 bf7e1607..c6f6f7d6 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 @@ -129,7 +142,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 @@ -267,19 +280,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} -> @@ -287,12 +300,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 @@ -306,9 +319,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 @@ -319,8 +332,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 3c2120f2..e3dfabe8 100644 --- a/test/tesla/middleware/cache_test.exs +++ b/test/tesla/middleware/cache_test.exs @@ -242,37 +242,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 @@ -280,31 +280,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" @@ -330,52 +330,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 @@ -383,45 +383,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 @@ -621,27 +621,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 @@ -650,27 +650,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 @@ -796,7 +796,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 4ef79ed35a31804292470c255ed6ae78cb96f1dc Mon Sep 17 00:00:00 2001 From: Tymon Tobolski Date: Mon, 19 Nov 2018 10:57:17 +0100 Subject: [PATCH 09/11] Add redis to travis configuration --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index bd6e568a..97c14cf9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,6 @@ language: elixir +services: + - redis-server env: - MIX_ENV=test matrix: From 45b21b0ac8275dd71f5b05456e4f2356dd7e788c Mon Sep 17 00:00:00 2001 From: Tymon Tobolski Date: Thu, 5 Sep 2019 16:53:39 +0200 Subject: [PATCH 10/11] Update dependencies --- mix.lock | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/mix.lock b/mix.lock index 33a622db..7dc3b8b0 100644 --- a/mix.lock +++ b/mix.lock @@ -1,15 +1,15 @@ %{ "calendar": {:hex, :calendar, "0.18.0", "3379e03694b9faab085fa2b96fc033644395c23170248c8ce0db78e11c88056a", [:mix], [{:tzdata, "~> 0.5.20 or ~> 0.1.201603 or ~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, - "castore": {:hex, :castore, "0.1.2", "81adb0683c4ec8ebb97ad777ec1b050405282d55453df14567a3c73ae25932a6", [:mix], [], "hexpm"}, + "castore": {:hex, :castore, "0.1.3", "61d720c168d8e3a7d96f188f73d50d7ec79aa619cdabf0acd3782b01ff3a9f10", [:mix], [], "hexpm"}, "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, "con_cache": {:hex, :con_cache, "0.13.1", "047e097ab2a8c6876e12d0c29e29a86d487b592df97b98e3e2abedad574e215d", [:mix], [], "hexpm"}, "cowboy": {:hex, :cowboy, "2.5.0", "4ef3ae066ee10fe01ea3272edc8f024347a0d3eb95f6fbb9aed556dacbfc1337", [:rebar3], [{:cowlib, "~> 2.6.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.6.2", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, "cowlib": {:hex, :cowlib, "2.6.0", "8aa629f81a0fc189f261dc98a42243fa842625feea3c7ec56c48f4ccdb55490f", [:rebar3], [], "hexpm"}, "dialyxir": {:hex, :dialyxir, "1.0.0-rc.6", "78e97d9c0ff1b5521dd68041193891aebebce52fc3b93463c0a6806874557d7d", [:mix], [{:erlex, "~> 0.2.1", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm"}, - "earmark": {:hex, :earmark, "1.3.5", "0db71c8290b5bc81cb0101a2a507a76dca659513984d683119ee722828b424f6", [:mix], [], "hexpm"}, + "earmark": {:hex, :earmark, "1.4.0", "397e750b879df18198afc66505ca87ecf6a96645545585899f6185178433cc09", [:mix], [], "hexpm"}, "erlex": {:hex, :erlex, "0.2.4", "23791959df45fe8f01f388c6f7eb733cc361668cbeedd801bf491c55a029917b", [:mix], [], "hexpm"}, - "ex_doc": {:hex, :ex_doc, "0.21.1", "5ac36660846967cd869255f4426467a11672fec3d8db602c429425ce5b613b90", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, - "excoveralls": {:hex, :excoveralls, "0.11.1", "dd677fbdd49114fdbdbf445540ec735808250d56b011077798316505064edb2c", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, + "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, + "excoveralls": {:hex, :excoveralls, "0.11.2", "0c6f2c8db7683b0caa9d490fb8125709c54580b4255ffa7ad35f3264b075a643", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, "file_system": {:hex, :file_system, "0.2.7", "e6f7f155970975789f26e77b8b8d8ab084c59844d8ecfaf58cbda31c494d14aa", [:mix], [], "hexpm"}, "fuse": {:hex, :fuse, "2.4.2", "9106b08db8793a34cc156177d7e24c41bd638ee1b28463cb76562fde213e8ced", [:rebar3], [], "hexpm"}, @@ -31,9 +31,10 @@ "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm"}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, - "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], [], "hexpm"}, - "redix": {:hex, :redix, "0.8.2", "c25158f905bcf8842e9a11411d65b9257ac70057c4330521d1a4d2a44b4f7ecf", [:mix], [], "hexpm"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"}, - "tzdata": {:hex, :tzdata, "0.5.19", "7962a3997bf06303b7d1772988ede22260f3dae1bf897408ebdac2b4435f4e6a", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"}, + "ranch": {:hex, :ranch, "1.6.2", "6db93c78f411ee033dbb18ba8234c5574883acb9a75af0fb90a9b82ea46afa00", [:rebar3], [], "hexpm"}, + "redix": {:hex, :redix, "0.10.2", "a9eabf47898aa878650df36194aeb63966d74f5bd69d9caa37babb32dbb93c5d", [:mix], [{:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, + "telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"}, + "tzdata": {:hex, :tzdata, "1.0.1", "f6027a331af7d837471248e62733c6ebee86a72e57c613aa071ebb1f750fc71a", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, } From e300dba19cbfd8a9c5d8eb2610d9ffab9a0d2c1d Mon Sep 17 00:00:00 2001 From: Tymon Tobolski Date: Thu, 5 Sep 2019 16:55:40 +0200 Subject: [PATCH 11/11] Add redis to CI services --- .github/workflows/test.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 936e2eeb..108f6d8e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,11 @@ on: push jobs: test: runs-on: ubuntu-latest + services: + redis: + image: redis + ports: + - 6379/tcp strategy: matrix: elixir: @@ -57,6 +62,11 @@ jobs: test-poison3: runs-on: ubuntu-latest + services: + redis: + image: redis + ports: + - 6379/tcp container: image: elixir:1.9-slim steps: