my_api_client 0.1.0

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