├── Gemfile ├── lib ├── acorn_cache │ ├── version.rb │ ├── app_exception.rb │ ├── cache_writer.rb │ ├── cache_reader.rb │ ├── freshness_rules.rb │ ├── storage.rb │ ├── cache_maintenance.rb │ ├── cache_control_header.rb │ ├── request.rb │ ├── cache_controller.rb │ ├── server_response.rb │ ├── config.rb │ └── cached_response.rb └── acorn_cache.rb ├── .travis.yml ├── bin ├── setup └── console ├── .gitignore ├── CHANGES ├── Rakefile ├── test ├── cache_writer_test.rb ├── cache_reader_test.rb ├── config_test.rb ├── storage_test.rb ├── freshness_rules_test.rb ├── cache_maintenance_test.rb ├── cache_control_header_test.rb ├── cache_controller_test.rb ├── server_response_test.rb ├── acorn_cache_test.rb ├── request_test.rb └── cached_response_test.rb ├── LICENSE.txt ├── acorn_cache.gemspec └── README.md /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /lib/acorn_cache/version.rb: -------------------------------------------------------------------------------- 1 | module AcornCache 2 | VERSION = "0.3.0" 3 | end 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.3.0 4 | before_install: gem install bundler -v 1.10.4 5 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.rubocop.yml 3 | /notes.txt 4 | /.yardoc 5 | /Gemfile.lock 6 | /_yardoc/ 7 | /coverage/ 8 | /doc/ 9 | /pkg/ 10 | /spec/reports/ 11 | /tmp/ 12 | /*.gem 13 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | 0.1.0 2 | - Initial release 3 | 0.1.1 4 | - Add ServerResponse#etag_header 5 | - Fixed bug in CachedResponse#time_to_live 6 | 0.2.0 7 | - Add feature to remove cookies from server responses prior to caching 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require 'rake/testtask' 3 | 4 | Rake::TestTask.new do |t| 5 | t.libs << "lib" 6 | t.test_files = FileList['test/*_test.rb'] 7 | t.verbose 8 | end 9 | 10 | task :default => [:test] 11 | -------------------------------------------------------------------------------- /lib/acorn_cache/app_exception.rb: -------------------------------------------------------------------------------- 1 | class Rack::AcornCache 2 | class AppException < StandardError 3 | attr_reader :caught_exception 4 | 5 | def initialize(caught_exception) 6 | @caught_exception = caught_exception 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/acorn_cache/cache_writer.rb: -------------------------------------------------------------------------------- 1 | class Rack::AcornCache 2 | module CacheWriter 3 | def self.write(cache_key, serialized_response) 4 | storage.set(cache_key, serialized_response) 5 | end 6 | 7 | private 8 | 9 | def self.storage 10 | Rack::AcornCache.configuration.storage 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "acorn_cache" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /lib/acorn_cache/cache_reader.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | class Rack::AcornCache 4 | module CacheReader 5 | def self.read(cache_key) 6 | response = storage.get(cache_key) 7 | return false unless response 8 | response_hash = JSON.parse(response) 9 | CachedResponse.new(response_hash) 10 | end 11 | 12 | private 13 | 14 | def self.storage 15 | Rack::AcornCache.configuration.storage 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/cache_writer_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'acorn_cache/cache_writer' 3 | require 'mocha/mini_test' 4 | 5 | class CacheWriterTest < Minitest::Test 6 | def test_writes_to_cache_with_appropriate_values 7 | Rack::AcornCache.configure {} 8 | storage = mock("storage") 9 | storage.expects(:set).with("path", "response").returns("OK") 10 | Rack::AcornCache.configuration.expects(:storage).returns(storage) 11 | 12 | assert_equal Rack::AcornCache::CacheWriter.write("path", "response"), "OK" 13 | 14 | Rack::AcornCache.configuration = nil 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/acorn_cache/freshness_rules.rb: -------------------------------------------------------------------------------- 1 | class Rack::AcornCache 2 | module FreshnessRules 3 | def self.cached_response_fresh_for_request?(cached_response, request) 4 | return false unless cached_response 5 | if cached_response.fresh? 6 | if request.max_age_more_restrictive?(cached_response) 7 | return cached_response.date + request.max_age >= Time.now.gmtime 8 | elsif request.max_fresh 9 | return cached_response.expiration_date - request.max_fresh >= Time.now.gmtime 10 | end 11 | true 12 | else 13 | return false unless request.max_stale? 14 | return true if request.max_stale == true 15 | cached_response.expiration_date + request.max_stale >= Time.now.gmtime 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/acorn_cache/storage.rb: -------------------------------------------------------------------------------- 1 | require 'redis' 2 | require 'dalli' 3 | 4 | class Rack::AcornCache 5 | module Storage 6 | def self.redis 7 | args = { host: ENV["ACORNCACHE_REDIS_HOST"], 8 | port: ENV["ACORNCACHE_REDIS_PORT"].to_i } 9 | if ENV["ACORNCACHE_REDIS_PASSWORD"] 10 | args.merge!(password: ENV["ACORNCACHE_REDIS_PASSWORD"]) 11 | end 12 | 13 | @redis ||= Redis.new(args) 14 | end 15 | 16 | def self.memcached 17 | options = {} 18 | 19 | if ENV["ACORNCACHE_MEMCACHED_USERNAME"] 20 | options = { username: ENV["ACORNCACHE_MEMCACHED_USERNAME"], 21 | password: ENV["ACORNCACHE_MEMCACHED_PASSWORD"] } 22 | end 23 | 24 | @memcached ||= Dalli::Client.new(ENV["ACORNCACHE_MEMCACHED_URL"], options) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/acorn_cache/cache_maintenance.rb: -------------------------------------------------------------------------------- 1 | class Rack::AcornCache 2 | class CacheMaintenance 3 | attr_reader :response, :cache_key, :server_response, :cached_response 4 | 5 | def initialize(cache_key, server_response, cached_response) 6 | @cache_key = cache_key 7 | @server_response = server_response 8 | @cached_response = cached_response 9 | end 10 | 11 | def update_cache 12 | if !server_response 13 | @response = cached_response.add_acorn_cache_header! 14 | elsif !server_response.cacheable? && !server_response.status_304? 15 | @response = server_response 16 | elsif server_response.cacheable? 17 | @response = server_response.cache!(cache_key) 18 | elsif cached_response.matches?(server_response) 19 | @response = cached_response.update_date_and_recache!(cache_key) 20 | else 21 | @response = server_response 22 | end 23 | 24 | self 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/acorn_cache.rb: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | 3 | class Rack::AcornCache 4 | def initialize(app) 5 | @app = app 6 | end 7 | 8 | def call(env) 9 | dup._call(env) 10 | end 11 | 12 | def _call(env) 13 | request = Request.new(env) 14 | return @app.call(env) unless request.cacheable? 15 | 16 | begin 17 | CacheController.new(request, @app).response.to_a 18 | rescue AppException => e 19 | raise e.caught_exception 20 | rescue => e 21 | @app.call(env) 22 | end 23 | end 24 | end 25 | 26 | require 'acorn_cache/request' 27 | require 'acorn_cache/cache_controller' 28 | require 'acorn_cache/app_exception' 29 | require 'acorn_cache/config' 30 | require 'acorn_cache/cache_control_header' 31 | require 'acorn_cache/cache_writer' 32 | require 'acorn_cache/freshness_rules' 33 | require 'acorn_cache/storage' 34 | require 'acorn_cache/cache_maintenance' 35 | require 'acorn_cache/server_response' 36 | require 'acorn_cache/cached_response' 37 | require 'acorn_cache/cache_reader' 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Vincent J. DeVendra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/cache_reader_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'acorn_cache/cache_reader' 3 | require 'mocha/mini_test' 4 | 5 | class CacheReaderTest < MiniTest::Test 6 | def test_returns_false_if_response_from_storage_is_nil 7 | Rack::AcornCache.configure {} 8 | storage = mock('storage') 9 | storage.expects(:get).with("foo").returns(nil) 10 | Rack::AcornCache.configuration.expects(:storage).returns(storage) 11 | 12 | refute Rack::AcornCache::CacheReader.read("foo") 13 | end 14 | 15 | def test_returns_cached_response_object_if_response_from_storage 16 | Rack::AcornCache.configure {} 17 | storage = mock('storage') 18 | storage_response = mock('storage_reponse') 19 | response_hash = mock('response_hash') 20 | 21 | storage.expects(:get).with("foo").returns(storage_response) 22 | Rack::AcornCache.configuration.expects(:storage).returns(storage) 23 | JSON.expects(:parse).with(storage_response).returns(response_hash) 24 | Rack::AcornCache::CachedResponse 25 | .expects(:new) 26 | .with(response_hash) 27 | .returns('cache-response-object') 28 | 29 | result = Rack::AcornCache::CacheReader.read("foo") 30 | assert_equal 'cache-response-object', result 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/acorn_cache/cache_control_header.rb: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | 3 | class Rack::AcornCache 4 | class CacheControlHeader 5 | attr_accessor :max_age, :s_maxage, :no_cache, :no_store, 6 | :must_revalidate, :private, :max_fresh, :max_stale 7 | 8 | def initialize(header_string = "") 9 | return unless header_string && !header_string.empty? 10 | set_directive_instance_variables!(header_string) 11 | end 12 | 13 | alias_method :max_stale?, :max_stale 14 | alias_method :no_cache?, :no_cache 15 | alias_method :private?, :private 16 | alias_method :no_store?, :no_store 17 | alias_method :must_revalidate?, :must_revalidate 18 | 19 | def to_s 20 | instance_variables.map do |ivar| 21 | directive = ivar.to_s.sub("@", "").sub("_", "-") 22 | value = instance_variable_get(ivar) 23 | next directive if value == true 24 | next unless value 25 | "#{directive}=#{value}" 26 | end.compact.sort.join(", ") 27 | end 28 | 29 | private 30 | 31 | def set_directive_instance_variables!(header_string) 32 | header_string.gsub(/\s+/, "").split(",").each do |directive, result| 33 | k, v = directive.split("=") 34 | instance_variable_set(variable_symbol(k), directive_value(v)) 35 | end 36 | end 37 | 38 | def variable_symbol(directive) 39 | "@#{directive.gsub("-", "_")}".to_sym 40 | end 41 | 42 | def directive_value(value) 43 | return value.to_i if value =~ /^[0-9]+$/ 44 | return true if value.nil? 45 | value 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /acorn_cache.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'acorn_cache/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "acorn_cache" 8 | spec.version = AcornCache::VERSION 9 | spec.authors = ["Vincent J. DeVendra", "Perry Carbone"] 10 | spec.email = ["VinceDeVendra@gmail.com", "perrycarb@gmail.com"] 11 | 12 | spec.description = "AcornCache is a Ruby HTTP proxy caching library that is lightweight, configurable and can be easily integrated with any Rack-based web application. AcornCache allows you to improve page load times and lighten the load on your server by allowing you to implement an in-memory cache shared by every client requesting a resource on your server." 13 | spec.summary = "A HTTP proxy caching library for Rack apps" 14 | spec.homepage = "https://github.com/acorncache/acorn-cache" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.bindir = "exe" 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ["lib"] 21 | 22 | spec.add_development_dependency "bundler", "~> 1.10" 23 | spec.add_development_dependency "minitest" 24 | spec.add_development_dependency "rake", "~> 10.0" 25 | spec.add_development_dependency "pry" 26 | spec.add_development_dependency "mocha" 27 | spec.add_runtime_dependency "rack", "~> 1.6" 28 | spec.add_runtime_dependency "redis" 29 | spec.add_runtime_dependency "dalli" 30 | end 31 | -------------------------------------------------------------------------------- /lib/acorn_cache/request.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | 3 | class Rack::AcornCache 4 | class Request < Rack::Request 5 | extend Forwardable 6 | def_delegators :@cache_control_header, :no_cache?, :max_age, :max_fresh, 7 | :max_stale, :max_stale? 8 | 9 | def initialize(env) 10 | super 11 | @cache_control_header = CacheControlHeader.new(@env["HTTP_CACHE_CONTROL"]) 12 | end 13 | 14 | def update_conditional_headers!(cached_response) 15 | if cached_response.etag_header 16 | self.if_none_match = cached_response.etag_header 17 | end 18 | 19 | if cached_response.last_modified_header 20 | self.if_modified_since = cached_response.last_modified_header 21 | end 22 | end 23 | 24 | def max_age_more_restrictive?(cached_response) 25 | cached_response.stale_time_specified? && 26 | max_age && max_age < cached_response.time_to_live 27 | end 28 | 29 | def cacheable? 30 | get? && (config.cache_everything || page_rule?) 31 | end 32 | 33 | def page_rule 34 | @page_rule ||= config.page_rule_for_url(url) if config 35 | end 36 | 37 | def cache_key 38 | return base_url + path if page_rule? && page_rule[:ignore_query_params] 39 | url 40 | end 41 | 42 | alias_method :page_rule?, :page_rule 43 | 44 | def if_modified_since 45 | env["HTTP_IF_MODIFIED_SINCE"] 46 | end 47 | 48 | def if_none_match 49 | env["HTTP_IF_NONE_MATCH"] 50 | end 51 | 52 | def conditional? 53 | if_modified_since || if_none_match 54 | end 55 | 56 | private 57 | 58 | def config 59 | Rack::AcornCache.configuration 60 | end 61 | 62 | def if_none_match=(etag) 63 | env["HTTP_IF_NONE_MATCH"] = etag 64 | end 65 | 66 | def if_modified_since=(last_modified) 67 | env["HTTP_IF_MODIFIED_SINCE"] = last_modified 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/acorn_cache/cache_controller.rb: -------------------------------------------------------------------------------- 1 | class Rack::AcornCache 2 | class CacheController 3 | def initialize(request, app) 4 | @request = request 5 | @app = app 6 | end 7 | 8 | def response 9 | if request.no_cache? 10 | server_response = get_response_from_server 11 | else 12 | cached_response = check_for_cached_response 13 | 14 | if cached_response.must_be_revalidated? 15 | request.update_conditional_headers!(cached_response) 16 | server_response = get_response_from_server 17 | elsif !cached_response.fresh_for_request?(request) 18 | server_response = get_response_from_server 19 | elsif request.conditional? 20 | if cached_response.not_modified_for?(request) 21 | return not_modified(cached_response) 22 | end 23 | end 24 | end 25 | 26 | CacheMaintenance 27 | .new(request.cache_key, server_response, cached_response) 28 | .update_cache 29 | .response 30 | end 31 | 32 | private 33 | 34 | attr_reader :request, :app 35 | 36 | def get_response_from_server 37 | begin 38 | status, headers, body = @app.call(request.env) 39 | rescue => e 40 | raise AppException.new(e) 41 | end 42 | 43 | server_response = ServerResponse.new(status, headers, body) 44 | 45 | if request.page_rule? 46 | server_response.update_with_page_rules!(request.page_rule) 47 | else 48 | server_response 49 | end 50 | end 51 | 52 | def check_for_cached_response 53 | CacheReader.read(request.cache_key) || NullCachedResponse.new 54 | end 55 | 56 | def not_modified(cached_response) 57 | status = 304 58 | 59 | headers = cached_response.headers 60 | headers.delete("Content-Type") 61 | headers.delete("Content-Length") 62 | 63 | body = [] 64 | [status, headers, body] 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /test/config_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'acorn_cache/config' 3 | 4 | class ConfigurationTest < Minitest::Test 5 | def test_setting_configuration 6 | Rack::AcornCache.configure do |config| 7 | config.default_acorn_cache_ttl = 3600 8 | end 9 | 10 | assert_equal 3600, Rack::AcornCache.configuration.default_acorn_cache_ttl 11 | end 12 | 13 | def test_set_page_rules_without_defaults 14 | config = Rack::AcornCache::Configuration.new 15 | user_page_rules = {"http://foo.com" => { acorn_cache_ttl: 30 } } 16 | 17 | config.page_rules = user_page_rules 18 | 19 | assert_equal 30, config.page_rules["http://foo.com"][:acorn_cache_ttl] 20 | end 21 | 22 | def test_set_page_rules_with_defaults 23 | config = Rack::AcornCache::Configuration.new 24 | config.default_acorn_cache_ttl = 100 25 | user_page_rules = {"http://foo.com" => { browser_cache_ttl: 30 } } 26 | 27 | config.page_rules = user_page_rules 28 | 29 | assert config.page_rules["http://foo.com"] 30 | assert_equal 30, config.page_rules["http://foo.com"][:browser_cache_ttl] 31 | assert_equal 100, config.page_rules["http://foo.com"][:acorn_cache_ttl] 32 | end 33 | 34 | def test_set_page_rules_override_default 35 | config = Rack::AcornCache::Configuration.new 36 | config.default_acorn_cache_ttl = 100 37 | user_page_rules = { 38 | "http://foo.com" => { acorn_cache_ttl: 86400 } 39 | } 40 | 41 | config.page_rules = user_page_rules 42 | 43 | assert_equal 86400, config.page_rules["http://foo.com"][:acorn_cache_ttl] 44 | end 45 | 46 | def test_set_page_rules_with_respect_existing_headers_overrides_defaults 47 | config = Rack::AcornCache::Configuration.new 48 | config.default_acorn_cache_ttl = 100 49 | user_page_rules = { 50 | "http://foo.com" => { respect_existing_headers: true } 51 | } 52 | 53 | config.page_rules = user_page_rules 54 | refute config.page_rules["http://foo.com"][:acorn_cache_ttl] 55 | end 56 | 57 | def teardown 58 | Rack::AcornCache.configuration = nil 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/storage_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'acorn_cache/storage' 3 | require 'mocha/mini_test' 4 | 5 | class StorageTest < Minitest::Test 6 | def test_setup_new_redis_connection 7 | ENV["ACORNCACHE_REDIS_HOST"] = "Some Host" 8 | ENV["ACORNCACHE_REDIS_PORT"] = "1234" 9 | ENV["ACORNCACHE_REDIS_PASSWORD"] = "password" 10 | 11 | Redis.expects(:new).with(host: "Some Host", port: 1234, password: "password" ).returns("redis connection") 12 | 13 | assert_equal Rack::AcornCache::Storage.redis, "redis connection" 14 | 15 | Rack::AcornCache::Storage.remove_instance_variable(:@redis) 16 | Redis.unstub(:new) 17 | end 18 | 19 | def test_setup_new_redis_connection_without_password 20 | ENV["ACORNCACHE_REDIS_HOST"] = "Some Host" 21 | ENV["ACORNCACHE_REDIS_PORT"] = "1234" 22 | ENV["ACORNCACHE_REDIS_PASSWORD"] = nil 23 | 24 | Redis.expects(:new).with(host: "Some Host", port: 1234).returns("redis") 25 | 26 | assert_equal Rack::AcornCache::Storage.redis, "redis" 27 | 28 | Rack::AcornCache::Storage.remove_instance_variable(:@redis) 29 | 30 | Redis.unstub(:new) 31 | end 32 | 33 | def test_setup_new_memcached_connection 34 | ENV["ACORNCACHE_MEMCACHED_URL"] = "host:port" 35 | ENV["ACORNCACHE_MEMCACHED_USERNAME"] = "Ol' Pete" 36 | ENV["ACORNCACHE_MEMCACHED_PASSWORD"] = "sneaky_pete" 37 | 38 | Dalli::Client.expects(:new).with("host:port", username: "Ol' Pete", password: "sneaky_pete").returns("memcached") 39 | 40 | assert_equal Rack::AcornCache::Storage.memcached, "memcached" 41 | 42 | Rack::AcornCache::Storage.remove_instance_variable(:@memcached) 43 | end 44 | 45 | def test_setup_new_memcached_connection_without_username_and_password 46 | ENV["ACORNCACHE_MEMCACHED_URL"] = "host:port" 47 | ENV["ACORNCACHE_MEMCACHED_USERNAME"] = nil 48 | ENV["ACORNCACHE_MEMCACHED_PASSWORD"] = nil 49 | 50 | Dalli::Client.expects(:new).with("host:port", {}).returns("memcached") 51 | 52 | assert_equal Rack::AcornCache::Storage.memcached, "memcached" 53 | 54 | Rack::AcornCache::Storage.remove_instance_variable(:@memcached) 55 | end 56 | 57 | def teardown 58 | Redis.unstub(:new) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/acorn_cache/server_response.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'time' 3 | 4 | class Rack::AcornCache 5 | class ServerResponse < Rack::Response 6 | CACHEABLE_STATUS_CODES = [200, 203, 300, 301, 302, 404, 410] 7 | attr_reader :status, :headers, :body, :cache_control_header 8 | 9 | def initialize(status, headers, body) 10 | @status = status 11 | @headers = headers 12 | @body = body 13 | @cache_control_header = CacheControlHeader.new(headers["Cache-Control"]) 14 | end 15 | 16 | def update_date! 17 | @headers["Date"] = Time.now.httpdate unless @headers["Date"] 18 | end 19 | 20 | def cacheable? 21 | [:private?, :no_store?].none? { |directive| send(directive) } && 22 | CACHEABLE_STATUS_CODES.include?(status) 23 | end 24 | 25 | def status_304? 26 | status == 304 27 | end 28 | 29 | def etag_header 30 | headers["ETag"] 31 | end 32 | 33 | def serialize 34 | { status: status, headers: headers, body: body_string }.to_json 35 | end 36 | 37 | def body_string 38 | result = "" 39 | body.each { |part| result << part } 40 | result 41 | end 42 | 43 | def to_a 44 | [status, headers, body] 45 | end 46 | 47 | def cache!(cache_key) 48 | update_date! 49 | headers.delete("Set-Cookie") 50 | CacheWriter.write(cache_key, serialize) 51 | self 52 | end 53 | 54 | def update_with_page_rules!(page_rule) 55 | if page_rule[:must_revalidate] 56 | self.no_cache = true 57 | self.must_revalidate = true 58 | self.private = nil 59 | self.max_age = nil 60 | self.s_maxage = nil 61 | self.no_store = nil 62 | end 63 | 64 | if page_rule[:acorn_cache_ttl] || page_rule[:browser_cache_ttl] 65 | self.no_cache = nil 66 | self.no_store = nil 67 | self.must_revalidate = nil 68 | end 69 | 70 | if page_rule[:acorn_cache_ttl] 71 | self.max_age = nil 72 | self.s_maxage = page_rule[:acorn_cache_ttl] 73 | self.private = nil 74 | end 75 | 76 | if page_rule[:browser_cache_ttl] 77 | self.max_age = page_rule[:browser_cache_ttl] 78 | end 79 | 80 | headers["Cache-Control"] = cache_control_header.to_s 81 | self 82 | end 83 | 84 | def method_missing(method, *args) 85 | cache_control_header.send(method, *args) 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/acorn_cache/config.rb: -------------------------------------------------------------------------------- 1 | class Rack::AcornCache 2 | class << self 3 | attr_accessor :configuration 4 | end 5 | 6 | def self.configure 7 | self.configuration ||= Configuration.new 8 | yield(configuration) 9 | end 10 | 11 | class Configuration 12 | attr_writer :storage 13 | attr_reader :page_rules 14 | attr_accessor :default_acorn_cache_ttl, :default_browser_cache_ttl, 15 | :cache_everything, :default_ignore_query_params, :default_must_revalidate 16 | 17 | def initialize 18 | @cache_everything = false 19 | @storage = :redis 20 | end 21 | 22 | def page_rules=(user_page_rules) 23 | @page_rules = user_page_rules.each_with_object({}) do |(k, v), result| 24 | result[k] = build_page_rule(v) 25 | end 26 | end 27 | 28 | def page_rule_for_url(url) 29 | if cache_everything 30 | return default_page_rule unless page_rules 31 | no_page_rule_found = proc { return default_page_rule } 32 | else 33 | return nil unless page_rules 34 | no_page_rule_found = proc { return nil } 35 | end 36 | 37 | page_rules.find(no_page_rule_found) do |k, _| 38 | page_rule_key_matches_url?(k, url) 39 | end.last 40 | end 41 | 42 | def storage 43 | if @storage == :redis 44 | Rack::AcornCache::Storage.redis 45 | elsif @storage == :memcached 46 | Rack::AcornCache::Storage.memcached 47 | end 48 | end 49 | 50 | private 51 | 52 | def default_page_rule 53 | { acorn_cache_ttl: default_acorn_cache_ttl, 54 | browser_cache_ttl: default_browser_cache_ttl, 55 | ignore_query_params: default_ignore_query_params, 56 | must_revalidate: default_must_revalidate } 57 | end 58 | 59 | def build_page_rule(options) 60 | options[:ignore_query_params] = default_ignore_query_params 61 | 62 | return options if options[:respect_existing_headers] || options[:must_revalidate] 63 | { acorn_cache_ttl: default_acorn_cache_ttl, 64 | browser_cache_ttl: default_browser_cache_ttl }.merge(options) 65 | end 66 | 67 | def page_rule_key_matches_url?(page_rule_key, url) 68 | return url =~ page_rule_key if page_rule_key.is_a?(Regexp) 69 | string = page_rule_key.gsub("*", ".*") 70 | url =~ /^#{string}$/ 71 | end 72 | end 73 | 74 | #Example config setup: 75 | # Rack::AcornCache.configure do |config| 76 | # config.cache_everything = true 77 | # config.default_acorn_cache_ttl = 3600 78 | # config.page_rules = { 79 | # "http://example.com/*.js" => { browser_cache_ttl: 30, 80 | # regex: true }, 81 | # "another_url" => { acorn_cache_ttl: 100 }, 82 | # "foo.com" => { respect_existing_headers: true } 83 | # } 84 | # end 85 | end 86 | -------------------------------------------------------------------------------- /lib/acorn_cache/cached_response.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | require 'time' 3 | 4 | class Rack::AcornCache 5 | class CachedResponse 6 | extend Forwardable 7 | def_delegators :@cache_control_header, :s_maxage, :max_age, :no_cache?, :must_revalidate? 8 | 9 | attr_reader :body, :status, :headers, :date 10 | DEFAULT_MAX_AGE = 3600 11 | 12 | def initialize(args={}) 13 | @body = args["body"] 14 | @status = args["status"] 15 | @headers = args["headers"] 16 | @cache_control_header = CacheControlHeader.new(headers["Cache-Control"]) 17 | end 18 | 19 | def must_be_revalidated? 20 | no_cache? || must_revalidate? 21 | end 22 | 23 | def update_date! 24 | headers["Date"] = Time.now.httpdate 25 | end 26 | 27 | def serialize 28 | { headers: headers, status: status, body: body }.to_json 29 | end 30 | 31 | def to_a 32 | [status, headers, [body]] 33 | end 34 | 35 | def etag_header 36 | headers["ETag"] 37 | end 38 | 39 | def last_modified_header 40 | headers["Last-Modified"] 41 | end 42 | 43 | def update_date_and_recache!(cache_key) 44 | cached_response.update_date! 45 | CacheWriter.write(cache_key, cached_response.serialize) 46 | self 47 | end 48 | 49 | def add_acorn_cache_header! 50 | unless headers["X-Acorn-Cache"] 51 | headers["X-Acorn-Cache"] = "HIT" 52 | end 53 | self 54 | end 55 | 56 | def matches?(server_response) 57 | if etag_header 58 | server_response.etag_header == etag_header 59 | elsif last_modified_header 60 | server_response.last_modified_header == last_modified_header 61 | else 62 | false 63 | end 64 | end 65 | 66 | def not_modified_for?(request) 67 | if request.if_none_match && request.if_modified_since 68 | request.if_none_match == etag_header && 69 | not_modified_since?(Time.httpdate(request.if_modified_since)) 70 | elsif request.if_none_match 71 | request.if_none_match == etag_header 72 | else 73 | not_modified_since?(Time.httpdate(request.if_modified_since)) 74 | end 75 | end 76 | 77 | def time_to_live 78 | s_maxage || max_age || (expiration_date - date) 79 | end 80 | 81 | alias_method :stale_time_specified?, :time_to_live 82 | 83 | def fresh? 84 | expiration_date > Time.now 85 | end 86 | 87 | def date 88 | @date ||= Time.httpdate(date_header) 89 | end 90 | 91 | def expiration_date 92 | if s_maxage 93 | date + s_maxage 94 | elsif max_age 95 | date + max_age 96 | elsif expiration_header 97 | expiration 98 | else 99 | date + DEFAULT_MAX_AGE 100 | end 101 | end 102 | 103 | def time_until_expiration 104 | Time.now - expiration 105 | end 106 | 107 | def fresh_for_request?(request) 108 | FreshnessRules.cached_response_fresh_for_request?(self, request) 109 | end 110 | 111 | private 112 | 113 | def expiration_header_time 114 | Time.httpdate(expiration_header) 115 | end 116 | 117 | def not_modified_since?(time) 118 | Time.httpdate(last_modified_header) < time 119 | end 120 | 121 | def expiration_header 122 | @expiration_header ||= headers["Expiration"] 123 | end 124 | 125 | def expiration 126 | @expiration ||= Time.httpdate(expiration_header) 127 | end 128 | 129 | def date_header 130 | headers["Date"] 131 | end 132 | end 133 | 134 | class NullCachedResponse 135 | def must_be_revalidated? 136 | false 137 | end 138 | 139 | def matches?(server_response) 140 | false 141 | end 142 | 143 | def update!; end 144 | 145 | def fresh_for_request?(request) 146 | false 147 | end 148 | 149 | def not_modified_for?(request) 150 | false 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /test/freshness_rules_test.rb: -------------------------------------------------------------------------------- 1 | require 'acorn_cache/freshness_rules' 2 | require 'minitest/autorun' 3 | 4 | class FreshnessRulesTest < Minitest::Test 5 | def test_cached_response_fresh_for_request_when_no_cached_response_present 6 | cached_response = nil 7 | request = mock 8 | 9 | refute Rack::AcornCache::FreshnessRules.cached_response_fresh_for_request?(cached_response, request) 10 | end 11 | 12 | def test_when_cached_response_and_fresh_request_max_age_more_restrictive_cached_response_not_fresh_for_request 13 | cached_response = stub(present?: true, fresh?: true, date: Time.new(2015)) 14 | request = stub(max_age: 30) 15 | Time.stubs(:now).returns(Time.new(2016)) 16 | 17 | request.expects(:max_age_more_restrictive?).with(cached_response).returns(true) 18 | 19 | refute Rack::AcornCache::FreshnessRules.cached_response_fresh_for_request?(cached_response, request) 20 | end 21 | 22 | def test_when_cached_response_and_fresh_request_max_age_more_restrictive_cached_response_fresh_for_request 23 | cached_response = stub(present?: true, fresh?: true, date: Time.new(2016)) 24 | request = stub(max_age: 30) 25 | Time.stubs(:now).returns(Time.new(2016)) 26 | 27 | request.expects(:max_age_more_restrictive?).with(cached_response).returns(true) 28 | 29 | assert Rack::AcornCache::FreshnessRules.cached_response_fresh_for_request?(cached_response, request) 30 | end 31 | 32 | def test_when_cached_response_and_fresh_request_has_max_fresh_cached_response_not_fresh_for_request 33 | cached_response = stub(present?: true, fresh?: true, expiration_date: Time.new(2016) + 25) 34 | request = stub(max_age_more_restrictive?: false) 35 | Time.stubs(:now).returns(Time.new(2016)) 36 | 37 | request.expects(:max_fresh).returns(30).times(2) 38 | 39 | refute Rack::AcornCache::FreshnessRules.cached_response_fresh_for_request?(cached_response, request) 40 | end 41 | 42 | def test_when_cached_response_and_fresh_request_has_max_fresh_cached_response_fresh_for_request 43 | cached_response = stub(present?: true, fresh?: true, expiration_date: Time.new(2016) + 35) 44 | request = stub(max_age_more_restrictive?: false) 45 | Time.stubs(:now).returns(Time.new(2016)) 46 | 47 | request.expects(:max_fresh).returns(30).times(2) 48 | 49 | assert Rack::AcornCache::FreshnessRules.cached_response_fresh_for_request?(cached_response, request) 50 | end 51 | 52 | def test_cached_response_fresh_request_has_no_max_age_no_max_fresh 53 | cached_response = stub(present?: true, fresh?: true) 54 | request = stub(max_age_more_restrictive?: false, max_fresh: false) 55 | 56 | assert Rack::AcornCache::FreshnessRules.cached_response_fresh_for_request?(cached_response, request) 57 | end 58 | 59 | def test_cached_response_not_fresh_request_does_not_have_max_stale 60 | cached_response = stub(present?: true, fresh?: false) 61 | request = stub(max_stale?: false) 62 | 63 | refute Rack::AcornCache::FreshnessRules.cached_response_fresh_for_request?(cached_response, request) 64 | end 65 | 66 | def test_cached_response_not_fresh_request_max_stale_is_true 67 | cached_response = stub(present?: true, fresh?: false) 68 | request = stub(max_stale?: true, max_stale: true) 69 | 70 | assert Rack::AcornCache::FreshnessRules.cached_response_fresh_for_request?(cached_response, request) 71 | end 72 | 73 | def test_cached_response_not_fresh_request_max_stale_is_a_value_cached_response_not_fresh_for_request 74 | cached_response = stub(present?: true, fresh?: false, expiration_date: Time.new(2016)) 75 | request = stub(max_stale?: true, max_stale: 30) 76 | Time.stubs(:now).returns(Time.new(2016) + 35) 77 | 78 | refute Rack::AcornCache::FreshnessRules.cached_response_fresh_for_request?(cached_response, request) 79 | end 80 | 81 | def test_cached_response_not_fresh_request_max_stale_is_a_value_cached_response_fresh_for_request 82 | cached_response = stub(present?: true, fresh?: false, expiration_date: Time.new(2016)) 83 | request = stub(max_stale?: true, max_stale: 30) 84 | Time.stubs(:now).returns(Time.new(2016) + 25) 85 | 86 | assert Rack::AcornCache::FreshnessRules.cached_response_fresh_for_request?(cached_response, request) 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /test/cache_maintenance_test.rb: -------------------------------------------------------------------------------- 1 | require 'acorn_cache/cache_maintenance' 2 | require 'minitest/autorun' 3 | 4 | class CacheMaintenanceTest < MiniTest::Test 5 | def test_new 6 | cache_key = "/foo" 7 | server_response = [200, {}, ["bar"]] 8 | cached_response = [200, {}, ["foobar"]] 9 | 10 | cache_maintenance = 11 | Rack::AcornCache::CacheMaintenance.new(cache_key, server_response, cached_response) 12 | 13 | assert_equal "/foo", cache_maintenance.cache_key 14 | assert_equal [200, {}, ["bar"]], cache_maintenance.server_response 15 | assert_equal [200, {}, ["foobar"]], cache_maintenance.cached_response 16 | end 17 | 18 | def test_update_cache_with_server_response_nil 19 | cache_key = "/foo" 20 | server_response = nil 21 | cached_response = mock('cached_response') 22 | 23 | cached_response.expects(:add_acorn_cache_header!).returns(cached_response) 24 | cache_maintenance = 25 | Rack::AcornCache::CacheMaintenance.new(cache_key, server_response, cached_response) 26 | cache_maintenance.update_cache 27 | 28 | assert_equal cached_response, cache_maintenance.response 29 | end 30 | 31 | def test_update_cache_with_server_response_not_cacheable_or_304 32 | cache_key = "/foo" 33 | server_response = mock('server_response') 34 | cached_response = mock('cached_response') 35 | 36 | server_response.stubs(:cacheable?).returns(false) 37 | server_response.stubs(:status_304?).returns(false) 38 | 39 | cache_maintenance = 40 | Rack::AcornCache::CacheMaintenance.new(cache_key, server_response, cached_response) 41 | cache_maintenance.update_cache 42 | 43 | assert_equal server_response, cache_maintenance.response 44 | end 45 | 46 | def test_update_cache_with_server_response_cacheable 47 | cache_key = "/foo" 48 | server_response = stub(cacheable?: true, status_304?: false) 49 | cached_response = mock('cached_response') 50 | 51 | server_response.expects(:cache!).with(cache_key).returns(server_response) 52 | 53 | cache_maintenance = 54 | Rack::AcornCache::CacheMaintenance.new(cache_key, server_response, cached_response) 55 | cache_maintenance.update_cache 56 | 57 | assert_equal server_response, cache_maintenance.response 58 | end 59 | 60 | def test_update_cache_with_server_response_304_and_matches_cached_response 61 | cache_key = "/foo" 62 | server_response = mock('server_response') 63 | cached_response = mock('cached_response') 64 | 65 | server_response.stubs(:cacheable?).returns(false) 66 | server_response.stubs(:status_304?).returns(true) 67 | 68 | cached_response.expects(:matches?).with(server_response).returns(true) 69 | cached_response.expects(:update_date_and_recache!).with(cache_key).returns(cached_response) 70 | 71 | cache_maintenance = 72 | Rack::AcornCache::CacheMaintenance.new(cache_key, server_response, cached_response) 73 | cache_maintenance.update_cache 74 | 75 | assert_equal cached_response, cache_maintenance.response 76 | end 77 | 78 | def test_update_cache_with_server_response_304_and_doest_matche_cached_response 79 | cache_key = "/foo" 80 | server_response = mock('server_response') 81 | cached_response = mock('cached_response') 82 | 83 | server_response.stubs(:cacheable?).returns(false) 84 | server_response.stubs(:status_304?).returns(true) 85 | 86 | cached_response.expects(:matches?).with(server_response).returns(false) 87 | 88 | cache_maintenance = 89 | Rack::AcornCache::CacheMaintenance.new(cache_key, server_response, cached_response) 90 | cache_maintenance.update_cache 91 | 92 | assert_equal server_response, cache_maintenance.response 93 | end 94 | 95 | def test_update_cache_not_cacheable_or_304 96 | cache_key = "/foo" 97 | server_response = mock('server_response') 98 | cached_response = mock('cached_response') 99 | 100 | server_response.stubs(:cacheable?).returns(false) 101 | server_response.stubs(:status_304?).returns(false) 102 | 103 | cache_maintenance = 104 | Rack::AcornCache::CacheMaintenance.new(cache_key, server_response, cached_response) 105 | cache_maintenance.update_cache 106 | 107 | assert_equal server_response, cache_maintenance.response 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /test/cache_control_header_test.rb: -------------------------------------------------------------------------------- 1 | require 'acorn_cache/cache_control_header' 2 | require 'minitest/autorun' 3 | 4 | class CacheControlHeaderTest < MiniTest::Test 5 | def test_max_age 6 | header_string = "max-age=30" 7 | cache_control_header = Rack::AcornCache::CacheControlHeader.new(header_string) 8 | assert_equal 30, cache_control_header.max_age 9 | end 10 | 11 | def test_max_age_not_present 12 | header_string = "private" 13 | cache_control_header = Rack::AcornCache::CacheControlHeader.new(header_string) 14 | refute cache_control_header.max_age 15 | end 16 | 17 | def test_max_age_no_cache_control_header_present 18 | header_string = nil 19 | cache_control_header = Rack::AcornCache::CacheControlHeader.new(header_string) 20 | refute cache_control_header.max_age 21 | end 22 | 23 | def test_s_max_age 24 | header_string = "s-maxage=30" 25 | cache_control_header = Rack::AcornCache::CacheControlHeader.new(header_string) 26 | s_maxage = cache_control_header.s_maxage 27 | 28 | assert_equal 30, s_maxage 29 | end 30 | 31 | def test_assert_no_cache? 32 | header_string = "no-cache, private" 33 | cache_control_header = Rack::AcornCache::CacheControlHeader.new(header_string) 34 | assert cache_control_header.no_cache? 35 | end 36 | 37 | def test_refute_no_cache? 38 | header_string = "private" 39 | cache_control_header = Rack::AcornCache::CacheControlHeader.new(header_string) 40 | refute cache_control_header.no_cache? 41 | end 42 | 43 | def test_assert_no_store? 44 | header_string = "no-store, private" 45 | cache_control_header = Rack::AcornCache::CacheControlHeader.new(header_string) 46 | assert cache_control_header.no_store? 47 | end 48 | 49 | def test_refute_no_store? 50 | header_string = "private" 51 | cache_control_header = Rack::AcornCache::CacheControlHeader.new(header_string) 52 | refute cache_control_header.no_store? 53 | end 54 | 55 | def test_assert_must_revalidate? 56 | header_string = "no-store, must-revalidate, private" 57 | cache_control_header = Rack::AcornCache::CacheControlHeader.new(header_string) 58 | assert_equal true, cache_control_header.must_revalidate? 59 | end 60 | 61 | def test_refute_must_revalidate? 62 | header_string = "private" 63 | cache_control_header = Rack::AcornCache::CacheControlHeader.new(header_string) 64 | refute cache_control_header.must_revalidate? 65 | end 66 | 67 | def test_assert_private? 68 | header_string = "no-store, must-revalidate, private" 69 | cache_control_header = Rack::AcornCache::CacheControlHeader.new(header_string) 70 | assert_equal true, cache_control_header.private? 71 | end 72 | 73 | def test_refute_private? 74 | header_string = "no-store" 75 | cache_control_header = Rack::AcornCache::CacheControlHeader.new(header_string) 76 | refute cache_control_header.private? 77 | end 78 | 79 | def test_max_fresh 80 | header_string = "no-store, max-fresh=30, private" 81 | cache_control_header = Rack::AcornCache::CacheControlHeader.new(header_string) 82 | assert_equal 30, cache_control_header.max_fresh 83 | end 84 | 85 | def test_max_stale_with_no_value_specified 86 | header_string = "max-stale" 87 | cache_control_header = Rack::AcornCache::CacheControlHeader.new(header_string) 88 | assert_equal true, cache_control_header.max_stale 89 | end 90 | 91 | def test_max_stale_with_value_specified 92 | header_string = "max-stale=30" 93 | cache_control_header = Rack::AcornCache::CacheControlHeader.new(header_string) 94 | assert_equal 30, cache_control_header.max_stale 95 | end 96 | 97 | def test_max_stale? 98 | header_string = "max-stale" 99 | cache_control_header = Rack::AcornCache::CacheControlHeader.new(header_string) 100 | assert true, cache_control_header.max_stale? 101 | end 102 | 103 | def test_to_s_with_one_cache_control_directive 104 | cache_control_header = Rack::AcornCache::CacheControlHeader.new 105 | cache_control_header.max_age = 86400 106 | 107 | assert_equal "max-age=86400", cache_control_header.to_s 108 | end 109 | 110 | def test_to_s_with_multiple_cache_control_directives 111 | cache_control_header = Rack::AcornCache::CacheControlHeader.new 112 | cache_control_header.max_age = 86400 113 | cache_control_header.no_cache = true 114 | 115 | assert_equal "max-age=86400, no-cache", cache_control_header.to_s 116 | end 117 | 118 | def test_to_s_with_unknown_cache_control_directive 119 | cache_control_header = Rack::AcornCache::CacheControlHeader.new("foo=bar") 120 | 121 | assert_equal "foo=bar", cache_control_header.to_s 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /test/cache_controller_test.rb: -------------------------------------------------------------------------------- 1 | require 'acorn_cache/cache_controller' 2 | require 'minitest/autorun' 3 | 4 | class CacheControllerTest < MiniTest::Test 5 | def test_response_when_request_no_cache_returns_server_response 6 | request = stub(no_cache?: true, env: {}, cache_key: "/", page_rule?: false) 7 | app = stub(call: [200, {}, "foo"]) 8 | server_response = mock('server_response') 9 | cache_maintenance = mock('cache_maintenance') 10 | 11 | Rack::AcornCache::ServerResponse.expects(:new).with(200, {}, "foo").returns(server_response) 12 | Rack::AcornCache::CacheMaintenance.expects(:new).with("/", server_response, nil).returns(cache_maintenance) 13 | cache_maintenance.expects(:update_cache).returns(cache_maintenance) 14 | cache_maintenance.expects(:response).returns(server_response) 15 | 16 | cache_controller = Rack::AcornCache::CacheController.new(request, app) 17 | assert server_response, cache_controller.response 18 | end 19 | 20 | def test_response_when_request_no_cache_false_and_theres_no_cached_version 21 | request = stub(no_cache?: false, cache_key: "/", env: {}, page_rule?: false) 22 | server_response = mock('server_response') 23 | null_cached_response = stub(must_be_revalidated?: false, fresh_for_request?: false) 24 | app = stub(call: [200, {}, "foo"]) 25 | cache_maintenance = mock('cache_maintenance') 26 | 27 | Rack::AcornCache::CacheReader.expects(:read).returns(nil) 28 | Rack::AcornCache::NullCachedResponse.expects(:new).returns(null_cached_response) 29 | Rack::AcornCache::ServerResponse.expects(:new).with(200, {}, "foo").returns(server_response) 30 | Rack::AcornCache::CacheMaintenance.expects(:new).with("/", server_response, null_cached_response).returns(cache_maintenance) 31 | cache_maintenance.expects(:update_cache).returns(cache_maintenance) 32 | cache_maintenance.expects(:response).returns(server_response) 33 | 34 | cache_controller = Rack::AcornCache::CacheController.new(request, app) 35 | assert server_response, cache_controller.response 36 | end 37 | 38 | def test_response_when_request_no_cache_false_and_there_is_cached_version_and_must_be_revalidated 39 | request = stub(no_cache?: false, cache_key: "/", env: {}, page_rule?: false) 40 | server_response = mock('server_response') 41 | cached_response = stub(must_be_revalidated?: true) 42 | app = stub(call: [200, {}, "foo"]) 43 | cache_maintenance = mock('cache_maintenance') 44 | 45 | Rack::AcornCache::CacheReader.expects(:read).with("/").returns(cached_response) 46 | 47 | request.expects(:update_conditional_headers!).with(cached_response) 48 | Rack::AcornCache::ServerResponse.expects(:new).with(200, {}, "foo").returns(server_response) 49 | Rack::AcornCache::CacheMaintenance.expects(:new).with("/", server_response, cached_response).returns(cache_maintenance) 50 | cache_maintenance.expects(:update_cache).returns(cache_maintenance) 51 | cache_maintenance.expects(:response).returns(server_response) 52 | 53 | cache_controller = Rack::AcornCache::CacheController.new(request, app) 54 | assert server_response, cache_controller.response 55 | end 56 | 57 | def test_response_when_request_no_cache_false_and_there_is_cached_version_and_not_must_be_revalidated_and_isnt_fresh_for_request 58 | request = stub(no_cache?: false, cache_key: "/", env: {}, page_rule?: false) 59 | server_response = mock('server_response') 60 | cached_response = stub(must_be_revalidated?: false, fresh_for_request?: false) 61 | app = stub(call: [200, {}, "foo"]) 62 | cache_maintenance = mock('cache_maintenance') 63 | 64 | Rack::AcornCache::CacheReader.expects(:read).with("/").returns(cached_response) 65 | Rack::AcornCache::ServerResponse.expects(:new).with(200, {}, "foo").returns(server_response) 66 | Rack::AcornCache::CacheMaintenance.expects(:new).with("/", server_response, cached_response).returns(cache_maintenance) 67 | cache_maintenance.expects(:update_cache).returns(cache_maintenance) 68 | cache_maintenance.expects(:response).returns(server_response) 69 | 70 | cache_controller = Rack::AcornCache::CacheController.new(request, app) 71 | assert server_response, cache_controller.response 72 | end 73 | 74 | def test_response_when_request_no_cache_false_and_there_is_cached_version_and_not_must_be_revalidated_and_is_fresh_for_request 75 | request = stub(no_cache?: false, cache_key: "/", env: {}, conditional?: false) 76 | cached_response = stub(must_be_revalidated?: false, fresh_for_request?: true) 77 | app = stub(call: [200, {}, "foo"]) 78 | cache_maintenance = mock('cache_maintenance') 79 | 80 | Rack::AcornCache::CacheReader.expects(:read).with("/").returns(cached_response) 81 | Rack::AcornCache::CacheMaintenance.expects(:new).with("/", nil, cached_response).returns(cache_maintenance) 82 | cache_maintenance.expects(:update_cache).returns(cache_maintenance) 83 | cache_maintenance.expects(:response).returns(cached_response) 84 | 85 | cache_controller = Rack::AcornCache::CacheController.new(request, app) 86 | assert cached_response, cache_controller.response 87 | end 88 | 89 | def test_response_when_request_no_cache_true_and_page_rules_set 90 | request = stub(no_cache?: true, env: {}, cache_key: "/", page_rule?: true, page_rule: { acorn_cache_ttl: 30 }) 91 | app = stub(call: [200, {}, "foo"]) 92 | server_response = mock("server response") 93 | 94 | Rack::AcornCache::ServerResponse.stubs(:new).returns(server_response) 95 | Rack::AcornCache::CacheMaintenance.stubs(:new).returns(server_response) 96 | server_response.stubs(:update_cache).returns(server_response) 97 | server_response.stubs(:response) 98 | 99 | server_response.expects(:update_with_page_rules!).with(acorn_cache_ttl: 30) 100 | 101 | Rack::AcornCache::CacheController.new(request, app).response 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /test/server_response_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'acorn_cache/server_response' 3 | require 'mocha/mini_test' 4 | 5 | class ServerResponseTest < Minitest::Test 6 | 7 | attr_reader :status, :headers, :body 8 | 9 | def test_new 10 | @status = status 11 | @headers = headers 12 | @body = body 13 | 14 | Rack::AcornCache::CacheControlHeader.expects(:new).with("private") 15 | 16 | server_response = Rack::AcornCache::ServerResponse.new(200, { "Cache-Control" => "private" }, "test body") 17 | 18 | assert_equal 200, server_response.status 19 | assert_equal({ "Cache-Control" => "private" }, server_response.headers) 20 | assert_equal "test body", server_response.body 21 | end 22 | 23 | def test_private_delegation 24 | cache_control_header = mock("cache control header") 25 | cache_control_header.expects(:private?).returns(true) 26 | Rack::AcornCache::CacheControlHeader.expects(:new).returns(cache_control_header) 27 | 28 | server_response = Rack::AcornCache::ServerResponse.new(200, { "Cache-Control" => "private" }, "test body") 29 | 30 | assert server_response.private? 31 | end 32 | 33 | def test_no_store_delegation 34 | cache_control_header = mock("cache control header") 35 | cache_control_header.expects(:no_store?).returns(true) 36 | Rack::AcornCache::CacheControlHeader.expects(:new).returns(cache_control_header) 37 | 38 | server_response = Rack::AcornCache::ServerResponse.new(200, { "Cache-Control" => "no-store" }, "test body") 39 | 40 | assert server_response.no_store? 41 | end 42 | 43 | def test_update_date_when_date_already_exists 44 | server_response = Rack::AcornCache::ServerResponse.new(200, { "Date" => "Mon, 01 Jan 2000 01:00:01 GMT" }, "test body") 45 | 46 | assert "Mon, 01 Jan 2000 01:00:01 GMT", server_response.update_date! 47 | end 48 | 49 | def test_update_when_no_date_exists 50 | server_response = Rack::AcornCache::ServerResponse.new(200, { "Cache-Control" => "no-store" }, "test body") 51 | 52 | current_time = mock("current time") 53 | current_time.expects(:httpdate).returns(Time.now.httpdate) 54 | 55 | server_response.update_date! 56 | assert_equal current_time.httpdate, server_response.headers["Date"] 57 | end 58 | 59 | def test_cacheable_returns_true 60 | server_response = Rack::AcornCache::ServerResponse.new(200, { }, "test body") 61 | 62 | assert server_response.cacheable? 63 | end 64 | 65 | def test_cacheable_returns_false_for_cache_control 66 | server_response = Rack::AcornCache::ServerResponse.new(200, { "Cache-Control" => "no-store" }, "test body") 67 | 68 | refute server_response.cacheable? 69 | end 70 | 71 | def test_cacheable_returns_false_for_status 72 | server_response = Rack::AcornCache::ServerResponse.new(304, { }, "test body") 73 | 74 | refute server_response.cacheable? 75 | end 76 | 77 | def test_status_304? 78 | server_response = Rack::AcornCache::ServerResponse.new(304, { "Cache-Control" => "no-store" }, "test body") 79 | 80 | assert server_response.status_304? 81 | end 82 | 83 | def test_serialize 84 | server_response = Rack::AcornCache::ServerResponse.new(304, { "Cache-Control" => "no-store" }, ["test body"]) 85 | 86 | result = server_response.serialize 87 | 88 | assert_equal "{\"status\":304,\"headers\":{\"Cache-Control\":\"no-store\"},\"body\":\"test body\"}", result 89 | end 90 | 91 | def test_body_string 92 | server_response = Rack::AcornCache::ServerResponse.new(304, { "Cache-Control" => "no-store" }, ["test body part one", "test body part deux"]) 93 | 94 | result = server_response.body_string 95 | 96 | assert_equal "test body part onetest body part deux", result 97 | end 98 | 99 | def test_to_a 100 | server_response = Rack::AcornCache::ServerResponse.new(304, { "Cache-Control" => "no-store" }, "test body") 101 | 102 | result = server_response.to_a 103 | 104 | assert_equal [304, {"Cache-Control"=>"no-store"}, "test body"], result 105 | end 106 | 107 | def test_cache_updates_date 108 | server_response = Rack::AcornCache::ServerResponse.new(304, {"Cache-Control"=>"no-store"}, "test body") 109 | server_response.expects(:serialize).returns("Hey look I'm serialized!") 110 | Rack::AcornCache::CacheWriter.expects(:write).with("key", "Hey look I'm serialized!") 111 | 112 | server_response.cache!("key") 113 | end 114 | 115 | def test_update_with_page_rules_when_directives_are_removed 116 | page_rule = { acorn_cache_ttl: 30 } 117 | 118 | headers = { "Cache-Control" => "private, no-cache, no-store, must_revalidate" } 119 | response = Rack::AcornCache::ServerResponse.new(200, headers, "test body") 120 | 121 | assert response.private? 122 | assert response.no_cache? 123 | assert response.no_store? 124 | assert response.must_revalidate? 125 | 126 | response.update_with_page_rules!(page_rule) 127 | 128 | refute response.private? 129 | refute response.no_cache? 130 | refute response.no_store? 131 | refute response.must_revalidate? 132 | 133 | assert_equal 30, response.s_maxage 134 | end 135 | 136 | def test_update_with_page_rules_for_must_revalidate 137 | page_rule = { must_revalidate: true } 138 | 139 | headers = { "Cache-Control" => "no-store" } 140 | response = Rack::AcornCache::ServerResponse.new(200, headers, "test body") 141 | 142 | assert response.no_store? 143 | 144 | response.update_with_page_rules!(page_rule) 145 | 146 | assert response.must_revalidate? 147 | assert response.no_cache? 148 | refute response.no_store? 149 | end 150 | 151 | def test_update_with_page_rules_for_max_age 152 | page_rule = { browser_cache_ttl: 30 } 153 | 154 | headers = { "Cache-Control" => "no-cache, no-store" } 155 | response = Rack::AcornCache::ServerResponse.new(200, headers, "test body") 156 | 157 | assert response.no_store? 158 | assert response.no_cache? 159 | 160 | response.update_with_page_rules!(page_rule) 161 | 162 | assert_equal 30, response.max_age 163 | refute response.no_cache? 164 | refute response.no_store? 165 | end 166 | 167 | def test_update_with_age_rules_for_s_maxage 168 | page_rule = { acorn_cache_ttl: 30 } 169 | 170 | headers = { "Cache-Control" => "no-cache, no-store" } 171 | response = Rack::AcornCache::ServerResponse.new(200, headers, "test body") 172 | 173 | assert response.no_store? 174 | assert response.no_cache? 175 | 176 | response.update_with_page_rules!(page_rule) 177 | 178 | assert_equal 30, response.s_maxage 179 | refute response.no_cache? 180 | refute response.no_store? 181 | end 182 | 183 | def test_cache_deletes_cookie 184 | headers = { "Set-Cookie" => "foo=bar"} 185 | response = Rack::AcornCache::ServerResponse.new(200, headers, ["test body"]) 186 | Rack::AcornCache::CacheWriter.stubs(:write) 187 | 188 | response.cache!("foo") 189 | 190 | refute response.headers["Set-Cookie"] 191 | end 192 | end 193 | -------------------------------------------------------------------------------- /test/acorn_cache_test.rb: -------------------------------------------------------------------------------- 1 | require 'acorn_cache' 2 | require 'minitest/autorun' 3 | require 'mocha/mini_test' 4 | require 'time' 5 | 6 | class AcornCacheTest < Minitest::Test 7 | def test_call_returns_app_if_request_is_not_a_get 8 | env = { "REQUEST_METHOD" => "POST" } 9 | app = mock("app") 10 | app.stubs(:call).returns([200, { }, ["foo"]]) 11 | 12 | acorn_cache = Rack::AcornCache.new(app) 13 | 14 | assert_equal [200, { }, ["foo"]], acorn_cache.call(env) 15 | end 16 | 17 | def test_catch_and_re_raise_caught_app_exception 18 | env = { } 19 | request = stub(no_cache?: true, env: { }, cacheable?: true) 20 | Rack::AcornCache::Request.stubs(:new).returns(request) 21 | app = mock("app") 22 | app.stubs(:call).raises(StandardError) 23 | 24 | acorn_cache = Rack::AcornCache.new(app) 25 | 26 | assert_raises (StandardError) { acorn_cache.call(env) } 27 | end 28 | 29 | def test_catch_and_rescue_exception_from_cache_controller 30 | env = { } 31 | Rack::AcornCache::CacheController.stubs(:new).raises(StandardError) 32 | app = stub(call: [200, { }, ["foo"]]) 33 | 34 | acorn_cache = Rack::AcornCache.new(app) 35 | 36 | assert_equal [200, { }, ["foo"]], acorn_cache.call(env) 37 | end 38 | 39 | def test_call_passes_request_and_app_to_cache_controller_if_ok 40 | request = stub(cacheable?: true) 41 | response = mock("response") 42 | cache_controller = mock("cache controller") 43 | env = { "REQUEST_METHOD" => "GET" } 44 | app = mock("app") 45 | app.stubs(:call).returns([200, { }, ["foo"]]) 46 | Rack::AcornCache::CacheController.stubs(:new).with(request, app) 47 | .returns(cache_controller) 48 | Rack::AcornCache::Request.stubs(:new).with(env).returns(request) 49 | cache_controller.expects(:response).returns(response) 50 | response.expects(:to_a).returns([200, { }, ["foo"]]) 51 | 52 | acorn_cache = Rack::AcornCache.new(app) 53 | 54 | assert_equal [200, { }, ["foo"]], acorn_cache.call(env) 55 | end 56 | 57 | def test_when_request_is_not_get 58 | app = mock("app") 59 | env = Rack::MockRequest.env_for("http://foo.com/") 60 | env["REQUEST_METHOD"] = "POST" 61 | app.stubs(:call).with(env).returns([200, {}, ["foo"]]) 62 | 63 | acorn_cache = Rack::AcornCache.new(app) 64 | result = acorn_cache.call(env) 65 | assert_equal ([200, {}, ["foo"]]), result 66 | end 67 | 68 | def test_cache_not_checked_unless_page_rule_set 69 | app = mock("app") 70 | env = Rack::MockRequest.env_for("http://foo.com/") 71 | env["REQUEST_METHOD"] = "GET" 72 | app.stubs(:call).with(env).returns([200, {}, ["foo"]]) 73 | 74 | acorn_cache = Rack::AcornCache.new(app) 75 | 76 | Rack::AcornCache.configure do |config| 77 | config.page_rules = { 78 | "http://bar.com/" => { acorn_cache_ttl: 30 } 79 | } 80 | end 81 | 82 | result = acorn_cache.call(env) 83 | assert_equal ([200, {}, ["foo"]]), result 84 | end 85 | 86 | def test_cache_not_checked_unless_page_rule_set 87 | app = mock("app") 88 | env = Rack::MockRequest.env_for("http://foo.com/") 89 | env["REQUEST_METHOD"] = "GET" 90 | app.stubs(:call).with(env).returns([200, {}, ["foo"]]) 91 | 92 | acorn_cache = Rack::AcornCache.new(app) 93 | 94 | Rack::AcornCache.configure do |config| 95 | config.page_rules = { 96 | "http://bar.com/" => { acorn_cache_ttl: 30 } 97 | } 98 | end 99 | 100 | result = acorn_cache.call(env) 101 | assert_equal ([200, {}, ["foo"]]), result 102 | end 103 | 104 | def test_cache_checked_when_cache_everything_set_and_no_cached_repsonse 105 | app = mock("app") 106 | env = Rack::MockRequest.env_for("http://foo.com/") 107 | env["REQUEST_METHOD"] = "GET" 108 | response = ([200, {"Cache-Control" => "no-store" }, ["foo"]]) 109 | app.stubs(:call).with(env).returns(response) 110 | redis = mock("redis") 111 | Redis.expects(:new).returns(redis) 112 | redis.stubs(:get).returns(nil) 113 | 114 | acorn_cache = Rack::AcornCache.new(app) 115 | 116 | Rack::AcornCache.configure do |config| 117 | config.cache_everything = true 118 | end 119 | 120 | result = acorn_cache.call(env) 121 | assert_equal response, result 122 | end 123 | 124 | def test_cached_response_fresh 125 | app = mock("app") 126 | env = Rack::MockRequest.env_for("http://foo.com/") 127 | env["REQUEST_METHOD"] = "GET" 128 | 129 | redis = mock("redis") 130 | serialized_cached_response = "{\"status\":200,\"headers\":{\"Date\":\"Fri, 01 Jan 2016 00:00:00 GMT\",\"Cache-Control\":\"max-age=30\"},\"body\":\"foo\"}" 131 | redis.stubs(:get).returns(serialized_cached_response) 132 | Redis.expects(:new).returns(redis) 133 | Time.stubs(:now).returns(Time.gm(2016)) 134 | 135 | acorn_cache = Rack::AcornCache.new(app) 136 | 137 | Rack::AcornCache.configure do |config| 138 | config.page_rules = { 139 | "http://foo.com/" => { respect_existing_headers: true } 140 | } 141 | end 142 | 143 | response = [200, { "Date" => "Fri, 01 Jan 2016 00:00:00 GMT", "Cache-Control" => "max-age=30", "X-Acorn-Cache" => "HIT" }, ["foo"] ] 144 | 145 | result = acorn_cache.call(env) 146 | assert_equal response, result 147 | end 148 | 149 | def test_cached_response_expired_server_response_cacheable 150 | app = mock("app") 151 | env = Rack::MockRequest.env_for("http://foo.com/") 152 | env["REQUEST_METHOD"] = "GET" 153 | 154 | redis = mock("redis") 155 | serialized_cached_response = "{\"status\":200,\"headers\":{\"Date\":\"Fri, 01 Jan 2016 00:00:00 GMT\",\"Cache-Control\":\"max-age=0\"},\"body\":\"foo\"}" 156 | redis.stubs(:get).returns(serialized_cached_response) 157 | Redis.stubs(:new).returns(redis) 158 | Time.stubs(:now).returns(Time.gm(2016)) 159 | 160 | acorn_cache = Rack::AcornCache.new(app) 161 | 162 | Rack::AcornCache.configure do |config| 163 | config.page_rules = { 164 | "http://foo.com/" => { acorn_cache_ttl: 30, 165 | browser_cache_ttl: 45 } 166 | } 167 | end 168 | 169 | response = [200, {}, ["foo"]] 170 | 171 | app.expects(:call).with(env).returns(response) 172 | serialized_response = { status: 200, headers: { "Cache-Control" => "max-age=45, s-maxage=30", "Date" => Time.gm(2016).httpdate }, body: "foo" }.to_json 173 | redis.expects(:set).with('http://foo.com/', serialized_response) 174 | 175 | result = acorn_cache.call(env) 176 | assert_equal [200, {"Cache-Control"=>"max-age=45, s-maxage=30", "Date"=>"Fri, 01 Jan 2016 00:00:00 GMT"}, ["foo"]], result 177 | end 178 | 179 | def test_cached_response_expired_server_response_not_cacheable 180 | app = mock("app") 181 | env = Rack::MockRequest.env_for("http://foo.com/") 182 | env["REQUEST_METHOD"] = "GET" 183 | 184 | redis = mock("redis") 185 | serialized_cached_response = "{\"status\":200,\"headers\":{\"Date\":\"Fri, 01 Jan 2016 04:59:00:00 GMT\",\"Cache-Control\":\"max-age=0\"},\"body\":\"foo\"}" 186 | redis.stubs(:get).returns(serialized_cached_response) 187 | Redis.stubs(:new).returns(redis) 188 | Time.stubs(:now).returns(Time.gm(2016)) 189 | 190 | acorn_cache = Rack::AcornCache.new(app) 191 | 192 | Rack::AcornCache.configure do |config| 193 | config.page_rules = { 194 | "http://foo.com/" => { respect_existing_headers: true } 195 | } 196 | end 197 | 198 | response = [200, {"Cache-Control" => "no-store"}, ["foo"]] 199 | app.expects(:call).with(env).returns(response) 200 | redis.expects(:set).never 201 | 202 | result = acorn_cache.call(env) 203 | assert_equal response, result 204 | end 205 | 206 | def test_cached_response_needs_revalidated 207 | app = mock("app") 208 | env = Rack::MockRequest.env_for("http://foo.com") 209 | env["REQUEST_METHOD"] = "GET" 210 | 211 | redis = mock("redis") 212 | serialized_cached_response = "{\"status\":200,\"headers\":{\"Date\":\"Fri, 01 Jan 2016 05:00:00 GMT\",\"Cache-Control\":\"no-cache\", \"ETag\": \"12345\"},\"body\":\"foo\"}" 213 | redis.stubs(:get).returns(serialized_cached_response) 214 | Redis.stubs(:new).returns(redis) 215 | 216 | acorn_cache = Rack::AcornCache.new(app) 217 | 218 | Rack::AcornCache.configure do |config| 219 | config.page_rules = { 220 | "http://foo.com/" => { respect_existing_headers: true } 221 | } 222 | end 223 | 224 | response = [304, {"Cache-Control" => "no-store"}, []] 225 | modified_env = env 226 | modified_env["HTTP_IF_NONE_MATCH"] = "12345" 227 | app.expects(:call).with(modified_env).returns(response) 228 | 229 | result = acorn_cache.call(env) 230 | assert_equal response, result 231 | end 232 | 233 | def test_conditional_request_with_fresh_cached_version 234 | app = mock("app") 235 | env = Rack::MockRequest.env_for("http://foo.com") 236 | env["REQUEST_METHOD"] = "GET" 237 | env["HTTP_IF_NONE_MATCH"] = "12345" 238 | 239 | redis = mock("redis") 240 | serialized_cached_response = "{\"status\":200,\"headers\":{\"Date\":\"Fri, 01 Jan 2016 05:00:00 GMT\", \"Expires\": \"Sat, 02 Jan 2016 00:00:00 GMT \", \"ETag\": \"12345\"},\"body\":\"foo\"}" 241 | Redis.stubs(:new).returns(redis) 242 | redis.stubs(:get).returns(serialized_cached_response) 243 | Time.stubs(:now).returns(Time.new(2015)) 244 | 245 | acorn_cache = Rack::AcornCache.new(app) 246 | 247 | Rack::AcornCache.configure do |config| 248 | config.page_rules = { 249 | "http://foo.com/" => { respect_existing_headers: true } 250 | } 251 | end 252 | 253 | result = acorn_cache.call(env) 254 | assert_equal 304, result[0] 255 | assert_empty result[2] 256 | end 257 | 258 | def teardown 259 | Rack::AcornCache.configuration = nil 260 | if Rack::AcornCache::Storage.instance_variable_get(:@redis) 261 | Rack::AcornCache::Storage.remove_instance_variable(:@redis) 262 | end 263 | end 264 | end 265 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/acorncache/acorn-cache.svg?branch=master)](https://travis-ci.org/acorncache/acorn-cache) 2 | 3 | ![AcornCache](http://i.imgur.com/6zdrz8A.png?1) 4 | 5 | AcornCache is a Ruby HTTP proxy caching library that is lightweight, configurable and can be easily integrated with any Rack-based web application. AcornCache allows you to improve page load times and lighten the load on your server by allowing you to implement an in-memory cache shared by every client requesting a resource on your server. 6 | 7 | Features currently available include the following: 8 | 9 | * Honors origin server cache control directives according to RFC2616 standards unless directed otherwise. 10 | * Allows for easily configuring: 11 | * which resources should be cached, 12 | * for how long, and 13 | * whether query params should be ignored 14 | * Allows for basic browser caching behavior modification by changing out cache control header directives. 15 | * Uses Redis or Memcached to store cached server responses. 16 | * Adds a custom header to mark responses returned from the cache (`X-Acorn-Cache: HIT`) 17 | * Removes cookies from server responses prior to caching. 18 | 19 | ## Getting Started 20 | 21 | [![Join the chat at https://gitter.im/acorncache/acorn-cache](https://badges.gitter.im/acorncache/acorn-cache.svg)](https://gitter.im/acorncache/acorn-cache?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 22 | 23 | #### Installation 24 | 25 | Add this line to your application's Gemfile: 26 | 27 | ```ruby 28 | gem 'acorn_cache' 29 | ``` 30 | 31 | And then execute: 32 | 33 | $ bundle 34 | 35 | Or install it yourself: 36 | 37 | $ gem install acorn_cache 38 | 39 | 40 | AcornCache must be included in the middleware pipeline of your Rails or Rack application. 41 | 42 | With Rails, add the following config option to the appropriate environment, probably ```config/environments/production.rb```. Note that we recommend AcornCache be positioned at the top of your middleware stack. Replace `Rack::Sendfile` in the example if necessary. 43 | 44 | ```ruby 45 | config.middleware.insert_before(Rack::Sendfile, Rack::AcornCache) 46 | ``` 47 | 48 | You should now see ```Rack::AcornCache``` listed at the top of your middleware pipeline when you run `rake middleware`. 49 | 50 | For non-Rails Rack apps, just include the following in your rackup (.ru) file: 51 | ```ruby 52 | require 'acorn_cache' 53 | 54 | use Rack::AcornCache 55 | ``` 56 | 57 | #### Setting Up Storage 58 | By default, AcornCache uses Redis to store server responses. You must include 59 | your Redis host, port, and password (if you have one set) as environment variables. 60 | 61 | ``` 62 | ACORNCACHE_REDIS_HOST="your_host_name" 63 | ACORNCACHE_REDIS_PORT="your_port_number" 64 | ACORNCACHE_REDIS_PASSWORD="your_password" 65 | ``` 66 | You may also choose to use memcached. If so, set the URL (including host and 67 | port) and, if you have SASL authentication, username and password. 68 | 69 | ``` 70 | ACORNCACHE_MEMCACHED_URL="your_url" 71 | ACORNCACHE_MEMCACHED_USERNAME="your_username" 72 | ACORNCACHE_MEMCACHED_PASSWORD="your_password" 73 | ``` 74 | To switch to Memcached, add the following line to your AcornCache config: 75 | ```ruby 76 | config.storage = :memcached 77 | ``` 78 | 79 | #### Configuration 80 | AcornCache has a range of configuration options. If you're using Rails, set them in an initializer: `config/initializers/acorn_cache.rb` 81 | 82 | Without configuration, AcornCache won't cache anything. Two basic configuration 83 | patterns are possible. The most common will be to specify page rules telling 84 | AcornCache how long to store a resource. 85 | 86 | The config below specifies two URLs to cache and specifies the time to live, i.e., the time the resource at that location should live in AcornCache and the browser cache. With this config, AcornCache will only cache the resources at these two URLs: 87 | 88 | 89 | 90 | ```ruby 91 | if Rails.env.production? 92 | Rack::AcornCache.configure do |config| 93 | config.page_rules = { 94 | "http://foo.com/" => { browser_cache_ttl: 30 }, 95 | "http://foo.com/bar" => { acorn_cache_ttl: 100 } 96 | } 97 | end 98 | end 99 | ``` 100 | 101 | 102 | If you choose to do so, you can have AcornCache act as an RFC compliant 103 | shared proxy-cache for every resource on your server. For information concerning standard RFC caching rules, 104 | please refer to the Further Information section below. To operate in this mode, just set: 105 | 106 | ```ruby 107 | config.cache_everything = true 108 | ``` 109 | Keep in mind that you can override standard caching behavior even when in cache everything mode by specifying a page rule. 110 | 111 | See below for all available options. 112 | 113 | ## Page Rules 114 | Configuration options can be set for individual URLs via the 115 | `page-rules` config option. The value of `page-rules` must be set to a hash. The hash must have a key that is either 1) a URL string, or 2) a pattern that matches the URL of the page(s) for which you are setting the rule, and a value that specifies the caching rule(s) for the page or pages. Here's an example: 116 | 117 | ```ruby 118 | Rack::AcornCache.configure do |config| 119 | config.page_rules = { 120 | "http://foo.com/" => { acorn_cache_ttl: 1800, 121 | browser_cache_ttl: 800 }, 122 | "http://foo.com/helpcenter*" => { browser_cache_ttl: 3600, 123 | ignore_query_params: true }, 124 | /^https?:\/\/foo.com\/docs/ => { respect_existing_headers: true, 125 | ignore_query_params: true } 126 | } 127 | end 128 | ``` 129 | #### Deciding Which Resources Are Cached 130 | Resources best suited for caching are public (not behind authentication) and don't change very often. 131 | AcornCache provides you with three options for defining the URLs for the resources that you want to cache: 132 | 133 | 1. You can define a single URL explicitly: 134 | ```ruby 135 | "http://foo.com/" => { acorn_cache_ttl: 100 } 136 | ``` 137 | 138 | 2. You can use wildcards to identify multiple pages for a which a given set of rules applies: 139 | ```ruby 140 | "http://foo.com/helpcenter*" => { browser_cache_ttl: 86400 } 141 | ``` 142 | 143 | 3. You can use regex pattern matching simply by using a `Regexp` object as the 144 | key: 145 | ```ruby 146 | /^https?:\/\/.+\.com/ => { acorn_cache_ttl: 100 } 147 | ``` 148 | 149 | 150 | #### Deciding How Resources Are Cached 151 | ##### Override Existing Cache Control Headers 152 | Suppose you don't know or want to change the cache control headers provided by your server. AcornCache gives you the ability to control how a resource is 153 | cached by both AcornCache and the browser cache simply by specifying the 154 | appropriate page rule settings. 155 | 156 | AcornCache provides four options, which can be set either as defaults or within 157 | individual page rules. 158 | 159 | 1. `acorn_cache_ttl` - 160 | This option specifies the time a resource should live in AcornCache before 161 | expiring. It works by overriding the `s-maxage` directive in the cache control 162 | header with the specified value. Time should be given in seconds. It also removes any directives that would 163 | prevent caching in a shared proxy cache, like `private` or `no-store`. 164 | 165 | 2. `browser_cache_ttl` - 166 | This option specifies the time in seconds a resource should live in private 167 | browser caches before expiring. It works by overriding the `max-age` directive 168 | in the cache control header with the specified value. It also removes any 169 | directives that would prevent caching in a private cache, like `no-store`. 170 | 171 | 3. `ignore_query_params` - 172 | If the query params in a request shouldn't affect the response from your server, 173 | you can set this option to `true` so that all requests for a URL, regardless of 174 | the specified params, share the same cache entry. This means that if a resource 175 | living at `http://foo.com` is cached with AcornCache, a request to 176 | `http://foo.com/?bar=baz` will respond with that cached resource without creating another 177 | cache entry. 178 | 179 | 4. `must_revalidate` - 180 | When set to `true`, the content of the cache will be checked against the origin server using `ETag` or `Last-Modified` headers. With this configuration, AcornCache will not use a cache entry without first revalidating it with the origin server. 181 | 182 | These four options can be set either as defaults or for individual page rules. 183 | Default settings apply to any page that AcornCache is allowed to cache unless 184 | they are overwritten by a page rule. For example, if your 185 | config looks like this... 186 | 187 | ```ruby 188 | RackAcornCache.configure do |config| 189 | config.default_acorn_cache_ttl = 30 190 | config.page_rules = { 191 | "http://foo.com/" => { use_defaults: true }, 192 | "http://foo.com/helpdocs" => { acorn_cache_ttl: 100 } 193 | } 194 | end 195 | ``` 196 | 197 | ...then the server response returned by a request to `foo.com/` will be cached in AcornCache for 30 seconds, but the server response returned by a request to `foo.com/helpdocs` will be cached for 100 seconds. 198 | 199 | ##### Respect Existing Cache Control Headers 200 | AcornCache provides you with the ability to respect the cache control headers that were provided from the client or origin server. This can be achieved by setting `respect_existing_headers: true` for a page or given set of pages. This option is useful when you don't want to cache everything but you also want to control caching behavior by ensuring that responses come from your server with the proper cache control headers. If you choose this option, you will likely want to ensure that your response has an `s-maxage` directive, as AcornCache operates as a shared cache. 201 | 202 | ## Further Information 203 | 204 | AcornCache's rules and caching guidelines strictly follow RFC 2616 standards. [This flow chart](http://i.imgur.com/o63TJAa.jpg) details the logic and rules that AcornCache is built upon and defines its default behavior. 205 | 206 | ## Contributing 207 | 208 | Bug reports and pull requests are welcome on GitHub at https://github.com/acorncache/acorn-cache. 209 | 210 | 211 | ## License 212 | 213 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 214 | -------------------------------------------------------------------------------- /test/request_test.rb: -------------------------------------------------------------------------------- 1 | require 'acorn_cache/request' 2 | require 'minitest/autorun' 3 | require 'time' 4 | 5 | class RequestTest < Minitest::Test 6 | def test_no_cache_delegation 7 | env = {} 8 | cache_control_header = mock("cache control header") 9 | cache_control_header.expects(:no_cache?).returns(true) 10 | Rack::AcornCache::CacheControlHeader.expects(:new).returns(cache_control_header) 11 | 12 | cached_response = Rack::AcornCache::Request.new(env) 13 | assert cached_response.no_cache? 14 | end 15 | 16 | def test_max_age_delegation 17 | env = {} 18 | cache_control_header = mock("cache control header") 19 | cache_control_header.expects(:max_age).returns(30) 20 | Rack::AcornCache::CacheControlHeader.expects(:new).returns(cache_control_header) 21 | 22 | request = Rack::AcornCache::Request.new(env) 23 | assert_equal 30, request.max_age 24 | end 25 | 26 | def test_max_fresh_delegation 27 | env = {} 28 | cache_control_header = mock("cache control header") 29 | cache_control_header.expects(:max_fresh).returns(30) 30 | Rack::AcornCache::CacheControlHeader.expects(:new).returns(cache_control_header) 31 | 32 | request = Rack::AcornCache::Request.new(env) 33 | assert_equal 30, request.max_fresh 34 | end 35 | 36 | def test_max_stale_delegation 37 | cache_control_header = mock("cache control header") 38 | cache_control_header.expects(:max_stale).returns(true) 39 | Rack::AcornCache::CacheControlHeader.expects(:new).returns(cache_control_header) 40 | 41 | env = {} 42 | request = Rack::AcornCache::Request.new(env) 43 | assert request.max_stale 44 | end 45 | 46 | def test_update_conditional_headers 47 | cached_response = mock('cached_response') 48 | cached_response.stubs(:etag_header).returns("1317121") 49 | cached_response.stubs(:last_modified_header).returns("a long time ago") 50 | 51 | env = {} 52 | request = Rack::AcornCache::Request.new(env) 53 | request.update_conditional_headers!(cached_response) 54 | assert_equal "1317121", request.env["HTTP_IF_NONE_MATCH"] 55 | assert_equal "a long time ago", request.env["HTTP_IF_MODIFIED_SINCE"] 56 | end 57 | 58 | def test_update_conditional_headers_when_cached_response_has_no_relevant_headers 59 | cached_response = mock('cached_response') 60 | cached_response.stubs(:etag_header).returns(nil) 61 | cached_response.stubs(:last_modified_header).returns(nil) 62 | 63 | env = {} 64 | request = Rack::AcornCache::Request.new(env) 65 | request.update_conditional_headers!(cached_response) 66 | refute request.env["HTTP_IF_NONE_MATCH"] 67 | refute request.env["HTTP_IF_MODIFIED_SINCE"] 68 | end 69 | 70 | def test_max_age_more_restrictive_when_no_cached_response_stale_time_specified 71 | cached_response = stub(stale_time_specified?: false) 72 | request = Rack::AcornCache::Request.new({}) 73 | 74 | refute request.max_age_more_restrictive?(cached_response) 75 | end 76 | 77 | def test_max_age_more_restrictive_when_request_has_no_max_age 78 | cached_response = stub(stale_time_specified?: true) 79 | request = Rack::AcornCache::Request.new({}) 80 | 81 | refute request.max_age_more_restrictive?(cached_response) 82 | end 83 | 84 | def test_max_age_more_restrictive_when_max_age_greater_than_cached_response_time_to_live 85 | cached_response = stub(stale_time_specified?: true, time_to_live: 30) 86 | env = { "HTTP_CACHE_CONTROL" => "max-age=40" } 87 | request = Rack::AcornCache::Request.new(env) 88 | 89 | refute request.max_age_more_restrictive?(cached_response) 90 | end 91 | 92 | def test_max_age_more_restrictive_when_max_age_less_than_cached_response_time_to_live 93 | cached_response = stub(stale_time_specified?: true, time_to_live: 30) 94 | env = { "HTTP_CACHE_CONTROL" => "max-age=20" } 95 | request = Rack::AcornCache::Request.new(env) 96 | 97 | assert request.max_age_more_restrictive?(cached_response) 98 | end 99 | 100 | def test_page_rule_when_none_specified 101 | request = Rack::AcornCache::Request.new({}) 102 | 103 | refute request.page_rule? 104 | refute request.page_rule 105 | end 106 | 107 | def test_page_rule_when_specified 108 | Rack::AcornCache.configure do |config| 109 | config.page_rules = { "foo.com" => { acorn_cache_ttl: 30 } } 110 | end 111 | 112 | request = Rack::AcornCache::Request.new({}) 113 | request.stubs(:url).returns("foo.com") 114 | 115 | assert request.page_rule? 116 | assert_includes(request.page_rule, :acorn_cache_ttl) 117 | assert_equal 30, request.page_rule[:acorn_cache_ttl] 118 | end 119 | 120 | def test_cacheable_when_cache_everything_true_and_no_page_rule_set_for_url 121 | Rack::AcornCache.configure do |config| 122 | config.cache_everything = true 123 | config.page_rules = { "foo.com" => { acorn_cache_ttl: 30 } } 124 | end 125 | 126 | request = Rack::AcornCache::Request.new({}) 127 | request.stubs(:url).returns("bar.com") 128 | request.stubs(:get?).returns(true) 129 | 130 | assert request.cacheable? 131 | end 132 | 133 | def test_cacheable_when_cache_everything_false_and_page_rule_set_for_url_with_normal_string 134 | Rack::AcornCache.configure do |config| 135 | config.cache_everything = false 136 | config.page_rules = { "foo.com" => { acorn_cache_ttl: 30 } } 137 | end 138 | 139 | request = Rack::AcornCache::Request.new({}) 140 | request.stubs(:url).returns("foo.com") 141 | request.stubs(:get?).returns(true) 142 | 143 | assert request.cacheable? 144 | end 145 | 146 | def test_cacheable_when_cache_everything_false_and_page_rule_set_for_url_with_wildcard_string 147 | Rack::AcornCache.configure do |config| 148 | config.cache_everything = false 149 | config.page_rules = { "f*.com" => { acorn_cache_ttl: 30 } } 150 | end 151 | 152 | request = Rack::AcornCache::Request.new({}) 153 | request.stubs(:url).returns("foo.com") 154 | request.stubs(:get?).returns(true) 155 | 156 | assert request.cacheable? 157 | end 158 | 159 | def test_cacheable_when_cache_everything_false_and_no_page_rule_set_for_url_with_wildcard_string 160 | Rack::AcornCache.configure do |config| 161 | config.cache_everything = false 162 | config.page_rules = { "b*.com" => { acorn_cache_ttl: 30 } } 163 | end 164 | 165 | request = Rack::AcornCache::Request.new({}) 166 | request.stubs(:url).returns("foo.com") 167 | request.stubs(:get?).returns(true) 168 | 169 | refute request.cacheable? 170 | end 171 | 172 | def test_cacheable_when_cache_everything_false_page_rule_set_for_url_with_wildcard_string_specifying_cache_all_http 173 | Rack::AcornCache.configure do |config| 174 | config.cache_everything = false 175 | config.page_rules = { "http://*.com" => { acorn_cache_ttl: 30 } } 176 | end 177 | 178 | request = Rack::AcornCache::Request.new({}) 179 | request.stubs(:url).returns("http://foo.com") 180 | request.stubs(:get?).returns(true) 181 | 182 | assert request.cacheable? 183 | end 184 | 185 | def test_cacheable_when_cache_everything_false_page_rule_set_for_url_with_wildcard_string_specifying_cache_all_https 186 | Rack::AcornCache.configure do |config| 187 | config.cache_everything = false 188 | config.page_rules = { "https://*.com" => { acorn_cache_ttl: 30 } } 189 | end 190 | 191 | request = Rack::AcornCache::Request.new({}) 192 | request.stubs(:url).returns("http://foo.com") 193 | request.stubs(:get?).returns(true) 194 | 195 | refute request.cacheable? 196 | end 197 | 198 | def test_cacheable_when_cache_everything_false_page_rule_set_for_url_with_wildcard_string_specifying_cache_all_js 199 | Rack::AcornCache.configure do |config| 200 | config.cache_everything = false 201 | config.page_rules = { "*.js" => { acorn_cache_ttl: 30 } } 202 | end 203 | 204 | request = Rack::AcornCache::Request.new({}) 205 | request.stubs(:url).returns("foo.js") 206 | request.stubs(:get?).returns(true) 207 | 208 | assert request.cacheable? 209 | end 210 | 211 | def test_cacheable_when_cache_everything_false_page_rule_set_for_url_with_regex 212 | Rack::AcornCache.configure do |config| 213 | config.cache_everything = false 214 | config.page_rules = { /fo{2}\.com/ => { acorn_cache_ttl: 30 } } 215 | end 216 | 217 | request = Rack::AcornCache::Request.new({}) 218 | request.stubs(:url).returns("foo.com") 219 | request.stubs(:get?).returns(true) 220 | 221 | assert request.cacheable? 222 | end 223 | 224 | def test_cacheable_when_cache_everything_false_no_page_rule_set_for_url_with_regex 225 | Rack::AcornCache.configure do |config| 226 | config.cache_everything = false 227 | config.page_rules = { /^fo{2}\.com$/ => { acorn_cache_ttl: 30 } } 228 | end 229 | 230 | request = Rack::AcornCache::Request.new({}) 231 | request.stubs(:url).returns("bar.foo.com/baz") 232 | request.stubs(:get?).returns(true) 233 | 234 | refute request.cacheable? 235 | end 236 | 237 | def test_cache_key_when_no_page_rule_set 238 | request = Rack::AcornCache::Request.new({}) 239 | request.stubs(:url).returns("http://foo.com/bar?baz=true") 240 | 241 | assert_equal "http://foo.com/bar?baz=true", request.cache_key 242 | end 243 | 244 | def test_cache_key_when_defualt_ignore_query_params_set 245 | Rack::AcornCache.configure do |config| 246 | config.cache_everything = true 247 | config.default_ignore_query_params = true 248 | end 249 | 250 | env = { "rack.url_scheme" => "http", 251 | "HTTP_HOST" => "foo.com", 252 | "PATH_INFO" => "/bar", 253 | "SERVER_PORT" => 80, 254 | "QUERY_STRING" => "baz=true"} 255 | 256 | request = Rack::AcornCache::Request.new(env) 257 | 258 | assert_equal "http://foo.com/bar?baz=true", request.url 259 | assert_equal "http://foo.com/bar", request.cache_key 260 | end 261 | 262 | def test_if_modified_since 263 | date = Time.new(2016).httpdate 264 | env = { "HTTP_IF_MODIFIED_SINCE" => date } 265 | request = Rack::AcornCache::Request.new(env) 266 | 267 | assert_equal date, request.if_modified_since 268 | end 269 | 270 | def test_if_none_match 271 | env = { "HTTP_IF_NONE_MATCH" => "12345" } 272 | request = Rack::AcornCache::Request.new(env) 273 | 274 | assert_equal "12345", request.if_none_match 275 | end 276 | 277 | 278 | def test_conditional 279 | env = { "HTTP_IF_NONE_MATCH" => "12345" } 280 | request = Rack::AcornCache::Request.new(env) 281 | 282 | assert request.conditional? 283 | end 284 | 285 | def test_not_conditional 286 | request = Rack::AcornCache::Request.new({}) 287 | 288 | refute request.conditional? 289 | end 290 | 291 | def teardown 292 | Rack::AcornCache.configuration = nil 293 | end 294 | end 295 | -------------------------------------------------------------------------------- /test/cached_response_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'acorn_cache/cached_response' 3 | require 'mocha/mini_test' 4 | 5 | class CachedResponseTest < Minitest::Test 6 | def test_new 7 | args = { "status" => 200, 8 | "headers" => { "Cache-Control" => "private" }, 9 | "body" => "test body" } 10 | 11 | Rack::AcornCache::CacheControlHeader.expects(:new).with("private") 12 | 13 | cached_response = Rack::AcornCache::CachedResponse.new(args) 14 | 15 | assert_equal 200, cached_response.status 16 | assert_equal({ "Cache-Control" => "private" }, cached_response.headers) 17 | assert_equal "test body", cached_response.body 18 | end 19 | 20 | def test_no_cache_delegation 21 | args = { "status" => 200, 22 | "headers" => { "Cache-Control" => "no-cache" }, 23 | "body" => "test body" } 24 | 25 | cache_control_header = mock("cache control header") 26 | cache_control_header.expects(:no_cache?).returns(true) 27 | Rack::AcornCache::CacheControlHeader.expects(:new).returns(cache_control_header) 28 | 29 | cached_response = Rack::AcornCache::CachedResponse.new(args) 30 | assert cached_response.no_cache? 31 | end 32 | 33 | def test_must_revalidate_delegation 34 | args = { "status" => 200, 35 | "headers" => { "Cache-Control" => "must-revalidate" }, 36 | "body" => "test body" } 37 | 38 | cache_control_header = mock("cache control header") 39 | cache_control_header.expects(:must_revalidate?).returns(true) 40 | Rack::AcornCache::CacheControlHeader.expects(:new).returns(cache_control_header) 41 | 42 | cached_response = Rack::AcornCache::CachedResponse.new(args) 43 | assert cached_response.must_revalidate? 44 | end 45 | 46 | def test_max_age_delegation 47 | args = { "status" => 200, 48 | "headers" => { "Cache-Control" => "max-age=30" }, 49 | "body" => "test body" } 50 | 51 | cache_control_header = mock("cache control header") 52 | cache_control_header.expects(:max_age).returns(30) 53 | Rack::AcornCache::CacheControlHeader.expects(:new).returns(cache_control_header) 54 | 55 | cached_response = Rack::AcornCache::CachedResponse.new(args) 56 | assert_equal 30, cached_response.max_age 57 | end 58 | 59 | def test_s_max_age_delegation 60 | args = { "status" => 200, 61 | "headers" => { "Cache-Control" => "s-maxage=30" }, 62 | "body" => "test body" } 63 | 64 | cache_control_header = mock("cache control header") 65 | cache_control_header.expects(:s_maxage).returns(30) 66 | Rack::AcornCache::CacheControlHeader.expects(:new).returns(cache_control_header) 67 | 68 | cached_response = Rack::AcornCache::CachedResponse.new(args) 69 | assert_equal 30, cached_response.s_maxage 70 | end 71 | 72 | def test_must_be_revalidated 73 | args = { "status" => 200, 74 | "headers" => { "Cache-Control" => "no-cache" }, 75 | "body" => "test body" } 76 | 77 | cached_response = Rack::AcornCache::CachedResponse.new(args) 78 | cached_response.expects(:no_cache?).returns(true) 79 | 80 | assert cached_response.must_be_revalidated? 81 | end 82 | 83 | def test_update_date! 84 | args = { "status" => 200, 85 | "headers" => { "Cache-Control" => "no-cache" }, 86 | "body" => "test body" } 87 | 88 | cached_response = Rack::AcornCache::CachedResponse.new(args) 89 | current_time = mock('current_time') 90 | current_time.expects(:httpdate).returns(Time.now.httpdate) 91 | 92 | cached_response.update_date! 93 | assert_equal current_time.httpdate, cached_response.headers["Date"] 94 | end 95 | 96 | def test_serialize 97 | args = { "status" => 200, 98 | "headers" => { "Cache-Control" => "no-cache" }, 99 | "body" => "test body" } 100 | 101 | cached_response = Rack::AcornCache::CachedResponse.new(args) 102 | result = cached_response.serialize 103 | 104 | assert_equal "{\"headers\":{\"Cache-Control\":\"no-cache\"},\"status\":200,\"body\":\"test body\"}", result 105 | end 106 | 107 | def test_to_a 108 | args = { "status" => 200, 109 | "headers" => { "Cache-Control" => "no-cache" }, 110 | "body" => "test body" } 111 | 112 | cached_response = Rack::AcornCache::CachedResponse.new(args) 113 | result = cached_response.to_a 114 | 115 | assert_equal [200, {"Cache-Control"=>"no-cache"}, ["test body"]], result 116 | end 117 | 118 | def test_etag_header 119 | args = { "status" => 200, 120 | "headers" => { "ETag" => "-1087556166" }, 121 | "body" => "test body" } 122 | 123 | cached_response = Rack::AcornCache::CachedResponse.new(args) 124 | result = cached_response.etag_header 125 | 126 | assert_equal "-1087556166", result 127 | end 128 | 129 | def test_last_modified_header 130 | args = { "status" => 200, 131 | "headers" => { "Last-Modified" => "Mon, 01 Jan 2000 00:00:01 GMT" }, 132 | "body" => "test body" } 133 | 134 | cached_response = Rack::AcornCache::CachedResponse.new(args) 135 | result = cached_response.last_modified_header 136 | 137 | assert_equal "Mon, 01 Jan 2000 00:00:01 GMT", result 138 | end 139 | 140 | def test_update_date_and_recache! 141 | args = { "status" => 200, 142 | "headers" => { "X-Acorn-Cache" => "already-exists" }, 143 | "body" => "test body" } 144 | 145 | cached_response = Rack::AcornCache::CachedResponse.new(args) 146 | 147 | assert cached_response 148 | end 149 | 150 | def test_add_acorn_cache_header_when_already_present 151 | args = { "status" => 200, 152 | "headers" => { "X-Acorn-Cache" => "already-exists" }, 153 | "body" => "test body" } 154 | 155 | cached_response = Rack::AcornCache::CachedResponse.new(args) 156 | result = cached_response.add_acorn_cache_header! 157 | 158 | assert_equal "already-exists", result.headers["X-Acorn-Cache"] 159 | assert cached_response 160 | end 161 | 162 | def test_add_acorn_cache_header_when_not_already_present 163 | args = { "status" => 200, 164 | "headers" => { }, 165 | "body" => "test body" } 166 | 167 | cached_response = Rack::AcornCache::CachedResponse.new(args) 168 | result = cached_response.add_acorn_cache_header! 169 | 170 | assert_equal "HIT", result.headers["X-Acorn-Cache"] 171 | end 172 | 173 | def test_matches_when_etag_header_present 174 | args = { "status" => 200, 175 | "headers" => { "ETag" => "-1087556166" }, 176 | "body" => "test body" } 177 | 178 | cached_response = Rack::AcornCache::CachedResponse.new(args) 179 | server_response = mock("server response") 180 | server_response.expects(:etag_header).returns("-1087556166") 181 | 182 | assert cached_response.matches?(server_response) 183 | end 184 | 185 | def test_matches_when_last_modified_header_present 186 | args = { "status" => 200, 187 | "headers" => { "Last-Modified" => "Mon, 01 Jan 2000 00:00:01 GMT" }, 188 | "body" => "test body" } 189 | 190 | cached_response = Rack::AcornCache::CachedResponse.new(args) 191 | server_response = mock("server response") 192 | server_response.expects(:last_modified_header).returns("Mon, 01 Jan 2000 00:00:01 GMT") 193 | 194 | assert cached_response.matches?(server_response) 195 | end 196 | 197 | def test_matches_when_neither_header_is_present 198 | args = { "status" => 200, 199 | "headers" => { }, 200 | "body" => "test body" } 201 | 202 | cached_response = Rack::AcornCache::CachedResponse.new(args) 203 | server_response = mock("server response") 204 | 205 | refute cached_response.matches?(server_response) 206 | end 207 | 208 | def test_time_to_live_when_s_maxage 209 | args = { "status" => 200, 210 | "headers" => { "Cache-Control" => "s-maxage=30" }, 211 | "body" => "test body" } 212 | 213 | cached_response = Rack::AcornCache::CachedResponse.new(args) 214 | result = cached_response.time_to_live 215 | 216 | assert_equal 30, result 217 | end 218 | 219 | def test_time_to_live_when_maxage 220 | args = { "status" => 200, 221 | "headers" => { "Cache-Control" => "max-age=30" }, 222 | "body" => "test body" } 223 | 224 | cached_response = Rack::AcornCache::CachedResponse.new(args) 225 | result = cached_response.time_to_live 226 | 227 | assert_equal 30, result 228 | end 229 | 230 | def test_time_to_live_when_expiration_header 231 | args = { "status" => 200, 232 | "headers" => { "Expiration" => "Mon, 01 Jan 2000 00:00:21 GMT", "Date" => "Mon, 01 Jan 2000 00:00:01 GMT" }, 233 | "body" => "test body" } 234 | 235 | cached_response = Rack::AcornCache::CachedResponse.new(args) 236 | result = cached_response.time_to_live 237 | 238 | assert_equal 20, result 239 | end 240 | 241 | def test_fresh_returns_true_using_max_age 242 | args = { "status" => 200, 243 | "headers" => { "Cache-Control" => "max-age=30", "Date" => "Mon, 01 Jan 2000 00:00:01 GMT" }, 244 | "body" => "test body" } 245 | 246 | Time.stubs(:now).returns(Time.at(0)) 247 | cached_response = Rack::AcornCache::CachedResponse.new(args) 248 | cached_response.expects(:date).returns(Time.httpdate("Mon, 01 Jan 2000 00:00:01 GMT")) 249 | 250 | assert cached_response.fresh? 251 | end 252 | 253 | def test_fresh_returns_false_using_max_age 254 | args = { "status" => 200, 255 | "headers" => { "Cache-Control" => "max-age=30", "Date" => "Mon, 01 Jan 2000 00:00:01 GMT" }, 256 | "body" => "test body" } 257 | 258 | Time.stubs(:now).returns(Time.new(2016, "jan", 1, 1, 1, 1)) 259 | cached_response = Rack::AcornCache::CachedResponse.new(args) 260 | cached_response.expects(:date).returns(Time.httpdate("Mon, 01 Jan 2000 00:00:01 GMT")) 261 | 262 | refute cached_response.fresh? 263 | end 264 | 265 | def test_date 266 | args = { "status" => 200, 267 | "headers" => { "Date" => "Mon, 01 Jan 2000 00:00:01 GMT" }, 268 | "body" => "test body" } 269 | 270 | cached_response = Rack::AcornCache::CachedResponse.new(args) 271 | result = cached_response.date 272 | 273 | assert_equal Time.httpdate("Mon, 01 Jan 2000 00:00:01 GMT"), result 274 | end 275 | 276 | def test_expiration_date_if_s_maxage 277 | args = { "status" => 200, 278 | "headers" => { "Date" => "Mon, 01 Jan 2000 00:00:01 GMT", "Cache-Control" => "s-maxage=30" }, 279 | "body" => "test body" } 280 | 281 | cached_response = Rack::AcornCache::CachedResponse.new(args) 282 | result = cached_response.expiration_date 283 | 284 | assert_equal Time.httpdate("Mon, 01 Jan 2000 00:00:31 GMT"), result 285 | end 286 | 287 | def test_expiration_date_if_maxage 288 | args = { "status" => 200, 289 | "headers" => { "Date" => "Mon, 01 Jan 2000 00:00:01 GMT", "Cache-Control" => "max-age=30" }, 290 | "body" => "test body" } 291 | 292 | cached_response = Rack::AcornCache::CachedResponse.new(args) 293 | result = cached_response.expiration_date 294 | 295 | assert_equal Time.httpdate("Mon, 01 Jan 2000 00:00:31 GMT"), result 296 | end 297 | 298 | def test_expiration_date_if_expiration_header 299 | args = { "status" => 200, 300 | "headers" => { "Expiration" => "Mon, 01 Jan 2000 00:00:01 GMT" }, "body" => "test body" } 301 | 302 | cached_response = Rack::AcornCache::CachedResponse.new(args) 303 | result = cached_response.expiration_date 304 | 305 | assert_equal Time.httpdate("Mon, 01 Jan 2000 00:00:01 GMT"), result 306 | end 307 | 308 | def test_expiration_date_for_default_max_age 309 | args = { "status" => 200, 310 | "headers" => { "Date" => "Mon, 01 Jan 2000 00:00:01 GMT" }, 311 | "body" => "test body" } 312 | 313 | cached_response = Rack::AcornCache::CachedResponse.new(args) 314 | result = cached_response.expiration_date 315 | 316 | assert_equal Time.httpdate("Mon, 01 Jan 2000 01:00:01 GMT"), result 317 | end 318 | 319 | def test_not_modified_for_request 320 | request = stub(if_modified_since: Time.new(2016).httpdate, 321 | if_none_match: "12345") 322 | 323 | args = { "status" => 200, 324 | "headers" => { "Date" => "Mon, 01 Jan 2000 00:00:01 GMT", 325 | "ETag" => "12345", 326 | "Last-Modified" => Time.new(2015).httpdate }, 327 | "body" => "test body" } 328 | 329 | cached_response = Rack::AcornCache::CachedResponse.new(args) 330 | 331 | assert cached_response.not_modified_for?(request) 332 | end 333 | 334 | def test_not_modified_for_request_only_etag 335 | request = stub(if_modified_since: nil, if_none_match: "12345") 336 | 337 | args = { "status" => 200, 338 | "headers" => { "Date" => "Mon, 01 Jan 2000 00:00:01 GMT", 339 | "ETag" => "12345" }, 340 | "body" => "test body" } 341 | 342 | cached_response = Rack::AcornCache::CachedResponse.new(args) 343 | 344 | assert cached_response.not_modified_for?(request) 345 | end 346 | 347 | def test_not_modified_for_request_only_if_modified_since 348 | request = stub(if_modified_since: Time.new(2016).httpdate, 349 | if_none_match: nil) 350 | 351 | args = { "status" => 200, 352 | "headers" => { "Date" => "Mon, 01 Jan 2000 00:00:01 GMT", 353 | "Last-Modified" => Time.new(2015).httpdate }, 354 | "body" => "test body" } 355 | 356 | cached_response = Rack::AcornCache::CachedResponse.new(args) 357 | 358 | assert cached_response.not_modified_for?(request) 359 | end 360 | 361 | def test_modified_for_request_only_etag_differs 362 | request = stub(if_modified_since: Time.new(2016).httpdate, 363 | if_none_match: "1234") 364 | 365 | args = { "status" => 200, 366 | "headers" => { "Date" => "Mon, 01 Jan 2000 00:00:01 GMT", 367 | "ETag" => "12345", 368 | "Last-Modified" => Time.new(2015).httpdate }, 369 | "body" => "test body" } 370 | 371 | cached_response = Rack::AcornCache::CachedResponse.new(args) 372 | 373 | refute cached_response.not_modified_for?(request) 374 | end 375 | 376 | def test_modified_for_request_only_modifed_since 377 | request = stub(if_modified_since: Time.new(2015).httpdate, 378 | if_none_match: "12345") 379 | 380 | args = { "status" => 200, 381 | "headers" => { "Date" => "Mon, 01 Jan 2000 00:00:01 GMT", 382 | "ETag" => "12345", 383 | "Last-Modified" => Time.new(2016).httpdate }, 384 | "body" => "test body" } 385 | 386 | cached_response = Rack::AcornCache::CachedResponse.new(args) 387 | 388 | refute cached_response.not_modified_for?(request) 389 | end 390 | end 391 | 392 | class NullCachedResponseTest < Minitest::Test 393 | def test_fresh_for_request? 394 | null_cached_response = Rack::AcornCache::NullCachedResponse.new 395 | 396 | refute null_cached_response.fresh_for_request?("something") 397 | end 398 | 399 | def test_must_be_revalidated? 400 | null_cached_response = Rack::AcornCache::NullCachedResponse.new 401 | 402 | refute null_cached_response.must_be_revalidated? 403 | end 404 | 405 | def test_matches? 406 | server_response = mock('server response') 407 | null_cached_response = Rack::AcornCache::NullCachedResponse.new 408 | 409 | refute null_cached_response.matches?(server_response) 410 | end 411 | 412 | def test_update! 413 | null_cached_response = Rack::AcornCache::NullCachedResponse.new 414 | 415 | null_cached_response.update! 416 | end 417 | end 418 | --------------------------------------------------------------------------------