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],
    'allow_stale'     => [X, X, 0, 1, 1],
    'validator_match' => [0, 1, X, 0, 1]
  },
  :actions => {
    'revalidate'      => [],
    'add_age'         => [1, 1, X, 1, 1],
    'add_x_cache'     => %w(REVALIDATED REVALIDATED EXPIRED STALE STALE),
    'add_warning'     => [X, X, X, '111 - "Revalidation Failed"', '111 - "Revalidation Failed"'],
    'add_status'      => [200, 304, 504, 200, 304],
    'return_body'     => ['cached', X, 'Gateway Timeout', 'stale']
  }
}
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) || value.nil? || value.empty?
    }
  }
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'].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_preconditionsObject

The validators are required for revalidation source: tools.ietf.org/html/rfc7232#section-2



365
366
367
368
369
# File 'lib/helpers.rb', line 365

def helper_has_preconditions
  Proc.new {|request, cached|
    request['If-None-Match'] || cached['ETag'] || cached['Last-Modified']
  }
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 no 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 there's a Last-Modified header
  # source: https://tools.ietf.org/html/rfc7234#section-4.2.2
  if cached['Last-Modified']
    result = (now - cached['Last-Modified']['timestamp']) / 10

    # Don't return 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

Returns:

  • (Boolean)


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



336
337
338
# File 'lib/helpers.rb', line 336

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

#helper_make_request_timerObject



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

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
313
314
# File 'lib/helpers.rb', line 305

def helper_max_stale
  ->(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
    else
      true
    end
  }
end

#helper_min_freshObject



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

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



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

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       = [headers_304, age, warning, x_cache, headers_url].compact.reduce &:merge

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

#helper_response_headersObject



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

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



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/cache_rules.rb', line 142

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



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/cache_rules.rb', line 122

def revalidate_response(*args)
  url, request, cached = *args
  has_preconditions    = helper_has_preconditions.(request, cached)

  # 1. get the column
  column = if has_preconditions
    res_headers = helper_response_headers.(helper_make_request_timer.(args))
    RESPONSE_MAP[helper_run_validate.call(RESPONSE_TABLE[:conditions], request, cached, res_headers).join]
  else
    res_headers = {}
    2 # return column 2 (504 EXPIRED)
  end

  # 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



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

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

Returns:

  • (Boolean)


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

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

Returns:

  • (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

Returns:

  • (Boolean)


43
44
45
46
47
48
49
50
51
52
53
54
55
56
# 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

  return 1 if headers[:cached]['Cache-Control'] &&
                headers[:cached]['Cache-Control']['max-age'] &&
                  current_age > headers[:cached]['Cache-Control']['max-age']['token'].to_i


  to_bit { (response_is_fresh != true) }
end

#validate_is_error?(headers) ⇒ Boolean

Returns:

  • (Boolean)


120
121
122
# File 'lib/validations.rb', line 120

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

Returns:

  • (Boolean)


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

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 serve a response without revalidation

Returns:

  • (Boolean)


87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/validations.rb', line 87

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_headers['Cache-Control'] && request_headers['Cache-Control']['no-cache'])

  _, current_age = helper_freshness_lifetime.call cached_headers

  # If max-age is 0 or if the current age is above the max-age
  # source: https://tools.ietf.org/html/rfc7234#section-5.2.1.1
  return 1 if request_headers['Cache-Control'] &&
                request_headers['Cache-Control']['max-age'] &&
                  (request_headers['Cache-Control']['max-age']['token'].to_s == "0" || current_age > request_headers['Cache-Control']['max-age']['token'].to_i)

  # source: https://tools.ietf.org/html/rfc7234#section-5.2.2.2
  # source: https://tools.ietf.org/html/rfc7234#section-3.2
  if cached_headers['Cache-Control']
    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")
  end

  # 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

Returns:

  • (Boolean)


58
59
60
# File 'lib/validations.rb', line 58

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

Returns:

  • (Boolean)


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

Returns:

  • (Boolean)


124
125
126
127
# File 'lib/validations.rb', line 124

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']) || request['If-None-Match'].include?("*")) }
end