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.
- checksums.yaml +7 -0
- data/.gitignore +19 -0
- data/Gemfile +4 -0
- data/LICENSE +22 -0
- data/README.md +36 -0
- data/Rakefile +1 -0
- data/example/lib/my_api.rb +12 -0
- data/example/lib/my_api/api_config.rb +33 -0
- data/example/lib/my_api/customer.rb +8 -0
- data/example/lib/my_api/rails/log_subscriber.rb +10 -0
- data/example/lib/my_api/rails/railtie.rb +13 -0
- data/example/lib/my_api/version.rb +3 -0
- data/lib/ncore.rb +14 -0
- data/lib/ncore/associations.rb +81 -0
- data/lib/ncore/attributes.rb +157 -0
- data/lib/ncore/base.rb +35 -0
- data/lib/ncore/builder.rb +34 -0
- data/lib/ncore/client.rb +301 -0
- data/lib/ncore/collection.rb +11 -0
- data/lib/ncore/configuration.rb +56 -0
- data/lib/ncore/exceptions.rb +45 -0
- data/lib/ncore/identity.rb +26 -0
- data/lib/ncore/lifecycle.rb +36 -0
- data/lib/ncore/methods/all.rb +30 -0
- data/lib/ncore/methods/build.rb +16 -0
- data/lib/ncore/methods/count.rb +13 -0
- data/lib/ncore/methods/create.rb +33 -0
- data/lib/ncore/methods/delete.rb +19 -0
- data/lib/ncore/methods/delete_single.rb +19 -0
- data/lib/ncore/methods/find.rb +26 -0
- data/lib/ncore/methods/find_single.rb +34 -0
- data/lib/ncore/methods/update.rb +29 -0
- data/lib/ncore/rails/active_model.rb +44 -0
- data/lib/ncore/rails/log_subscriber.rb +116 -0
- data/lib/ncore/singleton_base.rb +33 -0
- data/lib/ncore/ssl/ca-certificates.crt +4027 -0
- data/lib/ncore/util.rb +61 -0
- data/lib/ncore/version.rb +3 -0
- data/ncore.gemspec +27 -0
- metadata +158 -0
@@ -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
|
data/lib/ncore/client.rb
ADDED
@@ -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,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
|