cache_rules 0.1.1

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.
data/Rakefile ADDED
@@ -0,0 +1,14 @@
1
+ desc "Run the tests"
2
+ task :test do
3
+ run_test
4
+ end
5
+
6
+ def run_test
7
+ require 'rake/testtask'
8
+
9
+ Rake::TestTask.new do |t|
10
+ t.libs << "test"
11
+ t.test_files = FileList['test/helper.rb', 'test/test_*.rb']
12
+ end
13
+
14
+ end
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env gem build
2
+ # encoding: utf-8
3
+
4
+ require "base64"
5
+ require 'date'
6
+
7
+ Gem::Specification.new do |s|
8
+ s.name = 'cache_rules'
9
+ s.version = '0.1.1'
10
+
11
+ s.date = Date.today.to_s
12
+
13
+ s.summary = "CacheRules validates requests and responses for cached HTTP data based on RFCs 7230-7235"
14
+ s.description = "#{s.summary}. The goal is to faciliate implementation of well-behaved caching solutions which adhere to RFC standards."
15
+
16
+ s.author = 'Alexander Williams'
17
+ s.email = Base64.decode64("YXdpbGxpYW1zQGFsZXh3aWxsaWFtcy5jYQ==\n")
18
+
19
+ s.homepage = 'https://unscramble.co.jp'
20
+
21
+ s.require_paths = ["lib"]
22
+ s.files = `git ls-files`.split("\n")
23
+
24
+ # Tests
25
+ s.add_development_dependency "fakeweb", '~> 1.3'
26
+ s.add_development_dependency 'minitest', '~> 5.5.0'
27
+ s.add_development_dependency 'minitest-reporters', '~> 1.0.0'
28
+ s.add_development_dependency 'simplecov'
29
+
30
+ license = 'MPL-2.0'
31
+ s.required_ruby_version = ::Gem::Requirement.new("~> 1.9")
32
+
33
+ s.post_install_message = "C.R.E.A.M."
34
+ end
data/lib/actions.rb ADDED
@@ -0,0 +1,40 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
+ #
5
+ # Copyright (c) 2014-2015 Alexander Williams, Unscramble <license@unscramble.jp>
6
+
7
+ module CacheRules
8
+ extend self
9
+
10
+ # HTTP Header Actions
11
+
12
+ def action_revalidate(result)
13
+ result[:value]
14
+ end
15
+
16
+ # Generate an age equal to the current cached entry's age
17
+ # source: https://tools.ietf.org/html/rfc7234#section-4
18
+ def action_add_age(result)
19
+ current_age = helper_current_age Time.now.gmtime.to_i, result[:cached]
20
+
21
+ {'Age' => current_age}
22
+ end
23
+
24
+ def action_add_x_cache(result)
25
+ {'Cache-Lookup' => result[:value]}
26
+ end
27
+
28
+ def action_add_warning(result)
29
+ {'Warning' => result[:value]}
30
+ end
31
+
32
+ def action_add_status(result)
33
+ result[:value]
34
+ end
35
+
36
+ def action_return_body(result)
37
+ result[:value]
38
+ end
39
+
40
+ end
@@ -0,0 +1,188 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
+ #
5
+ # Copyright (c) 2014-2015 Alexander Williams, Unscramble <license@unscramble.jp>
6
+ #
7
+ # Original Source: https://github.com/aw/CacheRules
8
+ #
9
+ #
10
+ # This library validates requests and responses for cached HTTP data.
11
+ #
12
+ # Rules based on RFCs 7230-7235 - https://tools.ietf.org/wg/httpbis/
13
+ #
14
+ # Usage:
15
+ # => CacheRules.validate(url, request_headers, cached_headers)
16
+ #
17
+ # If you grew up on the crime side:
18
+ # => CacheRules::Everything::Around::Me.validate(url, request_headers, cached_headers)
19
+
20
+ require 'net/http'
21
+ require 'date'
22
+ require 'time'
23
+ require 'uri'
24
+
25
+ require 'actions.rb'
26
+ require 'formatting.rb'
27
+ require 'helpers.rb'
28
+ require 'validations.rb'
29
+
30
+ module CacheRules
31
+ extend self
32
+
33
+ HEADERS_NO_CACHE = %w(
34
+ Set-Cookie Cookie
35
+ Accept-Ranges Range If-Range Content-Range
36
+ Referer From
37
+ Authorization Proxy-Authorization
38
+ )
39
+
40
+ HEADERS_HTTPDATE = %w(
41
+ Last-Modified If-Modified-Since If-Unmodified-Since
42
+ Expires Date
43
+ X-Cache-Req-Date X-Cache-Res-Date
44
+ )
45
+
46
+ HEADERS_CSV = %w(
47
+ Connection Trailer Transfer-Encoding Upgrade Via
48
+ Accept Accept-Charset Accept-Encoding Accept-Language Allow
49
+ Content-Encoding Content-Language Vary
50
+ Cache-Control Warning Pragma If-Match If-None-Match
51
+ )
52
+
53
+ HEADERS_NUMBER = %w(
54
+ Age Content-Length Max-Forwards
55
+ )
56
+
57
+ HEADERS_304 = %w(Cache-Control Content-Location Date ETag Expires Vary)
58
+
59
+ OPTIONS_CACHE = HEADERS_CSV.select {|header| header == 'Cache-Control' }
60
+ OPTIONS_CSV = HEADERS_CSV.reject {|header| header == 'Cache-Control' }
61
+ OPTIONS_RETRY = %w(Retry-After)
62
+
63
+ X = nil
64
+
65
+ # Decision table for request/cached headers
66
+ REQUEST_TABLE = {
67
+ :conditions => {
68
+ 'cached' => [0, 0, 1, 1, 1, 1, 1, 1, 1],
69
+ 'must_revalidate' => [X, X, X, X, 0, 0, 0, 1, X],
70
+ 'no_cache' => [X, X, 0, 0, 0, 0, 0, 0, 1],
71
+ 'precond_match' => [X, X, 0, 1, 0, 1, X, X, X],
72
+ 'expired' => [X, X, 0, 0, 1, 1, 1, 1, X],
73
+ 'only_if_cached' => [0, 1, X, X, X, X, X, X, X],
74
+ 'allow_stale' => [X, X, X, X, 1, 1, 0, X, X]
75
+ },
76
+ :actions => {
77
+ 'revalidate' => [X, X, X, X, X, X, X, 1, 1],
78
+ 'add_age' => [X, X, 1, 1, 1, 1],
79
+ 'add_x_cache' => %w(MISS MISS HIT HIT STALE STALE EXPIRED),
80
+ 'add_warning' => [X, X, X, X, '110 - "Response is Stale"', '110 - "Response is Stale"'],
81
+ 'add_status' => [307, 504, 200, 304, 200, 304, 504],
82
+ 'return_body' => [X, 'Gateway Timeout', 'cached', X, 'stale', X, 'Gateway Timeout']
83
+ }
84
+ }
85
+
86
+ # Decision table for revalidated responses
87
+ RESPONSE_TABLE = {
88
+ :conditions => {
89
+ 'is_error' => [0, 0, 1, 1, 1, 0],
90
+ 'expired' => [0, 0, X, X, X, 1],
91
+ 'allow_stale' => [X, X, 0, 1, 1, X],
92
+ 'validator_match' => [0, 1, X, 0, 1, X]
93
+ },
94
+ :actions => {
95
+ 'revalidate' => [],
96
+ 'add_age' => [1, 1, X, 1, 1],
97
+ 'add_x_cache' => %w(REVALIDATED REVALIDATED EXPIRED STALE STALE EXPIRED),
98
+ 'add_warning' => [X, X, X, '111 - "Revalidation Failed"', '111 - "Revalidation Failed"'],
99
+ 'add_status' => [200, 304, 504, 200, 304, 307],
100
+ 'return_body' => ['cached', X, 'Gateway Timeout', 'stale', X]
101
+ }
102
+ }
103
+
104
+ # Build the map tables in advance for faster lookups i.e: O(1)
105
+ REQUEST_MAP = helper_table_map(REQUEST_TABLE[:conditions])
106
+ RESPONSE_MAP = helper_table_map(RESPONSE_TABLE[:conditions])
107
+
108
+ # Public: Validate a URL and the request/cached/response headers
109
+ # TODO: validate the required parameters to ensure they are set correctly
110
+ def validate(url, request_headers, cached_headers = {})
111
+ # 1. normalize the request headers
112
+ normalized_headers = normalize.call request_headers
113
+ actions = REQUEST_TABLE[:actions]
114
+
115
+ # 2. get the column matching the request headers
116
+ column = REQUEST_MAP[helper_run_validate.call(REQUEST_TABLE[:conditions], normalized_headers, cached_headers).join]
117
+ response = Proc.new { helper_response url, actions, column, cached_headers }
118
+ revalidate = Proc.new { revalidate_response url, normalized_headers, cached_headers }
119
+
120
+ # 3. return the response or revalidate
121
+ actions['revalidate'][column] == 1 ? revalidate.call : response.call
122
+ end
123
+
124
+ # Revalidates a response by fetching headers from the origin server
125
+ def revalidate_response(url, request_headers, cached_headers)
126
+ request_date = Time.now.gmtime.httpdate
127
+ response = make_request.call(url, request_headers, cached_headers).call
128
+ response_date = Time.now.gmtime.httpdate
129
+ response_headers = normalize.(response.to_hash.map &:flatten)
130
+
131
+ response_headers['Date'] = response_date if response_headers['Date']
132
+ response_headers['X-Cache-Req-Date'] = request_date
133
+ response_headers['X-Cache-Res-Date'] = response_date
134
+ response_headers['Status'] = response.code
135
+
136
+ # 1. get the column
137
+ column = RESPONSE_MAP[helper_run_validate.call(RESPONSE_TABLE[:conditions], request_headers, cached_headers, response_headers).join]
138
+
139
+ # 2. return the response
140
+ helper_response url, RESPONSE_TABLE[:actions], column, cached_headers
141
+ rescue => error
142
+ {:code => 504, :body => 'Gateway Timeout', :headers => [], :error => error.message, :debug => error}
143
+ end
144
+
145
+ # Returns a net/http response Object
146
+ def make_request
147
+ ->(url, request_headers, cached_headers) {
148
+ uri = URI.parse url
149
+ http = Net::HTTP.new uri.host, uri.port
150
+ http.open_timeout = 2
151
+ http.read_timeout = 60
152
+ http.use_ssl = uri.scheme == 'https'
153
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
154
+
155
+ request = Net::HTTP::Head.new uri.request_uri
156
+
157
+ # Two possible validators: entity tags and timestamp
158
+ # source: https://tools.ietf.org/html/rfc7234#section-4.3.1
159
+ entity_tags = Proc.new { helper_combine_etags request_headers, cached_headers }.call
160
+ timestamp = Proc.new { helper_timestamp request_headers, cached_headers }
161
+
162
+ # Set the precondition header before making the request
163
+ request['If-None-Match'] = entity_tags if entity_tags
164
+ ts = timestamp.call unless entity_tags
165
+ request['If-Modified-Since'] = ts if ts && !entity_tags
166
+
167
+ # Make the HTTP(s) request
168
+ helper_make_request http, request
169
+ }
170
+ end
171
+
172
+ end
173
+
174
+ module CacheRules
175
+ module Everything
176
+ module Around
177
+ module Me
178
+ extend CacheRules
179
+
180
+ # C.R.E.A.M.
181
+ def self.get_the_money
182
+ "Dolla Dolla Bill Y'all"
183
+ end
184
+
185
+ end
186
+ end
187
+ end
188
+ end
data/lib/formatting.rb ADDED
@@ -0,0 +1,161 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
+ #
5
+ # Copyright (c) 2014-2015 Alexander Williams, Unscramble <license@unscramble.jp>
6
+
7
+ module CacheRules
8
+ extend self
9
+
10
+ # HTTP Header Formatting
11
+
12
+ # Create a normalized Hash of HTTP headers
13
+ def normalize
14
+ ->(headers) {
15
+ Hash[normalize_fields.(combine.(clean.(Array(headers).map &format_key)))]
16
+ }
17
+ end
18
+
19
+ # Format the key to look like this: Last-Modified
20
+ def format_key
21
+ Proc.new {|key, value|
22
+ k = key.downcase == 'etag' ? 'ETag' : key.split('-').map(&:capitalize).join('-')
23
+ [ k, value ]
24
+ }
25
+ end
26
+
27
+ # Intentionally drop these headers to avoid caching them
28
+ # If-Modified-Since should be dropped if the date isn't valid
29
+ # source: https://tools.ietf.org/html/rfc7232#section-3.3
30
+ def clean
31
+ ->(headers) {
32
+ Array(headers).reject {|key, value|
33
+ HEADERS_NO_CACHE.include?(key) || helper_is_if_modified_error?(key, value)
34
+ }
35
+ }
36
+ end
37
+
38
+ # Combine headers with a comma if the field-names are duplicate
39
+ def combine
40
+ ->(headers) {
41
+ Array(headers).group_by {|h, _| h }.map {|k, v|
42
+ v = HEADERS_CSV.include?(k) ? v.map {|_, x| x }.join(', ') : v[0][1] # OPTIMIZE
43
+ [ k, v ]
44
+ }
45
+ }
46
+ end
47
+
48
+ # Normalizes the value (field-value) of each header
49
+ def normalize_fields
50
+ ->(headers) {
51
+ Array(headers).map &format_field
52
+ }
53
+ end
54
+
55
+ # Returns a Hash of Strings
56
+ def unnormalize_fields
57
+ ->(headers) {
58
+ Array(headers).reduce({}) {|hash, (key, value)|
59
+ hash.merge Hash[[format_field.call(key, value, true)]]
60
+ }
61
+ }
62
+ end
63
+
64
+ # Returns a Hash, Array, Integer or String based on the supplied arguments
65
+ def format_field
66
+ Proc.new {|key, header, stringify|
67
+ f = format_value header, stringify
68
+
69
+ value = case key
70
+ when *HEADERS_HTTPDATE then f.call('httpdate') # => Hash
71
+ when *OPTIONS_CACHE then f.call('cache_control') # => Array
72
+ when *OPTIONS_CSV then f.call('csv') # => Array
73
+ when *OPTIONS_RETRY then f.call('retry_after') # => Hash or Integer
74
+ else header # => String
75
+ end
76
+ [ key, value ]
77
+ }
78
+ end
79
+
80
+ # Returns the value of the field
81
+ def format_value(header, stringify = nil)
82
+ Proc.new {|field|
83
+ stringify ? send("#{ field }_string", header) : send("#{ field }", header)
84
+ }
85
+ end
86
+
87
+ def httpdate(header)
88
+ timestamp = httpdate_helper header
89
+
90
+ {
91
+ 'httpdate' => Time.at(timestamp).gmtime.httpdate,
92
+ 'timestamp' => timestamp
93
+ }
94
+ end
95
+
96
+ def httpdate_string(header)
97
+ timestamp = httpdate_helper header['httpdate']
98
+
99
+ Time.at(timestamp).gmtime.httpdate
100
+ end
101
+
102
+ # Correctly parse the 3 Date/Time formats and convert to GMT
103
+ # source: https://tools.ietf.org/html/rfc7234#section-4.2
104
+ def httpdate_helper(header)
105
+ # source: https://tools.ietf.org/html/rfc7231#section-7.1.1.1
106
+ DateTime.parse(header).to_time.to_i
107
+ rescue => e
108
+ # If the supplied date is invalid, use a time in the past (5 minutes ago)
109
+ # source: https://tools.ietf.org/html/rfc7234#section-5.3
110
+ Time.now.gmtime.to_i - 300
111
+ end
112
+
113
+ # OPTIMIZE: this regex is copied from JavaScript, could be greatly simplified
114
+ # Returns a Hash with the directive as key, token (or nil), quoted-string (or nil)
115
+ def cache_control(header = '')
116
+ result = header.scan /(?:^|(?:\s*\,\s*))([^\x00-\x20\(\)<>@\,;\:\\"\/\[\]\?\=\{\}\x7F]+)(?:\=(?:([^\x00-\x20\(\)<>@\,;\:\\"\/\[\]\?\=\{\}\x7F]+)|(?:\"((?:[^"\\]|\\.)*)\")))?/
117
+ result.reduce({}) {|hash, x|
118
+ hash.merge({
119
+ x[0].downcase => {
120
+ 'token' => x[1],
121
+ 'quoted_string' => x[2]
122
+ }
123
+ })
124
+ }
125
+ end
126
+
127
+ # Parses the Cache-Control header and returns a comma-separated String
128
+ def cache_control_string(header)
129
+ Array(header).map {|x|
130
+ token = x[1]['token']
131
+ quote = x[1]['quoted_string']
132
+ directive = x[0]
133
+
134
+ if token && quote.nil? then "#{ directive }=#{ token }"
135
+ elsif token.nil? && quote then "#{ directive }=\"#{ quote }\""
136
+ else directive
137
+ end
138
+ }.join ', '
139
+ end
140
+
141
+ def csv(header = '')
142
+ header.split(',').map(&:strip)
143
+ end
144
+
145
+ def csv_string(header)
146
+ Array(header).join ', '
147
+ end
148
+
149
+ # "The value of this field can be either an HTTP-date or a number of seconds..."
150
+ # source: https://tools.ietf.org/html/rfc7234#section-7.1.3
151
+ def retry_after(header)
152
+ Integer(header).abs
153
+ rescue => e
154
+ httpdate header
155
+ end
156
+
157
+ def retry_after_string(header)
158
+ header.to_s
159
+ end
160
+
161
+ end
data/lib/helpers.rb ADDED
@@ -0,0 +1,326 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
+ #
5
+ # Copyright (c) 2014-2015 Alexander Williams, Unscramble <license@unscramble.jp>
6
+
7
+ module CacheRules
8
+ extend self
9
+
10
+ # Create a map with all possible combinations
11
+ def helper_table_map(conditions)
12
+ (2**conditions.length).times.map(&helper_row_col_hash(conditions)).reduce(:merge)
13
+ end
14
+
15
+ # Returns a hash representing a row/column, for the table map
16
+ def helper_row_col_hash(conditions)
17
+ Proc.new {|index|
18
+ row = helper_bit_string conditions.length, index
19
+ col = helper_parse_conditions({:conditions => conditions, :answers => row.chars.map(&:to_i)})
20
+
21
+ {row => col}
22
+ }
23
+ end
24
+
25
+ # Returns a string of 0s and 1s
26
+ def helper_bit_string(num_conditions, index)
27
+ index.to_s(2).rjust num_conditions, '0'
28
+ end
29
+
30
+ # Returns the matching column number, or nil
31
+ def helper_parse_conditions(table)
32
+ # Loop through each answer and hope to end up with the exact column match
33
+ result = table[:answers].each_index.map(&helper_loop_conditions(table)).reduce(:&).compact
34
+ result[0] if result.length == 1
35
+ end
36
+
37
+ # Loop through each condition and see if the answer matches
38
+ def helper_loop_conditions(table)
39
+ Proc.new {|index|
40
+ table[:conditions].values[index].map.each_with_index {|x, i|
41
+ i if x == table[:answers][index] || x.nil?
42
+ }
43
+ }
44
+ end
45
+
46
+ # Returns a bit Array of answers for the conditions
47
+ def helper_run_validate
48
+ Proc.new {|table, request, cached, response|
49
+ table.keys.map {|x|
50
+ headers = {:request => request, :cached => cached, :response => response}
51
+ send("validate_#{ x }?", headers)
52
+ }
53
+ }
54
+ end
55
+
56
+ # Returns an Array of actions to be performed based on the column number
57
+ def helper_run_action(actions, column, cached)
58
+ actions.map {|key, value|
59
+ send("action_#{ key }", {:value => value[column], :cached => cached}) unless value[column].nil?
60
+ }
61
+ end
62
+
63
+ # Returns the response body, code and headers based on the actions results
64
+ def helper_response(url, actions, column, cached)
65
+ _, age, x_cache, warning, status, body = helper_run_action actions, column, cached
66
+
67
+ headers_304 = helper_headers_304.call(cached) if status == 304
68
+ headers_url = {'Location' => url} if status == 307
69
+
70
+ headers = [age, warning, x_cache, headers_304, headers_url].compact.reduce &:merge
71
+
72
+ {:body => body, :code => status, :headers => headers}
73
+ end
74
+
75
+ # Returns a Boolean after trying to parse the If-Modified-Since, or nil
76
+ def helper_is_if_modified_error?(key, value)
77
+ if key == 'If-Modified-Since'
78
+ begin
79
+ false if DateTime.parse(value)
80
+ rescue ArgumentError => e
81
+ true
82
+ end
83
+ end
84
+ end
85
+
86
+ # Generate the same headers if they exist for 304 responses
87
+ # source: https://tools.ietf.org/html/rfc7232#section-4.1
88
+ def helper_headers_304
89
+ Proc.new {|cached|
90
+ unnormalize_fields.call cached.select {|x| HEADERS_304.include? x }
91
+ }
92
+ end
93
+
94
+ # Header can be a String or Array
95
+ def helper_has_star(header)
96
+ header && header.include?("*")
97
+ end
98
+
99
+ # Combine entity tags if they exist
100
+ # source: https://tools.ietf.org/html/rfc7234#section-4.3.2
101
+ def helper_combine_etags(request, cached)
102
+ return "*" if helper_has_star(request['If-None-Match'])
103
+
104
+ request['If-None-Match'] ? request['If-None-Match'].push(cached['ETag']).uniq.compact.join(', ') : cached['ETag']
105
+ end
106
+
107
+ # Use the last modified date if it exists
108
+ # source: https://tools.ietf.org/html/rfc7234#section-4.3.2
109
+ def helper_timestamp(request, cached)
110
+ return request['If-Modified-Since']['httpdate'] if request['If-Modified-Since']
111
+
112
+ cached['Last-Modified']['httpdate'] if cached['Last-Modified']
113
+ end
114
+
115
+ # source: https://tools.ietf.org/html/rfc7232#section-2.3
116
+ def helper_weak_compare
117
+ etag = /^(W\/)?(\"\w+\")$/
118
+
119
+ ->(etag1, etag2) {
120
+ # source: https://tools.ietf.org/html/rfc7232#section-2.3.2
121
+ opaque_tag1 = etag.match etag1
122
+ opaque_tag2 = etag.match etag2
123
+
124
+ return false if opaque_tag1.nil? || opaque_tag2.nil?
125
+
126
+ opaque_tag1[2] == opaque_tag2[2]
127
+ }
128
+ end
129
+
130
+ # Must use the 'weak comparison' function
131
+ # source: https://tools.ietf.org/html/rfc7232#section-3.2
132
+ def helper_etag_match(request, cached)
133
+ return unless request && cached
134
+
135
+ request.any? {|x|
136
+ helper_weak_compare.call(x, cached)
137
+ }
138
+ end
139
+
140
+ # It is not possible for a response's ETag to contain a "star", don't check for it
141
+ # source: https://tools.ietf.org/html/rfc7232#section-2.3.2
142
+ def helper_etag(request, cached)
143
+ helper_has_star(request['If-None-Match']) || helper_etag_match(request['If-None-Match'], cached['ETag'])
144
+ end
145
+
146
+ # source: https://tools.ietf.org/html/rfc7232#section-3.3
147
+ def helper_last_modified(request, cached)
148
+ rules = {
149
+ :response_time => cached['X-Cache-Res-Date']['timestamp'], # Required
150
+ :date_value => (cached['Date']['timestamp'] if cached['Date']),
151
+ :cached_last_modified => (cached['Last-Modified']['timestamp'] if cached['Last-Modified']),
152
+ :if_modified_since => (request['If-Modified-Since']['timestamp'] if request['If-Modified-Since'])
153
+ }
154
+ return unless rules[:if_modified_since]
155
+
156
+ return true if
157
+ helper_304_rule1(rules) ||
158
+ helper_304_rule2(rules) ||
159
+ helper_304_rule3(rules) ||
160
+ helper_304_rule4(rules)
161
+
162
+ end
163
+
164
+ # "A cache recipient SHOULD generate a 304 (Not Modified) response if..."
165
+ # source: https://tools.ietf.org/html/rfc7234#section-4.3.2
166
+ def helper_304_rule1(rules)
167
+ rules[:cached_last_modified] &&
168
+ rules[:cached_last_modified] <= rules[:if_modified_since]
169
+ end
170
+
171
+ def helper_304_rule2(rules)
172
+ rules[:cached_last_modified].nil? &&
173
+ rules[:date_value] &&
174
+ rules[:date_value] <= rules[:if_modified_since]
175
+ end
176
+
177
+ def helper_304_rule3(rules)
178
+ rules[:date_value].nil? &&
179
+ rules[:cached_last_modified].nil? &&
180
+ rules[:response_time] <= rules[:if_modified_since]
181
+ end
182
+
183
+ # "The presented Last-Modified time is at least 60 seconds before the Date value." ¯\_(ツ)_/¯
184
+ # source: https://tools.ietf.org/html/rfc7232#section-2.2.2
185
+ def helper_304_rule4(rules)
186
+ rules[:if_modified_since] &&
187
+ rules[:date_value] &&
188
+ rules[:if_modified_since] <= (rules[:date_value] - 60)
189
+ end
190
+
191
+ # Don't allow stale if no-cache or no-store headers exist
192
+ # source: https://tools.ietf.org/html/rfc7234#section-4.2.4
193
+ def helper_validate_allow_stale(request_headers, cached_headers)
194
+ return true if (( request = request_headers['Cache-Control'] )) &&
195
+ ( request['no-cache'] || request['no-store'] )
196
+
197
+ return true if (( cached = cached_headers['Cache-Control'] )) &&
198
+ ( cached['no-cache'] ||
199
+ cached['no-store'] ||
200
+ cached['must-revalidate'] ||
201
+ cached['s-maxage'] ||
202
+ cached['proxy-revalidate'] )
203
+
204
+ # Legacy support for HTTP/1.0 Pragma header
205
+ # source: https://tools.ietf.org/html/rfc7234#section-5.4
206
+ return true if request_headers['Pragma'] == 'no-cache'
207
+ end
208
+
209
+ def helper_apparent_age(response_time, date_value)
210
+ Proc.new {
211
+ [0, (response_time - date_value)].max
212
+ }
213
+ end
214
+
215
+ def helper_corrected_age_value(response_time, request_time, age_value)
216
+ Proc.new {
217
+ # NOTE: It's technically IMPOSSIBLE for response_time to be LOWER THAN request_time
218
+ response_delay = response_time - request_time
219
+ age_value + response_delay
220
+ }
221
+ end
222
+
223
+ def helper_corrected_initial_age(cached, corrected_age_value, apparent_age)
224
+ Proc.new {
225
+ if cached['Via'] && cached['Age'] && cached['Via'].none? {|x| x.match /1\.0/ }
226
+ # corrected_age_value.call
227
+ [0, corrected_age_value.call].max # safeguard just in case
228
+ else
229
+ [apparent_age.call, corrected_age_value.call].max
230
+ end
231
+ }
232
+ end
233
+
234
+ # Calculate the current_age of the cached response
235
+ # source: https://tools.ietf.org/html/rfc7234#section-4.2.3
236
+ def helper_current_age(now, cached)
237
+ date_value = cached['Date']['timestamp'] # Required
238
+ request_time = cached['X-Cache-Req-Date']['timestamp'] # Required
239
+ response_time = cached['X-Cache-Res-Date']['timestamp'] # Required
240
+ age_value = cached['Age'].nil? ? 0 : cached['Age']['timestamp']
241
+
242
+ apparent_age = helper_apparent_age response_time, date_value
243
+ corrected_age_value = helper_corrected_age_value response_time, request_time, age_value
244
+ corrected_initial_age = helper_corrected_initial_age cached, corrected_age_value, apparent_age
245
+
246
+ resident_time = now - response_time
247
+ corrected_initial_age.call + resident_time
248
+ end
249
+
250
+ # Calculate the Freshness Lifetime of the cached response
251
+ # source: https://tools.ietf.org/html/rfc7234#section-4.2.1
252
+ def helper_freshness_lifetime
253
+ now = Time.now.gmtime.to_i
254
+
255
+ ->(cached) {
256
+ current_age = helper_current_age now, cached
257
+
258
+ # source: https://tools.ietf.org/html/rfc7234#section-4.2
259
+ freshness_lifetime = helper_explicit(cached) || helper_heuristic(now, cached, current_age)
260
+
261
+ [freshness_lifetime, current_age]
262
+ }
263
+ end
264
+
265
+ # If the expire times are explicitly declared
266
+ # source: https://tools.ietf.org/html/rfc7234#section-4.2.1
267
+ def helper_explicit(cached_headers)
268
+ if (( cached = cached_headers['Cache-Control'] ))
269
+ return cached['s-maxage']['token'] if cached['s-maxage']
270
+ return cached['max-age']['token'] if cached['max-age']
271
+ end
272
+
273
+ return (cached_headers['Expires']['timestamp'] - cached_headers['Date']['timestamp']) if cached_headers['Expires']
274
+ end
275
+
276
+ # Calculate Heuristic Freshness if there's not explicit expiration time
277
+ # source: https://tools.ietf.org/html/rfc7234#section-4.2.2
278
+ def helper_heuristic(now, cached, current_age)
279
+ # Use 10% only if the response is public and there's a Last-Modified header
280
+ # source: https://tools.ietf.org/html/rfc7234#section-4.2.2
281
+ if cached['Cache-Control'] && cached['Cache-Control']['public'] && cached['Last-Modified']
282
+ result = (now - cached['Last-Modified']['timestamp']) / 10
283
+
284
+ # Don't cache heuristic responses more than 24 hours old, and avoid sending a 113 Warning ;)
285
+ # source: https://tools.ietf.org/html/rfc7234#section-4.2.2
286
+ current_age > 86400 ? 0 : result
287
+ else
288
+ 0
289
+ end
290
+ end
291
+
292
+ # source: https://tools.ietf.org/html/rfc7234#section-5.2.1.2
293
+ def helper_max_stale
294
+ Proc.new {|request, freshness_lifetime, current_age|
295
+ if request && request['max-stale']
296
+ token = request['max-stale']['token']
297
+ token ? (freshness_lifetime.to_i + token.to_i) > current_age : true
298
+ end
299
+ }
300
+ end
301
+
302
+ # source: https://tools.ietf.org/html/rfc7234#section-5.2.1.3
303
+ def helper_min_fresh
304
+ Proc.new {|request, freshness_lifetime, current_age|
305
+ if request && request['min-fresh']
306
+ token = request['min-fresh']['token']
307
+ freshness_lifetime.to_i >= (current_age + token.to_i)
308
+ end
309
+ }
310
+ end
311
+
312
+ # source: https://tools.ietf.org/html/rfc7234#section-5.2.2.2
313
+ def helper_no_cache
314
+ Proc.new {|cached_headers|
315
+ nocache = cached_headers['Cache-Control']['no-cache']
316
+ # "If the no-cache response directive specifies one or more field-names..."
317
+ (nocache && nocache['quoted_string']) &&
318
+ nocache['quoted_string'].split(',').map(&:strip).length > 0
319
+ }
320
+ end
321
+
322
+ def helper_make_request(http, request)
323
+ Proc.new { http.request request }
324
+ end
325
+
326
+ end