ncore 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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