Current section

Files

Jump to
request_cache_plug lib request_cache plug.ex
Raw

lib/request_cache/plug.ex

defmodule RequestCache.Plug do
require Logger
alias RequestCache.{Util, Metrics}
@moduledoc """
This plug allows you to cache GraphQL requests based off their query name and
variables. This should be placed right after telemetry and before parsers so that it can
stop any processing of the requests and immediately return a response.
Please see `RequestCache` for more details
"""
@behaviour Plug
# This is compile time so we can check quicker
@graphql_paths RequestCache.Config.graphql_paths()
@request_cache_header "rc-cache-status"
@request_cache_key_header "rc-cache-key"
def request_cache_header, do: @request_cache_header
def request_cache_key_header, do: @request_cache_key_header
@impl Plug
def init(opts), do: opts
@impl Plug
def call(conn, opts) do
if RequestCache.Config.enabled?() do
Util.verbose_log("[RequestCache.Plug] Hit request cache while enabled")
call_for_api_type(conn, opts)
else
Util.verbose_log("[RequestCache.Plug] Hit request cache while disabled")
conn
end
end
defp call_for_api_type(%Plug.Conn{
request_path: path,
method: "GET",
query_string: query_string
} = conn, opts) when path in @graphql_paths do
Util.verbose_log("[RequestCache.Plug] GraphQL query detected")
maybe_return_cached_result(conn, opts, path, query_string)
end
defp call_for_api_type(%Plug.Conn{
request_path: path,
method: "GET"
} = conn, opts) when path not in @graphql_paths do
Util.verbose_log("[RequestCache.Plug] REST path detected")
cache_key = rest_cache_key(conn)
case request_cache_module(conn, opts).get(cache_key) do
{:ok, nil} ->
Metrics.inc_rest_cache_miss(%{cache_key: cache_key})
Util.verbose_log("[RequestCache.Plug] REST enabling cache for conn and will cache if set")
conn
|> enable_request_cache_for_conn
|> cache_before_send_if_requested(cache_key, opts)
{:ok, cached_result} ->
Metrics.inc_rest_cache_hit(%{cache_key: cache_key})
Util.verbose_log("[RequestCache.Plug] Returning cached result for #{cache_key}")
halt_and_return_result(conn, cache_key, cached_result)
{:error, e} ->
log_error(e, conn, opts)
enable_request_cache_for_conn(conn)
end
end
defp call_for_api_type(conn, _opts), do: conn
defp maybe_return_cached_result(conn, opts, request_path, query_string) do
cache_key = Util.create_key(request_path, query_string)
case request_cache_module(conn, opts).get(cache_key) do
{:ok, nil} ->
Metrics.inc_graphql_cache_miss(event_metadata(conn, cache_key, opts))
conn
|> enable_request_cache_for_conn
|> cache_before_send_if_requested(cache_key, opts)
{:ok, cached_result} ->
Metrics.inc_graphql_cache_hit(event_metadata(conn, cache_key, opts))
Util.verbose_log("[RequestCache.Plug] Returning cached result for #{cache_key}")
halt_and_return_result(conn, cache_key, cached_result)
{:error, e} ->
log_error(e, conn, opts)
enable_request_cache_for_conn(conn)
end
end
defp halt_and_return_result(conn, cache_key, result) do
conn
|> Plug.Conn.halt()
|> Plug.Conn.put_resp_header(@request_cache_header, "HIT")
|> Plug.Conn.put_resp_header(@request_cache_key_header, cache_key)
|> maybe_put_content_type(result)
|> Plug.Conn.send_resp(200, result)
end
defp maybe_put_content_type(conn, result) do
case Plug.Conn.get_resp_header(conn, "content-type") do
[_ | _] -> conn
[] ->
cond do
String.starts_with?(result, ["{", "["]) -> Plug.Conn.put_resp_content_type(conn, "application/json")
String.starts_with?(result, ["<"]) -> Plug.Conn.put_resp_content_type(conn, "text/html")
true -> conn
end
end
end
defp rest_cache_key(%Plug.Conn{request_path: path, query_string: query_string}) do
Util.create_key(path, query_string)
end
defp cache_before_send_if_requested(conn, cache_key, opts) do
Plug.Conn.register_before_send(conn, fn new_conn ->
if enabled_for_request?(new_conn) do
Util.verbose_log("[RequestCache.Plug] Cache enabled before send, setting into cache...")
ttl = request_cache_ttl(new_conn, opts)
with :ok <- request_cache_module(new_conn, opts).put(cache_key, ttl, to_string(new_conn.resp_body)) do
Metrics.inc_cache_put(event_metadata(conn, cache_key, opts))
Util.verbose_log("[RequestCache.Plug] Successfully put #{cache_key} into cache\n#{new_conn.resp_body}")
end
new_conn
else
Util.verbose_log("[RequestCache.Plug] Cache disabled in before_send callback")
new_conn
end
end)
end
@spec event_metadata(Plug.Conn.t, String.t, Keyword.t) :: map()
defp event_metadata(conn, cache_key, opts) do
%{
cache_key: cache_key,
labels: request_cache_labels(conn),
ttl: request_cache_ttl(conn, opts)
}
end
defp request_cache_module(conn, opts) do
conn_request(conn)[:cache] || opts[:cache] || RequestCache.Config.request_cache_module()
end
defp request_cache_ttl(conn, opts) do
conn_request(conn)[:ttl] || opts[:ttl] || RequestCache.Config.default_ttl()
end
defp request_cache_labels(conn) do
conn_request(conn)[:labels]
end
defp request_cache_cached_errors(conn) do
conn_request(conn)[:cached_errors] || RequestCache.Config.cached_errors()
end
defp enabled_for_request?(%Plug.Conn{} = conn) do
plug_present? = !!conn_private_key_item(conn, :enabled?)
marked_for_cache? = !!conn_private_key_item(conn, :cache_request?)
if plug_present? do
Util.verbose_log("[RequestCache.Plug] Plug enabled for request")
end
if marked_for_cache? do
Util.verbose_log("[RequestCache.Plug] Plug has been marked for cache")
end
plug_present? && marked_for_cache? && response_error_and_cached?(conn)
end
defp response_error_and_cached?(%Plug.Conn{status: 200, request_path: path}) when path not in @graphql_paths do
true
end
defp response_error_and_cached?(%Plug.Conn{status: 200, request_path: path} = conn) when path in @graphql_paths do
empty_errors? = String.contains?(conn.resp_body, empty_errors_pattern())
no_errors? = !String.contains?(conn.resp_body, error_pattern())
empty_errors? or
no_errors? or
gql_resp_has_known_error?(request_cache_cached_errors(conn), conn.resp_body)
end
defp response_error_and_cached?(%Plug.Conn{status: status} = conn) do
cached_error_codes = request_cache_cached_errors(conn)
cached_error_codes !== [] and
(cached_error_codes === :all or Plug.Conn.Status.reason_atom(status) in cached_error_codes)
end
defp gql_resp_has_known_error?([], _resp_body), do: false
defp gql_resp_has_known_error?(:all, _resp_body), do: true
defp gql_resp_has_known_error?(cached_errors, resp_body) do
String.contains?(resp_body, error_codes_pattern(cached_errors))
end
def empty_errors_pattern, do: :binary.compile_pattern("\"errors\": []")
def error_pattern, do: :binary.compile_pattern("\"errors\":")
def error_codes_pattern(cached_errors) do
cached_errors
|> Enum.flat_map(&["code\":\"#{&1}", "code\" :\"#{&1}", "code\": \"#{&1}", "code\" : \"#{&1}"])
|> :binary.compile_pattern
end
defp conn_request(%Plug.Conn{} = conn) do
conn_private_key_item(conn, :request) || []
end
defp conn_private_key_item(%Plug.Conn{private: private}, name) do
get_in(private, [conn_private_key(), name])
|| get_in(private, [:absinthe, :context, conn_private_key(), name])
end
if RequestCache.Application.dependency_found?(:absinthe_plug) do
defp enable_request_cache_for_conn(conn) do
context = conn.private[:absinthe][:context] || %{}
conn
|> deep_merge_to_private(enabled?: true)
|> Absinthe.Plug.put_options(
context: Util.deep_merge(context, %{conn_private_key() => [enabled?: true]})
)
end
else
defp enable_request_cache_for_conn(conn) do
deep_merge_to_private(conn, enabled?: true)
end
end
def store_request(conn, opts) when is_list(opts) do
if conn.private[conn_private_key()][:enabled?] do
Util.verbose_log("[RequestCache.Plug] Storing REST request in #{conn_private_key()}")
deep_merge_to_private(conn,
cache_request?: true,
request: opts
)
else
Util.log_cache_disabled_message()
conn
end
end
def store_request(conn, ttl) when is_integer(ttl) do
store_request(conn, [ttl: ttl])
end
defp conn_private_key do
RequestCache.Config.conn_private_key()
end
defp deep_merge_to_private(conn, params) do
(conn.private[conn_private_key()] || [])
|> Util.deep_merge(params)
|> then(&Plug.Conn.put_private(conn, conn_private_key(), &1))
end
defp log_error(error, conn, opts) do
{:current_stacktrace, stacktrace} = Process.info(self(), :current_stacktrace)
# credo:disable-for-lines:3
Logger.error(
"[RequestCache.Plug] recieved an error from #{inspect(request_cache_module(conn, opts))}",
[crash_reason: {error, stacktrace}]
)
end
end