hurley-http-cache 0.1.0.beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +13 -0
- data/README.md +93 -0
- data/lib/hurley-http-cache.rb +1 -0
- data/lib/hurley/http_cache.rb +242 -0
- data/lib/hurley/http_cache/cache_control.rb +118 -0
- data/lib/hurley/http_cache/request.rb +51 -0
- data/lib/hurley/http_cache/response.rb +186 -0
- data/lib/hurley/http_cache/storage.rb +182 -0
- data/test/http_cache/cache_control_test.rb +79 -0
- data/test/http_cache/request_test.rb +74 -0
- data/test/http_cache/response_test.rb +116 -0
- data/test/http_cache/storage_test.rb +80 -0
- data/test/live_http_cache_test.rb +238 -0
- data/test/support/server.rb +103 -0
- data/test/test_helper.rb +23 -0
- metadata +114 -0
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'delegate'
|
2
|
+
require 'hurley/header'
|
3
|
+
require 'hurley/http_cache/cache_control'
|
4
|
+
|
5
|
+
module Hurley
|
6
|
+
class HttpCache
|
7
|
+
# Internal: Delegator that extends the Hurley::Request class with some
|
8
|
+
# behavior required for the HTTP caching mechanism.
|
9
|
+
class Request < DelegateClass(Hurley::Request)
|
10
|
+
|
11
|
+
# Internal: Check if the request can be cached.
|
12
|
+
#
|
13
|
+
# Returns true or false.
|
14
|
+
def cacheable?
|
15
|
+
return false if verb != :get && verb != :head
|
16
|
+
return false if cache_control.no_store?
|
17
|
+
true
|
18
|
+
end
|
19
|
+
|
20
|
+
# Internal: Check if the request can't be cached, accordingly to the
|
21
|
+
# 'Cache-Control' header.
|
22
|
+
#
|
23
|
+
# Returns true or false.
|
24
|
+
def no_cache?
|
25
|
+
cache_control.no_cache?
|
26
|
+
end
|
27
|
+
|
28
|
+
# Internal: Get a Hash that represents the request that can be properly
|
29
|
+
# serialized.
|
30
|
+
#
|
31
|
+
# Returns a Hash.
|
32
|
+
def serializable_hash
|
33
|
+
{
|
34
|
+
'verb' => verb.to_s,
|
35
|
+
'url' => url,
|
36
|
+
'header' => header.to_hash
|
37
|
+
}
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
# Internal: Get a CacheControl object to inspect the directives in the
|
43
|
+
# 'Cache-Control' header.
|
44
|
+
#
|
45
|
+
# Returns a CacheControl object.
|
46
|
+
def cache_control
|
47
|
+
@cache_control ||= CacheControl.new(header['Cache-Control'])
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,186 @@
|
|
1
|
+
require 'time'
|
2
|
+
require 'delegate'
|
3
|
+
require 'hurley/http_cache/cache_control'
|
4
|
+
|
5
|
+
module Hurley
|
6
|
+
class HttpCache
|
7
|
+
# Internal: Delegator that extends the Hurley::Response class with some
|
8
|
+
# behavior required for the HTTP caching mechanism.
|
9
|
+
class Response < DelegateClass(Hurley::Response)
|
10
|
+
|
11
|
+
# Internal: List of status codes that can be cached:
|
12
|
+
# * 200 - 'OK'
|
13
|
+
# * 203 - 'Non-Authoritative Information'
|
14
|
+
# * 300 - 'Multiple Choices'
|
15
|
+
# * 301 - 'Moved Permanently'
|
16
|
+
# * 302 - 'Found'
|
17
|
+
# * 404 - 'Not Found'
|
18
|
+
# * 410 - 'Gone'
|
19
|
+
CACHEABLE_STATUS_CODES = [200, 203, 300, 301, 302, 404, 410]
|
20
|
+
|
21
|
+
# Internal: Initialize a new Response object, and sets the 'Date' header
|
22
|
+
# if its missing.
|
23
|
+
def initialize(response)
|
24
|
+
super(response)
|
25
|
+
@now = Time.now
|
26
|
+
header['Date'] ||= @now.httpdate
|
27
|
+
end
|
28
|
+
|
29
|
+
# Internal: Recreate a Response object from a Hurley::Request and a
|
30
|
+
# serialized response retrieved from the cache.
|
31
|
+
# a
|
32
|
+
#
|
33
|
+
# request - A Hurley::Request instance of an incoming request.
|
34
|
+
# response - A Hash of a persisted response object.
|
35
|
+
#
|
36
|
+
# Returns a Hurley::HttpCache::Response object.
|
37
|
+
def self.restore(request, response)
|
38
|
+
instance = Hurley::Response.new(request) do |res|
|
39
|
+
res.status_code = response['status_code']
|
40
|
+
res.header.update(response['header'])
|
41
|
+
res.body = response['body']
|
42
|
+
end
|
43
|
+
|
44
|
+
new(instance)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Internal: Check if the response can be cached by the client when the
|
48
|
+
# client is acting as a shared cache per RFC 2616. This is validated by
|
49
|
+
# the 'Cache-Control' directives, the response status code and it's
|
50
|
+
# freshness or validation status.
|
51
|
+
#
|
52
|
+
# Returns false if the 'Cache-Control' says that we can't store the
|
53
|
+
# response, or it can be stored in private caches only, or if isn't fresh
|
54
|
+
# or it can't be revalidated with the origin server. Otherwise, returns
|
55
|
+
# true.
|
56
|
+
def cacheable_in_shared_cache?
|
57
|
+
cacheable?(shared: true)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Internal: Check if the response can be cached by the client when the
|
61
|
+
# client is acting as a private cache per RFC 2616. This is validated by
|
62
|
+
# the 'Cache-Control' directives, the response status code and it's
|
63
|
+
# freshness or validation status.
|
64
|
+
#
|
65
|
+
# Returns false if the 'Cache-Control' says that we can't store the
|
66
|
+
# response, or if isn't fresh or it can't be revalidated with the origin
|
67
|
+
# server. Otherwise, returns true.
|
68
|
+
def cacheable_in_private_cache?
|
69
|
+
cacheable?(shared: false)
|
70
|
+
end
|
71
|
+
|
72
|
+
# Internal: Check if the Response returned a 'Not Modified' status code.
|
73
|
+
#
|
74
|
+
# Returns true if the response status code is 304.
|
75
|
+
def not_modified?
|
76
|
+
status_code == 304
|
77
|
+
end
|
78
|
+
|
79
|
+
# Internal: Check the response freshness based on expiration header.
|
80
|
+
# The calculated 'ttl' should be present and bigger than 0.
|
81
|
+
#
|
82
|
+
# Returns true if the response is fresh, otherwise false.
|
83
|
+
def fresh?
|
84
|
+
ttl && ttl > 0
|
85
|
+
end
|
86
|
+
|
87
|
+
# Internal: Get the 'ETag' header.
|
88
|
+
def etag
|
89
|
+
header['ETag']
|
90
|
+
end
|
91
|
+
|
92
|
+
# Internal: Get the 'Last-Modified' header.
|
93
|
+
def last_modified
|
94
|
+
header['Last-Modified']
|
95
|
+
end
|
96
|
+
|
97
|
+
# Internal: Get a Hash that represents the response that can be properly
|
98
|
+
# serialized.
|
99
|
+
#
|
100
|
+
# Returns a Hash.
|
101
|
+
def serializable_hash
|
102
|
+
{
|
103
|
+
'status_code' => status_code,
|
104
|
+
'header' => header.to_hash,
|
105
|
+
'body' => body
|
106
|
+
}
|
107
|
+
end
|
108
|
+
|
109
|
+
private
|
110
|
+
|
111
|
+
# Internal: Parse the 'Date' header back into a Time object.
|
112
|
+
#
|
113
|
+
# Returns the Time object.
|
114
|
+
def date
|
115
|
+
Time.httpdate(header['Date'])
|
116
|
+
end
|
117
|
+
|
118
|
+
# Internal: Get the response max age.
|
119
|
+
# The max age is extracted from one of the following:
|
120
|
+
# * The shared max age directive from the 'Cache-Control' header;
|
121
|
+
# * The max age directive from the 'Cache-Control' header;
|
122
|
+
# * The difference between the 'Expires' header and the response
|
123
|
+
# date.
|
124
|
+
#
|
125
|
+
# Returns the max age value in seconds or nil if all options above fails.
|
126
|
+
def max_age
|
127
|
+
cache_control.shared_max_age ||
|
128
|
+
cache_control.max_age ||
|
129
|
+
(expires && (expires - @now))
|
130
|
+
end
|
131
|
+
|
132
|
+
# Internal: Get the response age in seconds.
|
133
|
+
#
|
134
|
+
# Returns the 'Age' header if present, or subtracts the response 'date'
|
135
|
+
# from the current time.
|
136
|
+
def age
|
137
|
+
(header['Age'] || (@now - date)).to_i
|
138
|
+
end
|
139
|
+
|
140
|
+
# Internal: Calculate the 'Time to live' left on the Response.
|
141
|
+
#
|
142
|
+
# Returns the remaining seconds for the response, or nil the 'max_age'
|
143
|
+
# isn't present.
|
144
|
+
def ttl
|
145
|
+
max_age - age if max_age
|
146
|
+
end
|
147
|
+
|
148
|
+
# Internal: Check if this response can be revalidated.
|
149
|
+
#
|
150
|
+
# Returns true if the 'headers' contains a 'Last-Modified' or an 'ETag'
|
151
|
+
# entry.
|
152
|
+
def validateable?
|
153
|
+
header.key?('Last-Modified') || header.key?('ETag')
|
154
|
+
end
|
155
|
+
|
156
|
+
# Internal: The logic behind cacheable_in_private_cache? and
|
157
|
+
# cacheable_in_shared_cache? The logic is the same except for the
|
158
|
+
# treatment of the private Cache-Control directive.
|
159
|
+
def cacheable?(shared:)
|
160
|
+
return false if (cache_control.private? && shared) || cache_control.no_store?
|
161
|
+
|
162
|
+
cacheable_status_code? && (validateable? || fresh?)
|
163
|
+
end
|
164
|
+
|
165
|
+
# Internal: Validate the response status against the
|
166
|
+
# `CACHEABLE_STATUS_CODES' constant.
|
167
|
+
#
|
168
|
+
# Returns true if the constant includes the response status code.
|
169
|
+
def cacheable_status_code?
|
170
|
+
CACHEABLE_STATUS_CODES.include?(status_code)
|
171
|
+
end
|
172
|
+
|
173
|
+
# Internal: Get the 'Expires' in a Time object.
|
174
|
+
#
|
175
|
+
# Returns the Time object, or nil if the header isn't present.
|
176
|
+
def expires
|
177
|
+
header['Expires'] && Time.httpdate(header['Expires'])
|
178
|
+
end
|
179
|
+
|
180
|
+
# Internal: Gets the 'CacheControl' object.
|
181
|
+
def cache_control
|
182
|
+
@cache_control ||= CacheControl.new(header['Cache-Control'])
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
@@ -0,0 +1,182 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'digest/sha1'
|
3
|
+
|
4
|
+
require 'hurley/header'
|
5
|
+
|
6
|
+
module Hurley
|
7
|
+
class HttpCache
|
8
|
+
# Internal: A Storage class to manage the caching of request and responses.
|
9
|
+
#
|
10
|
+
# Examples
|
11
|
+
#
|
12
|
+
# # Create a Storage object using Rails default cache store.
|
13
|
+
# Hurley::HttpCache::Storage.new(store: Rails.cache)
|
14
|
+
#
|
15
|
+
# # Create a new Storage using Marshal for serialization.
|
16
|
+
# Hurley::HttpCache::Storage.new(store: Rails.cache, serializer: Marshal)
|
17
|
+
class Storage
|
18
|
+
# Public: Get the underlying cache store object.
|
19
|
+
attr_reader :cache
|
20
|
+
|
21
|
+
# Internal: Initialize a new Storage object with a cache backend.
|
22
|
+
#
|
23
|
+
# :logger - A Logger object to be used to emit warnings.
|
24
|
+
# :store - An cache store object that should respond to 'dump' and
|
25
|
+
# 'load'.
|
26
|
+
# :serializer - A serializer object that should respond to 'dump' and
|
27
|
+
# 'load'.
|
28
|
+
def initialize(store: nil, serializer: nil, logger: nil)
|
29
|
+
@cache = store || MemoryStore.new
|
30
|
+
@serializer = serializer || JSON
|
31
|
+
@logger = logger
|
32
|
+
assert_valid_store!
|
33
|
+
end
|
34
|
+
|
35
|
+
# Internal: Store a response inside the cache.
|
36
|
+
#
|
37
|
+
# request - A Hurley::HttpCache::::Request instance of the executed HTTP
|
38
|
+
# request.
|
39
|
+
# response - The Hurley::HttpCache::Response instance to be stored.
|
40
|
+
#
|
41
|
+
# Returns nothing.
|
42
|
+
def write(request, response)
|
43
|
+
key = cache_key_for(request.url)
|
44
|
+
entry = serialize_entry(request.serializable_hash, response.serializable_hash)
|
45
|
+
|
46
|
+
entries = cache.read(key) || []
|
47
|
+
|
48
|
+
entries.reject! do |(cached_request, cached_response)|
|
49
|
+
response_matches?(request, cached_request, cached_response)
|
50
|
+
end
|
51
|
+
|
52
|
+
entries << entry
|
53
|
+
cache.write(key, entries)
|
54
|
+
rescue Encoding::UndefinedConversionError => e
|
55
|
+
warn { "Response could not be serialized: #{e.message}. Try using Marshal to serialize." }
|
56
|
+
raise e
|
57
|
+
end
|
58
|
+
|
59
|
+
# Internal: Attempt to retrieve an stored response that suits the incoming
|
60
|
+
# HTTP request.
|
61
|
+
#
|
62
|
+
# request - A Hurley::HttpCache::::Request instance of the incoming HTTP
|
63
|
+
# request.
|
64
|
+
#
|
65
|
+
# Returns a Hash.
|
66
|
+
def read(request)
|
67
|
+
cache_key = cache_key_for(request.url)
|
68
|
+
entries = cache.read(cache_key)
|
69
|
+
lookup_response(request, entries)
|
70
|
+
end
|
71
|
+
|
72
|
+
def delete(url)
|
73
|
+
cache_key = cache_key_for(url)
|
74
|
+
cache.delete(cache_key)
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
|
79
|
+
# Internal: Retrieve a response Hash from the list of entries that match
|
80
|
+
# the given request.
|
81
|
+
#
|
82
|
+
# request - A Hurley::HttpCache::::Request instance of the incoming HTTP
|
83
|
+
# request.
|
84
|
+
# entries - An Array of pairs of Hashes (request, response).
|
85
|
+
#
|
86
|
+
# Returns a Hash or nil.
|
87
|
+
def lookup_response(request, entries)
|
88
|
+
if entries
|
89
|
+
entries = entries.map { |entry| deserialize_entry(*entry) }
|
90
|
+
_, response = entries.find { |req, res| response_matches?(request, req, res) }
|
91
|
+
response
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Internal: Check if a cached response and request matches the given
|
96
|
+
# request.
|
97
|
+
#
|
98
|
+
# request - A Hurley::HttpCache::::Request instance of the
|
99
|
+
# current HTTP request.
|
100
|
+
# cached_request - The Hash of the request that was cached.
|
101
|
+
# cached_response - The Hash of the response that was cached.
|
102
|
+
#
|
103
|
+
# Returns true or false.
|
104
|
+
def response_matches?(request, cached_request, cached_response)
|
105
|
+
request.verb.to_s == cached_request['verb'] &&
|
106
|
+
vary_matches?(cached_response, request, cached_request)
|
107
|
+
end
|
108
|
+
|
109
|
+
def vary_matches?(cached_response, request, cached_request)
|
110
|
+
headers = Hurley::Header.new(cached_response['header'])
|
111
|
+
vary = headers['Vary'].to_s
|
112
|
+
|
113
|
+
vary.empty? || (vary != '*' && vary.split(/[\s,]+/).all? do |header|
|
114
|
+
request.header[header] == cached_request['header'][header]
|
115
|
+
end)
|
116
|
+
end
|
117
|
+
|
118
|
+
def serialize_entry(*objects)
|
119
|
+
objects.map { |object| serialize_object(object) }
|
120
|
+
end
|
121
|
+
|
122
|
+
def serialize_object(object)
|
123
|
+
@serializer.dump(object)
|
124
|
+
end
|
125
|
+
|
126
|
+
def deserialize_entry(*objects)
|
127
|
+
objects.map { |object| deserialize_object(object) }
|
128
|
+
end
|
129
|
+
|
130
|
+
def deserialize_object(object)
|
131
|
+
@serializer.load(object)
|
132
|
+
end
|
133
|
+
|
134
|
+
# Internal: Computes the cache key for a specific request, taking in
|
135
|
+
# account the current serializer to avoid cross serialization issues.
|
136
|
+
#
|
137
|
+
# url - The request URL.
|
138
|
+
#
|
139
|
+
# Returns a String.
|
140
|
+
def cache_key_for(url)
|
141
|
+
prefix = (@serializer.is_a?(Module) ? @serializer : @serializer.class).name
|
142
|
+
Digest::SHA1.hexdigest("#{prefix}#{url}")
|
143
|
+
end
|
144
|
+
|
145
|
+
# Internal: Checks if the given cache object supports the
|
146
|
+
# expect API ('read' and 'write').
|
147
|
+
#
|
148
|
+
# Raises an 'ArgumentError'.
|
149
|
+
#
|
150
|
+
# Returns nothing.
|
151
|
+
def assert_valid_store!
|
152
|
+
unless cache.respond_to?(:read) && cache.respond_to?(:write) && cache.respond_to?(:delete)
|
153
|
+
raise ArgumentError.new("#{cache.inspect} is not a valid cache store as it does not responds to 'read', 'write' or 'delete'.")
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
def warn
|
158
|
+
@logger.warn { yield } if @logger
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
# Internal: A Hash based store to be used by the 'Storage' class
|
163
|
+
# when a 'store' is not provided for the middleware setup.
|
164
|
+
class MemoryStore
|
165
|
+
def initialize
|
166
|
+
@cache = {}
|
167
|
+
end
|
168
|
+
|
169
|
+
def read(key)
|
170
|
+
@cache[key]
|
171
|
+
end
|
172
|
+
|
173
|
+
def delete(key)
|
174
|
+
@cache.delete(key)
|
175
|
+
end
|
176
|
+
|
177
|
+
def write(key, value)
|
178
|
+
@cache[key] = value
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
require File.expand_path('../../test_helper', __FILE__)
|
2
|
+
|
3
|
+
class CacheControlTest < MiniTest::Test
|
4
|
+
def test_parse_cache_control_pairs
|
5
|
+
cache_control = Hurley::HttpCache::CacheControl.new('max-age=600, max-stale=300, min-fresh=570')
|
6
|
+
assert_equal cache_control.max_age, 600
|
7
|
+
end
|
8
|
+
|
9
|
+
def test_parse_cache_control_flags
|
10
|
+
cache_control = Hurley::HttpCache::CacheControl.new('no-cache')
|
11
|
+
|
12
|
+
assert_predicate cache_control, :no_cache?
|
13
|
+
end
|
14
|
+
|
15
|
+
def test_parse_cache_control
|
16
|
+
cache_control =
|
17
|
+
Hurley::HttpCache::CacheControl.new('max-age=600,must-revalidate,min-fresh=3000,foo=bar,baz')
|
18
|
+
|
19
|
+
assert_equal cache_control.max_age, 600
|
20
|
+
assert_predicate cache_control, :must_revalidate?
|
21
|
+
end
|
22
|
+
|
23
|
+
def test_strip_leading_and_trailing_spaces
|
24
|
+
cache_control = Hurley::HttpCache::CacheControl.new(' public, max-age = 600 ')
|
25
|
+
|
26
|
+
assert_predicate cache_control, :public?
|
27
|
+
assert_equal cache_control.max_age, 600
|
28
|
+
end
|
29
|
+
|
30
|
+
def test_ignore_blank_segments
|
31
|
+
cache_control = Hurley::HttpCache::CacheControl.new('max-age=600,,s-maxage=300')
|
32
|
+
|
33
|
+
assert_equal cache_control.max_age, 600
|
34
|
+
assert_equal cache_control.shared_max_age, 300
|
35
|
+
end
|
36
|
+
|
37
|
+
def test_sort_directives
|
38
|
+
cache_control = Hurley::HttpCache::CacheControl.new('foo=bar, z, x, y, bling=baz, zoom=zib, b, a')
|
39
|
+
|
40
|
+
assert_equal cache_control.to_s, 'a, b, x, y, z, bling=baz, foo=bar, zoom=zib'
|
41
|
+
end
|
42
|
+
|
43
|
+
def test_parses_max_age_directive
|
44
|
+
cache_control = Hurley::HttpCache::CacheControl.new('public, max-age=600')
|
45
|
+
|
46
|
+
assert_equal cache_control.max_age, 600
|
47
|
+
end
|
48
|
+
|
49
|
+
def test_parse_shared_max_age_directive
|
50
|
+
cache_control = Hurley::HttpCache::CacheControl.new('public, s-maxage=600')
|
51
|
+
|
52
|
+
assert_equal cache_control.shared_max_age, 600
|
53
|
+
end
|
54
|
+
|
55
|
+
def test_parse_public_directive
|
56
|
+
cache_control = Hurley::HttpCache::CacheControl.new('public')
|
57
|
+
assert_predicate cache_control, :public?
|
58
|
+
end
|
59
|
+
|
60
|
+
def test_parse_private_directive
|
61
|
+
cache_control = Hurley::HttpCache::CacheControl.new('private')
|
62
|
+
assert_predicate cache_control, :private?
|
63
|
+
end
|
64
|
+
|
65
|
+
def test_parse_no_cache_directive
|
66
|
+
cache_control = Hurley::HttpCache::CacheControl.new('no-cache')
|
67
|
+
assert_predicate cache_control, :no_cache?
|
68
|
+
end
|
69
|
+
|
70
|
+
def test_parse_must_revalidate_directive
|
71
|
+
cache_control = Hurley::HttpCache::CacheControl.new('must-revalidate')
|
72
|
+
assert_predicate cache_control, :must_revalidate?
|
73
|
+
end
|
74
|
+
|
75
|
+
def test_parse_proxy_revalidate_directive
|
76
|
+
cache_control = Hurley::HttpCache::CacheControl.new('proxy-revalidate')
|
77
|
+
assert_predicate cache_control, :proxy_revalidate?
|
78
|
+
end
|
79
|
+
end
|