-
Notifications
You must be signed in to change notification settings - Fork 27
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #8 from amezcua/add-http-response-caching
Added caching HTTP client that will cache Google Http responses until…
- Loading branch information
Showing
9 changed files
with
249 additions
and
12 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
defmodule Jwt.Cache do | ||
@callback invalidate() :: {:ok} | {:error, any} | ||
@callback get(uri :: String.t) :: {:ok, struct} | {:error, any} | ||
@callback set(uri :: String.t, data :: struct) :: {:ok, struct} | {:error, any} | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
defmodule Jwt.Cache.Ets do | ||
use GenServer | ||
@behaviour Jwt.Cache | ||
|
||
require Logger | ||
|
||
@cache_name EtsCache | ||
@cache_table_name :certstable | ||
|
||
def start(_type, _args) do | ||
link = GenServer.start_link(__MODULE__, [], name: @cache_name) | ||
Logger.debug("Jwt.Cache.Ets Started: #{inspect link}") | ||
link | ||
end | ||
|
||
def invalidate(), do: GenServer.call(@cache_name, :invalidate) | ||
def get(uri), do: GenServer.call(@cache_name, {:get, uri}) | ||
def set(uri, data), do: GenServer.call(@cache_name, {:set, uri, data}) | ||
|
||
def init(_), do: {:ok, _invalidate()} | ||
def handle_call(:invalidate, _from, _), do: {:reply, _invalidate(), []} | ||
def handle_call({:get, uri}, _from, _), do: {:reply, _get(uri), uri} | ||
def handle_call({:set, uri, data}, _from, _), do: {:reply, _set(uri, data), uri} | ||
|
||
defp _invalidate() do | ||
:ets.info(@cache_table_name) | ||
|> create_cache | ||
|> case do | ||
@cache_table_name -> {:ok, @cache_table_name} | ||
_ -> {:error, "Failed to invalidate the cache"} | ||
end | ||
end | ||
|
||
defp create_cache(:undefined) do | ||
cache = :ets.new(@cache_table_name, [:named_table, :public]) | ||
Logger.debug("Jwt.Cache.Ets.create_cache: #{inspect cache}") | ||
cache | ||
end | ||
|
||
defp create_cache(_) do | ||
:ets.delete(@cache_table_name) | ||
create_cache(:undefined) | ||
end | ||
|
||
defp _get(uri) do | ||
value = :ets.lookup(@cache_table_name, uri) | ||
Logger.debug "#{inspect uri} -> #{inspect value}" | ||
case value do | ||
[] -> {:error, uri} | ||
_ -> {:ok, elem(Enum.at(value, 0), 1)} | ||
end | ||
end | ||
|
||
defp _set(uri, data) do | ||
Logger.debug "#{inspect uri} saving to cache." | ||
:ets.insert(@cache_table_name, {uri, data}) | ||
|> case do | ||
true -> {:ok, data} | ||
false -> {:error, uri} | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
|
||
defmodule Jwt.HttpCacheClient do | ||
require Logger | ||
@expires_header "Expires" | ||
|
||
|
||
def get!(uri, cache \\ Jwt.Cache.Ets, httpclient \\ HTTPoison) do | ||
{_result, value} = get(uri, cache, httpclient) | ||
value | ||
end | ||
|
||
def get(uri, cache \\ Jwt.Cache.Ets, httpclient \\ HTTPoison) do | ||
cache.get(uri) | ||
|> case do | ||
{:ok, cachedvalue} -> | ||
case expired_entry?(cachedvalue) do | ||
true -> request_and_cache(uri, cache, httpclient) | ||
false -> {:ok, cachedvalue} | ||
end | ||
{:error, _} -> request_and_cache(uri, cache, httpclient) | ||
end | ||
end | ||
|
||
defp expired_entry?(cachedvalue) do | ||
case Enum.find cachedvalue.headers, fn(header) -> elem(header, 0) == @expires_header end do | ||
{@expires_header, expiresvalue} -> expired_date?(expiresvalue) | ||
_ -> true | ||
end | ||
end | ||
|
||
defp request_and_cache(uri, cache, httpclient) do | ||
Logger.debug "Requesting URL: #{inspect uri}..." | ||
|
||
httpclient.get(uri) | ||
|> case do | ||
{:ok, response} -> cache_if_expires_header_present(uri, response, cache) | ||
{:error, _} -> {:error, uri} | ||
end | ||
end | ||
|
||
defp cache_if_expires_header_present(uri, response, cache) do | ||
case Enum.any?(response.headers, fn header -> elem(header, 0) == @expires_header end) do | ||
true -> cache.set(uri, response) | ||
false -> {:ok, response} | ||
end | ||
end | ||
|
||
defp expired_date?(date) do | ||
shifted_time = Timex.shift(Timex.parse!(date, "{RFC1123}"), minutes: -20) | ||
Timex.before? shifted_time, Timex.now | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,111 @@ | ||
defmodule CachingHttpClientTest do | ||
use ExUnit.Case, async: true | ||
|
||
require Logger | ||
|
||
@validtesturl "https://accounts.google.com/.well-known/openid-configuration" | ||
@invalidtesturl "https://www.google.com" | ||
@testcache Jwt.Cache.Ets | ||
|
||
setup do | ||
@testcache.invalidate() | ||
:ok | ||
end | ||
|
||
test "Response not in cache is cached" do | ||
Application.put_env(:jwt, :httpclient, HTTPoison) | ||
|
||
Jwt.HttpCacheClient.get(@validtesturl, @testcache) | ||
{result, value} = @testcache.get(@validtesturl) | ||
|
||
assert result == :ok | ||
assert value != nil | ||
end | ||
|
||
test "Invalid url is not cached" do | ||
invaliduri = "invalid" | ||
|
||
Jwt.HttpCacheClient.get(invaliduri, @testcache) | ||
|
||
assert {:error, invaliduri} == @testcache.get(invaliduri) | ||
end | ||
|
||
test "Cached values are returned instead of hitting the network" do | ||
cachedresponse = cacheableresponse(Timex.shift(Timex.now, hours: 1)) | ||
|
||
Logger.debug("Cached response is: #{inspect cachedresponse}") | ||
|
||
@testcache.set(@validtesturl, cachedresponse) | ||
|
||
{client_result, client_value} = Jwt.HttpCacheClient.get(@validtesturl, @testcache) | ||
{cached_result, cached_value} = @testcache.get(@validtesturl) | ||
|
||
assert client_result == :ok | ||
assert client_value == cachedresponse | ||
assert cached_result == :ok | ||
assert cached_value == cachedresponse | ||
end | ||
|
||
test "Do not cache responses that do not include the Expires header" do | ||
testurl = "http://fakeurl" | ||
testresponse = noncacheableresponse() | ||
Application.put_env(:jwt, :fake_response, {:ok, testresponse}) | ||
|
||
{client_result, client_value} = Jwt.HttpCacheClient.get(testurl, @testcache, CachingHttpClientTest.FakeHttpClient) | ||
{cached_result, cached_value} = @testcache.get(testurl) | ||
|
||
assert client_result == :ok | ||
assert client_value == testresponse | ||
assert cached_result == :error | ||
assert cached_value == testurl | ||
end | ||
|
||
test "Cache response if expires header present" do | ||
testurl = "http://fakeurl" | ||
response = cacheableresponse() | ||
Application.put_env(:jwt, :fake_response, {:ok, response}) | ||
|
||
Jwt.HttpCacheClient.get(testurl, @testcache, CachingHttpClientTest.FakeHttpClient) | ||
{result, value} = @testcache.get(testurl) | ||
|
||
assert result == :ok | ||
assert value == response | ||
end | ||
|
||
test "Expired responses are discarded and downloaded and cached" do | ||
testurl = "http://fakeurl" | ||
expiredresponse = cacheableresponse(Timex.shift(Timex.now, hours: -1)) | ||
currentresponse = cacheableresponse(Timex.now) | ||
Application.put_env(:jwt, :fake_response, {:ok, currentresponse}) | ||
@testcache.set(testurl, expiredresponse) | ||
|
||
{client_result, client_value} = Jwt.HttpCacheClient.get(testurl, @testcache, CachingHttpClientTest.FakeHttpClient) | ||
{cache_result, cache_value} = @testcache.get(testurl) | ||
|
||
assert client_result == :ok | ||
assert client_value == currentresponse | ||
assert cache_result == :ok | ||
assert cache_value == currentresponse | ||
end | ||
|
||
defp noncacheableresponse() do | ||
%{body: "{\n \"issuer\": \"https://accounts.google.com\",\n \"authorization_endpoint\": \"https://accounts.google.com/o/oauth2/v2/auth\",\n \"token_endpoint\": \"https://www.googleapis.com/oauth2/v4/token\",\n \"userinfo_endpoint\": \"https://www.googleapis.com/oauth2/v3/userinfo\",\n \"revocation_endpoint\": \"https://accounts.google.com/o/oauth2/revoke\",\n \"jwks_uri\": \"https://www.googleapis.com/oauth2/v3/certs\",\n \"response_types_supported\": [\n \"code\",\n \"token\",\n \"id_token\",\n \"code token\",\n \"code id_token\",\n \"token id_token\",\n \"code token id_token\",\n \"none\"\n ],\n \"subject_types_supported\": [\n \"public\"\n ],\n \"id_token_signing_alg_values_supported\": [\n \"RS256\"\n ],\n \"scopes_supported\": [\n \"openid\",\n \"email\",\n \"profile\"\n ],\n \"token_endpoint_auth_methods_supported\": [\n \"client_secret_post\",\n \"client_secret_basic\"\n ],\n \"claims_supported\": [\n \"aud\",\n \"email\",\n \"email_verified\",\n \"exp\",\n \"family_name\",\n \"given_name\",\n \"iat\",\n \"iss\",\n \"locale\",\n \"name\",\n \"picture\",\n \"sub\"\n ],\n \"code_challenge_methods_supported\": [\n \"plain\",\n \"S256\"\n ]\n}\n", | ||
headers: [ | ||
{"Vary", "Accept-Encoding"}, | ||
{"Content-Type", "application/json"}], | ||
status_code: 200} | ||
end | ||
|
||
defp cacheableresponse(expires_date \\ Timex.now) do | ||
%{body: "{\n \"issuer\": \"https://accounts.google.com\",\n \"authorization_endpoint\": \"https://accounts.google.com/o/oauth2/v2/auth\",\n \"token_endpoint\": \"https://www.googleapis.com/oauth2/v4/token\",\n \"userinfo_endpoint\": \"https://www.googleapis.com/oauth2/v3/userinfo\",\n \"revocation_endpoint\": \"https://accounts.google.com/o/oauth2/revoke\",\n \"jwks_uri\": \"https://www.googleapis.com/oauth2/v3/certs\",\n \"response_types_supported\": [\n \"code\",\n \"token\",\n \"id_token\",\n \"code token\",\n \"code id_token\",\n \"token id_token\",\n \"code token id_token\",\n \"none\"\n ],\n \"subject_types_supported\": [\n \"public\"\n ],\n \"id_token_signing_alg_values_supported\": [\n \"RS256\"\n ],\n \"scopes_supported\": [\n \"openid\",\n \"email\",\n \"profile\"\n ],\n \"token_endpoint_auth_methods_supported\": [\n \"client_secret_post\",\n \"client_secret_basic\"\n ],\n \"claims_supported\": [\n \"aud\",\n \"email\",\n \"email_verified\",\n \"exp\",\n \"family_name\",\n \"given_name\",\n \"iat\",\n \"iss\",\n \"locale\",\n \"name\",\n \"picture\",\n \"sub\"\n ],\n \"code_challenge_methods_supported\": [\n \"plain\",\n \"S256\"\n ]\n}\n", | ||
headers: [ | ||
{"Vary", "Accept-Encoding"}, | ||
{"Content-Type", "application/json"}, | ||
{"Expires", Timex.format!(expires_date, "{RFC1123}")}], | ||
status_code: 200} | ||
end | ||
|
||
defmodule FakeHttpClient do | ||
def get(_uri), do: Application.get_env(:jwt, :fake_response) | ||
end | ||
end |