Module: CacheRules

Extended by:
CacheRules
Included in:
CacheRules, Everything::Around::Me
Defined in:
lib/actions.rb,
lib/helpers.rb,
lib/formatting.rb,
lib/cache_rules.rb,
lib/cache_rules.rb,
lib/validations.rb

Overview

This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at mozilla.org/MPL/2.0/.

Copyright © 2014-2015 Alexander Williams, Unscramble <[email protected]>

Defined Under Namespace

Modules: Everything

Constant Summary collapse

HEADERS_NO_CACHE =
%w(
  Set-Cookie Cookie
  Accept-Ranges Range If-Range Content-Range
  Referer From Host
  Authorization Proxy-Authorization
)
HEADERS_HTTPDATE =
%w(
  Last-Modified If-Modified-Since If-Unmodified-Since
  Expires Date
  X-Cache-Req-Date X-Cache-Res-Date
)
HEADERS_CSV =
%w(
  Connection Trailer Transfer-Encoding Upgrade Via
  Accept Accept-Charset Accept-Encoding Accept-Language Allow
  Content-Encoding Content-Language Vary
  Cache-Control Warning Pragma If-Match If-None-Match
)
HEADERS_NUMBER =
%w(
  Age Content-Length Max-Forwards
)
OPTIONS_CACHE =
HEADERS_CSV.select {|header| header == 'Cache-Control' }
OPTIONS_CSV =
HEADERS_CSV.reject {|header| header == 'Cache-Control' }
OPTIONS_RETRY =
%w(Retry-After)
X =
nil
REQUEST_TABLE =

Decision table for request/cached headers

{
  :conditions => {
    'cached'          => [0, 0, 1, 1, 1, 1, 1, 1, 1],
    'must_revalidate' => [X, X, X, X, 0, 0, 0, 1, X],
    'no_cache'        => [X, X, 0, 0, 0, 0, 0, 0, 1],
    'precond_match'   => [X, X, 0, 1, 0, 1, X, X, X],
    'expired'         => [X, X, 0, 0, 1, 1, 1, 1, X],
    'only_if_cached'  => [0, 1, X, X, X, X, X, X, X],
    'allow_stale'     => [X, X, X, X, 1, 1, 0, X, X]
  },
  :actions => {
    'revalidate'      => [X, X, X, X, X, X, X, 1, 1],
    'add_age'         => [X, X, 1, 1, 1, 1],
    'add_x_cache'     => %w(MISS MISS HIT HIT STALE STALE EXPIRED),
    'add_warning'     => [X, X, X, X, '110 - "Response is Stale"', '110 - "Response is Stale"'],
    'add_status'      => [307, 504, 200, 304, 200, 304, 504],
    'return_body'     => [X, 'Gateway Timeout', 'cached', X, 'stale', X, 'Gateway Timeout']
  }
}
RESPONSE_TABLE =

Decision table for revalidated responses

{
  :conditions => {
    'is_error'        => [0, 0, 1, 1, 1, 0],
    'expired'         => [0, 0, X, X, X, 1],
    'allow_stale'     => [X, X, 0, 1, 1, X],
    'validator_match' => [0, 1, X, 0, 1, X]
  },
  :actions => {
    'revalidate'      => [],
    'add_age'         => [1, 1, X, 1, 1],
    'add_x_cache'     => %w(REVALIDATED REVALIDATED EXPIRED STALE STALE EXPIRED),
    'add_warning'     => [X, X, X, '111 - "Revalidation Failed"', '111 - "Revalidation Failed"'],
    'add_status'      => [200, 304, 504, 200, 304, 307],
    'return_body'     => ['cached', X, 'Gateway Timeout', 'stale', X]
  }
}
REQUEST_MAP =

Build the map tables in advance for faster lookups i.e: O(1)

helper_table_map(REQUEST_TABLE[:conditions])
RESPONSE_MAP =
helper_table_map(RESPONSE_TABLE[:conditions])

Instance Method Summary collapse

Instance Method Details

#action_add_age(result) ⇒ Object

Generate an age equal to the current cached entry’s age source: tools.ietf.org/html/rfc7234#section-4



18
19
20
21
22
# File 'lib/actions.rb', line 18

def action_add_age(result)
  current_age = helper_current_age Time.now.gmtime.to_i, result[:cached]

  {'Age' => current_age.to_s}
end

#action_add_status(result) ⇒ Object



32
33
34
# File 'lib/actions.rb', line 32

def action_add_status(result)
  result[:value]
end

#action_add_warning(result) ⇒ Object



28
29
30
# File 'lib/actions.rb', line 28

def action_add_warning(result)
  {'Warning' => result[:value]}
end

#action_add_x_cache(result) ⇒ Object



24
25
26
# File 'lib/actions.rb', line 24

def action_add_x_cache(result)
  {'Cache-Lookup' => result[:value]}
end

#action_return_body(result) ⇒ Object



36
37
38
# File 'lib/actions.rb', line 36

def action_return_body(result)
  result[:value]
end

#action_revalidate(result) ⇒ Object

HTTP Header Actions



12
13
14
# File 'lib/actions.rb', line 12

def action_revalidate(result)
  result[:value]
end

#cache_control(header = '') ⇒ Object

OPTIMIZE: this regex is copied from JavaScript, could be greatly simplified Returns a Hash with the directive as key, token (or nil), quoted-string (or nil)



115
116
117
118
119
120
121
122
123
124
125
# File 'lib/formatting.rb', line 115

def cache_control(header = '')
  result = header.scan /(?:^|(?:\s*\,\s*))([^\x00-\x20\(\)<>@\,;\:\\"\/\[\]\?\=\{\}\x7F]+)(?:\=(?:([^\x00-\x20\(\)<>@\,;\:\\"\/\[\]\?\=\{\}\x7F]+)|(?:\"((?:[^"\\]|\\.)*)\")))?/
  result.reduce({}) {|hash, x|
    hash.merge({
      x[0].downcase => {
        'token'         => x[1],
        'quoted_string' => x[2]
      }
    })
  }
end

#cache_control_string(header) ⇒ Object

Parses the Cache-Control header and returns a comma-separated String



128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/formatting.rb', line 128

def cache_control_string(header)
  Array(header).map {|x|
    token     = x[1]['token']
    quote     = x[1]['quoted_string']
    directive = x[0]

    if    token && quote.nil? then "#{ directive }=#{ token }"
    elsif token.nil? && quote then "#{ directive }=\"#{ quote }\""
    else  directive
    end
  }.join ', '
end

#cleanObject

Intentionally drop these headers to avoid caching them If-Modified-Since should be dropped if the date isn’t valid source: tools.ietf.org/html/rfc7232#section-3.3



30
31
32
33
34
35
36
# File 'lib/formatting.rb', line 30

def clean
  ->(headers) {
    Array(headers).reject {|key, value|
      HEADERS_NO_CACHE.include?(key) || helper_is_if_modified_error?(key, value)
    }
  }
end

#combineObject

Combine headers with a comma if the field-names are duplicate



39
40
41
42
43
44
45
46
# File 'lib/formatting.rb', line 39

def combine
  ->(headers) {
    Array(headers).group_by {|h, _| h }.map {|k, v|
      v = HEADERS_CSV.include?(k) ? v.map {|_, x| x }.join(', ') : v[0][1] # OPTIMIZE
      [ k, v ]
    }
  }
end

#csv(header = '') ⇒ Object



141
142
143
# File 'lib/formatting.rb', line 141

def csv(header = '')
  header.split(',').map(&:strip)
end

#csv_string(header) ⇒ Object



145
146
147
# File 'lib/formatting.rb', line 145

def csv_string(header)
  Array(header).join ', '
end

#format_fieldObject

Returns a Hash, Array, Integer or String based on the supplied arguments



65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/formatting.rb', line 65

def format_field
  Proc.new {|key, header, stringify|
    f = format_value header, stringify

    value = case key
      when *HEADERS_HTTPDATE then f.call('httpdate')      # => Hash
      when *OPTIONS_CACHE    then f.call('cache_control') # => Array
      when *OPTIONS_CSV      then f.call('csv')           # => Array
      when *OPTIONS_RETRY    then f.call('retry_after')   # => Hash or Integer
      else header                                         # => String
    end
    [ key, value ]
  }
end

#format_keyObject

Format the key to look like this: Last-Modified



20
21
22
23
24
25
# File 'lib/formatting.rb', line 20

def format_key
  Proc.new {|key, value|
    k = key.downcase == 'etag' ? 'ETag' : key.split('-').map(&:capitalize).join('-')
    [ k, value ]
  }
end

#format_value(header, stringify = nil) ⇒ Object

Returns the value of the field



81
82
83
84
85
# File 'lib/formatting.rb', line 81

def format_value(header, stringify = nil)
  Proc.new {|field|
    stringify ? send("#{ field }_string", header) : send("#{ field }", header)
  }
end

#helper_304_rule1(rules) ⇒ Object

“A cache recipient SHOULD generate a 304 (Not Modified) response if…” source: tools.ietf.org/html/rfc7234#section-4.3.2



178
179
180
181
# File 'lib/helpers.rb', line 178

def helper_304_rule1(rules)
  rules[:cached_last_modified] &&
  rules[:cached_last_modified] <= rules[:if_modified_since]
end

#helper_304_rule2(rules) ⇒ Object



183
184
185
186
187
# File 'lib/helpers.rb', line 183

def helper_304_rule2(rules)
  rules[:cached_last_modified].nil? &&
  rules[:date_value] &&
  rules[:date_value] <= rules[:if_modified_since]
end

#helper_304_rule3(rules) ⇒ Object



189
190
191
192
193
# File 'lib/helpers.rb', line 189

def helper_304_rule3(rules)
  rules[:date_value].nil? &&
  rules[:cached_last_modified].nil? &&
  rules[:response_time] <= rules[:if_modified_since]
end

#helper_304_rule4(rules) ⇒ Object

“The presented Last-Modified time is at least 60 seconds before the Date value.” ¯_(ツ)_/¯source: tools.ietf.org/html/rfc7232#section-2.2.2



197
198
199
200
201
# File 'lib/helpers.rb', line 197

def helper_304_rule4(rules)
  rules[:if_modified_since] &&
  rules[:date_value] &&
  rules[:if_modified_since] <= (rules[:date_value] - 60)
end

#helper_apparent_age(response_time, date_value) ⇒ Object



221
222
223
224
225
# File 'lib/helpers.rb', line 221

def helper_apparent_age(response_time, date_value)
  Proc.new {
    [0, (response_time - date_value)].max
  }
end

#helper_bit_string(num_conditions, index) ⇒ Object

Returns a string of 0s and 1s



26
27
28
# File 'lib/helpers.rb', line 26

def helper_bit_string(num_conditions, index)
  index.to_s(2).rjust num_conditions, '0'
end

#helper_combine_etags(request, cached) ⇒ Object

Combine entity tags if they exist source: tools.ietf.org/html/rfc7234#section-4.3.2



113
114
115
116
117
# File 'lib/helpers.rb', line 113

def helper_combine_etags(request, cached)
  return "*" if helper_has_star(request['If-None-Match'])

  request['If-None-Match'] ? request['If-None-Match'].push(cached['ETag']).uniq.compact.join(', ') : cached['ETag']
end

#helper_corrected_age_value(response_time, request_time, age_value) ⇒ Object



227
228
229
230
231
232
233
# File 'lib/helpers.rb', line 227

def helper_corrected_age_value(response_time, request_time, age_value)
  Proc.new {
    # NOTE: It's technically IMPOSSIBLE for response_time to be LOWER THAN request_time
    response_delay = response_time - request_time
    age_value + response_delay
  }
end

#helper_corrected_initial_age(cached, corrected_age_value, apparent_age) ⇒ Object



235
236
237
238
239
240
241
242
243
244
# File 'lib/helpers.rb', line 235

def helper_corrected_initial_age(cached, corrected_age_value, apparent_age)
  Proc.new {
    if cached['Via'] && cached['Age'] && cached['Via'].none? {|x| x.match /1\.0/ }
      # corrected_age_value.call
      [0, corrected_age_value.call].max # safeguard just in case
    else
      [apparent_age.call, corrected_age_value.call].max
  end
  }
end

#helper_current_age(now, cached) ⇒ Object

Calculate the current_age of the cached response source: tools.ietf.org/html/rfc7234#section-4.2.3



248
249
250
251
252
253
254
255
256
257
258
259
260
# File 'lib/helpers.rb', line 248

def helper_current_age(now, cached)
  date_value            = cached['Date']['timestamp']             # Required
  request_time          = cached['X-Cache-Req-Date']['timestamp'] # Required
  response_time         = cached['X-Cache-Res-Date']['timestamp'] # Required
  age_value             = cached['Age'].nil? ? 0 : cached['Age']['timestamp'].to_i

  apparent_age          = helper_apparent_age           response_time, date_value
  corrected_age_value   = helper_corrected_age_value    response_time, request_time, age_value
  corrected_initial_age = helper_corrected_initial_age  cached, corrected_age_value, apparent_age

  resident_time = now - response_time
  corrected_initial_age.call + resident_time
end

#helper_etag(request, cached) ⇒ Object

It is not possible for a response’s ETag to contain a “star”, don’t check for it source: tools.ietf.org/html/rfc7232#section-2.3.2



154
155
156
# File 'lib/helpers.rb', line 154

def helper_etag(request, cached)
  helper_has_star(request['If-None-Match']) || helper_etag_match(request['If-None-Match'], cached['ETag'])
end

#helper_etag_match(request, cached) ⇒ Object

Must use the ‘weak comparison’ function source: tools.ietf.org/html/rfc7232#section-3.2



144
145
146
147
148
149
150
# File 'lib/helpers.rb', line 144

def helper_etag_match(request, cached)
  return unless request && cached

  request.any? {|x|
    helper_weak_compare.call(x, cached)
  }
end

#helper_explicit(cached_headers) ⇒ Object

If the expire times are explicitly declared source: tools.ietf.org/html/rfc7234#section-4.2.1



279
280
281
282
283
284
285
286
# File 'lib/helpers.rb', line 279

def helper_explicit(cached_headers)
  if (( cached = cached_headers['Cache-Control'] ))
    return cached['s-maxage']['token'] if cached['s-maxage']
    return cached['max-age']['token']  if cached['max-age']
  end

  return (cached_headers['Expires']['timestamp'] - cached_headers['Date']['timestamp']) if cached_headers['Expires']
end

#helper_freshness_lifetimeObject

Calculate the Freshness Lifetime of the cached response source: tools.ietf.org/html/rfc7234#section-4.2.1



264
265
266
267
268
269
270
271
272
273
274
275
# File 'lib/helpers.rb', line 264

def helper_freshness_lifetime
  now = Time.now.gmtime.to_i

  ->(cached) {
    current_age = helper_current_age now, cached

    # source: https://tools.ietf.org/html/rfc7234#section-4.2
    freshness_lifetime = helper_explicit(cached) || helper_heuristic(now, cached, current_age)

    [freshness_lifetime, current_age]
  }
end

#helper_has_star(header) ⇒ Object

Header can be a String or Array



107
108
109
# File 'lib/helpers.rb', line 107

def helper_has_star(header)
  header && header.include?("*")
end

#helper_headers_200_304Object

Generate the same headers if they exist for 200/304 responses source: tools.ietf.org/html/rfc7232#section-4.1



89
90
91
92
93
94
95
96
# File 'lib/helpers.rb', line 89

def helper_headers_200_304
  Proc.new {|cached, response|
    new_headers = response.select &helper_remove_warning_1xx
    unnormalize_fields.call cached.merge(new_headers).reject {|key, _|
      key == 'X-Cache-Req-Date' || key == 'X-Cache-Res-Date' || key == 'Status'
    }
  }
end

#helper_heuristic(now, cached, current_age) ⇒ Object

Calculate Heuristic Freshness if there’s not explicit expiration time source: tools.ietf.org/html/rfc7234#section-4.2.2



290
291
292
293
294
295
296
297
298
299
300
301
302
# File 'lib/helpers.rb', line 290

def helper_heuristic(now, cached, current_age)
  # Use 10% only if the response is public and there's a Last-Modified header
  # source: https://tools.ietf.org/html/rfc7234#section-4.2.2
  if cached['Cache-Control'] && cached['Cache-Control']['public'] && cached['Last-Modified']
    result = (now - cached['Last-Modified']['timestamp']) / 10

    # Don't cache heuristic responses more than 24 hours old, and avoid sending a 113 Warning ;)
    # source: https://tools.ietf.org/html/rfc7234#section-4.2.2
    current_age > 86400 ? 0 : result
  else
    0
  end
end

#helper_is_if_modified_error?(key, value) ⇒ Boolean

Returns a Boolean after trying to parse the If-Modified-Since, or nil



77
78
79
80
81
82
83
84
85
# File 'lib/helpers.rb', line 77

def helper_is_if_modified_error?(key, value)
  if key == 'If-Modified-Since'
    begin
      false if DateTime.parse(value)
    rescue ArgumentError => e
      true
    end
  end
end

#helper_last_modified(request, cached) ⇒ Object



159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/helpers.rb', line 159

def helper_last_modified(request, cached)
  rules = {
    :response_time         => cached['X-Cache-Res-Date']['timestamp'],   # Required
    :date_value            => (cached['Date']['timestamp']               if cached['Date']),
    :cached_last_modified  => (cached['Last-Modified']['timestamp']      if cached['Last-Modified']),
    :if_modified_since     => (request['If-Modified-Since']['timestamp'] if request['If-Modified-Since'])
  }
  return unless rules[:if_modified_since]

  return true if
    helper_304_rule1(rules) ||
    helper_304_rule2(rules) ||
    helper_304_rule3(rules) ||
    helper_304_rule4(rules)

end

#helper_loop_conditions(table) ⇒ Object

Loop through each condition and see if the answer matches



38
39
40
41
42
43
44
# File 'lib/helpers.rb', line 38

def helper_loop_conditions(table)
  Proc.new {|index|
    table[:conditions].values[index].map.each_with_index {|x, i|
      i if x == table[:answers][index] || x.nil?
    }
  }
end

#helper_make_request(http, request) ⇒ Object



334
335
336
# File 'lib/helpers.rb', line 334

def helper_make_request(http, request)
  Proc.new { http.request request }
end

#helper_make_request_timerObject



338
339
340
341
342
343
344
345
346
# File 'lib/helpers.rb', line 338

def helper_make_request_timer
  Proc.new {|url, request, cached|
    {
      :req_date  => Time.now.gmtime.httpdate,
      :res       => make_request.call(url, request, cached).call,
      :res_date  => Time.now.gmtime.httpdate
    }
  }
end

#helper_max_staleObject



305
306
307
308
309
310
311
312
# File 'lib/helpers.rb', line 305

def helper_max_stale
  Proc.new {|request, freshness_lifetime, current_age|
    if request && request['max-stale']
      token = request['max-stale']['token']
      token ? (freshness_lifetime.to_i + token.to_i) > current_age : true
    end
  }
end

#helper_min_freshObject



315
316
317
318
319
320
321
322
# File 'lib/helpers.rb', line 315

def helper_min_fresh
  Proc.new {|request, freshness_lifetime, current_age|
    if request && request['min-fresh']
      token = request['min-fresh']['token']
      freshness_lifetime.to_i >= (current_age + token.to_i)
    end
  }
end

#helper_no_cacheObject



325
326
327
328
329
330
331
332
# File 'lib/helpers.rb', line 325

def helper_no_cache
  Proc.new {|cached_headers|
    nocache = cached_headers['Cache-Control']['no-cache']
    # "If the no-cache response directive specifies one or more field-names..."
    (nocache && nocache['quoted_string']) &&
      nocache['quoted_string'].split(',').map(&:strip).length > 0
  }
end

#helper_parse_conditions(table) ⇒ Object

Returns the matching column number, or nil



31
32
33
34
35
# File 'lib/helpers.rb', line 31

def helper_parse_conditions(table)
  # Loop through each answer and hope to end up with the exact column match
  result = table[:answers].each_index.map(&helper_loop_conditions(table)).reduce(:&).compact
  result[0] if result.length == 1
end

#helper_remove_warning_1xxObject

delete 1xx Warning headers source: tools.ietf.org/html/rfc7234#section-4.3.4



100
101
102
103
104
# File 'lib/helpers.rb', line 100

def helper_remove_warning_1xx
  Proc.new {|key, value|
    {key => value} unless key == 'Warning' && value.reject! {|x| x =~ /^1\d{2}/ } && value.length == 0
  }
end

#helper_response(url, actions, column, cached, response = {}) ⇒ Object

Returns the response body, code and headers based on the actions results



64
65
66
67
68
69
70
71
72
73
74
# File 'lib/helpers.rb', line 64

def helper_response(url, actions, column, cached, response = {})
  _, age, x_cache, warning, status, body = helper_run_action actions, column, cached

  normalized    = normalize.call response
  headers_304   = helper_headers_200_304.call(cached, normalized) if status == 200 || status == 304
  headers_url   = {'Location' => url}                             if status == 307

  headers       = [age, warning, x_cache, headers_304, headers_url].compact.reduce &:merge

  {:body => body, :code => status, :headers => headers}
end

#helper_response_headersObject



348
349
350
351
352
353
354
355
356
357
358
359
# File 'lib/helpers.rb', line 348

def helper_response_headers
  Proc.new {|result|
    res_headers = normalize.(result[:res].to_hash.map &:flatten)

    res_headers['Date']             = result[:res_date] if res_headers['Date']
    res_headers['X-Cache-Req-Date'] = result[:req_date]
    res_headers['X-Cache-Res-Date'] = result[:res_date]
    res_headers['Status']           = result[:res].code

    res_headers
  }
end

#helper_row_col_hash(conditions) ⇒ Object

Returns a hash representing a row/column, for the table map



16
17
18
19
20
21
22
23
# File 'lib/helpers.rb', line 16

def helper_row_col_hash(conditions)
  Proc.new {|index|
    row = helper_bit_string conditions.length, index
    col = helper_parse_conditions({:conditions => conditions, :answers => row.chars.map(&:to_i)})

    {row => col}
  }
end

#helper_run_action(actions, column, cached) ⇒ Object

Returns an Array of actions to be performed based on the column number



57
58
59
60
61
# File 'lib/helpers.rb', line 57

def helper_run_action(actions, column, cached)
  actions.map {|key, value|
    send("action_#{ key }", {:value => value[column], :cached => cached}) unless value[column].nil?
  }
end

#helper_run_validateObject

Returns a bit Array of answers for the conditions



47
48
49
50
51
52
53
54
# File 'lib/helpers.rb', line 47

def helper_run_validate
  Proc.new {|table, request, cached, response|
    table.keys.map {|x|
      headers = {:request => request, :cached => cached, :response => response}
      send("validate_#{ x }?", headers)
    }
  }
end

#helper_table_map(conditions) ⇒ Object

Create a map with all possible combinations



11
12
13
# File 'lib/helpers.rb', line 11

def helper_table_map(conditions)
  (2**conditions.length).times.map(&helper_row_col_hash(conditions)).reduce(:merge)
end

#helper_timestamp(request, cached) ⇒ Object

Use the last modified date if it exists source: tools.ietf.org/html/rfc7234#section-4.3.2



121
122
123
124
125
# File 'lib/helpers.rb', line 121

def helper_timestamp(request, cached)
  return request['If-Modified-Since']['httpdate'] if request['If-Modified-Since']

  cached['Last-Modified']['httpdate'] if cached['Last-Modified']
end

#helper_validate_allow_stale(request_headers, cached_headers) ⇒ Object

Don’t allow stale if no-cache or no-store headers exist source: tools.ietf.org/html/rfc7234#section-4.2.4



205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
# File 'lib/helpers.rb', line 205

def helper_validate_allow_stale(request_headers, cached_headers)
  return true if (( request = request_headers['Cache-Control'] )) &&
    ( request['no-cache'] || request['no-store'] )

  return true if (( cached = cached_headers['Cache-Control'] )) &&
    ( cached['no-cache'] ||
      cached['no-store'] ||
      cached['must-revalidate'] ||
      cached['s-maxage'] ||
      cached['proxy-revalidate'] )

  # Legacy support for HTTP/1.0 Pragma header
  # source: https://tools.ietf.org/html/rfc7234#section-5.4
  return true if request_headers['Pragma'] == 'no-cache'
end

#helper_weak_compareObject



128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/helpers.rb', line 128

def helper_weak_compare
  etag = /^(W\/)?(\"\w+\")$/

  ->(etag1, etag2) {
    # source: https://tools.ietf.org/html/rfc7232#section-2.3.2
    opaque_tag1 = etag.match etag1
    opaque_tag2 = etag.match etag2

    return false if opaque_tag1.nil? || opaque_tag2.nil?

    opaque_tag1[2] == opaque_tag2[2]
  }
end

#httpdate(header) ⇒ Object



87
88
89
90
91
92
93
94
# File 'lib/formatting.rb', line 87

def httpdate(header)
  timestamp = httpdate_helper header

  {
    'httpdate'  => Time.at(timestamp).gmtime.httpdate,
    'timestamp' => timestamp
  }
end

#httpdate_helper(header) ⇒ Object

Correctly parse the 3 Date/Time formats and convert to GMT source: tools.ietf.org/html/rfc7234#section-4.2



104
105
106
107
108
109
110
111
# File 'lib/formatting.rb', line 104

def httpdate_helper(header)
  # source: https://tools.ietf.org/html/rfc7231#section-7.1.1.1
  DateTime.parse(header).to_time.to_i
rescue => e
  # If the supplied date is invalid, use a time in the past (5 minutes ago)
  # source: https://tools.ietf.org/html/rfc7234#section-5.3
  Time.now.gmtime.to_i - 300
end

#httpdate_string(header) ⇒ Object



96
97
98
99
100
# File 'lib/formatting.rb', line 96

def httpdate_string(header)
  timestamp = httpdate_helper header['httpdate']

  Time.at(timestamp).gmtime.httpdate
end

#make_requestObject

Returns a net/http response Object



137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/cache_rules.rb', line 137

def make_request
  ->(url, request_headers, cached_headers) {
    uri               = URI.parse url
    http              = Net::HTTP.new uri.host, uri.port
    http.open_timeout = 2
    http.read_timeout = 60
    http.use_ssl      = uri.scheme == 'https'
    http.verify_mode  = OpenSSL::SSL::VERIFY_PEER

    request           = Net::HTTP::Head.new uri.request_uri

    # Two possible validators: entity tags and timestamp
    # source: https://tools.ietf.org/html/rfc7234#section-4.3.1
    entity_tags = Proc.new { helper_combine_etags request_headers, cached_headers }.call
    timestamp   = Proc.new { helper_timestamp     request_headers, cached_headers }

    # Set the precondition header before making the request
    request['If-None-Match'] = entity_tags if entity_tags
    ts = timestamp.call unless entity_tags
    request['If-Modified-Since'] = ts if ts && !entity_tags

    # Make the HTTP(s) request
    helper_make_request http, request
  }
end

#normalizeObject

Create a normalized Hash of HTTP headers



13
14
15
16
17
# File 'lib/formatting.rb', line 13

def normalize
  ->(headers) {
    Hash[normalize_fields.(combine.(clean.(Array(headers).map &format_key)))]
  }
end

#normalize_fieldsObject

Normalizes the value (field-value) of each header



49
50
51
52
53
# File 'lib/formatting.rb', line 49

def normalize_fields
  ->(headers) {
    Array(headers).map &format_field
  }
end

#retry_after(header) ⇒ Object

“The value of this field can be either an HTTP-date or a number of seconds…” source: tools.ietf.org/html/rfc7234#section-7.1.3



151
152
153
154
155
# File 'lib/formatting.rb', line 151

def retry_after(header)
  Integer(header).abs
rescue => e
  httpdate header
end

#retry_after_string(header) ⇒ Object



157
158
159
# File 'lib/formatting.rb', line 157

def retry_after_string(header)
  header.to_s
end

#revalidate_response(*args) ⇒ Object

Revalidates a response by fetching headers from the origin server



123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/cache_rules.rb', line 123

def revalidate_response(*args)
  url, request, cached = *args
  res_headers = helper_response_headers.(helper_make_request_timer.(args))

  # 1. get the column
  column = RESPONSE_MAP[helper_run_validate.call(RESPONSE_TABLE[:conditions], request, cached, res_headers).join]

  # 2. return the response
  helper_response url, RESPONSE_TABLE[:actions], column, cached, res_headers
rescue => error
  {:code => 504, :body => 'Gateway Timeout', :headers => [], :error => error.message, :debug => error}
end

#to_bit(&predicate) ⇒ Object

HTTP Header Validators

Parameters must always be normalized

Return must always be 0 or 1

The If-Match and If-Unmodified-Since conditional header fields are not applicable to a cache. source: tools.ietf.org/html/rfc7234#section-4.3.2



19
20
21
# File 'lib/validations.rb', line 19

def to_bit(&predicate)
  predicate.call ? 1 : 0
end

#unnormalize_fieldsObject

Returns a Hash of Strings



56
57
58
59
60
61
62
# File 'lib/formatting.rb', line 56

def unnormalize_fields
  ->(headers) {
    Array(headers).reduce({}) {|hash, (key, value)|
      hash.merge Hash[[format_field.call(key, value, true)]]
    }
  }
end

#validate(url, request_headers, cached_headers = {}) ⇒ Object

Public: Validate a URL and the request/cached/response headers TODO: validate the required parameters to ensure they are set correctly



108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/cache_rules.rb', line 108

def validate(url, request_headers, cached_headers = {})
  # 1. normalize the request headers
  normalized_headers = normalize.call request_headers
  actions            = REQUEST_TABLE[:actions]

  # 2. get the column matching the request headers
  column = REQUEST_MAP[helper_run_validate.call(REQUEST_TABLE[:conditions], normalized_headers, cached_headers).join]
  response    = Proc.new { helper_response url, actions, column, cached_headers }
  revalidate  = Proc.new { revalidate_response url, normalized_headers, cached_headers }

  # 3. return the response or revalidate
  actions['revalidate'][column] == 1 ? revalidate.call : response.call
end

#validate_allow_stale?(headers) ⇒ Boolean

Serving Stale Responses source: tools.ietf.org/html/rfc7234#section-4.2.4



59
60
61
62
63
64
65
66
67
68
69
# File 'lib/validations.rb', line 59

def validate_allow_stale?(headers)
  request, cached = headers.values_at :request, :cached
  return 0 if cached.length == 0 || helper_validate_allow_stale(request, cached)

  freshness_lifetime, current_age = helper_freshness_lifetime.call cached

  max_stale = helper_max_stale.call request['Cache-Control'], freshness_lifetime, current_age
  min_fresh = helper_min_fresh.call request['Cache-Control'], freshness_lifetime, current_age

  to_bit { (max_stale && min_fresh != false) || (max_stale.nil? && min_fresh) }
end

#validate_cached?(headers) ⇒ Boolean



23
24
25
# File 'lib/validations.rb', line 23

def validate_cached?(headers)
  to_bit { headers[:cached].length > 0 }
end

#validate_expired?(headers) ⇒ Boolean

Compare headers to see if the cached request is expired (Freshness) source: tools.ietf.org/html/rfc7234#section-4.2



43
44
45
46
47
48
49
50
51
# File 'lib/validations.rb', line 43

def validate_expired?(headers)
  return 0 if headers[:cached].length == 0

  freshness_lifetime, current_age = helper_freshness_lifetime.call headers[:cached]

  response_is_fresh = freshness_lifetime.to_i > current_age

  to_bit { response_is_fresh != true }
end

#validate_is_error?(headers) ⇒ Boolean



106
107
108
# File 'lib/validations.rb', line 106

def validate_is_error?(headers)
  to_bit { headers[:response]['Status'].to_i.between?(500,599) }
end

#validate_must_revalidate?(headers) ⇒ Boolean

Response Cache-Control Directives source: tools.ietf.org/html/rfc7234#section-5.2.2



73
74
75
76
77
78
79
# File 'lib/validations.rb', line 73

def validate_must_revalidate?(headers)
  return 1 if headers[:cached].length == 0

  # source: https://tools.ietf.org/html/rfc7234#section-5.2.2.1
  # source: https://tools.ietf.org/html/rfc7234#section-5.2.2.7
  to_bit { (( cached = headers[:cached]['Cache-Control'] )) && ( cached['must-revalidate'] || cached['proxy-revalidate'] ) }
end

#validate_no_cache?(headers) ⇒ Boolean

Verify if we’re explicitly told not to cache the response



82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/validations.rb', line 82

def validate_no_cache?(headers)
  request_headers, cached_headers = headers.values_at :request, :cached
  return 1 if cached_headers.length == 0

  # Must revalidate if this request header exists
  # source: https://tools.ietf.org/html/rfc7234#section-5.2.1.4
  return 1 if (( request = request_headers['Cache-Control'] )) &&
    request_headers['Cache-Control']['no-cache']

  # source: https://tools.ietf.org/html/rfc7234#section-5.2.2.2
  # source: https://tools.ietf.org/html/rfc7234#section-3.2
  return 1 if (( cached = cached_headers['Cache-Control'] )) &&
    helper_no_cache.call(cached_headers)                               ||
      (cached['no-cache'] && cached['no-cache']['quoted_string'].nil?) ||
      (cached['s-maxage'] && cached['s-maxage']['token'].to_s == "0")  ||
      (cached['max-age'] && cached['max-age']['token'].to_s   == "0")

  # source: https://tools.ietf.org/html/rfc7234#section-5.4
  # Legacy support for HTTP/1.0 Pragma header
  return 1 if request_headers['Pragma'] && request_headers['Pragma']['no-cache']

  return 0
end

#validate_only_if_cached?(headers) ⇒ Boolean



53
54
55
# File 'lib/validations.rb', line 53

def validate_only_if_cached?(headers)
  to_bit { headers[:request]['Cache-Control'] && headers[:request]['Cache-Control']['only-if-cached'] }
end

#validate_precond_match?(headers) ⇒ Boolean

Precedence: If-None-Match (ETag), then If-Modified-Since (Last-Modified) source: tools.ietf.org/html/rfc7232#section-6



29
30
31
32
33
34
35
36
37
38
39
# File 'lib/validations.rb', line 29

def validate_precond_match?(headers)
  request, cached = headers.values_at :request, :cached
  return 0 if cached.length == 0

  # Return when the If-None-Match header exists, ignore If-Modified-Since
  # source: https://tools.ietf.org/html/rfc7232#section-3.3
  etag_match = helper_etag(request, cached)
  return to_bit { etag_match } unless etag_match.nil?

  to_bit { helper_last_modified(request, cached) }
end

#validate_validator_match?(headers) ⇒ Boolean



110
111
112
113
# File 'lib/validations.rb', line 110

def validate_validator_match?(headers)
  request, response = headers.values_at :request, :response
  to_bit { response['ETag'] && request['If-None-Match'] && request['If-None-Match'].include?(response['ETag']) }
end