Blame view

lib/syspro/syspro_client.rb 12.7 KB
dc8aa5b6   Joe Weakley   Rubocop corrections
1
2
  # frozen_string_literal: true
  
51fb5579   Isaac Lewis   add client test, ...
3
  module Syspro
dc8aa5b6   Joe Weakley   Rubocop corrections
4
5
    # This class is the main syspro client
    class SysproClient # rubocop:disable Metrics/ClassLength
db76748d   Isaac Lewis   cop a bunch of St...
6
7
      attr_accessor :conn, :api_base
  
697a8854   Isaac Lewis   update tests
8
9
      @verify_ssl_warned = false
  
51fb5579   Isaac Lewis   add client test, ...
10
11
12
13
14
      def initialize(conn = nil)
        self.conn = conn || self.class.default_conn
        @system_profiler = SystemProfiler.new
      end
  
49716587   Isaac Lewis   refactor object s...
15
      def logon(username, password, company_id, company_password)
697a8854   Isaac Lewis   update tests
16
        Syspro::Logon.logon(username, password, company_id, company_password)
49716587   Isaac Lewis   refactor object s...
17
18
      end
  
dc8aa5b6   Joe Weakley   Rubocop corrections
19
      def get_syspro_version # rubocop:disable Naming/AccessorMethodName
697a8854   Isaac Lewis   update tests
20
        Syspro::GetVersion.get_version
49716587   Isaac Lewis   refactor object s...
21
22
      end
  
51fb5579   Isaac Lewis   add client test, ...
23
24
25
26
27
      def self.active_client
        Thread.current[:syspro_client] || default_client
      end
  
      def self.default_client
dc8aa5b6   Joe Weakley   Rubocop corrections
28
        Thread.current[:syspro_client_default_client] ||= SysproClient.new(default_conn) # rubocop:disable Metrics/LineLength
51fb5579   Isaac Lewis   add client test, ...
29
30
      end
  
db76748d   Isaac Lewis   cop a bunch of St...
31
32
33
      # A default Faraday connection to be used when one isn't configured. This
      # object should never be mutated, and instead instantiating your own
      # connection and wrapping it in a SysproClient object should be preferred.
dc8aa5b6   Joe Weakley   Rubocop corrections
34
      def self.default_conn # rubocop:disable Metrics/MethodLength
db76748d   Isaac Lewis   cop a bunch of St...
35
36
37
38
39
40
41
42
43
44
45
        # We're going to keep connections around so that we can take advantage
        # of connection re-use, so make sure that we have a separate connection
        # object per thread.
        Thread.current[:syspro_client_default_conn] ||= begin
          conn = Faraday.new do |c|
            c.use Faraday::Request::Multipart
            c.use Faraday::Request::UrlEncoded
            c.use Faraday::Response::RaiseError
            c.adapter Faraday.default_adapter
          end
  
dc8aa5b6   Joe Weakley   Rubocop corrections
46
47
48
49
50
51
          # For now, we're not verifying SSL certificates.
          # The warning will appear.
          # if Syspro.verify_ssl_certs
          # conn.ssl.verify = true
          # conn.ssl.cert_store = Syspro.ca_store
          # else
db76748d   Isaac Lewis   cop a bunch of St...
52
53
54
55
          conn.ssl.verify = false
  
          unless @verify_ssl_warned
            @verify_ssl_warned = true
dc8aa5b6   Joe Weakley   Rubocop corrections
56
57
            warn('WARNING: Running without SSL cert verification. ' \
              'You should never do this in production. ' \
db76748d   Isaac Lewis   cop a bunch of St...
58
59
              "Execute 'Syspro.verify_ssl_certs = true' to enable verification.")
          end
dc8aa5b6   Joe Weakley   Rubocop corrections
60
          # end
db76748d   Isaac Lewis   cop a bunch of St...
61
62
63
64
65
  
          conn
        end
      end
  
3d0157a5   Isaac Lewis   add logoff
66
67
      # Executes the API call within the given block. Usage looks like:
      #
4a8bba96   Isaac Lewis   cleanup
68
      #     client = SysproClient.new
3d0157a5   Isaac Lewis   add logoff
69
70
71
72
      #     charge, resp = client.request { Charge.create }
      #
      def request
        @last_response = nil
4a8bba96   Isaac Lewis   cleanup
73
74
        old_syspro_client = Thread.current[:syspro_client]
        Thread.current[:syspro_client] = self
3d0157a5   Isaac Lewis   add logoff
75
76
77
78
79
  
        begin
          res = yield
          [res, @last_response]
        ensure
4a8bba96   Isaac Lewis   cleanup
80
          Thread.current[:syspro_client] = old_syspro_client
3d0157a5   Isaac Lewis   add logoff
81
82
83
        end
      end
  
dc8aa5b6   Joe Weakley   Rubocop corrections
84
      def execute_request(method, path, user_id: nil, api_base: nil, headers: {}, params: {}) # rubocop:disable Metrics/LineLength, Metrics/MethodLength
db76748d   Isaac Lewis   cop a bunch of St...
85
        api_base ||= Syspro.api_base
dc8aa5b6   Joe Weakley   Rubocop corrections
86
        user_id  ||= ''
db76748d   Isaac Lewis   cop a bunch of St...
87
88
89
90
91
92
93
94
95
96
97
  
        params = Util.objects_to_ids(params)
        url = api_url(path, api_base)
  
        body = nil
        query_params = nil
  
        case method.to_s.downcase.to_sym
        when :get, :head, :delete
          query_params = params
        else
dc8aa5b6   Joe Weakley   Rubocop corrections
98
          body = if headers[:content_type] && headers[:content_type] == 'multipart/form-data' # rubocop:disable Metrics/LineLength
db76748d   Isaac Lewis   cop a bunch of St...
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
                   params
                 else
                   Util.encode_parameters(params)
                 end
        end
  
        headers = request_headers(method)
                  .update(Util.normalize_headers(headers))
  
        # stores information on the request we're about to make so that we don't
        # have to pass as many parameters around for logging.
        context = RequestLogContext.new
        context.body            = body
        context.method          = method
        context.path            = path
3d0157a5   Isaac Lewis   add logoff
114
        context.user_id         = user_id
dc8aa5b6   Joe Weakley   Rubocop corrections
115
        context.query_params    = query_params ? Util.encode_parameters(query_params) : nil # rubocop:disable Metrics/LineLength
db76748d   Isaac Lewis   cop a bunch of St...
116
117
118
  
        http_resp = execute_request_with_rescues(api_base, context) do
          conn.run_request(method, url, body, headers) do |req|
701afa86   Samuel J Clopton   Move configuratio...
119
120
            req.options.open_timeout = Syspro.configuration.open_timeout
            req.options.timeout = Syspro.configuration.read_timeout
db76748d   Isaac Lewis   cop a bunch of St...
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
            req.params = query_params unless query_params.nil?
          end
        end
  
        begin
          resp = SysproResponse.from_faraday_response(http_resp)
        rescue JSON::ParserError
          raise general_api_error(http_resp.status, http_resp.body)
        end
  
        # Allows SysproClient#request to return a response object to a caller.
        @last_response = resp
        [resp]
      end
  
      def general_api_error(status, body)
0c0af54a   Isaac Lewis   error handling; c...
137
        ApiError.new("Invalid response object from API: #{body.inspect} " \
db76748d   Isaac Lewis   cop a bunch of St...
138
139
140
141
                     "(HTTP response code was #{status})",
                     http_status: status, http_body: body)
      end
  
dc8aa5b6   Joe Weakley   Rubocop corrections
142
      def api_url(url = '', api_base = nil)
db76748d   Isaac Lewis   cop a bunch of St...
143
144
145
        (api_base || Syspro.api_base) + url
      end
  
dc8aa5b6   Joe Weakley   Rubocop corrections
146
      def request_headers(_method)
db76748d   Isaac Lewis   cop a bunch of St...
147
148
149
        user_agent = "Syspro/7 RubyBindings/#{Syspro::VERSION}"
  
        headers = {
dc8aa5b6   Joe Weakley   Rubocop corrections
150
151
          'User-Agent' => user_agent,
          'Content-Type' => 'application/x-www-form-urlencoded'
db76748d   Isaac Lewis   cop a bunch of St...
152
153
154
155
156
        }
  
        headers
      end
  
dc8aa5b6   Joe Weakley   Rubocop corrections
157
      def execute_request_with_rescues(api_base, context) # rubocop:disable Metrics/LineLength, Metrics/MethodLength
db76748d   Isaac Lewis   cop a bunch of St...
158
159
160
161
162
        num_retries = 0
        begin
          request_start = Time.now
          log_request(context, num_retries)
          resp = yield
db76748d   Isaac Lewis   cop a bunch of St...
163
164
165
          log_response(context, request_start, resp.status, resp.body)
  
        # We rescue all exceptions from a request so that we have an easy spot to
dc8aa5b6   Joe Weakley   Rubocop corrections
166
167
        # implement our retry logic across the board. We'll re-raise if it's a
        # type of exception that we didn't expect to handle.
db76748d   Isaac Lewis   cop a bunch of St...
168
        rescue StandardError => e
db76748d   Isaac Lewis   cop a bunch of St...
169
          if e.respond_to?(:response) && e.response
e3484f8f   Isaac Lewis   remove dup check
170
            log_response(context, request_start,
db76748d   Isaac Lewis   cop a bunch of St...
171
172
                         e.response[:status], e.response[:body])
          else
e3484f8f   Isaac Lewis   remove dup check
173
            log_response_error(context, request_start, e)
db76748d   Isaac Lewis   cop a bunch of St...
174
175
176
177
178
179
180
181
182
183
184
          end
  
          if self.class.should_retry?(e, num_retries)
            num_retries += 1
            sleep self.class.sleep_time(num_retries)
            retry
          end
  
          case e
          when Faraday::ClientError
            if e.response
96149efa   Isaac Lewis   working query browse
185
              handle_error_response(e.response, context)
db76748d   Isaac Lewis   cop a bunch of St...
186
            else
96149efa   Isaac Lewis   working query browse
187
              handle_network_error(e, context, num_retries, api_base)
db76748d   Isaac Lewis   cop a bunch of St...
188
189
190
191
192
193
194
195
196
197
198
199
            end
  
          # Only handle errors when we know we can do so, and re-raise otherwise.
          # This should be pretty infrequent.
          else
            raise
          end
        end
  
        resp
      end
  
dc8aa5b6   Joe Weakley   Rubocop corrections
200
201
      def handle_network_error(e, context, num_retries, api_base = nil) # rubocop:disable Metrics/LineLength, Metrics/MethodLength, Naming/UncommunicativeMethodParamName
        Util.log_error('Syspro network error',
96149efa   Isaac Lewis   working query browse
202
203
204
205
206
                       error_message: e.message,
                       request_id: context.request_id)
  
        case e
        when Faraday::ConnectionFailed
dc8aa5b6   Joe Weakley   Rubocop corrections
207
          message = 'Unexpected error communicating when trying to connect to Syspro.' # rubocop:disable Metrics/LineLength
96149efa   Isaac Lewis   working query browse
208
209
  
        when Faraday::SSLError
dc8aa5b6   Joe Weakley   Rubocop corrections
210
          message = 'Could not establish a secure connection to Syspro.'
96149efa   Isaac Lewis   working query browse
211
212
213
214
  
        when Faraday::TimeoutError
          api_base ||= Syspro.api_base
          message = "Could not connect to Syspro (#{api_base}). " \
dc8aa5b6   Joe Weakley   Rubocop corrections
215
216
            'Please check your internet connection and try again. ' \
            'If this problem persists, you should check your Syspro service status.' # rubocop:disable Metrics/LineLength
96149efa   Isaac Lewis   working query browse
217
218
  
        else
dc8aa5b6   Joe Weakley   Rubocop corrections
219
220
          message = 'Unexpected error communicating with Syspro. ' \
            'If this problem persists, talk to your Syspro implementation team.'
96149efa   Isaac Lewis   working query browse
221
222
223
  
        end
  
dc8aa5b6   Joe Weakley   Rubocop corrections
224
        message += " Request was retried #{num_retries} times." if num_retries.positive? # rubocop:disable Metrics/LineLength
96149efa   Isaac Lewis   working query browse
225
226
227
228
  
        raise ApiConnectionError, message + "\n\n(Network error: #{e.message})"
      end
  
dc8aa5b6   Joe Weakley   Rubocop corrections
229
      def self.should_retry?(e, num_retries) # rubocop:disable Metrics/LineLength, Naming/UncommunicativeMethodParamName
eef01045   Isaac Lewis   re-implement max_...
230
231
        return false if num_retries >= Syspro.max_network_retries
  
db76748d   Isaac Lewis   cop a bunch of St...
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
        # Retry on timeout-related problems (either on open or read).
        return true if e.is_a?(Faraday::TimeoutError)
  
        # Destination refused the connection, the connection was reset, or a
        # variety of other connection failures. This could occur from a single
        # saturated server, so retry in case it's intermittent.
        return true if e.is_a?(Faraday::ConnectionFailed)
  
        if e.is_a?(Faraday::ClientError) && e.response
          # 409 conflict
          return true if e.response[:status] == 409
        end
  
        false
      end
  
      def log_request(context, num_retries)
dc8aa5b6   Joe Weakley   Rubocop corrections
249
        Util.log_info('Request to Syspro API',
db76748d   Isaac Lewis   cop a bunch of St...
250
251
252
253
254
                      account: context.account,
                      api_version: context.api_version,
                      method: context.method,
                      num_retries: num_retries,
                      path: context.path)
dc8aa5b6   Joe Weakley   Rubocop corrections
255
        Util.log_debug('Request details',
db76748d   Isaac Lewis   cop a bunch of St...
256
257
258
259
260
                       body: context.body,
                       query_params: context.query_params)
      end
      private :log_request
  
dc8aa5b6   Joe Weakley   Rubocop corrections
261
262
      def log_response(context, request_start, status, body) # rubocop:disable Metrics/LineLength, Metrics/MethodLength
        Util.log_info('Response from Syspro API',
db76748d   Isaac Lewis   cop a bunch of St...
263
264
265
266
267
268
269
                      account: context.account,
                      api_version: context.api_version,
                      elapsed: Time.now - request_start,
                      method: context.method,
                      path: context.path,
                      request_id: context.request_id,
                      status: status)
dc8aa5b6   Joe Weakley   Rubocop corrections
270
        Util.log_debug('Response details',
db76748d   Isaac Lewis   cop a bunch of St...
271
272
273
274
275
                       body: body,
                       request_id: context.request_id)
      end
      private :log_response
  
dc8aa5b6   Joe Weakley   Rubocop corrections
276
277
      def log_response_error(context, request_start, e) # rubocop:disable Metrics/LineLength, Naming/UncommunicativeMethodParamName
        Util.log_error('Request error',
db76748d   Isaac Lewis   cop a bunch of St...
278
279
280
281
282
283
284
                       elapsed: Time.now - request_start,
                       error_message: e.message,
                       method: context.method,
                       path: context.path)
      end
      private :log_response_error
  
dc8aa5b6   Joe Weakley   Rubocop corrections
285
      def handle_error_response(http_resp, context) # rubocop:disable Metrics/LineLength, Metrics/MethodLength
96149efa   Isaac Lewis   working query browse
286
287
288
289
        begin
          resp = SysproResponse.from_faraday_hash(http_resp)
          error_data = resp.data[:error]
  
dc8aa5b6   Joe Weakley   Rubocop corrections
290
          raise SysproError, 'Indeterminate error' unless error_data
96149efa   Isaac Lewis   working query browse
291
292
293
294
295
296
297
298
299
300
301
302
303
304
        rescue Nokogiri::XML::SyntaxError, SysproError
          raise general_api_error(http_resp[:status], http_resp[:body])
        end
  
        error = if error_data.is_a?(String)
                  specific_oauth_error(resp, error_data, context)
                else
                  specific_api_error(resp, error_data, context)
                end
  
        error.response = resp
        raise(error)
      end
  
db76748d   Isaac Lewis   cop a bunch of St...
305
306
307
308
309
310
311
312
313
314
315
      # RequestLogContext stores information about a request that's begin made so
      # that we can log certain information. It's useful because it means that we
      # don't have to pass around as many parameters.
      class RequestLogContext
        attr_accessor :body
        attr_accessor :account
        attr_accessor :api_version
        attr_accessor :method
        attr_accessor :path
        attr_accessor :query_params
        attr_accessor :request_id
3d0157a5   Isaac Lewis   add logoff
316
        attr_accessor :user_id
db76748d   Isaac Lewis   cop a bunch of St...
317
318
319
320
321
322
      end
  
      # SystemProfiler extracts information about the system that we're running
      # in so that we can generate a rich user agent header to help debug
      # integrations.
      class SystemProfiler
dc8aa5b6   Joe Weakley   Rubocop corrections
323
324
325
        def self.uname # rubocop:disable Metrics/MethodLength
          if File.exist?('/proc/version')
            File.read('/proc/version').strip
db76748d   Isaac Lewis   cop a bunch of St...
326
          else
dc8aa5b6   Joe Weakley   Rubocop corrections
327
            case RbConfig::CONFIG['host_os']
db76748d   Isaac Lewis   cop a bunch of St...
328
329
330
331
332
            when /linux|darwin|bsd|sunos|solaris|cygwin/i
              uname_from_system
            when /mswin|mingw/i
              uname_from_system_ver
            else
dc8aa5b6   Joe Weakley   Rubocop corrections
333
              'unknown platform'
db76748d   Isaac Lewis   cop a bunch of St...
334
335
336
337
338
            end
          end
        end
  
        def self.uname_from_system
dc8aa5b6   Joe Weakley   Rubocop corrections
339
          (`uname -a 2>/dev/null` || '').strip
db76748d   Isaac Lewis   cop a bunch of St...
340
        rescue Errno::ENOENT
dc8aa5b6   Joe Weakley   Rubocop corrections
341
          'uname executable not found'
db76748d   Isaac Lewis   cop a bunch of St...
342
        rescue Errno::ENOMEM # couldn't create subprocess
dc8aa5b6   Joe Weakley   Rubocop corrections
343
          'uname lookup failed'
db76748d   Isaac Lewis   cop a bunch of St...
344
345
346
        end
  
        def self.uname_from_system_ver
dc8aa5b6   Joe Weakley   Rubocop corrections
347
          (`ver` || '').strip
db76748d   Isaac Lewis   cop a bunch of St...
348
        rescue Errno::ENOENT
dc8aa5b6   Joe Weakley   Rubocop corrections
349
          'ver executable not found'
db76748d   Isaac Lewis   cop a bunch of St...
350
        rescue Errno::ENOMEM # couldn't create subprocess
dc8aa5b6   Joe Weakley   Rubocop corrections
351
          'uname lookup failed'
db76748d   Isaac Lewis   cop a bunch of St...
352
353
354
355
356
357
        end
  
        def initialize
          @uname = self.class.uname
        end
  
dc8aa5b6   Joe Weakley   Rubocop corrections
358
359
        def user_agent # rubocop:disable Metrics/MethodLength
          lang_version = "#{RUBY_VERSION} p#{RUBY_PATCHLEVEL} (#{RUBY_RELEASE_DATE})" # rubocop:disable Metrics/LineLength
51fb5579   Isaac Lewis   add client test, ...
360
  
db76748d   Isaac Lewis   cop a bunch of St...
361
362
363
          {
            application: Syspro.app_info,
            bindings_version: Syspro::VERSION,
dc8aa5b6   Joe Weakley   Rubocop corrections
364
            lang: 'ruby',
db76748d   Isaac Lewis   cop a bunch of St...
365
366
            lang_version: lang_version,
            platform: RUBY_PLATFORM,
dc8aa5b6   Joe Weakley   Rubocop corrections
367
            engine: defined?(RUBY_ENGINE) ? RUBY_ENGINE : '',
db76748d   Isaac Lewis   cop a bunch of St...
368
            uname: @uname,
dc8aa5b6   Joe Weakley   Rubocop corrections
369
            hostname: Socket.gethostname
db76748d   Isaac Lewis   cop a bunch of St...
370
371
          }.delete_if { |_k, v| v.nil? }
        end
51fb5579   Isaac Lewis   add client test, ...
372
373
374
      end
    end
  end