api_adaptor 0.1.0 → 1.0.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.
@@ -9,11 +9,68 @@ require_relative "response"
9
9
  require "rest-client"
10
10
 
11
11
  module ApiAdaptor
12
+ # HTTP client for JSON APIs with comprehensive redirect handling and authentication support.
13
+ #
14
+ # JSONClient provides a low-level interface for making HTTP requests to JSON APIs. It handles
15
+ # automatic JSON parsing, configurable redirect following, authentication (bearer token and basic auth),
16
+ # timeout management, and comprehensive error handling.
17
+ #
18
+ # @example Basic usage with bearer token
19
+ # client = JSONClient.new(bearer_token: "abc123")
20
+ # response = client.get_json("https://api.example.com/users")
21
+ # users = response["data"]
22
+ #
23
+ # @example Custom timeout and redirect configuration
24
+ # client = JSONClient.new(
25
+ # timeout: 10,
26
+ # max_redirects: 5,
27
+ # follow_non_get_redirects: true
28
+ # )
29
+ #
30
+ # @example With basic authentication
31
+ # client = JSONClient.new(
32
+ # basic_auth: { user: "username", password: "password" }
33
+ # )
34
+ #
35
+ # @example Disable cross-origin redirects for security
36
+ # client = JSONClient.new(
37
+ # allow_cross_origin_redirects: false
38
+ # )
39
+ #
40
+ # @see Base for a higher-level API client framework
12
41
  class JsonClient
13
42
  include ApiAdaptor::ExceptionHandling
14
43
 
44
+ # @return [Logger] Logger instance for request/response logging
45
+ # @return [Hash] Client configuration options
15
46
  attr_accessor :logger, :options
16
47
 
48
+ # Initializes a new JSON HTTP client
49
+ #
50
+ # @param options [Hash] Configuration options
51
+ # @option options [String] :bearer_token Bearer token for Authorization header
52
+ # @option options [Hash] :basic_auth Basic authentication credentials with :user and :password keys
53
+ # @option options [Integer] :timeout Request timeout in seconds (default: 4)
54
+ # @option options [Integer] :max_redirects Maximum number of redirects to follow (default: 3)
55
+ # @option options [Boolean] :allow_cross_origin_redirects Allow redirects to different origins (default: true)
56
+ # @option options [Boolean] :forward_auth_on_cross_origin_redirects Forward auth headers on cross-origin redirects (default: false, security risk if enabled)
57
+ # @option options [Boolean] :follow_non_get_redirects Follow redirects for POST/PUT/PATCH/DELETE (default: false, only 307/308 supported)
58
+ # @option options [Logger] :logger Custom logger instance (default: NullLogger)
59
+ #
60
+ # @raise [RuntimeError] If disable_timeout or negative timeout is provided
61
+ #
62
+ # @example Default configuration
63
+ # client = JSONClient.new
64
+ # # timeout: 4s, max_redirects: 3, only GET/HEAD follow redirects
65
+ #
66
+ # @example Full configuration
67
+ # client = JSONClient.new(
68
+ # bearer_token: "secret",
69
+ # timeout: 10,
70
+ # max_redirects: 5,
71
+ # allow_cross_origin_redirects: false,
72
+ # logger: Logger.new($stdout)
73
+ # )
17
74
  def initialize(options = {})
18
75
  raise "It is no longer possible to disable the timeout." if options[:disable_timeout] || options[:timeout].to_i.negative?
19
76
 
@@ -21,6 +78,9 @@ module ApiAdaptor
21
78
  @options = options
22
79
  end
23
80
 
81
+ # Returns default HTTP headers for all requests
82
+ #
83
+ # @return [Hash] Default headers including Accept and User-Agent
24
84
  def self.default_request_headers
25
85
  {
26
86
  "Accept" => "application/json",
@@ -28,52 +88,161 @@ module ApiAdaptor
28
88
  }
29
89
  end
30
90
 
91
+ # Returns default headers for requests with JSON body
92
+ #
93
+ # @return [Hash] Default headers plus Content-Type: application/json
31
94
  def self.default_request_with_json_body_headers
32
95
  default_request_headers.merge(json_body_headers)
33
96
  end
34
97
 
98
+ # Returns Content-Type header for JSON requests
99
+ #
100
+ # @return [Hash] Content-Type header
35
101
  def self.json_body_headers
36
102
  {
37
103
  "Content-Type" => "application/json"
38
104
  }
39
105
  end
40
106
 
107
+ # Default request timeout in seconds
41
108
  DEFAULT_TIMEOUT_IN_SECONDS = 4
109
+
110
+ # Default maximum number of redirects to follow
42
111
  DEFAULT_MAX_REDIRECTS = 3
43
112
 
113
+ # Performs a GET request and returns the raw response
114
+ #
115
+ # @param url [String] The URL to request
116
+ #
117
+ # @return [RestClient::Response] Raw HTTP response
118
+ #
119
+ # @raise [HTTPClientError, HTTPServerError] On HTTP errors
120
+ # @raise [TimedOutException] On timeout
121
+ # @raise [EndpointNotFound] On connection refused
122
+ # @raise [InvalidUrl] On invalid URI
123
+ # @raise [TooManyRedirects] When max_redirects is exceeded
124
+ # @raise [RedirectLocationMissing] When redirect lacks Location header
44
125
  def get_raw!(url)
45
126
  do_raw_request(:get, url)
46
127
  end
47
128
 
129
+ # Performs a GET request and returns the raw response (alias for get_raw!)
130
+ #
131
+ # @param url [String] The URL to request
132
+ #
133
+ # @return [RestClient::Response] Raw HTTP response
134
+ #
135
+ # @see #get_raw!
48
136
  def get_raw(url)
49
137
  get_raw!(url)
50
138
  end
51
139
 
140
+ # Performs a GET request and parses the JSON response
141
+ #
142
+ # @param url [String] The URL to request
143
+ # @param additional_headers [Hash] Additional HTTP headers to include
144
+ # @yield [response] Optional block to create custom response object
145
+ # @yieldparam response [RestClient::Response] The raw HTTP response
146
+ # @yieldreturn [Object] Custom response object
147
+ #
148
+ # @return [Response, Object] Response object or custom object from block
149
+ #
150
+ # @raise [HTTPClientError, HTTPServerError] On HTTP errors
151
+ # @raise [TimedOutException] On timeout
152
+ #
153
+ # @example Basic usage
154
+ # response = client.get_json("https://api.example.com/users")
155
+ # users = response["data"]
156
+ #
157
+ # @example With custom response class
158
+ # users = client.get_json("https://api.example.com/users") do |r|
159
+ # UserListResponse.new(r)
160
+ # end
52
161
  def get_json(url, additional_headers = {}, &create_response)
53
162
  do_json_request(:get, url, nil, additional_headers, &create_response)
54
163
  end
55
164
 
165
+ # Performs a POST request with JSON body
166
+ #
167
+ # @param url [String] The URL to request
168
+ # @param params [Hash] Data to send as JSON in request body (default: {})
169
+ # @param additional_headers [Hash] Additional HTTP headers to include
170
+ #
171
+ # @return [Response] Response object with parsed JSON
172
+ #
173
+ # @raise [HTTPClientError, HTTPServerError] On HTTP errors
174
+ #
175
+ # @example
176
+ # response = client.post_json("https://api.example.com/users", {
177
+ # name: "Alice",
178
+ # email: "alice@example.com"
179
+ # })
56
180
  def post_json(url, params = {}, additional_headers = {})
57
181
  do_json_request(:post, url, params, additional_headers)
58
182
  end
59
183
 
184
+ # Performs a PUT request with JSON body
185
+ #
186
+ # @param url [String] The URL to request
187
+ # @param params [Hash] Data to send as JSON in request body
188
+ # @param additional_headers [Hash] Additional HTTP headers to include
189
+ #
190
+ # @return [Response] Response object with parsed JSON
191
+ #
192
+ # @raise [HTTPClientError, HTTPServerError] On HTTP errors
60
193
  def put_json(url, params, additional_headers = {})
61
194
  do_json_request(:put, url, params, additional_headers)
62
195
  end
63
196
 
197
+ # Performs a PATCH request with JSON body
198
+ #
199
+ # @param url [String] The URL to request
200
+ # @param params [Hash] Data to send as JSON in request body
201
+ # @param additional_headers [Hash] Additional HTTP headers to include
202
+ #
203
+ # @return [Response] Response object with parsed JSON
204
+ #
205
+ # @raise [HTTPClientError, HTTPServerError] On HTTP errors
64
206
  def patch_json(url, params, additional_headers = {})
65
207
  do_json_request(:patch, url, params, additional_headers)
66
208
  end
67
209
 
210
+ # Performs a DELETE request with optional JSON body
211
+ #
212
+ # @param url [String] The URL to request
213
+ # @param params [Hash] Optional data to send as JSON in request body (default: {})
214
+ # @param additional_headers [Hash] Additional HTTP headers to include
215
+ #
216
+ # @return [Response] Response object with parsed JSON
217
+ #
218
+ # @raise [HTTPClientError, HTTPServerError] On HTTP errors
68
219
  def delete_json(url, params = {}, additional_headers = {})
69
220
  do_json_request(:delete, url, params, additional_headers)
70
221
  end
71
222
 
223
+ # Performs a POST request with multipart/form-data
224
+ #
225
+ # @param url [String] The URL to request
226
+ # @param params [Hash] Multipart form data (may include file uploads)
227
+ #
228
+ # @return [Response] Response object
229
+ #
230
+ # @example Uploading a file
231
+ # client.post_multipart("https://api.example.com/upload", {
232
+ # file: File.open("image.jpg", "rb"),
233
+ # description: "Profile photo"
234
+ # })
72
235
  def post_multipart(url, params)
73
236
  r = do_raw_request(:post, url, params.merge(multipart: true))
74
237
  Response.new(r)
75
238
  end
76
239
 
240
+ # Performs a PUT request with multipart/form-data
241
+ #
242
+ # @param url [String] The URL to request
243
+ # @param params [Hash] Multipart form data (may include file uploads)
244
+ #
245
+ # @return [Response] Response object
77
246
  def put_multipart(url, params)
78
247
  r = do_raw_request(:put, url, params.merge(multipart: true))
79
248
  Response.new(r)
@@ -142,9 +311,9 @@ module ApiAdaptor
142
311
  def with_headers(method_params, default_headers, additional_headers)
143
312
  method_params.merge(
144
313
  headers: default_headers
145
- .merge(method_params[:headers] || {})
146
- .merge(ApiAdaptor::Headers.headers)
147
- .merge(additional_headers)
314
+ .merge(method_params[:headers] || {})
315
+ .merge(ApiAdaptor::Headers.headers)
316
+ .merge(additional_headers)
148
317
  )
149
318
  end
150
319
 
@@ -5,31 +5,66 @@ require "api_adaptor/response"
5
5
  require "link_header"
6
6
 
7
7
  module ApiAdaptor
8
- # Response class for lists of multiple items.
8
+ # Response wrapper for paginated API results.
9
9
  #
10
- # This expects responses to be in a common format, with the list of results
11
- # contained under the `results` key. The response may also have previous and
12
- # subsequent pages, indicated by entries in the response's `Link` header.
10
+ # ListResponse handles paginated responses using Link headers (RFC 5988) for navigation.
11
+ # It expects responses to have a "results" array and provides methods to navigate through pages.
12
+ #
13
+ # @example Basic usage
14
+ # response = client.get_list("/posts?page=1")
15
+ # response.results # => Array of items on current page
16
+ # response.next_page? # => true
17
+ # response.next_page # => ListResponse for page 2
18
+ #
19
+ # @example Iterating over current page
20
+ # response.each do |item|
21
+ # puts item["title"]
22
+ # end
23
+ #
24
+ # @example Fetching all pages
25
+ # response.with_subsequent_pages.each do |item|
26
+ # puts item["title"] # Automatically fetches additional pages
27
+ # end
13
28
  class ListResponse < Response
14
- # The ListResponse is instantiated with a reference back to the API client,
15
- # so it can make requests for the subsequent pages
29
+ # Initializes a new ListResponse with API client reference for pagination
30
+ #
31
+ # @param response [RestClient::Response] The raw HTTP response
32
+ # @param api_client [Base] API client instance for fetching additional pages
33
+ # @param options [Hash] Configuration options (see Response#initialize)
16
34
  def initialize(response, api_client, options = {})
17
35
  super(response, options)
18
36
  @api_client = api_client
19
37
  end
20
38
 
21
- # Pass calls to `self.each` to the `results` sub-object, so we can iterate
22
- # over the response directly
39
+ # @!method each(&block)
40
+ # Iterate over results on the current page only
41
+ # @yield [Hash] Each result item
42
+ # @see #with_subsequent_pages for iterating across all pages
43
+ #
44
+ # @!method to_ary
45
+ # Convert results to array
46
+ # @return [Array] Array of result items on current page
23
47
  def_delegators :results, :each, :to_ary
24
48
 
49
+ # Returns the array of results from the current page
50
+ #
51
+ # @return [Array<Hash>] Array of result items
25
52
  def results
26
53
  to_hash["results"]
27
54
  end
28
55
 
56
+ # Checks if there is a next page available
57
+ #
58
+ # @return [Boolean] true if next page exists
29
59
  def next_page?
30
60
  !page_link("next").nil?
31
61
  end
32
62
 
63
+ # Fetches the next page of results
64
+ #
65
+ # Results are memoized to avoid refetching the same page multiple times.
66
+ #
67
+ # @return [ListResponse, nil] Next page response or nil if no next page
33
68
  def next_page
34
69
  # This shouldn't be a performance problem, since the cache will generally
35
70
  # avoid us making multiple requests for the same page, but we shouldn't
@@ -38,33 +73,40 @@ module ApiAdaptor
38
73
  @next_page ||= (@api_client.get_list page_link("next").href if next_page?)
39
74
  end
40
75
 
76
+ # Checks if there is a previous page available
77
+ #
78
+ # @return [Boolean] true if previous page exists
41
79
  def previous_page?
42
80
  !page_link("previous").nil?
43
81
  end
44
82
 
83
+ # Fetches the previous page of results
84
+ #
85
+ # Results are memoized to avoid refetching the same page multiple times.
86
+ #
87
+ # @return [ListResponse, nil] Previous page response or nil if no previous page
45
88
  def previous_page
46
89
  # See the note in `next_page` for why this is memoised
47
90
  @previous_page ||= (@api_client.get_list(page_link("previous").href) if previous_page?)
48
91
  end
49
92
 
50
- # Transparently get all results across all pages. Compare this with #each
51
- # or #results which only iterate over the current page.
93
+ # Returns an enumerator that transparently fetches and iterates over all pages
52
94
  #
53
- # Example:
95
+ # Pages are fetched on demand as you iterate. If you call a method like #count,
96
+ # all pages will be fetched immediately. Results are memoized to avoid duplicate requests.
54
97
  #
55
- # list_response.with_subsequent_pages.each do |result|
56
- # ...
57
- # end
98
+ # @return [Enumerator] Enumerator for all results across all pages
58
99
  #
59
- # or:
100
+ # @example Iterate over all pages
101
+ # response.with_subsequent_pages.each do |item|
102
+ # puts item["title"]
103
+ # end
60
104
  #
61
- # list_response.with_subsequent_pages.count
105
+ # @example Count all items across all pages
106
+ # total_count = response.with_subsequent_pages.count
62
107
  #
63
- # Pages of results are fetched on demand. When iterating, that means
64
- # fetching pages as results from the current page are exhausted. If you
65
- # invoke a method such as #count, this method will fetch all pages at that
66
- # point. Note that the responses are stored so subsequent pages will not be
67
- # loaded multiple times.
108
+ # @example Convert all pages to array
109
+ # all_items = response.with_subsequent_pages.to_a
68
110
  def with_subsequent_pages
69
111
  Enumerator.new do |yielder|
70
112
  each { |i| yielder << i }
@@ -1,92 +1,106 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Null logger class. This is essentially the same as sending data down the
4
- # `/dev/null` black hole.
5
- #
6
- # @example Basic Usage
7
- #
8
- # logger = NullLogger.new
9
- # Rails.logger = logger
10
- #
11
- #
12
- # @example Basic Pattern Usage
13
- # class SomeService
14
- # def initialize(options = {})
15
- # @logger = options[:logger] || NullLogger.new
16
- # end
17
- #
18
- # def perform
19
- # @logger.debug -> { "do some work here" }
20
- # # .. ..
21
- # @logger.info -> { "finished working" }
22
- # end
23
- # end
24
- #
25
- # service = SomeService.new(logger: Logger.new(STDOUT))
26
- # service.perform
27
- #
28
- # silent = SomeService.new(logger: NullLogger.new
29
- # silent.perform
30
- #
31
3
  module ApiAdaptor
4
+ # Null logger that discards all log messages.
5
+ #
6
+ # This logger implements the Logger interface but does nothing with the messages,
7
+ # sending them to the metaphorical /dev/null. Useful for testing or when logging
8
+ # is not desired.
9
+ #
10
+ # @example Basic usage
11
+ # logger = NullLogger.new
12
+ # logger.info("This message is discarded")
13
+ #
14
+ # @example Service pattern with optional logging
15
+ # class SomeService
16
+ # def initialize(options = {})
17
+ # @logger = options[:logger] || NullLogger.new
18
+ # end
19
+ #
20
+ # def perform
21
+ # @logger.debug { "do some work here" }
22
+ # # Work happens...
23
+ # @logger.info { "finished working" }
24
+ # end
25
+ # end
26
+ #
27
+ # # With logging
28
+ # service = SomeService.new(logger: Logger.new($stdout))
29
+ # service.perform
30
+ #
31
+ # # Silent (no logging)
32
+ # silent = SomeService.new # Uses NullLogger by default
33
+ # silent.perform
32
34
  class NullLogger
33
- # @param _args Anything that we want to ignore
35
+ # Logs an unknown severity message (discarded)
36
+ #
37
+ # @param _args [Array] Message arguments (ignored)
34
38
  # @return [nil]
35
39
  def unknown(*_args)
36
40
  nil
37
41
  end
38
42
 
39
- # @param _args Anything that we want to ignore
43
+ # Logs a fatal message (discarded)
44
+ #
45
+ # @param _args [Array] Message arguments (ignored)
40
46
  # @return [nil]
41
47
  def fatal(*_args)
42
48
  nil
43
49
  end
44
50
 
45
- # @return [FALSE]
51
+ # @return [Boolean] false (fatal logging is never enabled)
46
52
  def fatal?
47
53
  false
48
54
  end
49
55
 
50
- # @param _args Anything that we want to ignore
56
+ # Logs an error message (discarded)
57
+ #
58
+ # @param _args [Array] Message arguments (ignored)
51
59
  # @return [nil]
52
60
  def error(*_args)
53
61
  nil
54
62
  end
55
63
 
56
- # @return [FALSE]
64
+ # @return [Boolean] false (error logging is never enabled)
57
65
  def error?
58
66
  false
59
67
  end
60
68
 
61
- # @param _args Anything that we want to ignore
69
+ # Logs a warning message (discarded)
70
+ #
71
+ # @param _args [Array] Message arguments (ignored)
62
72
  # @return [nil]
63
73
  def warn(*_args)
64
74
  nil
65
75
  end
66
76
 
67
- # @return [FALSE]
77
+ # @return [Boolean] false (warn logging is never enabled)
68
78
  def warn?
69
79
  false
70
80
  end
71
81
 
72
- # @param _args Anything that we want to ignore
82
+ # Logs an info message (discarded)
83
+ #
84
+ # @param _args [Array] Message arguments (ignored)
73
85
  # @return [nil]
74
86
  def info(*_args)
75
87
  nil
76
88
  end
77
89
 
78
- # @return [FALSE]
90
+ # @return [Boolean] false (info logging is never enabled)
79
91
  def info?
80
92
  false
81
93
  end
82
94
 
83
- # @param _args Anything that we want to ignore
95
+ # Logs a debug message (discarded)
96
+ #
97
+ # @param _args [Array] Message arguments (ignored)
84
98
  # @return [nil]
85
99
  def debug(*_args)
86
100
  nil
87
101
  end
88
102
 
89
- # @return [FALSE]
103
+ # @return [Boolean] false (debug logging is never enabled)
90
104
  def debug?
91
105
  false
92
106
  end