api_client_builder 1.0.0 → 1.4.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.
Files changed (35) hide show
  1. checksums.yaml +5 -5
  2. data/.gitignore +3 -0
  3. data/.rubocop.yml +34 -0
  4. data/.travis.yml +20 -0
  5. data/Gemfile +6 -0
  6. data/Jenkinsfile +13 -0
  7. data/LICENSE.txt +22 -0
  8. data/{readme.md → README.md} +65 -50
  9. data/api_client_builder.gemspec +15 -13
  10. data/build.sh +16 -0
  11. data/lib/api_client_builder.rb +14 -0
  12. data/lib/api_client_builder/api_client.rb +96 -0
  13. data/lib/api_client_builder/delete_request.rb +19 -0
  14. data/lib/api_client_builder/get_collection_request.rb +43 -0
  15. data/lib/api_client_builder/get_item_request.rb +27 -0
  16. data/lib/api_client_builder/post_request.rb +19 -0
  17. data/lib/api_client_builder/put_request.rb +19 -0
  18. data/lib/api_client_builder/request.rb +66 -0
  19. data/lib/api_client_builder/response.rb +32 -0
  20. data/lib/api_client_builder/url_generator.rb +37 -0
  21. data/lib/api_client_builder/version.rb +3 -0
  22. data/spec/lib/api_client_builder/api_client_spec.rb +13 -4
  23. data/spec/lib/api_client_builder/delete_request_spec.rb +29 -0
  24. data/spec/lib/api_client_builder/get_collection_request_spec.rb +7 -10
  25. data/spec/lib/api_client_builder/get_item_request_spec.rb +4 -6
  26. data/spec/lib/api_client_builder/post_request_spec.rb +3 -5
  27. data/spec/lib/api_client_builder/put_request_spec.rb +3 -5
  28. data/spec/lib/api_client_builder/request_spec.rb +7 -9
  29. data/spec/lib/api_client_builder/response_spec.rb +1 -2
  30. data/spec/lib/api_client_builder/test_client/client.rb +3 -3
  31. data/spec/lib/api_client_builder/test_client/http_client_handler.rb +1 -2
  32. data/spec/lib/api_client_builder/test_client/response_handler.rb +13 -14
  33. data/spec/lib/api_client_builder/url_generator_spec.rb +29 -7
  34. data/spec/spec_helper.rb +8 -2
  35. metadata +51 -31
@@ -0,0 +1,96 @@
1
+ module APIClientBuilder
2
+ # The base APIClient that defines the interface for defining an API Client.
3
+ # Should be sub-classed and then provided an HTTPClient handler and a
4
+ # response handler.
5
+ class APIClient
6
+ attr_reader :url_generator, :http_client
7
+
8
+ # @param opts [Hash] options hash
9
+ # @option opts [Symbol] :http_client The http client handler
10
+ # @option opts [Symbol] :paginator The response handler
11
+ def initialize(**opts)
12
+ @url_generator = APIClientBuilder::URLGenerator.new(opts[:domain])
13
+ @http_client = opts[:http_client]
14
+ end
15
+
16
+ # Used to define a GET api route on the base class. Will
17
+ # yield a method that takes the shape of 'get_type' that will
18
+ # return a CollectionResponse or ItemResponse based on plurality.
19
+ #
20
+ # @param type [Symbol] defines the route model
21
+ # @param plurality [Symbol] defines the routes plurality
22
+ # @param route [String] defines the routes endpoint
23
+ #
24
+ # @return [Request] either a GetCollection or GetItem request
25
+ def self.get(type, plurality, route, **_opts)
26
+ if plurality == :collection
27
+ define_method("get_#{type}") do |**params|
28
+ GetCollectionRequest.new(
29
+ type,
30
+ response_handler_build(http_client, @url_generator.build_route(route, **params), type)
31
+ )
32
+ end
33
+ elsif plurality == :singular
34
+ define_method("get_#{type}") do |**params|
35
+ GetItemRequest.new(
36
+ type,
37
+ response_handler_build(http_client, @url_generator.build_route(route, **params), type)
38
+ )
39
+ end
40
+ end
41
+ end
42
+
43
+ # Used to define a POST api route on the base class. Will
44
+ # yield a method that takes the shape of 'post_type' that will
45
+ # return a PostRequest.
46
+ #
47
+ # @param type [Symbol] defines the route model
48
+ # @param route [String] defines the routes endpoint
49
+ #
50
+ # @return [PostRequest] the request object that handles posts
51
+ def self.post(type, route)
52
+ define_method("post_#{type}") do |body, **params|
53
+ PostRequest.new(
54
+ type,
55
+ response_handler_build(http_client, @url_generator.build_route(route, **params), type),
56
+ body
57
+ )
58
+ end
59
+ end
60
+
61
+ # Used to define a PUT api route on the base class. Will
62
+ # yield a method that takes the shape of 'put_type' that will
63
+ # return a PutRequest.
64
+ #
65
+ # @param type [Symbol] defines the route model
66
+ # @param route [String] defines the routes endpoint
67
+ #
68
+ # @return [PutRequest] the request object that handles puts
69
+ def self.put(type, route)
70
+ define_method("put_#{type}") do |body, **params|
71
+ PutRequest.new(
72
+ type,
73
+ response_handler_build(http_client, @url_generator.build_route(route, **params), type),
74
+ body
75
+ )
76
+ end
77
+ end
78
+
79
+ # Used to define a DELETE api route on the base class. Will
80
+ # yield a method that takes the shape of 'delete_type' that will
81
+ # return a DeleteRequest.
82
+ #
83
+ # @param type [Symbol] defines the route model
84
+ # @param route [String] defines the routes endpoint
85
+ #
86
+ # @return [DeleteRequest] the request object that handles puts
87
+ def self.delete(type, route)
88
+ define_method("delete_#{type}") do |**params|
89
+ DeleteRequest.new(
90
+ type,
91
+ response_handler_build(http_client, @url_generator.build_route(route, **params), type)
92
+ )
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,19 @@
1
+ module APIClientBuilder
2
+ class DeleteRequest < Request
3
+ # Yields the response body if the response was successful. Will call
4
+ # the response handlers if there was not a successful response.
5
+ #
6
+ # @return [JSON] the http response body
7
+ def response
8
+ response = response_handler.delete_request
9
+
10
+ if response.success?
11
+ response
12
+ else
13
+ error_handlers.each do |handler|
14
+ handler.call(response, self)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,43 @@
1
+ module APIClientBuilder
2
+ # The multi item response object to be used as the container for
3
+ # collection responses from the defined API
4
+ class GetCollectionRequest < Request
5
+ include Enumerable
6
+
7
+ # Iterates over the pages and yields their items if they're successful
8
+ # responses. Else handles the error. Will retry the response if a retry
9
+ # strategy is defined concretely on the response handler.
10
+ #
11
+ # @return [JSON] the http response body
12
+ # rubocop:disable Metrics/AbcSize
13
+ def each
14
+ if block_given?
15
+ each_page do |page|
16
+ if page.success?
17
+ page.body.each do |item|
18
+ yield(item)
19
+ end
20
+ elsif response_handler.respond_to?(:retryable?) && response_handler.retryable?(page.status_code)
21
+ retried_page = attempt_retry
22
+
23
+ retried_page.body.each do |item|
24
+ yield(item)
25
+ end
26
+ else
27
+ notify_error_handlers(page)
28
+ end
29
+ end
30
+ else
31
+ Enumerator.new(self, :each)
32
+ end
33
+ end
34
+ # rubocop:enable Metrics/AbcSize
35
+
36
+ private
37
+
38
+ def each_page
39
+ yield(response_handler.get_first_page)
40
+ yield(response_handler.get_next_page) while response_handler.more_pages?
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,27 @@
1
+ module APIClientBuilder
2
+ # The single item response object to be used as the container for
3
+ # singular responses from the defined API
4
+ class GetItemRequest < Request
5
+ # Reads the first page from the pagination solution and yields the
6
+ # items if the response was successful. Else handles the error. Will
7
+ # retry the response if a retry strategy is defined concretely on the
8
+ # response handler.
9
+ #
10
+ # @return [JSON] the http response body
11
+ def response
12
+ page = response_handler.get_first_page
13
+
14
+ if page.success?
15
+ page.body
16
+ elsif response_handler.respond_to?(:retryable?) && response_handler.retryable?(page.status_code)
17
+ retried_page = attempt_retry
18
+
19
+ retried_page.body
20
+ else
21
+ error_handlers.each do |handler|
22
+ handler.call(page, self)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,19 @@
1
+ module APIClientBuilder
2
+ class PostRequest < Request
3
+ # Yields the response body if the response was successful. Will call
4
+ # the response handlers if there was not a successful response.
5
+ #
6
+ # @return [JSON] the http response body
7
+ def response
8
+ response = response_handler.post_request(@body)
9
+
10
+ if response.success?
11
+ response
12
+ else
13
+ error_handlers.each do |handler|
14
+ handler.call(response, self)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ module APIClientBuilder
2
+ class PutRequest < Request
3
+ # Yields the response body if the response was successful. Will call
4
+ # the response handlers if there was not a successful response.
5
+ #
6
+ # @return [JSON] the http response body
7
+ def response
8
+ response = response_handler.put_request(@body)
9
+
10
+ if response.success?
11
+ response
12
+ else
13
+ error_handlers.each do |handler|
14
+ handler.call(response, self)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,66 @@
1
+ module APIClientBuilder
2
+ class DefaultPageError < StandardError; end
3
+
4
+ class Request
5
+ attr_reader :type, :response_handler, :body, :error_handlers_collection
6
+
7
+ # @param type [Symbol] defines the object type to be processed
8
+ # @option response_handler [ResponseHandler] the response handler. Usually
9
+ # a pagination strategy
10
+ # @option body [Hash] the body of the response from the source
11
+ def initialize(type, response_handler, body = {})
12
+ @type = type
13
+ @response_handler = response_handler
14
+ @body = body
15
+ @error_handlers_collection = []
16
+ end
17
+
18
+ # Yields the collection of error handlers that have been populated
19
+ # via the on_error interface. If none are defined, this will provide a
20
+ # default error handler that will provide context about the error and
21
+ # also how to define a new error handler.
22
+ #
23
+ # @return [Array<Block>] the error handlers collection
24
+ def error_handlers
25
+ if error_handlers_collection.empty?
26
+ on_error do |page, _handler|
27
+ raise DefaultPageError, <<~MESSAGE
28
+ Default error for bad response. If you want to handle this error use #on_error
29
+ on the response in your api consumer. Error Code: #{page.status_code}.
30
+ MESSAGE
31
+ end
32
+ end
33
+ error_handlers_collection
34
+ end
35
+
36
+ # Used to define custom error handling on this response.
37
+ # The error handlers will be called if there is not a success
38
+ #
39
+ # @param block [Lambda] the error handling block to be stored in
40
+ # the error_handlers list
41
+ def on_error(&block)
42
+ @error_handlers_collection << block
43
+ end
44
+
45
+ private
46
+
47
+ def attempt_retry
48
+ page = response_handler.retry_request
49
+
50
+ if page.success?
51
+ response_handler.reset_retries
52
+ return page
53
+ elsif response_handler.retryable?(page.status_code)
54
+ attempt_retry
55
+ else
56
+ notify_error_handlers(page)
57
+ end
58
+ end
59
+
60
+ def notify_error_handlers(page)
61
+ error_handlers.each do |handler|
62
+ handler.call(page, self)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,32 @@
1
+ module APIClientBuilder
2
+ # The default page object to be used to hold the response from the API
3
+ # in your response handler object. Any response object that will replace this
4
+ # must response to #success?, and #items
5
+ class Response
6
+ attr_accessor :body, :status_code
7
+
8
+ # @param body [String/Array/Hash] the response body
9
+ # @param status_code [Integer] the response status code
10
+ # @param success_range [Array<Integer>] the success range of this response
11
+ def initialize(body, status_code, success_range)
12
+ @body = body
13
+ @status_code = status_code
14
+ @success_range = success_range
15
+ @failed_reason = nil
16
+ end
17
+
18
+ # Used to mark why the response failed
19
+ def mark_failed(reason)
20
+ @failed_reason = reason
21
+ end
22
+
23
+ # Defines the success conditional for a response by determining whether
24
+ # or not the status code of the response is within the defined success
25
+ # range
26
+ #
27
+ # @return [Boolean] whether or not the response is a success
28
+ def success?
29
+ @failed_reason.nil? && @success_range.include?(@status_code)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,37 @@
1
+ module APIClientBuilder
2
+ class NoURLError < StandardError; end
3
+ class URLGenerator
4
+ # Receives a domain and parses it into a URI
5
+ #
6
+ # @param domain [String] the domain of the API
7
+ def initialize(domain)
8
+ @base_uri = URI.parse(domain)
9
+ @base_uri = URI.parse('https://' + domain) if @base_uri.scheme.nil?
10
+ @base_uri.path << '/' unless @base_uri.path.end_with?('/')
11
+ end
12
+
13
+ # Defines a full API route and interpolates parameters into the route
14
+ # provided that the parameter sent in has a key that matches the parameter
15
+ # that is defined by the route.
16
+ #
17
+ # @param route [String] defines the route endpoint
18
+ # @param params [Hash] the optional params to be interpolated into the route
19
+ #
20
+ # @raise [ArgumentError] if route defined param is not provided
21
+ #
22
+ # @return [URI] the fully built route
23
+ def build_route(route, **params)
24
+ string_params = route.split(%r{[\/=]}).select { |param| param.start_with?(':') }
25
+ symboled_params = string_params.map { |param| param.tr(':', '').to_sym }
26
+
27
+ new_route = route.clone
28
+ symboled_params.each do |param|
29
+ value = params[param]
30
+ raise ArgumentError, "Param :#{param} is required" unless value
31
+ new_route.gsub!(":#{param}", CGI.escape(value.to_s))
32
+ end
33
+
34
+ @base_uri.merge(new_route)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,3 @@
1
+ module APIClientBuilder
2
+ VERSION = '1.4.0'.freeze
3
+ end
@@ -1,11 +1,10 @@
1
1
  require 'spec_helper'
2
- require 'lib/api_client_builder/test_client/client'
3
- require 'api_client_builder/api_client'
2
+ require_relative 'test_client/client'
4
3
 
5
4
  module APIClientBuilder
6
5
  describe APIClient do
7
- let(:domain) {'https://www.domain.com/api/endpoints/'}
8
- let(:client) {TestClient::Client.new(domain: domain)}
6
+ let(:domain) { 'https://www.domain.com/api/endpoints/' }
7
+ let(:client) { TestClient::Client.new(domain: domain) }
9
8
 
10
9
  describe '.get' do
11
10
  context 'plurality is :collection' do
@@ -42,5 +41,15 @@ module APIClientBuilder
42
41
  expect(client.put_some_object({})).to be_a(APIClientBuilder::PutRequest)
43
42
  end
44
43
  end
44
+
45
+ describe '.delete' do
46
+ it 'defines a delete method on the client' do
47
+ expect(client).to respond_to(:delete_some_object)
48
+ end
49
+
50
+ it 'returns a DeleteRequest object' do
51
+ expect(client.delete_some_object({})).to be_a(APIClientBuilder::DeleteRequest)
52
+ end
53
+ end
45
54
  end
46
55
  end
@@ -0,0 +1,29 @@
1
+ require 'spec_helper'
2
+ require_relative 'test_client/client'
3
+
4
+ module APIClientBuilder
5
+ describe DeleteRequest do
6
+ describe '#response' do
7
+ context 'request was successful' do
8
+ it 'returns a response object' do
9
+ client = TestClient::Client.new(domain: 'https://www.domain.com/api/endpoints/')
10
+
11
+ some_object = client.delete_some_object({}).response
12
+ expect(some_object.body).to eq('good delete request')
13
+ end
14
+ end
15
+
16
+ context 'request was unsuccessful' do
17
+ it 'calls the error handlers' do
18
+ client = TestClient::Client.new(domain: 'https://www.domain.com/api/endpoints/')
19
+
20
+ bad_response = APIClientBuilder::Response.new('bad request', 400, [200])
21
+ allow_any_instance_of(TestClient::ResponseHandler).to receive(:delete_request).and_return(bad_response)
22
+ expect { client.delete_some_object({}).response }.to raise_error(
23
+ APIClientBuilder::DefaultPageError, /Error Code: 400/
24
+ )
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,6 +1,5 @@
1
1
  require 'spec_helper'
2
- require 'api_client_builder/get_collection_request'
3
- require 'lib/api_client_builder/test_client/client'
2
+ require_relative 'test_client/client'
4
3
 
5
4
  module APIClientBuilder
6
5
  describe GetCollectionRequest do
@@ -21,9 +20,8 @@ module APIClientBuilder
21
20
  bad_response = APIClientBuilder::Response.new('bad request', 500, [200])
22
21
  allow_any_instance_of(TestClient::ResponseHandler).to receive(:get_first_page).and_return(bad_response)
23
22
  allow_any_instance_of(TestClient::ResponseHandler).to receive(:retry_request).and_return(bad_response)
24
- expect{ client.get_some_objects.each{} }.to raise_error(
25
- APIClientBuilder::DefaultPageError,
26
- "Default error for bad response. If you want to handle this error use #on_error on the response in your api consumer. Error Code: 500"
23
+ expect { client.get_some_objects.each {} }.to raise_error(
24
+ APIClientBuilder::DefaultPageError, /Error Code: 500/
27
25
  )
28
26
  end
29
27
 
@@ -32,7 +30,7 @@ module APIClientBuilder
32
30
  client = TestClient::Client.new(domain: 'https://www.domain.com/api/endpoints/')
33
31
 
34
32
  bad_response = APIClientBuilder::Response.new('bad request', 500, [200])
35
- good_response = APIClientBuilder::Response.new([1,2,3], 200, [200])
33
+ good_response = APIClientBuilder::Response.new([1, 2, 3], 200, [200])
36
34
  allow_any_instance_of(TestClient::ResponseHandler).to receive(:get_first_page).and_return(bad_response)
37
35
  allow_any_instance_of(TestClient::ResponseHandler).to receive(:more_pages?).and_return(false)
38
36
  allow_any_instance_of(TestClient::ResponseHandler).to receive(:retry_request).and_return(good_response)
@@ -47,13 +45,12 @@ module APIClientBuilder
47
45
  client = TestClient::Client.new(domain: 'https://www.domain.com/api/endpoints/')
48
46
 
49
47
  bad_response = APIClientBuilder::Response.new('bad request', 400, [200])
50
- good_response = APIClientBuilder::Response.new([1,2,3], 200, [200])
48
+ good_response = APIClientBuilder::Response.new([1, 2, 3], 200, [200])
51
49
  allow_any_instance_of(TestClient::ResponseHandler).to receive(:get_first_page).and_return(bad_response)
52
50
  allow_any_instance_of(TestClient::ResponseHandler).to receive(:more_pages?).and_return(false)
53
51
  allow_any_instance_of(TestClient::ResponseHandler).to receive(:retry_request).and_return(good_response)
54
- expect{ client.get_some_objects.each{} }.to raise_error(
55
- APIClientBuilder::DefaultPageError,
56
- "Default error for bad response. If you want to handle this error use #on_error on the response in your api consumer. Error Code: 400"
52
+ expect { client.get_some_objects.each {} }.to raise_error(
53
+ APIClientBuilder::DefaultPageError, /Error Code: 400/
57
54
  )
58
55
  end
59
56
  end