diff --git a/app/controllers/api/order_cycles_controller.rb b/app/controllers/api/order_cycles_controller.rb index 5404db76c4..84733fb4a0 100644 --- a/app/controllers/api/order_cycles_controller.rb +++ b/app/controllers/api/order_cycles_controller.rb @@ -1,11 +1,15 @@ module Api class OrderCyclesController < Api::BaseController include EnterprisesHelper - respond_to :json + include ApiActionCaching skip_authorization_check skip_before_filter :authenticate_user, :ensure_api_key, only: [:taxons, :properties] + caches_action :taxons, :properties, + expires_in: CacheService::FILTERS_EXPIRY, + cache_path: proc { |controller| controller.request.url } + def products render_no_products unless order_cycle.open? diff --git a/app/models/concerns/api_action_caching.rb b/app/models/concerns/api_action_caching.rb new file mode 100644 index 0000000000..b3e030e408 --- /dev/null +++ b/app/models/concerns/api_action_caching.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# API controllers inherit from ActionController::Metal to keep them slim and fast. +# This concern adds the minimum requirements needed to use Action Caching in the API. + +module ApiActionCaching + extend ActiveSupport::Concern + + included do + include ActionController::Caching + include ActionController::Caching::Actions + include AbstractController::Layouts + + # These configs are not assigned to the controller automatically with ActionController::Metal + self.cache_store = Rails.configuration.cache_store + self.perform_caching = true + + # ActionController::Caching asks for a controller's layout, but they're not used in the API + layout false + end +end diff --git a/app/services/cache_service.rb b/app/services/cache_service.rb new file mode 100644 index 0000000000..610b4ca6b4 --- /dev/null +++ b/app/services/cache_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class CacheService + FILTERS_EXPIRY = 30.seconds.freeze + + def self.cache(cache_key, options = {}) + Rails.cache.fetch cache_key.to_s, options do + yield + end + end + + # Yields a cached query, expired by the most recently updated record for a given class. + # E.g: if *any* Spree::Taxon record is updated, all keys based on Spree::Taxon will auto-expire. + def self.cached_data_by_class(cache_key, cached_class) + Rails.cache.fetch "#{cache_key}-#{cached_class}-#{latest_timestamp_by_class(cached_class)}" do + yield + end + end + + # Gets the :updated_at value of the most recently updated record for a given class, and returns + # it as a timestamp, eg: `1583836069`. + def self.latest_timestamp_by_class(cached_class) + cached_class.maximum(:updated_at).to_i + end +end diff --git a/spec/features/consumer/caching/shops_caching_spec.rb b/spec/features/consumer/caching/shops_caching_spec.rb new file mode 100644 index 0000000000..7d70796e37 --- /dev/null +++ b/spec/features/consumer/caching/shops_caching_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "spec_helper" + +feature "Shops caching", js: true, caching: true do + include WebHelper + include UIComponentHelper + + let!(:distributor) { create(:distributor_enterprise, with_payment_and_shipping: true, is_primary_producer: true) } + let!(:order_cycle) { create(:open_order_cycle, distributors: [distributor], coordinator: distributor) } + + describe "API action caching on taxons and properties" do + let!(:taxon) { create(:taxon, name: "Cached Taxon") } + let!(:taxon2) { create(:taxon, name: "New Taxon") } + let!(:property) { create(:property, presentation: "Cached Property") } + let!(:property2) { create(:property, presentation: "New Property") } + let!(:product) { create(:product, taxons: [taxon], primary_taxon: taxon, properties: [property]) } + let(:exchange) { order_cycle.exchanges.to_enterprises(distributor).outgoing.first } + + let(:test_domain) { "#{Capybara.current_session.server.host}:#{Capybara.current_session.server.port}" } + let(:taxons_key) { "views/#{test_domain}/api/order_cycles/#{order_cycle.id}/taxons?distributor=#{distributor.id}" } + let(:properties_key) { "views/#{test_domain}/api/order_cycles/#{order_cycle.id}/properties?distributor=#{distributor.id}" } + let(:options) { { expires_in: CacheService::FILTERS_EXPIRY } } + + before do + exchange.variants << product.variants.first + end + + it "caches rendered response for taxons and properties, with the provided options" do + visit enterprise_shop_path(distributor) + + expect(page).to have_content "Cached Taxon" + expect(page).to have_content "Cached Property" + + expect_cached taxons_key, options + expect_cached properties_key, options + end + + it "keeps data cached for a short time on subsequent requests" do + # One minute ago... + Timecop.travel(Time.zone.now - 1.minute) do + visit enterprise_shop_path(distributor) + + expect(page).to have_content taxon.name + expect(page).to have_content property.presentation + + product.update_attribute(:taxons, [taxon2]) + product.update_attribute(:primary_taxon, taxon2) + product.update_attribute(:properties, [property2]) + + visit enterprise_shop_path(distributor) + + expect(page).to have_content taxon.name # Taxon list is unchanged + expect(page).to have_content property.presentation # Property list is unchanged + end + + # A while later... + visit enterprise_shop_path(distributor) + + expect(page).to have_content taxon2.name + expect(page).to have_content property2.presentation + end + end + + def expect_cached(key, options = {}) + expect(Rails.cache.exist?(key, options)).to be true + end +end diff --git a/spec/services/cache_service_spec.rb b/spec/services/cache_service_spec.rb new file mode 100644 index 0000000000..22a6e2b772 --- /dev/null +++ b/spec/services/cache_service_spec.rb @@ -0,0 +1,50 @@ +require 'spec_helper' + +describe CacheService do + let(:rails_cache) { Rails.cache } + + describe "#cache" do + before do + rails_cache.stub(:fetch) + end + + it "provides a wrapper for basic #fetch calls to Rails.cache" do + CacheService.cache("test-cache-key", expires_in: 10.seconds) do + "TEST" + end + + expect(rails_cache).to have_received(:fetch).with("test-cache-key", expires_in: 10.seconds) + end + end + + describe "#cached_data_by_class" do + let(:timestamp) { Time.now.to_i } + + before do + rails_cache.stub(:fetch) + CacheService.stub(:latest_timestamp_by_class) { timestamp } + end + + it "caches data by timestamp for last record of that class" do + CacheService.cached_data_by_class("test-cache-key", Enterprise) do + "TEST" + end + + expect(CacheService).to have_received(:latest_timestamp_by_class).with(Enterprise) + expect(rails_cache).to have_received(:fetch).with("test-cache-key-Enterprise-#{timestamp}") + end + end + + describe "#latest_timestamp_by_class" do + let!(:taxon1) { create(:taxon) } + let!(:taxon2) { create(:taxon) } + + it "gets the :updated_at value of the last record for a given class and returns a timestamp" do + taxon1.touch + expect(CacheService.latest_timestamp_by_class(Spree::Taxon)).to eq taxon1.updated_at.to_i + + taxon2.touch + expect(CacheService.latest_timestamp_by_class(Spree::Taxon)).to eq taxon2.updated_at.to_i + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 5eb8cad557..ec5311f1eb 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -114,6 +114,15 @@ RSpec.configure do |config| .each { |s| s.driver.reset! } end + # Enable caching in any specs tagged with `caching: true`. Usage is exactly the same as the + # well-known `js: true` tag used to enable javascript in feature specs. + config.around(:each, :caching) do |example| + caching = ActionController::Base.perform_caching + ActionController::Base.perform_caching = example.metadata[:caching] + example.run + ActionController::Base.perform_caching = caching + end + config.before(:all) { restart_phantomjs } # Geocoding