business_calendar 1.0.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f7e437b2a3580ac2cac07f883e3acc42dfa39e0bd3506e95e1e46a1441d14f7d
4
- data.tar.gz: 13769d2f539ae93f3579c1b46378c5da5708b039b63f186e910d9f528e4b5041
3
+ metadata.gz: 216930bd93cbe52079f053f6bb5f67cfb3efbaa0478bb4474a98e1b4912023b7
4
+ data.tar.gz: 1b0242038fdbfd28978c7e9551b3f225ad43413bd7b19d19f9e9fe59c409e6e5
5
5
  SHA512:
6
- metadata.gz: 625699922d3aa15522193ab57de09fa8ac5c0d13623afd47bfdbda65b2e2b2798d623ea316c0db5570b3e0c4c69de18cb4a47793167bcacf458642c516a2e0af
7
- data.tar.gz: de9860000e204fc1fb05a853c437277bda370033046cc2ff43d954af432b715d39bba09f8a9d085621876f2cf508e6434063ad8ea238e01cde8eea955b7b1f42
6
+ metadata.gz: 4744b858e9faa599b605c0912337dff1480ac092644d9b3b3a9666c4657daaa8e98b5e905529246ac8580c793786f8334cdaf2023d62e8a07e2dd094ab124daf
7
+ data.tar.gz: ef328442115bcbb191992c2279d99accb5a1bd39e3cadc753404a15504549e8943889f0cd6739ad4c265a5465aacb0ee4b5e6127544fe68e688a586466f9b864
@@ -1,5 +1,15 @@
1
1
  # business_calendar changes by version
2
2
 
3
+ 1.1.0
4
+ ---------
5
+
6
+ - Cache parsed responses from API endpoints for TTL (Time to Live) duration.
7
+ - Increase default TTL duration from 5 minutes to 1 day.
8
+ Holidays are not expected to frequently change.
9
+ - Allow disabling cache clearing by setting `ttl` to `false`.
10
+ - Set hard limit to size of memoized holidays cache,
11
+ since large quantities of user supplied dates could consume excessive memory / cause DoS.
12
+
3
13
  1.0.0
4
14
  ---------
5
15
 
@@ -18,8 +18,7 @@ module BusinessCalendar
18
18
  end
19
19
 
20
20
  def for_endpoint(additions, removals, options = {})
21
- ttl = options["ttl"] || 300
22
- Calendar.new(holiday_determiner_for_endpoint(additions, removals, options), options.merge({"ttl" => ttl}))
21
+ Calendar.new(holiday_determiner_for_endpoint(additions, removals, options), options)
23
22
  end
24
23
 
25
24
  private
@@ -46,6 +45,10 @@ module BusinessCalendar
46
45
  :additions_only => cfg['additions_only'] )
47
46
  end
48
47
 
48
+ def holiday_dates_for_endpoint(client, endpoint)
49
+ Proc.new { JSON.parse(client.get(endpoint).body).fetch('holidays').map { |s| Date.parse s } }
50
+ end
51
+
49
52
  def holiday_determiner_for_endpoint(additions_endpoint, removals_endpoint, opts)
50
53
  client = Faraday.new do |conn|
51
54
  conn.response :selective_errors
@@ -53,11 +56,11 @@ module BusinessCalendar
53
56
  end
54
57
 
55
58
  additions = if additions_endpoint
56
- Proc.new { JSON.parse(client.get(additions_endpoint).body).fetch('holidays').map { |s| Date.parse s } }
59
+ holiday_dates_for_endpoint(client, additions_endpoint)
57
60
  end
58
61
 
59
62
  removals = if removals_endpoint
60
- Proc.new { JSON.parse(client.get(removals_endpoint).body).fetch('holidays').map { |s| Date.parse s } }
63
+ holiday_dates_for_endpoint(client, removals_endpoint)
61
64
  end
62
65
 
63
66
  HolidayDeterminer.new(
@@ -65,7 +68,8 @@ module BusinessCalendar
65
68
  opts["holiday_names"] || [],
66
69
  :additions => additions,
67
70
  :removals => removals,
68
- :additions_only => opts["additions_only"] || []
71
+ :additions_only => opts["additions_only"] || [],
72
+ :ttl => opts['ttl']
69
73
  )
70
74
  end
71
75
 
@@ -1,9 +1,12 @@
1
1
  class BusinessCalendar::Calendar
2
+ DEFAULT_TIME_TO_LIVE = 24 * 60 * 60
2
3
  attr_reader :holiday_determiner
3
4
 
4
5
  # @param [Proc[Date -> Boolean]] a proc which returns whether or not a date is a
5
6
  # holiday.
6
7
  def initialize(holiday_determiner, options = {})
8
+ ttl = options['ttl']
9
+ @time_to_live = ttl.nil? ? DEFAULT_TIME_TO_LIVE : ttl
7
10
  @options = options
8
11
  @holiday_cache = {}
9
12
  @holiday_determiner = holiday_determiner
@@ -14,7 +17,7 @@ class BusinessCalendar::Calendar
14
17
  def is_holiday?(date)
15
18
  date = date.send(:to_date) if date.respond_to?(:to_date, true)
16
19
 
17
- clear_cache if @options["ttl"]
20
+ clear_cache if should_clear_cache?
18
21
 
19
22
  @holiday_cache[date] ||= holiday_determiner.call(date)
20
23
  end
@@ -22,7 +25,7 @@ class BusinessCalendar::Calendar
22
25
  # @param [Date] date
23
26
  # @return [Boolean] Whether or not banking can be done on <date>.
24
27
  def is_business_day?(date)
25
- return false if !@options["business_weekends"] && (date.saturday? || date.sunday?)
28
+ return false if !@options['business_weekends'] && (date.saturday? || date.sunday?)
26
29
  return false if is_holiday?(date)
27
30
  true
28
31
  end
@@ -108,6 +111,19 @@ class BusinessCalendar::Calendar
108
111
  end
109
112
 
110
113
  private
114
+
115
+ def should_clear_cache?
116
+ return false unless @time_to_live
117
+
118
+ # limit size using a heuristic, to prevent cache growing arbitrarily large
119
+ !@last_cleared || (Time.now - @last_cleared) >= @time_to_live || @holiday_cache.size > 365 * 3
120
+ end
121
+
122
+ def clear_cache
123
+ @last_cleared = Time.now
124
+ @holiday_cache = {}
125
+ end
126
+
111
127
  def with_one_or_many(thing_or_things)
112
128
  if thing_or_things.is_a? Enumerable
113
129
  thing_or_things.collect do |thing|
@@ -117,11 +133,4 @@ class BusinessCalendar::Calendar
117
133
  yield thing_or_things
118
134
  end
119
135
  end
120
-
121
- def clear_cache
122
- if !@issued_at || (Time.now - @issued_at) >= @options["ttl"]
123
- @issued_at = Time.now
124
- @holiday_cache = {}
125
- end
126
- end
127
136
  end
@@ -1,9 +1,12 @@
1
1
  require 'holidays'
2
2
 
3
3
  class BusinessCalendar::HolidayDeterminer
4
+ DEFAULT_TIME_TO_LIVE = 24 * 60 * 60
4
5
  attr_reader :regions, :holiday_names, :additions, :removals, :additions_only
5
6
 
6
7
  def initialize(regions, holiday_names, opts = {})
8
+ ttl = opts[:ttl]
9
+ @time_to_live = ttl.nil? ? DEFAULT_TIME_TO_LIVE : ttl
7
10
  @regions = regions
8
11
  @holiday_names = holiday_names
9
12
  @additions = opts[:additions] || []
@@ -12,6 +15,8 @@ class BusinessCalendar::HolidayDeterminer
12
15
  end
13
16
 
14
17
  def call(date)
18
+ clear_cache if should_clear_cache?
19
+
15
20
  if additions.include? date
16
21
  true
17
22
  elsif removals.include? date
@@ -23,11 +28,24 @@ class BusinessCalendar::HolidayDeterminer
23
28
  end
24
29
 
25
30
  private
31
+
32
+ def should_clear_cache?
33
+ return false unless @time_to_live
34
+
35
+ !@last_cleared || (Time.now - @last_cleared) >= @time_to_live
36
+ end
37
+
38
+ def clear_cache
39
+ @last_cleared = Time.now
40
+ @additions_cache = nil
41
+ @removals_cache = nil
42
+ end
43
+
26
44
  def additions
27
- @additions.is_a?(Proc) ? @additions.call : @additions
45
+ @additions_cache ||= @additions.is_a?(Proc) ? @additions.call : @additions
28
46
  end
29
47
 
30
48
  def removals
31
- @removals.is_a?(Proc) ? @removals.call : @removals
49
+ @removals_cache ||= @removals.is_a?(Proc) ? @removals.call : @removals
32
50
  end
33
51
  end
@@ -1,3 +1,3 @@
1
1
  module BusinessCalendar
2
- VERSION = "1.0.0"
2
+ VERSION = "1.1.0"
3
3
  end
@@ -2,7 +2,7 @@ require 'spec_helper'
2
2
 
3
3
  describe BusinessCalendar::HolidayDeterminer do
4
4
  let(:regions) { [ :us ] }
5
- let(:opts) {{}}
5
+ let(:opts) { {} }
6
6
  subject { BusinessCalendar::HolidayDeterminer.new(regions, ["Independence Day"], opts) }
7
7
 
8
8
  it "initializes with list of regions and a list of accepted holidays" do
@@ -191,31 +191,175 @@ describe BusinessCalendar do
191
191
  it_behaves_like "weekends as business days"
192
192
  end
193
193
 
194
- it 'hits the configured endpoint for each call to an addition or removal' do
194
+ it 'hits the configured endpoint and then reuses the cached result' do
195
+ subject.is_business_day?('2014-07-03'.to_date)
195
196
  subject.is_business_day?('2014-07-03'.to_date)
196
197
  subject.is_business_day?('2014-07-04'.to_date)
197
198
  subject.is_holiday?('2014-07-06'.to_date)
199
+ subject.is_holiday?('2014-07-06'.to_date)
198
200
  subject.is_holiday?('2014-12-24'.to_date)
199
201
 
200
- expect(a_request(:get, additions)).to have_been_made.times(4)
201
- expect(a_request(:get, removals)).to have_been_made.times(3)
202
+ expect(a_request(:get, additions)).to have_been_made.times(1)
203
+ expect(a_request(:get, removals)).to have_been_made.times(1)
202
204
  end
203
205
 
204
- it 'caches holidays for 5 min' do
205
- start = Time.now
206
+ context 'after 24 hours without specifying a Time to Live override' do
207
+ subject { BusinessCalendar.for_endpoint(additions, removals) }
208
+ let!(:start) { Time.now }
209
+ let!(:one_day) { 86400 }
206
210
 
207
- allow(Time).to receive(:now) { start }
211
+ it 'expires the holidays cache' do
212
+ allow(Time).to receive(:now) { start }
208
213
 
209
- subject.is_business_day?('2014-07-04'.to_date)
210
- subject.is_business_day?('2014-07-04'.to_date)
214
+ subject.is_business_day?('2014-01-01'.to_date)
211
215
 
212
- expect(a_request(:get, additions)).to have_been_made.times(1)
216
+ # initial request was made
217
+ expect(a_request(:get, additions)).to have_been_made.times(1)
213
218
 
214
- allow(Time).to receive(:now) { start + 301 }
219
+ subject.is_business_day?('2014-07-04'.to_date)
220
+ subject.is_business_day?('2014-11-28'.to_date)
215
221
 
216
- subject.is_business_day?('2014-07-04'.to_date)
222
+ # cache from initial request was still used
223
+ expect(a_request(:get, additions)).to have_been_made.times(1)
224
+
225
+ # 24 hours + 1 second have passed
226
+ # cache should be cleared and fresh API request made
227
+ allow(Time).to receive(:now) { start + one_day + 1 }
228
+
229
+ subject.is_business_day?('2014-01-01'.to_date)
230
+
231
+ # 2nd request was made
232
+ expect(a_request(:get, additions)).to have_been_made.times(2)
233
+
234
+ subject.is_business_day?('2014-07-04'.to_date)
235
+ subject.is_business_day?('2014-11-28'.to_date)
236
+
237
+ # 2nd request is now cached, so no new request should have been issued
238
+ expect(a_request(:get, additions)).to have_been_made.times(2)
239
+ end
240
+ end
241
+
242
+ context 'turning off Time to Live functionality' do
243
+ subject { BusinessCalendar.for_endpoint(additions, removals, {'ttl' => false}) }
244
+ let!(:start) { Time.now }
245
+ let!(:one_day) { 86400 }
246
+
247
+ it 'will never clear the cache' do
248
+ allow(Time).to receive(:now) { start }
249
+
250
+ subject.is_business_day?('2014-01-01'.to_date)
251
+ expect(a_request(:get, additions)).to have_been_made.times(1)
252
+
253
+ allow(Time).to receive(:now) { start + 301 }
254
+ subject.is_business_day?('2014-01-01'.to_date)
255
+ subject.is_business_day?('2014-07-04'.to_date)
256
+ subject.is_business_day?('2014-07-04'.to_date)
257
+ subject.is_business_day?('2014-11-28'.to_date)
258
+ expect(a_request(:get, additions)).to have_been_made.times(1)
259
+
260
+ allow(Time).to receive(:now) { start + one_day + 1 }
261
+ subject.is_business_day?('2014-01-01'.to_date)
262
+ subject.is_business_day?('2014-07-04'.to_date)
263
+ subject.is_business_day?('2014-11-28'.to_date)
264
+ expect(a_request(:get, additions)).to have_been_made.times(1)
265
+ end
266
+ end
267
+
268
+ context 'setting Time to Live override to zero' do
269
+ subject { BusinessCalendar.for_endpoint(additions, removals, {'ttl' => 0}) }
270
+ let!(:start) { Time.now }
271
+
272
+ it 'will always clear the cache' do
273
+ allow(Time).to receive(:now) { start }
274
+
275
+ subject.is_holiday?('2014-01-01'.to_date)
276
+ expect(a_request(:get, additions)).to have_been_made.times(1)
277
+
278
+ allow(Time).to receive(:now) { start + 301 }
279
+
280
+ subject.is_holiday?('2014-01-01'.to_date)
281
+ subject.is_holiday?('2014-07-04'.to_date)
282
+ subject.is_holiday?('2014-07-04'.to_date)
283
+ subject.is_holiday?('2014-11-28'.to_date)
284
+
285
+ expect(a_request(:get, additions)).to have_been_made.times(5)
286
+
287
+ subject.is_holiday?('2014-01-01'.to_date)
288
+ subject.is_holiday?('2014-01-01'.to_date)
289
+ subject.is_holiday?('2014-07-04'.to_date)
290
+ subject.is_holiday?('2014-11-28'.to_date)
291
+ expect(a_request(:get, additions)).to have_been_made.times(9)
292
+ end
293
+ end
217
294
 
218
- expect(a_request(:get, additions)).to have_been_made.times(2)
295
+ context 'using a 5 minute Time to Live override' do
296
+ subject { BusinessCalendar.for_endpoint(additions, removals, {'ttl' => 300}) }
297
+ let!(:start) { Time.now }
298
+
299
+ it 'expires the holidays cache after the specified time has elapsed' do
300
+ allow(Time).to receive(:now) { start }
301
+
302
+ # initial request made and cached
303
+ subject.is_business_day?('2014-01-01'.to_date)
304
+ subject.is_business_day?('2014-07-04'.to_date)
305
+ subject.is_business_day?('2014-11-28'.to_date)
306
+ expect(a_request(:get, additions)).to have_been_made.times(1)
307
+
308
+ # 120 seconds pass, cache should still be used
309
+ allow(Time).to receive(:now) { start + 120 }
310
+ subject.is_business_day?('2014-01-01'.to_date)
311
+ subject.is_business_day?('2014-07-04'.to_date)
312
+ subject.is_business_day?('2014-11-28'.to_date)
313
+ expect(a_request(:get, additions)).to have_been_made.times(1)
314
+
315
+ # 301 seconds pass, cache is expired and 2nd request made
316
+ allow(Time).to receive(:now) { start + 301 }
317
+ subject.is_business_day?('2014-01-01'.to_date)
318
+ subject.is_business_day?('2014-07-04'.to_date)
319
+ subject.is_business_day?('2014-11-28'.to_date)
320
+ expect(a_request(:get, additions)).to have_been_made.times(2)
321
+
322
+ # 2nd request is now cached, so no new request should have been issued
323
+ subject.is_business_day?('2014-01-01'.to_date)
324
+ subject.is_business_day?('2014-07-04'.to_date)
325
+ subject.is_business_day?('2014-11-28'.to_date)
326
+ expect(a_request(:get, additions)).to have_been_made.times(2)
327
+ end
328
+ end
329
+
330
+ context 'with a few years in dates filling the cache' do
331
+ let!(:start) { Time.now }
332
+
333
+ # NOTE: this test cheats a bit to test class internals / implementation
334
+ it 'will clear out the holiday cache but keep the cached API result' do
335
+ allow(Time).to receive(:now) { start }
336
+ subject.is_business_day?('2014-07-04'.to_date)
337
+ expect(a_request(:get, additions)).to have_been_made.times(1)
338
+
339
+ expect(a_request(:get, additions)).to have_been_made.times(1)
340
+ (2014..2017).each do |year|
341
+ (1..12).each do |month|
342
+ (1..28).each do |day|
343
+ subject.is_business_day?("#{year}-#{month}-#{day}".to_date)
344
+ end
345
+ end
346
+ end
347
+
348
+ # about to reach cache size threshold
349
+ expect(subject.instance_variable_get('@holiday_cache').size).to be(960)
350
+
351
+ (1..7).each do |month|
352
+ (1..28).each do |day|
353
+ subject.is_business_day?("#2018-#{month}-#{day}".to_date)
354
+ end
355
+ end
356
+
357
+ # now holiday cache will have been cleared and should be small again
358
+ expect(subject.instance_variable_get('@holiday_cache').size).to be(4)
359
+
360
+ # and cached API response was still used
361
+ expect(a_request(:get, additions)).to have_been_made.times(1)
362
+ end
219
363
  end
220
364
 
221
365
  context 'http request fails' do
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: business_calendar
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert Nubel
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-11-12 00:00:00.000000000 Z
11
+ date: 2019-04-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: holidays
@@ -192,8 +192,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
192
192
  - !ruby/object:Gem::Version
193
193
  version: '0'
194
194
  requirements: []
195
- rubyforge_project:
196
- rubygems_version: 2.7.8
195
+ rubygems_version: 3.0.3
197
196
  signing_key:
198
197
  specification_version: 4
199
198
  summary: Country-aware business-date logic and handling.