api_call_cache 0.0.3

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 397acaee7206b32cca9bbe9a53a27bdd4a5834e4
4
+ data.tar.gz: 7644716942c4850d6d9aaef4f2612ef79c0276da
5
+ SHA512:
6
+ metadata.gz: 4813d89d3022de7a31b9a73f2a32497b46bcf631f3dbb0daa98aba10dfc666e616ea4cafc336aa40c1888a12825fac787f40cf08a8054a998e00de64e61991a0
7
+ data.tar.gz: 5666df2248a6a204c75fea2c81a4510f72a3901d95a685b4ee0ddc5ece4dd5443c1b363150df773c1d138f7d876e5855e08239f82ff06beed62ba466c125a193
@@ -0,0 +1,3 @@
1
+ class ApiCallCache
2
+ VERSION = '0.0.3'
3
+ end
@@ -0,0 +1,281 @@
1
+ require 'logger'
2
+ require 'redis'
3
+ require 'hashie'
4
+ require 'active_support/core_ext/object'
5
+ require 'httpclient'
6
+ require 'rack/oauth2'
7
+
8
+ require 'api_call_cache/version'
9
+
10
+ class ApiCallCache
11
+
12
+ def self.configure
13
+ yield self
14
+ end
15
+
16
+ def self.api_cache_redis_instance(redis_inst)
17
+
18
+ raise ArgumentError, 'requires a redis instance' unless redis_inst.is_a? Redis
19
+ @@redis_inst = redis_inst
20
+ end
21
+
22
+ def self.logger_path(log_file = 'log/api_call_cache.log', rotation = 'monthly')
23
+ @@acc_logger ||= Logger.new(log_file, rotation)
24
+ end
25
+
26
+ def self.base_urls(base_urls_hash)
27
+ @@acc_base_urls = Hashie.symbolize_keys(base_urls_hash)
28
+ end
29
+
30
+ def self.salt(salt_str)
31
+ raise ArgumentError, 'requires a string as salt' if salt_str.empty?
32
+ @@acc_salt = salt_str
33
+ end
34
+
35
+ def self.req_defaults
36
+ @req_opt_defaults ||= {
37
+ base_url_key: '',
38
+ access_token: '', # TODO_FIX -- will need NetHttp etc
39
+ from_cache: true,
40
+ override_cache_expiry: nil, # seconds. Overrides Cache-Control max-age value
41
+ override_params_cache_key: nil, # If provided, this value will be used
42
+ # as the 'params' part of the cache key,
43
+ # instead of manually 'hashed_params'
44
+ #
45
+ # Useful if the api doesn't accept explicit
46
+ # params, but is distinguished solely on the
47
+ # basis of some implicit data like
48
+ # access_token etc
49
+ }
50
+ end
51
+
52
+ def self.get_redis
53
+ @@redis_inst
54
+ end
55
+
56
+ def get_redis
57
+ self.class.get_redis
58
+ end
59
+
60
+ def self.set_tz_offset(offset = '+05:30')
61
+ @@tz_offset = offset
62
+ end
63
+
64
+ ##############################################################################
65
+ # Class methods
66
+ ##############################################################################
67
+ def self.api_get(base_url_key, rel_path, req_params = {}, req_opts = {})
68
+ req_opts = req_opts.merge({base_url_key: base_url_key})
69
+ cached_api_call(:get, rel_path, req_params, req_opts)
70
+ end
71
+
72
+ def self.cached_api_call(req_type, rel_path, req_params = {}, req_opts = {})
73
+ obj = self.new
74
+ obj.cached_api_call_core(req_type, rel_path, req_params, req_opts)
75
+ rescue Exception => exp
76
+ obj.acc_log_entry[:status] = 'exception'
77
+ obj.acc_log_entry[:desc] = exp.message
78
+ raise
79
+ ensure
80
+ @@acc_logger.info obj.acc_log_entry.to_a.flatten.join("\t") if @@acc_logger
81
+ end
82
+
83
+ def self.make_api_call(req_type, url, access_token, body=nil)
84
+ self.new.make_api_call(req_type, url, access_token, body)
85
+ end
86
+
87
+ ##############################################################################
88
+ # Instance methods
89
+ ##############################################################################
90
+ def cached_api_call_core(req_type, rel_path, req_params, req_opts)
91
+
92
+ Hashie.symbolize_keys!(req_opts)
93
+ req_opts = self.class.req_defaults.merge(req_opts)
94
+ req_type = req_type.to_s.downcase.to_sym
95
+
96
+ # Get from cache if required
97
+ cached_body = nil
98
+
99
+ base_url_key = req_opts[:base_url_key].to_s
100
+ raise 'Base URL KEY not found' if base_url_key.empty?
101
+
102
+ acc_log_entry[:api] = [base_url_key.to_s, rel_path.to_s].join('/')
103
+
104
+ base_url = @@acc_base_urls[base_url_key.to_sym].to_s
105
+ raise "Base URL not found for #{base_url_key}" if base_url.empty?
106
+
107
+ cache_key = gen_api_call_cache_key(req_type, base_url_key,
108
+ rel_path, req_params,
109
+ req_opts[:override_params_cache_key])
110
+
111
+ try_from_cache = (req_type == :get) && req_opts[:from_cache]
112
+ write_to_cache = (req_type == :get)
113
+ acc_log_entry[:cache_key] = cache_key
114
+ cache_miss = false
115
+
116
+ if try_from_cache
117
+ cached_info = get_redis.hgetall(cache_key)
118
+ cached_body = cached_info['body']
119
+ cached_status = cached_info['status'].to_i
120
+
121
+ cache_miss = cached_body.nil? # NOTE: 'nil?' used here on purpose instead of 'blank?'
122
+ acc_log_entry[:status] = cache_miss ? 'miss' : 'hit'
123
+
124
+ ttl = get_redis.ttl(cache_key).to_i.seconds
125
+ else
126
+ acc_log_entry[:status] = 'ignore'
127
+ end
128
+
129
+ if cache_miss
130
+
131
+ rel_url = gen_api_call_rel_url(rel_path, req_params)
132
+
133
+ acc_log_entry[:params_f] = req_params.to_s
134
+
135
+ tic = ::Time.now
136
+ api_resp = make_api_call(req_type, "#{base_url}/#{rel_url}",
137
+ req_opts[:access_token])
138
+ toc = ::Time.now
139
+
140
+ acc_log_entry[:resp_time] = ((toc - tic)*1000).round.to_s
141
+ acc_log_entry[:resp_code] = api_resp.status
142
+
143
+ result_body = api_resp.body
144
+ result_status = api_resp.status.to_i
145
+
146
+ if write_to_cache && api_resp.try(:ok?)
147
+ ttl = write_to_api_cache(cache_key, api_resp, result_body, result_status,
148
+ req_opts[:override_cache_expiry])
149
+
150
+ acc_log_entry[:expires_at] = (ttl.to_i.seconds.from_now).to_time.localtime(@@tz_offset).to_s
151
+ end
152
+ else
153
+ # Found in cache. Using it.
154
+ result_body = cached_body
155
+ result_status = cached_status
156
+ acc_log_entry[:expires_at] = (ttl.seconds.from_now).to_time.localtime(@@tz_offset).to_s
157
+ end
158
+
159
+ Hashie::Mash.new(status: result_status,
160
+ body: result_body,
161
+ source: (try_from_cache && !cache_miss) ? :cache : :api_call,
162
+ ok: ::HTTP::Status::SUCCESSFUL_STATUS.include?(result_status))
163
+ end
164
+
165
+ def write_to_api_cache(cache_key, api_resp, cache_body, cache_status, override_cache_expiry)
166
+ expiry = get_cache_expiry(api_resp, override_cache_expiry)
167
+
168
+ acc_log_entry[:write_back] = 'true'
169
+
170
+ if !expiry.zero?
171
+ get_redis.hmset(cache_key, 'body', cache_body,
172
+ 'status', cache_status)
173
+ end
174
+
175
+ get_redis.expire(cache_key, expiry)
176
+ expiry
177
+ end
178
+
179
+ def make_api_call(req_type, url, access_token, body = nil)
180
+ access_token = Rack::OAuth2::AccessToken::Bearer.new(access_token: access_token)
181
+ return access_token.send(req_type, url, body)
182
+ rescue HTTPClient::ReceiveTimeoutError => exp
183
+ return Hashie::Mash.new(status: 408,
184
+ body: {}.to_json,
185
+ ok?: false,
186
+ timeout?: true)
187
+ rescue SocketError => exp
188
+ return Hashie::Mash.new(status: 400,
189
+ body: {error: exp.message}.to_json,
190
+ ok?: false)
191
+ rescue Errno::ECONNREFUSED => exp
192
+ return Hashie::Mash.new(status: 403,
193
+ body: {error: exp.message}.to_json,
194
+ ok?: false)
195
+ end
196
+
197
+ def gen_api_call_rel_url(rel_path, req_params)
198
+ rel_path += '.json'
199
+ req_params.empty? ? rel_path : [rel_path, req_params.to_query].join('?')
200
+ end
201
+
202
+ def gen_api_call_cache_key(_req_type, base_url_key, rel_path, req_params, hashed_params = nil)
203
+
204
+ if hashed_params.nil? || hashed_params.empty?
205
+ # Since params hash key hasn't been overridden using
206
+ plain = req_params.sort.to_h.to_query # sort req_params alphabetically
207
+ hashed_params = OpenSSL::HMAC.hexdigest('sha256', @@acc_salt, plain)
208
+ end
209
+
210
+ acc_log_entry[:params_h] = hashed_params
211
+
212
+ ['api_call_cache', 'api', base_url_key, rel_path, hashed_params].join(':')
213
+ end
214
+
215
+
216
+ def get_cache_expiry(response, user_expiry = nil)
217
+ # Extract server defined TTL
218
+ ext_ttl = nil
219
+ if user_expiry.nil?
220
+ cache_ctrl_hdr = response.headers['Api-Cache-Control'].to_s
221
+ cache_ctrl_hdr = response.headers['Cache-Control'] if cache_ctrl_hdr.empty?
222
+ cache_ctrl_hdr.split(',').map(&:strip).each do |opt|
223
+ opts = opt.split('=')
224
+ next if opts.first != 'max-age'
225
+ ext_ttl = opts.last.to_i
226
+ break
227
+ end
228
+ end
229
+
230
+ expiry = user_expiry || ext_ttl || FALLBACK_TTL
231
+
232
+ acc_log_entry[:user_ttl] = user_expiry.to_s
233
+ acc_log_entry[:src_ttl] = ext_ttl.to_s
234
+
235
+ # Dither cache expiry
236
+ dither_factor = 0.1
237
+
238
+ # final expiry = given expiry + 10% variance <max 2 minutes>
239
+ expiry += rand * ([expiry * dither_factor, 5.minutes].min)
240
+ expiry = expiry.round
241
+
242
+ acc_log_entry[:act_ttl] = expiry.to_s
243
+
244
+ expiry
245
+ end
246
+
247
+ attr_accessor :_acc_log_entry
248
+
249
+ def acc_log_entry
250
+ @_acc_log_entry ||= {
251
+ time: log_time,
252
+ status: '', # hit/miss/exception/ignore
253
+ api: '',
254
+ resp_time: '',
255
+ resp_code: '',
256
+ write_back: 'false',
257
+ user_ttl: '', # Suggested by the internal user for overriding
258
+ src_ttl: '', # Suggested by the external server
259
+ act_ttl: '', # Actual TTL after dithering
260
+ params_h: '', # hashed relative path
261
+ params_f: '', # full form relative path
262
+ cache_key: '',
263
+ desc: '',
264
+ }
265
+ end
266
+
267
+ def log_time
268
+ time = Time.now.localtime(@@tz_offset)
269
+ usec = time.usec.to_s.rjust(6, '0')
270
+ time.strftime "%Y-%m-%d %H:%M:%S.#{usec} %z"
271
+ end
272
+ end
273
+
274
+ ApiCallCache.configure do |config|
275
+ config.base_urls({})
276
+
277
+ config.logger_path nil
278
+
279
+ config.salt 'api-call-cache'
280
+ config.set_tz_offset '+05:30'
281
+ end
metadata ADDED
@@ -0,0 +1,132 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: api_call_cache
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.3
5
+ platform: ruby
6
+ authors:
7
+ - Akshay Rao
8
+ - Rupesh Joshi
9
+ autorequire:
10
+ bindir: exe
11
+ cert_chain: []
12
+ date: 2018-08-01 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: bundler
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '1'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '1'
28
+ - !ruby/object:Gem::Dependency
29
+ name: rack-oauth2
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: '1.2'
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '1.2'
42
+ - !ruby/object:Gem::Dependency
43
+ name: redis
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '3.2'
49
+ type: :runtime
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '3.2'
56
+ - !ruby/object:Gem::Dependency
57
+ name: hashie
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '3.4'
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '3.4'
70
+ - !ruby/object:Gem::Dependency
71
+ name: activesupport
72
+ requirement: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '2.1'
77
+ type: :runtime
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '2.1'
84
+ - !ruby/object:Gem::Dependency
85
+ name: httpclient
86
+ requirement: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '2.7'
91
+ type: :runtime
92
+ prerelease: false
93
+ version_requirements: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '2.7'
98
+ description: Useful for inter-service GET HTTP calls
99
+ email:
100
+ - 14akshayrao@gmail.com
101
+ executables: []
102
+ extensions: []
103
+ extra_rdoc_files: []
104
+ files:
105
+ - lib/api_call_cache.rb
106
+ - lib/api_call_cache/version.rb
107
+ homepage: https://github.com/peerlearning/api-call-cache/
108
+ licenses:
109
+ - MIT
110
+ metadata:
111
+ allowed_push_host: https://rubygems.org
112
+ post_install_message:
113
+ rdoc_options: []
114
+ require_paths:
115
+ - lib
116
+ required_ruby_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: '2.2'
121
+ required_rubygems_version: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ requirements: []
127
+ rubyforge_project:
128
+ rubygems_version: 2.6.14
129
+ signing_key:
130
+ specification_version: 4
131
+ summary: Framework to manage caching of http api calls
132
+ test_files: []