response_bank 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 22412918297172ba47f51811bb250ace6dbb2b3d8eb26405f0f970a18b2479de
4
+ data.tar.gz: 94022c0b2e796e9a0a8a0fcc47f7f4ba40f64118458695554683d21452009388
5
+ SHA512:
6
+ metadata.gz: eb15b72956d34a954d829f1ac5666568e5b0c1b4eeebd7a127d8f88b5d103efbd856fe0d4466c675826868a2e8fb8fd572004891753a0bf1799ba3bc04a1cc81
7
+ data.tar.gz: 86ac266abcbe74dbf7fb72c833def9283fdd5943c0666fc1bf6dbed34008161c3458d85f1d365f7b8d6aacdd4af1a5b8822bc792c33de094668490d6108d4a87
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2008-2019 Tobias Lütke
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,91 @@
1
+ # ResponseBank [![Build Status](https://secure.travis-ci.org/Shopify/response_bank.png)](http://travis-ci.org/Shopify/response_bank)
2
+
3
+ ### Features
4
+
5
+ * Serve gzip'd content
6
+ * Add ETag and 304 Not Modified headers
7
+ * Generational caching
8
+ * No explicit expiry
9
+
10
+ ### Support
11
+
12
+ This gem supports the following versions of Ruby and Rails:
13
+
14
+ * Ruby 2.4.0+
15
+ * Rails 5.0.0+
16
+
17
+ ### Usage
18
+
19
+ 1. include the gem in your Gemfile
20
+
21
+ ```ruby
22
+ gem 'response_bank'
23
+ ```
24
+
25
+ 2. use `#response_cache` method to any desired controller's action
26
+
27
+ ```ruby
28
+ class PostsController < ApplicationController
29
+ def show
30
+ response_cache do
31
+ @post = @shop.posts.find(params[:id])
32
+ respond_with(@post)
33
+ end
34
+ end
35
+ end
36
+ ```
37
+
38
+ 3. **(optional)** override custom cache key data. For default, cache key is defined by URL and query string
39
+
40
+ ```ruby
41
+ class PostsController < ApplicationController
42
+ before_action :set_shop
43
+
44
+ def index
45
+ response_cache do
46
+ @post = @shop.posts
47
+ respond_with(@post)
48
+ end
49
+ end
50
+
51
+ def show
52
+ response_cache do
53
+ @post = @shop.posts.find(params[:id])
54
+ respond_with(@post)
55
+ end
56
+ end
57
+
58
+ def another_action
59
+ # custom cache key data
60
+ cache_key = {
61
+ action: action_name,
62
+ format: request.format,
63
+ shop_updated_at: @shop.updated_at
64
+ # you may add more keys here
65
+ }
66
+ response_cache cache_key do
67
+ @post = @shop.posts.find(params[:id])
68
+ respond_with(@post)
69
+ end
70
+ end
71
+
72
+ # override default cache key data globally per class
73
+ def cache_key_data
74
+ {
75
+ action: action_name,
76
+ format: request.format,
77
+ params: params.slice(:id),
78
+ shop_version: @shop.version
79
+ # you may add more keys here
80
+ }
81
+ end
82
+
83
+ def set_shop
84
+ # @shop = ...
85
+ end
86
+ end
87
+ ```
88
+
89
+ ### License
90
+
91
+ ResponseBank is released under the [MIT License](LICENSE.txt).
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+ require 'response_bank/middleware'
3
+ require 'response_bank/railtie' if defined?(Rails)
4
+ require 'response_bank/response_cache_handler'
5
+ require 'msgpack'
6
+
7
+ module ResponseBank
8
+ class << self
9
+ attr_accessor :cache_store
10
+ attr_writer :logger
11
+
12
+ def log(message)
13
+ @logger.info("[ResponseBank] #{message}")
14
+ end
15
+
16
+ def acquire_lock(_cache_key)
17
+ raise NotImplementedError, "Override ResponseBank.acquire_lock in an initializer."
18
+ end
19
+
20
+ def write_to_cache(_key)
21
+ yield
22
+ end
23
+
24
+ def compress(content)
25
+ io = StringIO.new
26
+ gz = Zlib::GzipWriter.new(io)
27
+ gz.write(content)
28
+ io.string
29
+ ensure
30
+ gz.close
31
+ end
32
+
33
+ def decompress(content)
34
+ Zlib::GzipReader.new(StringIO.new(content)).read
35
+ end
36
+
37
+ def cache_key_for(data)
38
+ case data
39
+ when Hash
40
+ return data.inspect unless data.key?(:key)
41
+
42
+ key = hash_value_str(data[:key])
43
+
44
+ return key unless data.key?(:version)
45
+
46
+ version = hash_value_str(data[:version])
47
+
48
+ [key, version].join(":")
49
+ when Array
50
+ data.inspect
51
+ when Time, DateTime
52
+ data.to_i
53
+ when Date
54
+ data.to_time.to_i
55
+ when true, false, Integer, Symbol, String
56
+ data.inspect
57
+ else
58
+ data.to_s.inspect
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def hash_value_str(data)
65
+ if data.is_a?(Hash)
66
+ data.values.join(",")
67
+ else
68
+ data.to_s
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+ module ResponseBank
3
+ module Controller
4
+ private
5
+
6
+ # Only get? and head? requests should be cached.
7
+ def cacheable_request?
8
+ (request.get? || request.head?) && (request.params[:cache] != 'false')
9
+ end
10
+
11
+ # Override this method with additional information that changes to invalidate the cache.
12
+ def cache_version_data
13
+ {}
14
+ end
15
+
16
+ def cache_key_data
17
+ { 'request' => { 'env' => request.env.slice('PATH_INFO', 'QUERY_STRING') } }
18
+ end
19
+
20
+ def force_refill_cache?
21
+ params[:fill_cache] == "true"
22
+ end
23
+
24
+ def serve_unversioned_cacheable_entry?
25
+ false
26
+ end
27
+
28
+ # If you're okay with serving pages that are not at the newest version, bump this up
29
+ # to whatever number of seconds you're comfortable with.
30
+ def cache_age_tolerance_in_seconds
31
+ 0
32
+ end
33
+
34
+ def response_cache(key_data = nil, version_data = nil, &block)
35
+ cacheable_req = cacheable_request?
36
+
37
+ unless cache_configured? && cacheable_req
38
+ ResponseBank.log("Uncacheable request. cache_configured='#{!!cache_configured?}'" \
39
+ " cacheable_request='#{cacheable_req}' params_cache='#{request.params[:cache] != 'false'}'")
40
+ response.headers['Cache-Control'] = 'no-cache, no-store' unless cacheable_req
41
+ return yield
42
+ end
43
+
44
+ handler = ResponseBank::ResponseCacheHandler.new(
45
+ key_data: key_data || cache_key_data,
46
+ version_data: version_data || cache_version_data,
47
+ env: request.env,
48
+ cache_age_tolerance: cache_age_tolerance_in_seconds,
49
+ serve_unversioned: serve_unversioned_cacheable_entry?,
50
+ force_refill_cache: force_refill_cache?,
51
+ headers: response.headers,
52
+ &block
53
+ )
54
+
55
+ status, _headers, body = handler.run!
56
+
57
+ return if request.env['cacheable.miss']
58
+
59
+ case request.env['cacheable.store']
60
+ when 'server'
61
+ render(status: status, plain: body.first)
62
+ when 'client'
63
+ head(:not_modified)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+ require 'useragent'
3
+
4
+ module ResponseBank
5
+ class Middleware
6
+ REQUESTED_WITH = "HTTP_X_REQUESTED_WITH"
7
+ ACCEPT = "HTTP_ACCEPT"
8
+ USER_AGENT = "HTTP_USER_AGENT"
9
+
10
+ def initialize(app)
11
+ @app = app
12
+ end
13
+
14
+ def call(env)
15
+ env['cacheable.cache'] = false
16
+ gzip = env['gzip'] = env['HTTP_ACCEPT_ENCODING'].to_s.include?("gzip")
17
+
18
+ status, headers, body = @app.call(env)
19
+
20
+ if env['cacheable.cache']
21
+ if [200, 404, 301, 304].include?(status)
22
+ headers['ETag'] = env['cacheable.key']
23
+ headers['X-Alternate-Cache-Key'] = env['cacheable.unversioned-key']
24
+
25
+ if ie_ajax_request?(env)
26
+ headers["Expires"] = "-1"
27
+ end
28
+ end
29
+
30
+ if [200, 404, 301].include?(status) && env['cacheable.miss']
31
+ # Flatten down the result so that it can be stored to memcached.
32
+ if body.is_a?(String)
33
+ body_string = body
34
+ else
35
+ body_string = +""
36
+ body.each { |part| body_string << part }
37
+ end
38
+
39
+ body_gz = ResponseBank.compress(body_string)
40
+
41
+ # Store result
42
+ cache_data = [status, headers['Content-Type'], body_gz, timestamp]
43
+ cache_data << headers['Location'] if status == 301
44
+
45
+ ResponseBank.write_to_cache(env['cacheable.key']) do
46
+ payload = MessagePack.dump(cache_data)
47
+ ResponseBank.cache_store.write(env['cacheable.key'], payload, raw: true)
48
+
49
+ if env['cacheable.unversioned-key']
50
+ ResponseBank.cache_store.write(env['cacheable.unversioned-key'], payload, raw: true)
51
+ end
52
+ end
53
+
54
+ # since we had to generate the gz version above already we may
55
+ # as well serve it if the client wants it
56
+ if gzip
57
+ headers['Content-Encoding'] = "gzip"
58
+ body = [body_gz]
59
+ end
60
+ end
61
+
62
+ # Add X-Cache header
63
+ miss = env['cacheable.miss']
64
+ x_cache = miss ? 'miss' : 'hit'
65
+ x_cache += ", #{env['cacheable.store']}" unless miss
66
+ headers['X-Cache'] = x_cache
67
+ end
68
+
69
+ [status, headers, body]
70
+ end
71
+
72
+ private
73
+
74
+ def timestamp
75
+ Time.now.to_i
76
+ end
77
+
78
+ def ie_ajax_request?(env)
79
+ return false unless !env[USER_AGENT].nil? && !env[USER_AGENT].empty?
80
+
81
+ if env[REQUESTED_WITH] == "XmlHttpRequest" || env[ACCEPT] == "application/json"
82
+ UserAgent.parse(env["HTTP_USER_AGENT"]).is_a?(UserAgent::Browsers::InternetExplorer)
83
+ else
84
+ false
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+ module ResponseBank
3
+ module ModelExtensions
4
+ def self.included(base)
5
+ super
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ module ClassMethods
10
+ def cache_store
11
+ ActionController::Base.cache_store
12
+ end
13
+ end
14
+
15
+ def cache_store
16
+ ActionController::Base.cache_store
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+ require 'rails'
3
+ require 'response_bank/controller'
4
+ require 'response_bank/model_extensions'
5
+
6
+ module ResponseBank
7
+ class Railtie < ::Rails::Railtie
8
+ initializer "cachable.configure_active_record" do |config|
9
+ config.middleware.insert_after(Rack::Head, ResponseBank::Middleware)
10
+
11
+ ActiveSupport.on_load(:action_controller) do
12
+ include ResponseBank::Controller
13
+ end
14
+
15
+ ActiveSupport.on_load(:active_record) do
16
+ include ResponseBank::ModelExtensions
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+ require 'digest/md5'
3
+
4
+ module ResponseBank
5
+ class ResponseCacheHandler
6
+ def initialize(
7
+ key_data:,
8
+ version_data:,
9
+ env:,
10
+ cache_age_tolerance:,
11
+ serve_unversioned:,
12
+ headers:,
13
+ force_refill_cache: false,
14
+ cache_store: ResponseBank.cache_store,
15
+ &block
16
+ )
17
+ @cache_miss_block = block
18
+
19
+ @key_data = key_data
20
+ @version_data = version_data
21
+ @env = env
22
+ @cache_age_tolerance = cache_age_tolerance
23
+
24
+ @serve_unversioned = serve_unversioned
25
+ @force_refill_cache = force_refill_cache
26
+ @cache_store = cache_store
27
+ @headers = headers || {}
28
+ end
29
+
30
+ def run!
31
+ @env['cacheable.cache'] = true
32
+ @env['cacheable.key'] = versioned_key_hash
33
+ @env['cacheable.unversioned-key'] = unversioned_key_hash
34
+
35
+ ResponseBank.log(cacheable_info_dump)
36
+
37
+ if @force_refill_cache
38
+ refill_cache
39
+ else
40
+ try_to_serve_from_cache
41
+ end
42
+ end
43
+
44
+ def versioned_key_hash
45
+ @versioned_key_hash ||= key_hash(versioned_key)
46
+ end
47
+
48
+ def unversioned_key_hash
49
+ @unversioned_key_hash ||= key_hash(unversioned_key)
50
+ end
51
+
52
+ private
53
+
54
+ def key_hash(key)
55
+ "cacheable:#{Digest::MD5.hexdigest(key)}"
56
+ end
57
+
58
+ def versioned_key
59
+ @versioned_key ||= ResponseBank.cache_key_for(key: @key_data, version: @version_data)
60
+ end
61
+
62
+ def unversioned_key
63
+ @unversioned_key ||= ResponseBank.cache_key_for(key: @key_data)
64
+ end
65
+
66
+ def cacheable_info_dump
67
+ log_info = [
68
+ "Raw cacheable.key: #{versioned_key}",
69
+ "cacheable.key: #{versioned_key_hash}",
70
+ ]
71
+
72
+ if @env['HTTP_IF_NONE_MATCH']
73
+ log_info.push("If-None-Match: #{@env['HTTP_IF_NONE_MATCH']}")
74
+ end
75
+
76
+ log_info.join(', ')
77
+ end
78
+
79
+ def try_to_serve_from_cache
80
+ # Etag
81
+ response = serve_from_browser_cache(versioned_key_hash)
82
+
83
+ return response if response
84
+
85
+ # Memcached
86
+ response = if @serve_unversioned
87
+ serve_from_cache(unversioned_key_hash, "Cache hit: server (unversioned)")
88
+ else
89
+ serve_from_cache(versioned_key_hash, "Cache hit: server")
90
+ end
91
+
92
+ return response if response
93
+
94
+ @env['cacheable.locked'] ||= false
95
+
96
+ if @env['cacheable.locked'] || ResponseBank.acquire_lock(versioned_key_hash)
97
+ # execute if we can get the lock
98
+ @env['cacheable.locked'] = true
99
+ elsif serving_from_noncurrent_but_recent_version_acceptable?
100
+ # serve a stale version
101
+ response = serve_from_cache(unversioned_key_hash, "Cache hit: server (recent)", @cache_age_tolerance)
102
+
103
+ return response if response
104
+ end
105
+
106
+ # No cache hit; this request cannot be handled from cache.
107
+ # Yield to the controller and mark for writing into cache.
108
+ refill_cache
109
+ end
110
+
111
+ def serving_from_noncurrent_but_recent_version_acceptable?
112
+ @cache_age_tolerance > 0
113
+ end
114
+
115
+ def serve_from_browser_cache(cache_key_hash)
116
+ if @env["HTTP_IF_NONE_MATCH"] == cache_key_hash
117
+ @env['cacheable.miss'] = false
118
+ @env['cacheable.store'] = 'client'
119
+
120
+ @headers.delete('Content-Type')
121
+ @headers.delete('Content-Length')
122
+
123
+ ResponseBank.log("Cache hit: client")
124
+
125
+ [304, @headers, []]
126
+ end
127
+ end
128
+
129
+ def serve_from_cache(cache_key_hash, message, cache_age_tolerance = nil)
130
+ raw = @cache_store.read(cache_key_hash)
131
+
132
+ if raw
133
+ hit = MessagePack.load(raw)
134
+
135
+ @env['cacheable.miss'] = false
136
+ @env['cacheable.store'] = 'server'
137
+
138
+ status, content_type, body, timestamp, location = hit
139
+
140
+ if cache_age_tolerance && page_too_old?(timestamp, cache_age_tolerance)
141
+ ResponseBank.log("Found an unversioned cache entry, but it was too old (#{timestamp})")
142
+
143
+ nil
144
+ else
145
+ @headers['Content-Type'] = content_type
146
+
147
+ @headers['Location'] = location if location
148
+
149
+ if @env["gzip"]
150
+ @headers['Content-Encoding'] = "gzip"
151
+ else
152
+ # we have to uncompress because the client doesn't support gzip
153
+ ResponseBank.log("uncompressing for client without gzip")
154
+ body = ResponseBank.decompress(body)
155
+ end
156
+
157
+ ResponseBank.log(message)
158
+
159
+ [status, @headers, [body]]
160
+ end
161
+ end
162
+ end
163
+
164
+ def page_too_old?(timestamp, cache_age_tolerance)
165
+ !timestamp || timestamp < (Time.now.to_i - cache_age_tolerance)
166
+ end
167
+
168
+ def refill_cache
169
+ @env['cacheable.miss'] = true
170
+
171
+ ResponseBank.log("Refilling cache")
172
+
173
+ @cache_miss_block.call
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module ResponseBank
3
+ VERSION = "1.0.0"
4
+ end
metadata ADDED
@@ -0,0 +1,152 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: response_bank
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Tobias Lütke
8
+ - Burke Libbey
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2020-01-27 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: useragent
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '0'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '0'
28
+ - !ruby/object:Gem::Dependency
29
+ name: msgpack
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: '0'
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '0'
42
+ - !ruby/object:Gem::Dependency
43
+ name: minitest
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: 5.13.0
49
+ type: :development
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: 5.13.0
56
+ - !ruby/object:Gem::Dependency
57
+ name: mocha
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: 1.10.0
63
+ type: :development
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: 1.10.0
70
+ - !ruby/object:Gem::Dependency
71
+ name: rake
72
+ requirement: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ - !ruby/object:Gem::Dependency
85
+ name: rails
86
+ requirement: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '5.0'
91
+ type: :development
92
+ prerelease: false
93
+ version_requirements: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '5.0'
98
+ - !ruby/object:Gem::Dependency
99
+ name: tzinfo-data
100
+ requirement: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: 1.2019.3
105
+ type: :development
106
+ prerelease: false
107
+ version_requirements: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: 1.2019.3
112
+ description:
113
+ email:
114
+ - tobi@shopify.com
115
+ - burke@burkelibbey.org
116
+ executables: []
117
+ extensions: []
118
+ extra_rdoc_files: []
119
+ files:
120
+ - LICENSE.txt
121
+ - README.md
122
+ - lib/response_bank.rb
123
+ - lib/response_bank/controller.rb
124
+ - lib/response_bank/middleware.rb
125
+ - lib/response_bank/model_extensions.rb
126
+ - lib/response_bank/railtie.rb
127
+ - lib/response_bank/response_cache_handler.rb
128
+ - lib/response_bank/version.rb
129
+ homepage: ''
130
+ licenses:
131
+ - MIT
132
+ metadata: {}
133
+ post_install_message:
134
+ rdoc_options: []
135
+ require_paths:
136
+ - lib
137
+ required_ruby_version: !ruby/object:Gem::Requirement
138
+ requirements:
139
+ - - ">="
140
+ - !ruby/object:Gem::Version
141
+ version: 2.4.0
142
+ required_rubygems_version: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - ">="
145
+ - !ruby/object:Gem::Version
146
+ version: '0'
147
+ requirements: []
148
+ rubygems_version: 3.1.2
149
+ signing_key:
150
+ specification_version: 4
151
+ summary: Simple response caching for Ruby applications
152
+ test_files: []