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/.gitignore +3 -0
- data/.travis.yml +8 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +47 -0
- data/LICENSE +340 -0
- data/NOTICE +26 -0
- data/README.md +145 -0
- data/Rakefile +14 -0
- data/cache_rules.gemspec +34 -0
- data/lib/actions.rb +40 -0
- data/lib/cache_rules.rb +188 -0
- data/lib/formatting.rb +161 -0
- data/lib/helpers.rb +326 -0
- data/lib/validations.rb +111 -0
- data/test/helper.rb +18 -0
- data/test/test_cache_rules.rb +253 -0
- data/test/test_formatting.rb +135 -0
- data/test/test_helpers.rb +396 -0
- data/test/test_tables.rb +75 -0
- data/test/test_validations.rb +206 -0
- metadata +132 -0
data/Rakefile
ADDED
data/cache_rules.gemspec
ADDED
|
@@ -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
|
data/lib/cache_rules.rb
ADDED
|
@@ -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
|