Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add caching #54

Merged
merged 5 commits into from
Sep 17, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class LayoutPagesController < JsonApi::Admin::PagesController
private

def page_scope
page_scope_with_includes.layoutpages
base_page_scope.layoutpages
end
end
end
Expand Down
17 changes: 17 additions & 0 deletions app/controllers/alchemy/json_api/admin/pages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,26 @@ module JsonApi
module Admin
class PagesController < JsonApi::PagesController
prepend_before_action { authorize! :edit_content, Alchemy::Page }
before_action :set_current_preview, only: :show

private

def cache_duration
0
end

def caching_options
{ public: false, must_revalidate: true }
end

def set_current_preview
Alchemy::Page.current_preview = @page
end

def last_modified_for(page)
page.updated_at
end

def page_version_type
:draft_version
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ class LayoutPagesController < JsonApi::PagesController
private

def page_scope
page_scope_with_includes.layoutpages
base_page_scope.layoutpages
end
end
end
Expand Down
58 changes: 44 additions & 14 deletions app/controllers/alchemy/json_api/pages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,57 @@
module Alchemy
module JsonApi
class PagesController < JsonApi::BaseController
before_action :load_page, only: :show
before_action :load_page_for_cache_key, only: :show

def index
allowed = [:page_layout, :urlname]

jsonapi_filter(page_scope, allowed) do |filtered|
# decorate with our page model that has a eager loaded elements collection
pages = filtered.result.map { |page| api_page(page) }
jsonapi_paginate(pages) do |paginated|
render jsonapi: paginated
jsonapi_filter(page_scope, allowed) do |filtered_pages|
@pages = filtered_pages.result
if stale?(last_modified: @pages.maximum(:published_at), etag: @pages.max_by(&:cache_key).cache_key)
# Only load pages with all includes when browser cache is stale
jsonapi_filter(page_scope_with_includes, allowed) do |filtered|
# decorate with our page model that has a eager loaded elements collection
filtered_pages = filtered.result.map { |page| api_page(page) }
jsonapi_paginate(filtered_pages) do |paginated|
render jsonapi: paginated
end
end
end
end

expires_in cache_duration, { public: @pages.none?(&:restricted?) }.merge(caching_options)
end

def show
render jsonapi: api_page(@page)
if stale?(last_modified: last_modified_for(@page), etag: @page.cache_key)
# Only load page with all includes when browser cache is stale
render jsonapi: api_page(load_page)
end

expires_in cache_duration, { public: !@page.restricted? }.merge(caching_options)
end

private

def cache_duration
ENV.fetch("ALCHEMY_JSON_API_CACHE_DURATION", 3).to_i.hours
end

def caching_options
{ must_revalidate: true }
end

# Get page w/o includes to get cache key
def load_page_for_cache_key
@page = page_scope.where(id: params[:path]).
or(page_scope.where(urlname: params[:path])).first!
end

def last_modified_for(page)
page.published_at
end

def jsonapi_meta(pages)
pagination = jsonapi_pagination_meta(pages)

Expand All @@ -38,20 +69,19 @@ def load_page

def load_page_by_id
return unless params[:path] =~ /\A\d+\z/
page_scope.find_by(id: params[:path])
page_scope_with_includes.find_by(id: params[:path])
end

def load_page_by_urlname
page_scope.find_by(urlname: params[:path])
page_scope_with_includes.find_by(urlname: params[:path])
end

def page_scope
page_scope_with_includes.contentpages
base_page_scope.contentpages
end

def page_scope_with_includes
base_page_scope.
where(language: Language.current).
page_scope.
includes(
[
:legacy_urls,
Expand Down Expand Up @@ -80,9 +110,9 @@ def api_page(page)
def base_page_scope
# cancancan is not able to merge our complex AR scopes for logged in users
if can?(:edit_content, ::Alchemy::Page)
Alchemy::Page.all.joins(page_version_type)
Alchemy::Language.current.pages.joins(page_version_type)
else
Alchemy::Page.published.joins(page_version_type)
Alchemy::Language.current.pages.published.joins(page_version_type)
end
end

Expand Down
2 changes: 2 additions & 0 deletions app/serializers/alchemy/json_api/element_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ class ElementSerializer
:updated_at,
)

cache_options store: Rails.cache, namespace: "alchemy-jsonapi"

attribute :deprecated do |element|
!!element.definition[:deprecated]
end
Expand Down
2 changes: 2 additions & 0 deletions app/serializers/alchemy/json_api/page_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class PageSerializer
:updated_at,
)

cache_options store: Rails.cache, namespace: "alchemy-jsonapi"

attribute :legacy_urls do |page|
page.legacy_urls.map(&:urlname)
end
Expand Down
23 changes: 23 additions & 0 deletions spec/controllers/alchemy/json_api/admin/pages_controller_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

require "rails_helper"
require "alchemy/devise/test_support/factories"
require "alchemy/test_support/integration_helpers"

RSpec.describe Alchemy::JsonApi::Admin::PagesController do
include Devise::Test::ControllerHelpers
include Alchemy::TestSupport::IntegrationHelpers

routes { Alchemy::JsonApi::Engine.routes }

before { authorize_user(FactoryBot.build(:alchemy_author_user)) }

describe "#show" do
let(:page) { FactoryBot.create(:alchemy_page) }

it "stores page as preview" do
get :show, params: { path: page.urlname }
expect(Alchemy::Page.current_preview).to eq(page.id)
end
end
end
20 changes: 20 additions & 0 deletions spec/requests/alchemy/json_api/admin/layout_pages_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,26 @@
}
)
end

it "sets cache headers" do
get alchemy_json_api.admin_layout_page_path(page)
expect(response.headers["Last-Modified"]).to eq(page.updated_at.utc.httpdate)
expect(response.headers["ETag"]).to match(/W\/".+"/)
expect(response.headers["Cache-Control"]).to eq("max-age=0, private, must-revalidate")
end

context "if browser sends fresh cache headers" do
it "returns not modified" do
get alchemy_json_api.admin_layout_page_path(page)
etag = response.headers["ETag"]
get alchemy_json_api.admin_layout_page_path(page),
headers: {
"If-Modified-Since" => page.updated_at.utc.httpdate,
"If-None-Match" => etag,
}
expect(response.status).to eq(304)
end
end
end
end

Expand Down
20 changes: 20 additions & 0 deletions spec/requests/alchemy/json_api/admin/pages_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,26 @@

let(:document) { JSON.parse(response.body) }

it "sets cache headers" do
get alchemy_json_api.admin_page_path(page)
expect(response.headers["Last-Modified"]).to eq(page.updated_at.utc.httpdate)
expect(response.headers["ETag"]).to match(/W\/".+"/)
expect(response.headers["Cache-Control"]).to eq("max-age=0, private, must-revalidate")
end

context "if browser sends fresh cache headers" do
it "returns not modified" do
get alchemy_json_api.admin_page_path(page)
etag = response.headers["ETag"]
get alchemy_json_api.admin_page_path(page),
headers: {
"If-Modified-Since" => page.updated_at.utc.httpdate,
"If-None-Match" => etag,
}
expect(response.status).to eq(304)
end
end

it "gets a valid JSON:API document" do
subject
expect(response).to have_http_status(200)
Expand Down
2 changes: 1 addition & 1 deletion spec/requests/alchemy/json_api/layout_pages_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
end

context "when the language is incorrect" do
let!(:language) { FactoryBot.create(:alchemy_language) }
let!(:language) { Alchemy::Language.first || FactoryBot.create(:alchemy_language) }
let!(:other_language) { FactoryBot.create(:alchemy_language, :german) }
let(:page) { FactoryBot.create(:alchemy_page, :public, :layoutpage, language: other_language) }

Expand Down
109 changes: 107 additions & 2 deletions spec/requests/alchemy/json_api/pages_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,52 @@
end

describe "GET /alchemy/json_api/pages/:id" do
context "a published page" do
let(:page) do
FactoryBot.create(
:alchemy_page,
:public,
published_at: DateTime.yesterday,
)
end

it "sets public cache headers" do
get alchemy_json_api.page_path(page)
expect(response.headers["Last-Modified"]).to eq(page.published_at.utc.httpdate)
expect(response.headers["ETag"]).to match(/W\/".+"/)
expect(response.headers["Cache-Control"]).to eq("max-age=10800, public, must-revalidate")
end

context "if page is restricted" do
let(:page) do
FactoryBot.create(
:alchemy_page,
:public,
:restricted,
published_at: DateTime.yesterday,
)
end

it "sets private cache headers" do
get alchemy_json_api.page_path(page)
expect(response.headers["Cache-Control"]).to eq("max-age=10800, private, must-revalidate")
end
end

context "if browser sends fresh cache headers" do
it "returns not modified" do
get alchemy_json_api.page_path(page)
etag = response.headers["ETag"]
get alchemy_json_api.page_path(page),
headers: {
"If-Modified-Since" => page.published_at.utc.httpdate,
"If-None-Match" => etag,
}
expect(response.status).to eq(304)
end
end
end

it "gets a valid JSON:API document" do
get alchemy_json_api.page_path(page)
expect(response).to have_http_status(200)
Expand Down Expand Up @@ -63,7 +109,7 @@
end

context "when the language is incorrect" do
let!(:language) { FactoryBot.create(:alchemy_language) }
let!(:language) { Alchemy::Language.first || FactoryBot.create(:alchemy_language) }
let!(:other_language) { FactoryBot.create(:alchemy_language, :german) }
let(:page) { FactoryBot.create(:alchemy_page, :public, language: other_language) }

Expand Down Expand Up @@ -102,9 +148,47 @@
context "with layoutpages and unpublished pages" do
let!(:layoutpage) { FactoryBot.create(:alchemy_page, :layoutpage, :public) }
let!(:non_public_page) { FactoryBot.create(:alchemy_page) }
let!(:public_page) { FactoryBot.create(:alchemy_page, :public) }
let!(:public_page) { FactoryBot.create(:alchemy_page, :public, published_at: Date.yesterday) }

context "as anonymous user" do
let!(:pages) { [public_page] }

it "sets public cache headers of latest published page" do
get alchemy_json_api.pages_path
expect(response.headers["Last-Modified"]).to eq(pages.max_by(&:published_at).published_at.utc.httpdate)
expect(response.headers["ETag"]).to match(/W\/".+"/)
expect(response.headers["Cache-Control"]).to eq("max-age=10800, public, must-revalidate")
end

context "if one page is restricted" do
let!(:restricted_page) do
FactoryBot.create(
:alchemy_page,
:public,
:restricted,
published_at: DateTime.yesterday,
)
end

it "sets private cache headers" do
get alchemy_json_api.pages_path
expect(response.headers["Cache-Control"]).to eq("max-age=10800, private, must-revalidate")
end
end

context "if browser sends fresh cache headers" do
it "returns not modified" do
get alchemy_json_api.pages_path
etag = response.headers["ETag"]
get alchemy_json_api.pages_path,
headers: {
"If-Modified-Since" => pages.max_by(&:published_at).published_at.utc.httpdate,
"If-None-Match" => etag,
}
expect(response.status).to eq(304)
end
end

it "returns public content pages only" do
get alchemy_json_api.pages_path
document = JSON.parse(response.body)
Expand All @@ -131,6 +215,27 @@
end
end

context "with filters" do
let!(:standard_page) { FactoryBot.create(:alchemy_page, :public, published_at: 2.weeks.ago) }
let!(:news_page) { FactoryBot.create(:alchemy_page, :public, page_layout: "news", published_at: 1.week.ago) }
let!(:news_page2) { FactoryBot.create(:alchemy_page, :public, page_layout: "news", published_at: Date.yesterday) }

it "returns only matching pages" do
get alchemy_json_api.pages_path(filter: { page_layout_eq: "news" })
document = JSON.parse(response.body)
expect(document["data"]).not_to include(have_id(standard_page.id.to_s))
expect(document["data"]).to include(have_id(news_page.id.to_s))
expect(document["data"]).to include(have_id(news_page2.id.to_s))
end

it "sets cache headers of latest matching page" do
get alchemy_json_api.pages_path(filter: { page_layout_eq: "news" })
expect(response.headers["Last-Modified"]).to eq(news_page2.published_at.utc.httpdate)
expect(response.headers["ETag"]).to match(/W\/".+"/)
expect(response.headers["Cache-Control"]).to eq("max-age=10800, public, must-revalidate")
end
end

context "with pagination params" do
before do
FactoryBot.create_list(:alchemy_page, 3, :public)
Expand Down