resteze 0.1.0 → 0.3.1

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,25 @@
1
+ module Resteze
2
+ module ApiModule
3
+ extend ActiveSupport::Concern
4
+
5
+ delegate :api_module, :logger, :util, to: :class
6
+
7
+ module ClassMethods
8
+ def api_module
9
+ @api_module ||=
10
+ begin
11
+ parents = name.scan("::").inject([name]) { |mods, _n| mods << mods.last.deconstantize }
12
+ parents.map(&:constantize).detect { |mod| mod == Resteze || mod.include?(Resteze) }
13
+ end
14
+ end
15
+
16
+ def logger
17
+ api_module.logger
18
+ end
19
+
20
+ def util
21
+ api_module::Util
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,70 @@
1
+ module Resteze
2
+ module ApiResource
3
+ extend ActiveSupport::Concern
4
+ include Resteze::Request
5
+
6
+ def initialize(id = nil, values: {})
7
+ super(values)
8
+ id, @retrieve_params = util.normalize_id(id)
9
+ self.id = id if self.class.property?(:id)
10
+ end
11
+
12
+ def resource_path
13
+ unless id.present?
14
+ raise api_module::InvalidRequestError.new("Could not determine which PATH to request: #{self.class} instance has " \
15
+ "invalid ID: #{id.inspect}", "id")
16
+ end
17
+
18
+ self.class.resource_path(id)
19
+ end
20
+
21
+ def retrieve_method
22
+ :get
23
+ end
24
+
25
+ def retrieve_params
26
+ @retrieve_params || {}
27
+ end
28
+
29
+ def retrieve_headers
30
+ {}
31
+ end
32
+
33
+ def refresh
34
+ resp = request(
35
+ retrieve_method,
36
+ resource_path,
37
+ params: retrieve_params,
38
+ headers: retrieve_headers
39
+ )
40
+
41
+ initialize_from(resp.data)
42
+ end
43
+
44
+ module ClassMethods
45
+ def service_path
46
+ api_module.default_service_path(self)
47
+ end
48
+
49
+ def api_version
50
+ api_module.default_api_version(self)
51
+ end
52
+
53
+ def api_path(path)
54
+ [service_path, api_version, path].join("/".freeze).squeeze("/".freeze)
55
+ end
56
+
57
+ def resource_slug
58
+ api_module.default_resource_slug(self)
59
+ end
60
+
61
+ def resource_path(id = nil)
62
+ api_path([resource_slug, id].compact.map { |part| CGI.escape(part.to_s) }.join("/".freeze))
63
+ end
64
+
65
+ def retrieve(id)
66
+ new(id).refresh
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,242 @@
1
+ module Resteze
2
+ class Client
3
+ include Resteze::ApiModule
4
+
5
+ def self.user_agent
6
+ [
7
+ "Ruby/#{RUBY_VERSION}",
8
+ "Faraday/#{Faraday::VERSION} (#{faraday_adapter})",
9
+ "Resteze/#{Resteze::VERSION}",
10
+ "#{api_module}/#{api_module::VERSION}"
11
+ ].join(" ")
12
+ end
13
+
14
+ def self.proxy_options
15
+ return if api_module.try(:proxy).blank?
16
+
17
+ Faraday::ProxyOptions.from(api_module.proxy)
18
+ end
19
+
20
+ def self.client_name
21
+ @client_name ||= to_s.underscore
22
+ end
23
+
24
+ def self.active_client
25
+ Thread.current[client_name] || default_client
26
+ end
27
+
28
+ def self.default_client
29
+ Thread.current["#{client_name}_default_client"] ||= new(default_connection)
30
+ end
31
+
32
+ def self.default_connection
33
+ Thread.current["#{client_name}_default_connection"] ||= Faraday.new do |conn|
34
+ conn.use Faraday::Request::UrlEncoded
35
+ conn.use api_module::Middleware::RaiseError
36
+ conn.adapter Faraday.default_adapter
37
+ end
38
+ end
39
+
40
+ def self.faraday_adapter
41
+ @faraday_adapter ||= default_connection.builder.adapter.name.demodulize.underscore
42
+ end
43
+
44
+ # This can be overriden to customize
45
+ def self.api_url(path = "")
46
+ [api_module.api_base.chomp("/".freeze), path].join
47
+ end
48
+
49
+ attr_accessor :connection
50
+
51
+ delegate :logger,
52
+ :api_url,
53
+ :client_name,
54
+ to: :class
55
+
56
+ def initialize(connection = self.class.default_connection)
57
+ self.connection = connection
58
+ end
59
+
60
+ def request
61
+ @last_response = nil
62
+ old_client = Thread.current[client_name]
63
+ Thread.current[client_name] = self
64
+ begin
65
+ res = yield
66
+ [res, @last_response]
67
+ ensure
68
+ Thread.current[client_name] = old_client
69
+ end
70
+ end
71
+
72
+ # TODO: Look at refactoring this if possible to improve the Abc size
73
+ # rubocop:disable Metrics/AbcSize
74
+ def execute_request(method, path, headers: {}, params: {})
75
+ params = util.objects_to_ids(params)
76
+ body, query_params = process_params(method, params)
77
+ headers = request_headers.merge(util.normalize_headers(headers))
78
+ context = request_log_context(body:, method:, path:, query_params:, headers:)
79
+
80
+ http_resp = execute_request_with_rescues(context) do
81
+ connection.run_request(method, api_url(path), body, headers) do |req|
82
+ req.options.open_timeout = api_module.open_timeout
83
+ req.options.timeout = api_module.read_timeout
84
+ req.options.proxy = self.class.proxy_options
85
+ req.params = query_params unless query_params.nil?
86
+ end
87
+ end
88
+
89
+ api_module::Response.from_faraday_response(http_resp).tap do |response|
90
+ @last_response = response
91
+ end
92
+ end
93
+ # rubocop:enable Metrics/AbcSize
94
+
95
+ protected
96
+
97
+ # Override to customize
98
+ def request_headers
99
+ {
100
+ "User-Agent" => self.class.user_agent,
101
+ "Accept" => "application/json",
102
+ "Content-Type" => "application/json"
103
+ }
104
+ end
105
+
106
+ private
107
+
108
+ def process_params(method, params)
109
+ body = nil
110
+ query_params = nil
111
+ case method.to_s.downcase.to_sym
112
+ when :get, :head, :delete
113
+ query_params = params
114
+ else
115
+ body = params.to_json
116
+ end
117
+ [body, query_params]
118
+ end
119
+
120
+ def params_encoder
121
+ self.class.default_connection.options.params_encoder || Faraday::Utils.default_params_encoder
122
+ end
123
+
124
+ def request_log_context(method: nil, path: nil, query_params: nil, headers: nil, body: nil)
125
+ RequestLogContext.new.tap do |context|
126
+ context.method = method
127
+ context.path = path
128
+ context.query_params = params_encoder.encode(query_params)
129
+ context.headers = headers
130
+ context.body = body
131
+ end
132
+ end
133
+
134
+ def execute_request_with_rescues(context)
135
+ begin
136
+ request_start = Time.now
137
+ log_request(context)
138
+ resp = yield
139
+ context = context.dup_from_response(resp)
140
+ log_response(context, request_start, resp)
141
+ rescue StandardError => e
142
+ execute_request_rescue_log(e, context, request_start)
143
+ raise e
144
+ end
145
+
146
+ resp
147
+ end
148
+
149
+ def execute_request_rescue_log(err, context, request_start)
150
+ if err.respond_to?(:response) && err.response
151
+ error_context = context.dup_from_response(err.response)
152
+ log_response(error_context, request_start, err.response)
153
+ else
154
+ log_response_error(context, request_start, err)
155
+ end
156
+ end
157
+
158
+ def log_request(context)
159
+ logger.info do
160
+ payload = {
161
+ method: context.method,
162
+ path: context.path
163
+ }
164
+ "#{self.class} API Request: #{payload}"
165
+ end
166
+ logger.debug { request_details_in_http_syntax(context) }
167
+ end
168
+
169
+ def log_response(context, request_start, response)
170
+ status = response.respond_to?(:status) ? response.status : response[:status]
171
+ logger.debug { response_details_in_http_syntax(response) }
172
+ logger.info do
173
+ payload = {
174
+ elapsed: Time.now - request_start,
175
+ method: context.method,
176
+ path: context.path,
177
+ status:
178
+ }
179
+ "#{self.class} API Response: #{payload}"
180
+ end
181
+ end
182
+
183
+ def log_response_error(context, request_start, err)
184
+ logger.error do
185
+ payload = {
186
+ elapsed: Time.now - request_start,
187
+ error_message: err.message,
188
+ method: context.method,
189
+ path: context.path
190
+ }
191
+ "#{self.class} Request Error: #{payload}"
192
+ end
193
+ end
194
+
195
+ def request_details_in_http_syntax(context)
196
+ "#{self.class} Full Request:\n\n".tap do |s|
197
+ s << request_in_http_syntax(context)
198
+ s << headers_in_http_syntax(context.headers)
199
+ s << "\n\n#{context.body}\n" if context.body.present?
200
+ end
201
+ end
202
+
203
+ # TODO: Look into refactoring to improve Abc Size
204
+ # rubocop:disable Metrics/AbcSize
205
+ def response_details_in_http_syntax(response)
206
+ status = response.respond_to?(:status) ? response.status : response[:status]
207
+ body = response.respond_to?(:body) ? response.body : response[:body]
208
+ headers = response.respond_to?(:headers) ? response.headers : response[:headers]
209
+ "#{self.class} Full Response:\n\n".tap do |s|
210
+ s << "HTTP/1.1 #{status}\n"
211
+ s << headers_in_http_syntax(headers)
212
+ s << "\n\n"
213
+ s << (body.encoding == Encoding::ASCII_8BIT ? "(Binary Response)" : body)
214
+ s << "\n"
215
+ end
216
+ end
217
+ # rubocop:enable Metrics/AbcSize
218
+
219
+ def request_in_http_syntax(context)
220
+ method = context.method.to_s.upcase
221
+ method.tap do |s|
222
+ s << " "
223
+ s << [self.class.api_url(context.path), context.query_params].select(&:present?).join("?")
224
+ s << "\n"
225
+ end
226
+ end
227
+
228
+ def headers_in_http_syntax(headers)
229
+ headers.map { |k, v| [k, v].join(": ") }.join("\n")
230
+ end
231
+
232
+ class RequestLogContext
233
+ attr_accessor :body, :method, :path, :query_params, :headers
234
+
235
+ def dup_from_response(resp)
236
+ return self if resp.nil?
237
+
238
+ dup
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,38 @@
1
+ module Resteze
2
+ class Error < StandardError
3
+ attr_accessor :response
4
+ attr_reader :message, :code, :http_body, :http_headers, :http_status, :json_body
5
+
6
+ # def initialize(message = nil, http_status: nil, http_body: nil, json_body: nil,
7
+ # http_headers: nil, code: nil)
8
+ def initialize(message = nil, **kwargs)
9
+ super(**kwargs)
10
+ @message = message
11
+ @http_status = kwargs[:http_status]
12
+ @http_body = kwargs[:http_body]
13
+ @http_headers = kwargs.fetch(:http_headers, {})
14
+ @json_body = kwargs[:json_body]
15
+ @code = kwargs[:code]
16
+ end
17
+
18
+ def to_s
19
+ status = "HTTP #{@http_status}:" if @http_status.present?
20
+ [status, message].compact.join(" ")
21
+ end
22
+ end
23
+
24
+ class InvalidRequestError < Error
25
+ attr_accessor :param
26
+
27
+ def initialize(message, param, **keyword_args)
28
+ super(message, **keyword_args)
29
+ @param = param
30
+ end
31
+ end
32
+
33
+ class AuthenticationError < Error; end
34
+
35
+ class ApiConnectionError < Error; end
36
+
37
+ class ApiError < Error; end
38
+ end
@@ -0,0 +1,43 @@
1
+ module Resteze
2
+ module List
3
+ extend ActiveSupport::Concern
4
+ include ApiModule
5
+
6
+ module ClassMethods
7
+ def list(params: {})
8
+ resp = request(list_method, list_resource_path(params), params: list_params(params))
9
+ construct_list_from(resp.data)
10
+ end
11
+
12
+ def construct_list_from(payload)
13
+ values = payload.deep_symbolize_keys
14
+ api_module::ListObject.construct_from(values, self)
15
+ end
16
+
17
+ def construct_empty_list
18
+ payload = { list_key => [] }
19
+ construct_list_from(payload)
20
+ end
21
+
22
+ def list_params(params = {})
23
+ params
24
+ end
25
+
26
+ def list_headers(_params = {})
27
+ {}
28
+ end
29
+
30
+ def list_resource_path(_params = {})
31
+ resource_path
32
+ end
33
+
34
+ def list_key
35
+ api_module.default_list_key(self)
36
+ end
37
+
38
+ def list_method
39
+ :get
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,40 @@
1
+ module Resteze
2
+ module ListObject
3
+ extend ActiveSupport::Concern
4
+
5
+ def metadata
6
+ @metadata ||= {}
7
+ end
8
+
9
+ def initialize_from(values, metadata: {})
10
+ @metadata = metadata
11
+ values.each { |v| self << v }
12
+ self
13
+ end
14
+
15
+ def as_list_json(list_key)
16
+ metadata.as_json.merge({ list_key => as_json })
17
+ end
18
+
19
+ module ClassMethods
20
+ def object_key
21
+ :_list
22
+ end
23
+
24
+ def construct_from(payload, klass)
25
+ list_key = klass.list_key
26
+ payload = payload.deep_symbolize_keys
27
+ values = util.convert_to_object(payload[list_key], klass)
28
+ metadata = payload.except(list_key)
29
+
30
+ new.initialize_from(values, metadata:)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def populate_metadata(values)
37
+ @metadata = metadata.merge(values.except(self.class.object_key) || {})
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,17 @@
1
+ module Resteze
2
+ module Middleware
3
+ class RaiseError < Faraday::Response::RaiseError
4
+ include Resteze::ApiModule
5
+
6
+ def on_complete(env)
7
+ super
8
+ rescue Faraday::ConflictError => e
9
+ raise api_module::ConflictError, e
10
+ rescue Faraday::UnprocessableEntityError => e
11
+ raise api_module::UnprocessableEntityError, e
12
+ rescue Faraday::ResourceNotFound => e
13
+ raise api_module::ResourceNotFound, e
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,73 @@
1
+ module Resteze
2
+ class Object < Hashie::Trash
3
+ include Resteze::ApiModule
4
+ include Hashie::Extensions::DeepMerge
5
+
6
+ attr_reader :resteze_metadata, :property_bag
7
+
8
+ def initialize(attributes = {}, &)
9
+ @resteze_metadata = {}
10
+ @property_bag = {}
11
+
12
+ super
13
+ end
14
+
15
+ # This allows us to take advantage of the #property features of
16
+ # the Hashie::Dash, but also to support unexpected hash values
17
+ # and store them in the metadata property
18
+ def []=(property, value)
19
+ super
20
+ rescue NoMethodError
21
+ @property_bag = property_bag.merge({ property => value })
22
+ end
23
+
24
+ def self.class_name
25
+ name.demodulize
26
+ end
27
+
28
+ def self.object_key
29
+ api_module.default_object_key(self)
30
+ end
31
+
32
+ def self.construct_from(payload)
33
+ new.initialize_from(payload)
34
+ end
35
+
36
+ # This is replaced with the idea of Hashie in Channel Advisor
37
+ def initialize_from(values)
38
+ values = values.deep_symbolize_keys
39
+ if self.class.object_key
40
+ update_attributes(values[self.class.object_key] || {})
41
+ else
42
+ update_attributes(values)
43
+ end
44
+ populate_metadata(values)
45
+ self
46
+ end
47
+
48
+ def merge_from(values)
49
+ values = values.deep_symbolize_keys
50
+ if self.class.object_key
51
+ data = values[self.class.object_key] || {}
52
+ metadata = values.except(self.class.object_key) || {}
53
+ else
54
+ data = values
55
+ metadata = {}
56
+ end
57
+
58
+ deep_merge!(data)
59
+ @resteze_metadata.deep_merge!(metadata)
60
+ self
61
+ end
62
+
63
+ def persisted?
64
+ respond_to?(:id) && id.present?
65
+ end
66
+
67
+ private
68
+
69
+ def populate_metadata(values)
70
+ @resteze_metadata = values.except(self.class.object_key)
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,14 @@
1
+ module Resteze
2
+ module Request
3
+ extend ActiveSupport::Concern
4
+ include Resteze::ApiModule
5
+
6
+ delegate :request, to: :class
7
+
8
+ module ClassMethods
9
+ def request(method, url, params: {}, headers: {})
10
+ api_module::Client.active_client.execute_request(method, url, params:, headers:)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,52 @@
1
+ module Resteze
2
+ class Response
3
+ OK = 200
4
+ attr_accessor :data, :http_body, :http_headers, :http_status, :request_id
5
+
6
+ def self.from_faraday_response(faraday_response)
7
+ new.tap do |response|
8
+ response.http_body = faraday_response.body
9
+ response.http_headers = faraday_response.headers
10
+ response.http_status = faraday_response.status
11
+ response.request_id = faraday_response.headers["Request-Id"]
12
+
13
+ response.data = parse_body(
14
+ faraday_response.body,
15
+ status: response.http_status,
16
+ headers: response.http_headers
17
+ )
18
+ end
19
+ end
20
+
21
+ def self.batch_response?(headers)
22
+ headers["Content-Type".freeze].to_s.include?("boundary=batchresponse".freeze)
23
+ end
24
+
25
+ def self.parse_body(body, status: nil, headers: {})
26
+ return body if unparsable_body?(status:, headers:)
27
+
28
+ type = mime_type(headers)&.symbol || :json
29
+ case type
30
+ when :json
31
+ JSON.parse(body, symbolize_names: true)
32
+ when :xml
33
+ Hash.from_xml(body).deep_symbolize_keys
34
+ else
35
+ body
36
+ end
37
+ end
38
+
39
+ def self.unparsable_body?(status:, headers:)
40
+ status.to_i == 204 || (300..399).cover?(status.to_i) || batch_response?(headers)
41
+ end
42
+
43
+ def self.mime_type(headers = {})
44
+ mime_type = headers.transform_keys(&:downcase)["content-type"].to_s.split(";").first.to_s.strip
45
+ Mime::LOOKUP[mime_type] || Mime::Type.lookup_by_extension(:json)
46
+ end
47
+
48
+ def ok?
49
+ http_status == OK
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,35 @@
1
+ module Resteze
2
+ module Save
3
+ extend ActiveSupport::Concern
4
+
5
+ def save
6
+ resp = request(
7
+ save_method,
8
+ save_resource_path,
9
+ params: as_save_json,
10
+ headers: save_headers
11
+ )
12
+ process_save_response(resp)
13
+ end
14
+
15
+ def save_method
16
+ persisted? ? :put : :post
17
+ end
18
+
19
+ def save_resource_path
20
+ persisted? ? resource_path : self.class.resource_path
21
+ end
22
+
23
+ def as_save_json
24
+ as_json
25
+ end
26
+
27
+ def save_headers
28
+ respond_to?(:retrieve_headers) ? retrieve_headers : {}
29
+ end
30
+
31
+ def process_save_response(response)
32
+ merge_from(response.data)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,18 @@
1
+ module Resteze
2
+ module Testing
3
+ module Configuration
4
+ extend ActiveSupport::Concern
5
+
6
+ # Assert that a config property is supported and can be assigned
7
+ def assert_config_property(property, value = nil, subject: nil)
8
+ subject ||= (defined?(@subject) && @subject) || subject()
9
+ original_value = subject.send(property)
10
+
11
+ assert_equal value, subject.send(property), "config property #{property} value did not match expectation"
12
+ ensure
13
+ # Make sure to set the original value at the end
14
+ subject.send("#{property}=", original_value)
15
+ end
16
+ end
17
+ end
18
+ end