my_api_client 0.1.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
+ # frozen_string_literal: true
2
+
3
+ class ApplicationApiClient < MyApiClient::Base
4
+ # This is a super class for all API Client classes.
5
+ # Almost settings are inherited to child classes.
6
+
7
+ # Set the log output destination. The default is `Logger.new(STDOUT)`.
8
+ logger = Rails.logger
9
+
10
+ # Set the maximum number of seconds to wait on HTTP connection. If the
11
+ # connection does not open even after this number of seconds, the exception
12
+ # `MyApiClient::NetworkError` will raise. Setting nil will not time out.
13
+ # The default is 60 seconds.
14
+ #
15
+ # http_open_timeout 2.seconds
16
+
17
+ # Set the maximum number of seconds to block at one HTTP read. If it does not
18
+ # read even after this number of seconds, it will raise the exception
19
+ # MyApiClient::NetworkError. Setting nil will not time out. The default is 60
20
+ # seconds.
21
+ #
22
+ # http_read_timeout 3.seconds
23
+
24
+ # Catch the exception and re-execution after any seconds, like as ActiveJob.
25
+ # Please note that it is executed as a synchronous process unlike ActiveJob.
26
+ #
27
+ # retry_on MyApiClient::NetworkError, wait: 5.seconds, attempts: 3
28
+
29
+ # Set a HTTP response verifyment and behavior. If conflicting conditions are
30
+ # set, the processing defined later takes precedence
31
+ #
32
+ # error_handling status_code: 400..499, raise: MyApiClient::ClientError
33
+ # error_handling status_code: 500..599, raise: MyApiClient::ServerError
34
+ end
@@ -0,0 +1,8 @@
1
+ Description:
2
+ Generate a new api client spec files.
3
+
4
+ Example:
5
+ rails g rspec:api_client path/to/resource https://example.com get_user:get:path/to/resource`
6
+
7
+ This will create:
8
+ create spec/api_clients/path/to/resource_api_client_spec.rb
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'generators/rspec'
4
+
5
+ module Rspec
6
+ module Generators
7
+ # rails g rspec:api_client
8
+ class ApiClientGenerator < Base
9
+ source_root File.expand_path('templates', __dir__)
10
+
11
+ argument :endpoint,
12
+ type: :string,
13
+ default: 'https://example.com',
14
+ banner: '{schema and hostname}'
15
+ argument :requests,
16
+ type: :array,
17
+ default: %w[get_resource:get:path/to/resource post_resource:post:path/to/resource],
18
+ banner: '{action}:{method}:{path} {action}:{method}:{path}'
19
+
20
+ class_option :api_client_specs, type: :boolean, default: true
21
+
22
+ def generate_api_client_spec
23
+ return unless options[:api_client_specs]
24
+
25
+ file_path = File.join('spec/api_clients', "#{route_url.singularize}_api_client_spec.rb")
26
+ template 'api_client_spec.rb.erb', file_path
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_helper'
4
+
5
+ RSpec.describe <%= "#{class_name}ApiClient" %>, <%= type_metatag(:api_client) %> do
6
+ let(:api_client) { described_class.new }
7
+
8
+ <% requests.each do |request| -%>
9
+ <% action, http_method, pathname = request.sepalate(':') -%>
10
+ describe '#<%= action %>' do
11
+ end
12
+ <% end -%>
13
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'jsonpath'
5
+ require 'active_support'
6
+ require 'active_support/core_ext'
7
+ require 'sawyer'
8
+ require 'my_api_client/version'
9
+ require 'my_api_client/config'
10
+ require 'my_api_client/error_handling'
11
+ require 'my_api_client/exceptions'
12
+ require 'my_api_client/logger'
13
+ require 'my_api_client/errors'
14
+ require 'my_api_client/params/params'
15
+ require 'my_api_client/params/request'
16
+ require 'my_api_client/request'
17
+ require 'my_api_client/base'
18
+
19
+ if Sawyer::VERSION < '0.8.2'
20
+ module Sawyer
21
+ # NOTE: Old sawyer does not have attribute reader for response body.
22
+ # But new version sawyer is conflict some gems (e.g. octkit).
23
+ class Response
24
+ attr_reader :body, :env
25
+
26
+ alias _original_initialize initialize
27
+
28
+ def initialize(agent, res, options = {})
29
+ @body = res.body
30
+ _original_initialize(agent, res, options)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MyApiClient
4
+ # Description of Base
5
+ class Base
6
+ include MyApiClient::Config
7
+ include MyApiClient::ErrorHandling
8
+ include MyApiClient::Exceptions
9
+ include MyApiClient::Request
10
+
11
+ class_attribute :logger, instance_writer: false, default: ::Logger.new(STDOUT)
12
+ class_attribute :error_handlers, instance_writer: false, default: []
13
+
14
+ # NOTE: This class **MUST NOT** implement #initialize method. Because it
15
+ # will become constraint that need call #super in the #initialize at
16
+ # definition of the child classes.
17
+
18
+ HTTP_METHODS = %i[get post patch delete].freeze
19
+
20
+ HTTP_METHODS.each do |http_method|
21
+ class_eval <<~METHOD, __FILE__, __LINE__ + 1
22
+ # Description of #undefined
23
+ #
24
+ # @param pathname [String]
25
+ # @param headers [Hash, nil]
26
+ # @param query [Hash, nil]
27
+ # @param body [Hash, nil]
28
+ # @return [Sawyer::Resouce] description_of_returned_object
29
+ def #{http_method}(pathname, headers: nil, query: nil, body: nil)
30
+ _request :#{http_method}, pathname, headers, query, body, logger
31
+ end
32
+ METHOD
33
+ end
34
+ alias put patch
35
+ end
36
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MyApiClient
4
+ # Description of Config
5
+ module Config
6
+ extend ActiveSupport::Concern
7
+
8
+ CONFIG_METHODS = %i[endpoint http_read_timeout http_open_timeout].freeze
9
+
10
+ class_methods do
11
+ CONFIG_METHODS.each do |config_method|
12
+ class_eval <<~METHOD, __FILE__, __LINE__ + 1
13
+ def #{config_method}(#{config_method})
14
+ define_method :#{config_method}, -> { #{config_method} }
15
+ end
16
+ METHOD
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MyApiClient
4
+ # Provides `error_handling` as DSL.
5
+ #
6
+ # @note
7
+ # You need to define `class_attribute: error_handler, default: []` for the
8
+ # included class.
9
+ # @example
10
+ # error_handling status_code: 400..499, raise: MyApiClient::ClientError
11
+ # error_handling status_code: 500..599 do |params, logger|
12
+ # logger.warn 'Server error occurred.'
13
+ # raise MyApiClient::ServerError, params
14
+ # end
15
+ #
16
+ # error_handling json: { '$.errors.code': 10..19 }, with: :my_error_handling
17
+ # error_handling json: { '$.errors.code': 20 }, raise: MyApiClient::ApiLimitError
18
+ # error_handling json: { '$.errors.message': /Sorry/ }, raise: MyApiClient::ServerError
19
+ module ErrorHandling
20
+ extend ActiveSupport::Concern
21
+
22
+ class_methods do
23
+ # Description of .error_handling
24
+ #
25
+ # @param status_code [String, Range, Integer, Regexp] default: nil
26
+ # @param json [Hash] default: nil
27
+ # @param with [Symbol] default: nil
28
+ # @param raise [MyApiClient::Error] default: MyApiClient::Error
29
+ # @param block [Proc] describe_block_here
30
+ def error_handling(status_code: nil, json: nil, with: nil, raise: MyApiClient::Error)
31
+ temp = error_handlers.dup
32
+ temp << lambda { |response|
33
+ if match?(status_code, response.status) && match_all?(json, response.body)
34
+ if block_given?
35
+ ->(params, logger) { yield params, logger }
36
+ elsif with
37
+ with
38
+ else
39
+ ->(params, _logger) { raise raise, params }
40
+ end
41
+ end
42
+ }
43
+ self.error_handlers = temp
44
+ end
45
+
46
+ private
47
+
48
+ def match?(operator, target)
49
+ return true if operator.nil?
50
+
51
+ case operator
52
+ when String, Integer
53
+ operator == target
54
+ when Range
55
+ operator.include?(target)
56
+ when Regexp
57
+ operator =~ target.to_s
58
+ else
59
+ false
60
+ end
61
+ end
62
+
63
+ def match_all?(json, response_body)
64
+ return true if json.nil?
65
+ return false if response_body.blank?
66
+
67
+ json.all? do |path, operator|
68
+ target = JsonPath.new(path.to_s).first(response_body)
69
+ match?(operator, target)
70
+ end
71
+ end
72
+ end
73
+
74
+ # The error handlers defined later takes precedence
75
+ #
76
+ # @param response [Sawyer::Response] describe_params_here
77
+ # @return [Proc, Symbol, nil] description_of_returned_object
78
+ def error_handling(response)
79
+ error_handlers.reverse_each do |error_handler|
80
+ result = error_handler.call(response)
81
+ return result unless result.nil?
82
+ end
83
+ nil
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MyApiClient
4
+ # The ancestor class for all API request error
5
+ class Error < StandardError
6
+ attr_reader :params
7
+
8
+ # Description of #initialize
9
+ #
10
+ # @param params [MyApiClient::Params::Params] describe_params_here
11
+ # @param error_message [String] default: nil
12
+ def initialize(params, error_message = nil)
13
+ @params = params
14
+ super error_message
15
+ end
16
+
17
+ # Returns contents as string for to be readable for human
18
+ #
19
+ # @return [String] Contents as string
20
+ def inspect
21
+ { error: super, params: params }.inspect
22
+ end
23
+ end
24
+
25
+ NETWORK_ERRORS = [
26
+ Faraday::ClientError,
27
+ OpenSSL::SSL::SSLError,
28
+ Net::OpenTimeout,
29
+ Net::ReadTimeout,
30
+ SocketError,
31
+ ].freeze
32
+
33
+ # Raises it when occurred to some network error
34
+ class NetworkError < Error
35
+ attr_reader :original_error
36
+
37
+ # Description of #initialize
38
+ #
39
+ # @param params [MyApiClient::Params::Params] describe_params_here
40
+ # @param original_error [StandardError] Some network error
41
+ def initialize(params, original_error)
42
+ @original_error = original_error
43
+ super params, original_error.message
44
+ end
45
+
46
+ # Returns contents as string for to be readable for human
47
+ #
48
+ # @return [String] Contents as string
49
+ def inspect
50
+ { error: original_error, params: params }.inspect
51
+ end
52
+ end
53
+
54
+ # NOTE: The built-in error classes are following. Although they are prepared
55
+ # to save the trouble of defining, but you can create any error classes
56
+ # which inherit the ancestor error class.
57
+
58
+ # For 4xx client error
59
+ class ClientError < Error; end
60
+
61
+ # For 5xx server error
62
+ class ServerError < Error; end
63
+
64
+ # For API request limit error
65
+ class ApiLimitError < Error; end
66
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MyApiClient
4
+ # Description of Exceptions
5
+ module Exceptions
6
+ extend ActiveSupport::Concern
7
+ include ActiveSupport::Rescuable
8
+
9
+ # Description of #call
10
+ #
11
+ # @param args [Array<Object>] describe_args_here
12
+ # @return [Object] description_of_returned_object
13
+ def call(*args)
14
+ @args = args
15
+ send(*args)
16
+ rescue StandardError => e
17
+ @retry_count ||= 0
18
+ raise unless rescue_with_handler(e)
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :retry_count, :method_name, :args
24
+
25
+ class_methods do
26
+ def retry_on(*exception, wait: 1.second, attempts: 3)
27
+ rescue_from(*exception) do |error|
28
+ if retry_count < attempts
29
+ retry_calling(wait)
30
+ elsif block_given?
31
+ yield self, error
32
+ else
33
+ raise error
34
+ end
35
+ end
36
+ end
37
+
38
+ # Description of #discard_on
39
+ #
40
+ # @note
41
+ # !! It is implemented following ActiveJob, but I think this method is
42
+ # not useful in this gem. !!
43
+ # @param exception [Type] describe_exception_here
44
+ def discard_on(*exception)
45
+ rescue_from(*exception) do |error|
46
+ yield self, error if block_given?
47
+ end
48
+ end
49
+ end
50
+
51
+ def retry_calling(wait)
52
+ sleep(wait)
53
+ @retry_count += 1
54
+ call(*args)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MyApiClient
4
+ # Description of Logger
5
+ class Logger
6
+ attr_reader :logger, :method, :pathname
7
+
8
+ LOG_LEVEL = %i[debug info warn error fatal].freeze
9
+
10
+ # Description of #initialize
11
+ #
12
+ # @param logger [::Logger] describe_logger_here
13
+ # @param faraday [Faraday::Connection] describe_faraday_here
14
+ # @param method [String] HTTP method
15
+ # @param pathname [String] The path name
16
+ def initialize(logger, faraday, method, pathname)
17
+ @logger = logger
18
+ @method = method.to_s.upcase
19
+ @pathname = faraday.build_exclusive_url(pathname)
20
+ end
21
+
22
+ LOG_LEVEL.each do |level|
23
+ class_eval <<~METHOD, __FILE__, __LINE__ + 1
24
+ def #{level}(message)
25
+ logger.#{level}(format(message))
26
+ end
27
+ METHOD
28
+ end
29
+
30
+ private
31
+
32
+ def format(message)
33
+ "API request `#{method} #{pathname}`: \"#{message}\""
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MyApiClient
4
+ module Params
5
+ # Description of Params
6
+ class Params
7
+ attr_reader :request, :response
8
+
9
+ # Description of #initialize
10
+ #
11
+ # @param request [MyApiClient::Params::Request] describe_request_here
12
+ # @param response [Sawyer::Response, nil] describe_response_here
13
+ def initialize(request, response)
14
+ @request = request
15
+ @response = response
16
+ end
17
+
18
+ # Returns contents as string for to be readable for human
19
+ #
20
+ # @return [String] Contents as string
21
+ def inspect
22
+ { request: request, response: response }.inspect
23
+ end
24
+ end
25
+ end
26
+ end