ncore 1.0.0

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.
@@ -0,0 +1,34 @@
1
+ module NCore
2
+ module Builder
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ class_eval <<-INCL, __FILE__, __LINE__+1
7
+ include NCore::Exceptions
8
+
9
+ module Api
10
+ include NCore::Configuration
11
+ end
12
+
13
+ class Resource
14
+ extend Api
15
+ include NCore::Base
16
+ end
17
+ class SingletonResource
18
+ extend Api
19
+ include NCore::SingletonBase
20
+ end
21
+
22
+ class GenericObject < Resource
23
+ end
24
+
25
+ class << self
26
+ def configure(&block)
27
+ Api.instance_eval &block
28
+ end
29
+ end
30
+ INCL
31
+ end
32
+
33
+ end
34
+ end
@@ -0,0 +1,301 @@
1
+ module NCore
2
+ module Client
3
+ extend ActiveSupport::Concern
4
+
5
+ module ClassMethods
6
+
7
+ def request(method, url, request_credentials, params={}, headers={})
8
+ request_credentials ||= retrieve_credentials
9
+ request_credentials = parse_credentials(request_credentials)
10
+
11
+ base_url = request_credentials[:url] || retrieve_default_url
12
+ base_url << '/' unless base_url.ends_with?('/')
13
+ url = base_url + url
14
+
15
+ headers = build_headers(headers, request_credentials.except(:url))
16
+
17
+ path = URI.parse(url).path
18
+ if [:get, :head, :delete].include? method
19
+ qs = build_query_string params
20
+ url += qs
21
+ path += qs
22
+ payload = nil
23
+ else
24
+ payload = MultiJson.encode params
25
+ end
26
+
27
+ rest_opts = {
28
+ body: payload,
29
+ # connect_timeout: 10,
30
+ headers: headers,
31
+ method: method,
32
+ path: path,
33
+ read_timeout: 50,
34
+ url: url,
35
+ write_timeout: 50,
36
+ }
37
+
38
+ response = execute_request(rest_opts)
39
+ parsed = parse_response(response)
40
+ [parsed, request_credentials]
41
+ end
42
+
43
+
44
+ private
45
+
46
+ def retrieve_credentials
47
+ if credentials.blank?
48
+ raise parent::Error, credentials_error_message
49
+ end
50
+ credentials
51
+ end
52
+
53
+ def parse_credentials(creds)
54
+ creds.with_indifferent_access
55
+ end
56
+
57
+ def retrieve_default_url
58
+ if default_url.blank?
59
+ raise parent::Error, credentials_error_message
60
+ end
61
+ default_url
62
+ end
63
+
64
+ def build_headers(headers, req_credentials)
65
+ h = {}
66
+ [default_headers, auth_headers(req_credentials), headers].each do |set|
67
+ set.each do |k,v|
68
+ k = k.to_s.titlecase.gsub(/ /,'-')
69
+ h[k] = v
70
+ end
71
+ end
72
+ h
73
+ end
74
+
75
+ def build_query_string(params)
76
+ if params.any?
77
+ query_string = params.map do |k,v|
78
+ if v.is_a?(Array)
79
+ if v.empty?
80
+ "#{k.to_s}[]="
81
+ else
82
+ v.map do |v2|
83
+ "#{k.to_s}[]=#{CGI::escape(v2.to_s)}"
84
+ end.join('&')
85
+ end
86
+ else
87
+ "#{k.to_s}=#{CGI::escape(v.to_s)}"
88
+ end
89
+ end.join('&')
90
+ "?#{query_string}"
91
+ else
92
+ ''
93
+ end
94
+ end
95
+
96
+
97
+ def host_for_error(uri)
98
+ u = URI.parse uri
99
+ "#{u.host}:#{u.port}"
100
+ rescue
101
+ uri
102
+ end
103
+
104
+
105
+ # create a new excon client if necessary
106
+ # keeps a pool of the last 10 urls
107
+ # in almost all cases this will be more than enough
108
+ def excon_client(uri)
109
+ u = URI.parse uri
110
+ u.path = ''
111
+ u.user = u.password = u.query = nil
112
+ addr = u.to_s
113
+
114
+ if cl = pool[addr]
115
+ unless pool.keys.last == addr
116
+ pool.delete addr
117
+ pool[addr] = cl # move it to the end
118
+ end
119
+ cl
120
+ else
121
+ if pool.size >= 10 # keep a max of ten at once
122
+ to_close = pool.delete pool.keys.first
123
+ to_close.reset
124
+ end
125
+ ex_opts = {
126
+ connect_timeout: 10,
127
+ persistent: true
128
+ }
129
+ if verify_ssl_cert?
130
+ ex_opts.merge!(
131
+ ssl_ca_file: ssl_cert_bundle,
132
+ ssl_verify_peer: OpenSSL::SSL::VERIFY_PEER
133
+ )
134
+ else
135
+ ex_opts.merge! ssl_verify_peer: false
136
+ end
137
+ pool[addr] = Excon.new(addr, ex_opts)
138
+ end
139
+ end
140
+
141
+ def pool
142
+ Thread.current[:ncore_pool] ||= {}
143
+ end
144
+
145
+ def reset_pool
146
+ pool.each do |addr, cl|
147
+ cl.reset
148
+ end
149
+ pool.clear
150
+ end
151
+
152
+
153
+ def execute_request(rest_opts)
154
+ debug_request rest_opts if debug
155
+
156
+ tries = 0
157
+ response = nil
158
+ begin
159
+ ActiveSupport::Notifications.instrument(instrument_key, rest_opts) do
160
+
161
+ connection = excon_client(rest_opts[:url])
162
+ begin
163
+ tries += 1
164
+ response = connection.request rest_opts.except(:url)
165
+ rescue Excon::Errors::SocketError, Errno::EADDRNOTAVAIL => e
166
+ # retry when keepalive was closed
167
+ if tries <= 1 #&& e.message =~ /end of file reached/
168
+ retry
169
+ else
170
+ raise e
171
+ end
172
+ end
173
+ rest_opts[:status] = response.status rescue nil
174
+ debug_response response if debug
175
+ end
176
+ rescue Errno::ECONNRESET
177
+ raise parent::ConnectionError, "Connection reset for #{host_for_error rest_opts[:url]} : check network or visit #{status_page}."
178
+ rescue Errno::ECONNREFUSED
179
+ raise parent::ConnectionError, "Connection error for #{host_for_error rest_opts[:url]} : check network and DNS or visit #{status_page}."
180
+ rescue Excon::Errors::SocketError => e
181
+ if e.message =~ /Unable to verify certificate/
182
+ raise parent::ConnectionError, "Unable to verify certificate for #{host_for_error rest_opts[:url]} : verify URL or disable SSL certificate verification (insecure)."
183
+ elsif e.message =~ /Name or service not known/
184
+ raise parent::ConnectionError, "DNS error for #{host_for_error rest_opts[:url]} : check network and DNS or visit #{status_page}."
185
+ else
186
+ raise e
187
+ end
188
+ rescue SocketError => e
189
+ if e.message =~ /nodename nor servname provided/
190
+ raise parent::ConnectionError, "DNS error for #{host_for_error rest_opts[:url]} : check network and DNS or visit #{status_page}."
191
+ else
192
+ raise e
193
+ end
194
+ end
195
+
196
+ case response.status
197
+ when 401 # API auth valid; API call itself is an auth-related call and failed
198
+ raise parent::AuthenticationFailed
199
+ when 403 # API auth failed or insufficient permissions
200
+ raise parent::AccessDenied, "Access denied; check your API credentials and permissions."
201
+ when 404
202
+ raise parent::RecordNotFound
203
+ when 422
204
+ # pass through
205
+ when 429
206
+ raise parent::RateLimited
207
+ when 400..499
208
+ raise parent::Error, "Client error: #{response.status}\n #{response.body}"
209
+ when 500..599
210
+ raise parent::Error, "Server error: #{response.status}\n #{response.body}"
211
+ end
212
+ response
213
+ end
214
+
215
+ def parse_response(response)
216
+ if [202, 204].include?(response.status) && response.body.blank?
217
+ return {data: {}, errors: {}, metadata: {}}
218
+ end
219
+
220
+ begin
221
+ json = MultiJson.load(response.body||'', symbolize_keys: false) || {}
222
+ json = json.with_indifferent_access
223
+ rescue MultiJson::DecodeError, MultiJson::LoadError
224
+ raise parent::Error, "Unable to parse API response; HTTP status: #{response.status}; body: #{response.body.inspect}"
225
+ end
226
+ errors = json.delete(:errors) || []
227
+ if errors.any?
228
+ errors = errors.values.flatten
229
+ metadata, json = json, {}
230
+ else
231
+ if json[:collection]
232
+ data = json.delete :collection
233
+ metadata, json = json, data
234
+ json = [] if json.blank?
235
+ else
236
+ metadata = nil
237
+ end
238
+ end
239
+ {data: json, errors: errors, metadata: metadata}
240
+ end
241
+
242
+
243
+ def auth_headers(creds)
244
+ creds.inject({}) do |h,(k,v)|
245
+ if v.present?
246
+ h["#{auth_header_prefix}-#{k}"] = v
247
+ end
248
+ h
249
+ end
250
+ end
251
+
252
+ def verify_ssl_cert?
253
+ return @verify_ssl_cert unless @verify_ssl_cert.nil?
254
+ bundle_readable = File.readable?(ssl_cert_bundle)
255
+ if verify_ssl && bundle_readable
256
+ @verify_ssl_cert = true
257
+ else
258
+ m = 'WARNNG: SSL cert verification is disabled.'
259
+ unless verify_ssl
260
+ m += " Enable verification with: #{parent}::Api.verify_ssl = true."
261
+ end
262
+ unless bundle_readable
263
+ m += " Unable to read CA bundle #{ssl_cert_bundle}."
264
+ end
265
+ $stderr.puts m
266
+ @verify_ssl_cert = false
267
+ end
268
+ end
269
+
270
+ def debug_request(rest_opts)
271
+ return unless logger.debug?
272
+ logger << <<-DBG
273
+ #{'-=- '*18}
274
+ REQUEST:
275
+ #{rest_opts[:method].to_s.upcase} #{rest_opts[:url]}
276
+ #{rest_opts[:headers].map{|h,d| "#{h}: #{d}"}.join("\n ")}
277
+ #{rest_opts[:body] || 'nil'}
278
+ DBG
279
+ end
280
+
281
+ def debug_response(response)
282
+ return unless logger.debug?
283
+ json = MultiJson.load(response.body||'', symbolize_keys: false) rescue response.body
284
+ logger << <<-DBG
285
+ RESPONSE:
286
+ #{response.headers['Status']} | #{response.headers['Content-Type']} | #{response.body.size} bytes
287
+ #{response.headers.except('Status', 'Connection', 'Content-Type', 'X-Request-Id').map{|h,d| "#{h}: #{d}"}.join("\n ")}
288
+ #{json.pretty_inspect.split("\n").join("\n ")}
289
+ #{'-=- '*18}
290
+ DBG
291
+ end
292
+
293
+ end # ClassMethods
294
+
295
+
296
+ def request(*args)
297
+ self.class.request(*args)
298
+ end
299
+
300
+ end
301
+ end
@@ -0,0 +1,11 @@
1
+ module NCore
2
+ class Collection < Array
3
+
4
+ attr_accessor :metadata
5
+
6
+ def more_results?
7
+ metadata[:more_results]
8
+ end
9
+
10
+ end
11
+ end
@@ -0,0 +1,56 @@
1
+ module NCore
2
+ module Configuration
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ init_config_options
7
+ end
8
+
9
+
10
+ module ClassMethods
11
+
12
+ def init_config_options
13
+ mattr_accessor :default_url
14
+ self.default_url = 'https://api.example.com/v1/'
15
+
16
+ mattr_accessor :default_headers
17
+ self.default_headers = {
18
+ accept: 'application/json',
19
+ content_type: 'application/json',
20
+ user_agent: "NCore/ruby v#{VERSION}"
21
+ }
22
+
23
+ mattr_accessor :credentials
24
+
25
+ mattr_accessor :debug
26
+ self.debug = false
27
+
28
+ mattr_accessor :strict_attributes
29
+ self.strict_attributes = true
30
+
31
+ mattr_accessor :instrument_key
32
+ self.instrument_key = 'request.ncore'
33
+
34
+ mattr_accessor :status_page
35
+ self.status_page = 'the status page'
36
+
37
+ mattr_accessor :auth_header_prefix
38
+ self.auth_header_prefix = 'X-Api'
39
+
40
+ mattr_accessor :credentials_error_message
41
+ self.credentials_error_message = %Q{Missing API credentials. Set default credentials using "#{self.parent.name}.credentials = {api_user: YOUR_API_USER, api_key: YOUR_API_KEY}"}
42
+
43
+ mattr_accessor :verify_ssl
44
+ self.verify_ssl = true
45
+
46
+ mattr_accessor :ssl_cert_bundle
47
+ self.ssl_cert_bundle = File.dirname(__FILE__)+'/ssl/ca-certificates.crt'
48
+
49
+ mattr_accessor :logger
50
+ self.logger = Logger.new(STDOUT)
51
+ end
52
+
53
+ end
54
+
55
+ end
56
+ end
@@ -0,0 +1,45 @@
1
+ module NCore
2
+ module Exceptions
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ class_eval <<-INCL, __FILE__, __LINE__+1
7
+ class Error < StandardError ; end
8
+
9
+ class AccessDenied < Error ; end
10
+ class AuthenticationFailed < Error ; end
11
+ class ConnectionError < Error ; end
12
+ class RateLimited < Error ; end
13
+ class RecordNotFound < Error ; end
14
+ class UnsavedObjectError < Error ; end
15
+
16
+ class RecordInvalid < Error
17
+ attr_reader :object
18
+
19
+ def initialize(object)
20
+ @object = object
21
+ cl_name = object.class_name if object.respond_to?(:class_name)
22
+ cl_name ||= object.class.class_name if object.class.respond_to?(:class_name)
23
+ cl_name ||= object.name if object.respond_to?(:name)
24
+ cl_name ||= object.class.name
25
+ msg = "\#{cl_name} Invalid: \#{@object.errors.to_a.join(' ')}"
26
+ super msg
27
+ end
28
+ end
29
+
30
+ class QueryError < Error
31
+ attr_reader :errors
32
+
33
+ def initialize(errors)
34
+ @errors = errors
35
+ msg = "Error: \#{errors.to_a.join(' ')}"
36
+ super msg
37
+ end
38
+ end
39
+
40
+ class ValidationError < QueryError ; end
41
+ INCL
42
+ end
43
+
44
+ end
45
+ end