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.
@@ -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