parent
bce16f4557
commit
11e12b5761
@ -0,0 +1,122 @@ |
||||
# Pleroma: A lightweight social networking server |
||||
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> |
||||
# SPDX-License-Identifier: AGPL-3.0-only |
||||
|
||||
defmodule Pleroma.Plugs.Cache do |
||||
@moduledoc """ |
||||
Caches successful GET responses. |
||||
|
||||
To enable the cache add the plug to a router pipeline or controller: |
||||
|
||||
plug(Pleroma.Plugs.Cache) |
||||
|
||||
## Configuration |
||||
|
||||
To configure the plug you need to pass settings as the second argument to the `plug/2` macro: |
||||
|
||||
plug(Pleroma.Plugs.Cache, [ttl: nil, query_params: true]) |
||||
|
||||
Available options: |
||||
|
||||
- `ttl`: An expiration time (time-to-live). This value should be in milliseconds or `nil` to disable expiration. Defaults to `nil`. |
||||
- `query_params`: Take URL query string into account (`true`), ignore it (`false`) or limit to specific params only (list). Defaults to `true`. |
||||
|
||||
Additionally, you can overwrite the TTL inside a controller action by assigning `cache_ttl` to the connection struct: |
||||
|
||||
def index(conn, _params) do |
||||
ttl = 60_000 # one minute |
||||
|
||||
conn |
||||
|> assign(:cache_ttl, ttl) |
||||
|> render("index.html") |
||||
end |
||||
|
||||
""" |
||||
|
||||
import Phoenix.Controller, only: [current_path: 1, json: 2] |
||||
import Plug.Conn |
||||
|
||||
@behaviour Plug |
||||
|
||||
@defaults %{ttl: nil, query_params: true} |
||||
|
||||
@impl true |
||||
def init([]), do: @defaults |
||||
|
||||
def init(opts) do |
||||
opts = Map.new(opts) |
||||
Map.merge(@defaults, opts) |
||||
end |
||||
|
||||
@impl true |
||||
def call(%{method: "GET"} = conn, opts) do |
||||
key = cache_key(conn, opts) |
||||
|
||||
case Cachex.get(:web_resp_cache, key) do |
||||
{:ok, nil} -> |
||||
cache_resp(conn, opts) |
||||
|
||||
{:ok, record} -> |
||||
send_cached(conn, record) |
||||
|
||||
{atom, message} when atom in [:ignore, :error] -> |
||||
render_error(conn, message) |
||||
end |
||||
end |
||||
|
||||
def call(conn, _), do: conn |
||||
|
||||
# full path including query params |
||||
defp cache_key(conn, %{query_params: true}), do: current_path(conn) |
||||
|
||||
# request path without query params |
||||
defp cache_key(conn, %{query_params: false}), do: conn.request_path |
||||
|
||||
# request path with specific query params |
||||
defp cache_key(conn, %{query_params: query_params}) when is_list(query_params) do |
||||
query_string = |
||||
conn.params |
||||
|> Map.take(query_params) |
||||
|> URI.encode_query() |
||||
|
||||
conn.request_path <> "?" <> query_string |
||||
end |
||||
|
||||
defp cache_resp(conn, opts) do |
||||
register_before_send(conn, fn |
||||
%{status: 200, resp_body: body} = conn -> |
||||
ttl = Map.get(conn.assigns, :cache_ttl, opts.ttl) |
||||
key = cache_key(conn, opts) |
||||
content_type = content_type(conn) |
||||
record = {content_type, body} |
||||
|
||||
Cachex.put(:web_resp_cache, key, record, ttl: ttl) |
||||
|
||||
put_resp_header(conn, "x-cache", "MISS from Pleroma") |
||||
|
||||
conn -> |
||||
conn |
||||
end) |
||||
end |
||||
|
||||
defp content_type(conn) do |
||||
conn |
||||
|> Plug.Conn.get_resp_header("content-type") |
||||
|> hd() |
||||
end |
||||
|
||||
defp send_cached(conn, {content_type, body}) do |
||||
conn |
||||
|> put_resp_content_type(content_type, nil) |
||||
|> put_resp_header("x-cache", "HIT from Pleroma") |
||||
|> send_resp(:ok, body) |
||||
|> halt() |
||||
end |
||||
|
||||
defp render_error(conn, message) do |
||||
conn |
||||
|> put_status(:internal_server_error) |
||||
|> json(%{error: message}) |
||||
|> halt() |
||||
end |
||||
end |
@ -0,0 +1,186 @@ |
||||
# Pleroma: A lightweight social networking server |
||||
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> |
||||
# SPDX-License-Identifier: AGPL-3.0-only |
||||
|
||||
defmodule Pleroma.Plugs.CacheTest do |
||||
use ExUnit.Case, async: true |
||||
use Plug.Test |
||||
|
||||
alias Pleroma.Plugs.Cache |
||||
|
||||
@miss_resp {200, |
||||
[ |
||||
{"cache-control", "max-age=0, private, must-revalidate"}, |
||||
{"content-type", "cofe/hot; charset=utf-8"}, |
||||
{"x-cache", "MISS from Pleroma"} |
||||
], "cofe"} |
||||
|
||||
@hit_resp {200, |
||||
[ |
||||
{"cache-control", "max-age=0, private, must-revalidate"}, |
||||
{"content-type", "cofe/hot; charset=utf-8"}, |
||||
{"x-cache", "HIT from Pleroma"} |
||||
], "cofe"} |
||||
|
||||
@ttl 5 |
||||
|
||||
setup do |
||||
Cachex.clear(:web_resp_cache) |
||||
:ok |
||||
end |
||||
|
||||
test "caches a response" do |
||||
assert @miss_resp == |
||||
conn(:get, "/") |
||||
|> Cache.call(%{query_params: false, ttl: nil}) |
||||
|> put_resp_content_type("cofe/hot") |
||||
|> send_resp(:ok, "cofe") |
||||
|> sent_resp() |
||||
|
||||
assert_raise(Plug.Conn.AlreadySentError, fn -> |
||||
conn(:get, "/") |
||||
|> Cache.call(%{query_params: false, ttl: nil}) |
||||
|> put_resp_content_type("cofe/hot") |
||||
|> send_resp(:ok, "cofe") |
||||
|> sent_resp() |
||||
end) |
||||
|
||||
assert @hit_resp == |
||||
conn(:get, "/") |
||||
|> Cache.call(%{query_params: false, ttl: nil}) |
||||
|> sent_resp() |
||||
end |
||||
|
||||
test "ttl is set" do |
||||
assert @miss_resp == |
||||
conn(:get, "/") |
||||
|> Cache.call(%{query_params: false, ttl: @ttl}) |
||||
|> put_resp_content_type("cofe/hot") |
||||
|> send_resp(:ok, "cofe") |
||||
|> sent_resp() |
||||
|
||||
assert @hit_resp == |
||||
conn(:get, "/") |
||||
|> Cache.call(%{query_params: false, ttl: @ttl}) |
||||
|> sent_resp() |
||||
|
||||
:timer.sleep(@ttl + 1) |
||||
|
||||
assert @miss_resp == |
||||
conn(:get, "/") |
||||
|> Cache.call(%{query_params: false, ttl: @ttl}) |
||||
|> put_resp_content_type("cofe/hot") |
||||
|> send_resp(:ok, "cofe") |
||||
|> sent_resp() |
||||
end |
||||
|
||||
test "set ttl via conn.assigns" do |
||||
assert @miss_resp == |
||||
conn(:get, "/") |
||||
|> Cache.call(%{query_params: false, ttl: nil}) |
||||
|> put_resp_content_type("cofe/hot") |
||||
|> assign(:cache_ttl, @ttl) |
||||
|> send_resp(:ok, "cofe") |
||||
|> sent_resp() |
||||
|
||||
assert @hit_resp == |
||||
conn(:get, "/") |
||||
|> Cache.call(%{query_params: false, ttl: nil}) |
||||
|> sent_resp() |
||||
|
||||
:timer.sleep(@ttl + 1) |
||||
|
||||
assert @miss_resp == |
||||
conn(:get, "/") |
||||
|> Cache.call(%{query_params: false, ttl: nil}) |
||||
|> put_resp_content_type("cofe/hot") |
||||
|> send_resp(:ok, "cofe") |
||||
|> sent_resp() |
||||
end |
||||
|
||||
test "ignore query string when `query_params` is false" do |
||||
assert @miss_resp == |
||||
conn(:get, "/?cofe") |
||||
|> Cache.call(%{query_params: false, ttl: nil}) |
||||
|> put_resp_content_type("cofe/hot") |
||||
|> send_resp(:ok, "cofe") |
||||
|> sent_resp() |
||||
|
||||
assert @hit_resp == |
||||
conn(:get, "/?cofefe") |
||||
|> Cache.call(%{query_params: false, ttl: nil}) |
||||
|> sent_resp() |
||||
end |
||||
|
||||
test "take query string into account when `query_params` is true" do |
||||
assert @miss_resp == |
||||
conn(:get, "/?cofe") |
||||
|> Cache.call(%{query_params: true, ttl: nil}) |
||||
|> put_resp_content_type("cofe/hot") |
||||
|> send_resp(:ok, "cofe") |
||||
|> sent_resp() |
||||
|
||||
assert @miss_resp == |
||||
conn(:get, "/?cofefe") |
||||
|> Cache.call(%{query_params: true, ttl: nil}) |
||||
|> put_resp_content_type("cofe/hot") |
||||
|> send_resp(:ok, "cofe") |
||||
|> sent_resp() |
||||
end |
||||
|
||||
test "take specific query params into account when `query_params` is list" do |
||||
assert @miss_resp == |
||||
conn(:get, "/?a=1&b=2&c=3&foo=bar") |
||||
|> fetch_query_params() |
||||
|> Cache.call(%{query_params: ["a", "b", "c"], ttl: nil}) |
||||
|> put_resp_content_type("cofe/hot") |
||||
|> send_resp(:ok, "cofe") |
||||
|> sent_resp() |
||||
|
||||
assert @hit_resp == |
||||
conn(:get, "/?bar=foo&c=3&b=2&a=1") |
||||
|> fetch_query_params() |
||||
|> Cache.call(%{query_params: ["a", "b", "c"], ttl: nil}) |
||||
|> sent_resp() |
||||
|
||||
assert @miss_resp == |
||||
conn(:get, "/?bar=foo&c=3&b=2&a=2") |
||||
|> fetch_query_params() |
||||
|> Cache.call(%{query_params: ["a", "b", "c"], ttl: nil}) |
||||
|> put_resp_content_type("cofe/hot") |
||||
|> send_resp(:ok, "cofe") |
||||
|> sent_resp() |
||||
end |
||||
|
||||
test "ignore not GET requests" do |
||||
expected = |
||||
{200, |
||||
[ |
||||
{"cache-control", "max-age=0, private, must-revalidate"}, |
||||
{"content-type", "cofe/hot; charset=utf-8"} |
||||
], "cofe"} |
||||
|
||||
assert expected == |
||||
conn(:post, "/") |
||||
|> Cache.call(%{query_params: true, ttl: nil}) |
||||
|> put_resp_content_type("cofe/hot") |
||||
|> send_resp(:ok, "cofe") |
||||
|> sent_resp() |
||||
end |
||||
|
||||
test "ignore non-successful responses" do |
||||
expected = |
||||
{418, |
||||
[ |
||||
{"cache-control", "max-age=0, private, must-revalidate"}, |
||||
{"content-type", "tea/iced; charset=utf-8"} |
||||
], "🥤"} |
||||
|
||||
assert expected == |
||||
conn(:get, "/cofe") |
||||
|> Cache.call(%{query_params: true, ttl: nil}) |
||||
|> put_resp_content_type("tea/iced") |
||||
|> send_resp(:im_a_teapot, "🥤") |
||||
|> sent_resp() |
||||
end |
||||
end |
Loading…
Reference in new issue