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/.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: 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: diff --git a/lib/tesla/middleware/cache.ex b/lib/tesla/middleware/cache.ex new file mode 100644 index 00000000..c6f6f7d6 --- /dev/null +++ b/lib/tesla/middleware/cache.ex @@ -0,0 +1,393 @@ +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 + + @callback get(key) :: {:ok, data} | :not_found + @callback put(key, data) :: :ok + @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 + + 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 + + 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, 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 + 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, 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 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) + mode = Keyword.get(opts, :mode, :shared) + request = Request.new(env) + + with {:ok, {env, _}} <- process(request, next, store, mode) do + cleanup(env, store) + {: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)) + 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..5687bc66 100644 --- a/mix.exs +++ b/mix.exs @@ -72,6 +72,8 @@ defmodule Tesla.Mixfile do # other {: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 d2e1a14c..7dc3b8b0 100644 --- a/mix.lock +++ b/mix.lock @@ -1,14 +1,15 @@ %{ - "castore": {:hex, :castore, "0.1.2", "81adb0683c4ec8ebb97ad777ec1b050405282d55453df14567a3c73ae25932a6", [:mix], [], "hexpm"}, + "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.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,7 +32,9 @@ "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"}, + "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"}, } diff --git a/test/tesla/middleware/cache_test.exs b/test/tesla/middleware/cache_test.exs new file mode 100644 index 00000000..e3dfabe8 --- /dev/null +++ b/test/tesla/middleware/cache_test.exs @@ -0,0 +1,816 @@ +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 + + 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, "/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.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, mode: :private} + ] + + %{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, _}) + + defp assert_validated({:ok, %{method: method, url: url}}), + do: assert_receive({^method, ^url, 304}) +end