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.
data/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # ApiAdaptor
2
2
 
3
+ [![CI](https://github.com/huwd/api_adaptor/actions/workflows/quality-checks.yml/badge.svg)](https://github.com/huwd/api_adaptor/actions/workflows/quality-checks.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/api_adaptor.svg)](https://badge.fury.io/rb/api_adaptor)
5
+ [![Documentation](https://img.shields.io/badge/docs-YARD-blue.svg)](https://huwd.github.io/api_adaptor/)
6
+
3
7
  A basic adaptor to send HTTP requests and parse the responses.
4
8
  Intended to bootstrap the quick writing of Adaptors for specific APIs, without having to write the same old JSON request and processing time and time again.
5
9
 
@@ -17,6 +21,17 @@ If bundler is not being used to manage dependencies, install the gem by executin
17
21
  gem install api_adaptor
18
22
  ```
19
23
 
24
+ ## API Documentation
25
+
26
+ Full API documentation is available at [https://huwd.github.io/api_adaptor/](https://huwd.github.io/api_adaptor/)
27
+
28
+ Generate documentation locally:
29
+
30
+ ```bash
31
+ bundle exec yard # Generate docs to doc/
32
+ bundle exec yard server # Preview at http://localhost:8808
33
+ ```
34
+
20
35
  ## Releasing
21
36
 
22
37
  Publishing is handled by GitHub Actions when you push a version tag.
@@ -196,6 +211,76 @@ User agent would read
196
211
  test_app/1.0.0 (contact@example.com)
197
212
  ```
198
213
 
214
+ ## Development
215
+
216
+ After checking out the repo, run `bundle install` to install dependencies.
217
+
218
+ ### Running Tests
219
+
220
+ ```bash
221
+ bundle exec rspec # Run tests only
222
+ bundle exec rubocop # Run linter only
223
+ bundle exec rake # Run tests, linter, and build docs
224
+ ```
225
+
226
+ ### Code Coverage
227
+
228
+ SimpleCov tracks test coverage. View the report at `coverage/index.html` after running tests.
229
+
230
+ Current coverage: 92.3% line, 81.65% branch
231
+
232
+ ### Documentation
233
+
234
+ Generate YARD documentation:
235
+
236
+ ```bash
237
+ bundle exec yard # Generate docs to doc/
238
+ bundle exec yard server # Preview at http://localhost:8808
239
+ bundle exec yard stats # View documentation coverage
240
+ ```
241
+
242
+ ### Quality Standards
243
+
244
+ - **Tests:** RSpec with WebMock for HTTP mocking
245
+ - **Linting:** RuboCop with rubocop-yard for documentation
246
+ - **Coverage:** SimpleCov (minimum 80% line, 75% branch)
247
+ - **Documentation:** YARD for all public APIs
248
+
249
+ ## Troubleshooting
250
+
251
+ ### Redirects Not Following
252
+
253
+ By default, only GET/HEAD requests follow redirects. For POST/PUT/PATCH/DELETE:
254
+
255
+ ```ruby
256
+ client = MyApi.new("https://example.com", follow_non_get_redirects: true)
257
+ ```
258
+
259
+ ### Timeout Errors
260
+
261
+ Default timeout is 4 seconds. Configure longer timeouts:
262
+
263
+ ```ruby
264
+ client = MyApi.new("https://example.com", timeout: 10)
265
+ ```
266
+
267
+ ### Cross-Origin Redirect Security
268
+
269
+ By default, auth headers are stripped on cross-origin redirects. To allow (use with caution):
270
+
271
+ ```ruby
272
+ client = MyApi.new(
273
+ "https://example.com",
274
+ forward_auth_on_cross_origin_redirects: true # Security risk!
275
+ )
276
+ ```
277
+
278
+ Or disable cross-origin redirects entirely:
279
+
280
+ ```ruby
281
+ client = MyApi.new("https://example.com", allow_cross_origin_redirects: false)
282
+ ```
283
+
199
284
  ## Contributing
200
285
 
201
286
  Bug reports and pull requests are welcome on GitHub at <https://github.com/huwd/api_adaptor>. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/huwd/api_adaptor/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile CHANGED
@@ -9,4 +9,10 @@ require "rubocop/rake_task"
9
9
 
10
10
  RuboCop::RakeTask.new
11
11
 
12
- task default: %i[spec rubocop]
12
+ require "yard"
13
+
14
+ YARD::Rake::YardocTask.new(:yard) do |t|
15
+ t.stats_options = ["--list-undoc"]
16
+ end
17
+
18
+ task default: %i[spec rubocop yard]
@@ -6,20 +6,113 @@ require_relative "null_logger"
6
6
  require_relative "list_response"
7
7
 
8
8
  module ApiAdaptor
9
+ # Base class for building API-specific clients.
10
+ #
11
+ # Provides common functionality for JSON API clients including HTTP method delegation,
12
+ # URL construction, and pagination support. Subclass this to create clients for specific APIs.
13
+ #
14
+ # @example Creating a custom API client
15
+ # class MyApiClient < ApiAdaptor::Base
16
+ # def initialize
17
+ # super("https://api.example.com", bearer_token: "abc123")
18
+ # end
19
+ #
20
+ # def get_user(id)
21
+ # get_json("/users/#{id}")
22
+ # end
23
+ #
24
+ # def list_posts(page: 1)
25
+ # get_list("/posts?page=#{page}")
26
+ # end
27
+ # end
28
+ #
29
+ # @example Using default options
30
+ # ApiAdaptor::Base.default_options = { timeout: 10 }
31
+ # client = MyApiClient.new # Inherits 10-second timeout
32
+ #
33
+ # @see JSONClient for underlying HTTP client options
9
34
  class Base
35
+ # Raised when an invalid API URL is provided
10
36
  class InvalidAPIURL < StandardError
11
37
  end
12
38
 
13
39
  extend Forwardable
14
40
 
41
+ # Returns the underlying JSONClient instance, creating it if necessary
42
+ #
43
+ # @return [JSONClient] The HTTP client instance
15
44
  def client
16
45
  @client ||= create_client
17
46
  end
18
47
 
48
+ # Creates a new JSONClient with the configured options
49
+ #
50
+ # @return [JSONClient] A new HTTP client instance
19
51
  def create_client
20
52
  ApiAdaptor::JsonClient.new(options)
21
53
  end
22
54
 
55
+ # @!method get_json(url, &block)
56
+ # Performs a GET request and parses JSON response
57
+ # @param url [String] The URL to request
58
+ # @yield [Hash] The parsed JSON response
59
+ # @return [Response, Object] Response object or yielded value
60
+ # @see JSONClient#get_json
61
+ #
62
+ # @!method post_json(url, params = {})
63
+ # Performs a POST request with JSON body
64
+ # @param url [String] The URL to request
65
+ # @param params [Hash] Data to send as JSON
66
+ # @return [Response] Response object
67
+ # @see JSONClient#post_json
68
+ #
69
+ # @!method put_json(url, params = {})
70
+ # Performs a PUT request with JSON body
71
+ # @param url [String] The URL to request
72
+ # @param params [Hash] Data to send as JSON
73
+ # @return [Response] Response object
74
+ # @see JSONClient#put_json
75
+ #
76
+ # @!method patch_json(url, params = {})
77
+ # Performs a PATCH request with JSON body
78
+ # @param url [String] The URL to request
79
+ # @param params [Hash] Data to send as JSON
80
+ # @return [Response] Response object
81
+ # @see JSONClient#patch_json
82
+ #
83
+ # @!method delete_json(url, params = {})
84
+ # Performs a DELETE request
85
+ # @param url [String] The URL to request
86
+ # @param params [Hash] Optional data to send as JSON
87
+ # @return [Response] Response object
88
+ # @see JSONClient#delete_json
89
+ #
90
+ # @!method get_raw(url)
91
+ # Performs a GET request and returns raw response
92
+ # @param url [String] The URL to request
93
+ # @return [RestClient::Response] Raw response object
94
+ # @see JSONClient#get_raw
95
+ #
96
+ # @!method get_raw!(url)
97
+ # Performs a GET request and returns raw response, raising on errors
98
+ # @param url [String] The URL to request
99
+ # @return [RestClient::Response] Raw response object
100
+ # @raise [HTTPClientError, HTTPServerError] On HTTP errors
101
+ # @see JSONClient#get_raw!
102
+ #
103
+ # @!method put_multipart(url, params = {})
104
+ # Performs a PUT request with multipart/form-data
105
+ # @param url [String] The URL to request
106
+ # @param params [Hash] Multipart form data
107
+ # @return [Response] Response object
108
+ # @see JSONClient#put_multipart
109
+ #
110
+ # @!method post_multipart(url, params = {})
111
+ # Performs a POST request with multipart/form-data
112
+ # @param url [String] The URL to request
113
+ # @param params [Hash] Multipart form data
114
+ # @return [Response] Response object
115
+ # @see JSONClient#post_multipart
23
116
  def_delegators :client,
24
117
  :get_json,
25
118
  :post_json,
@@ -31,17 +124,40 @@ module ApiAdaptor
31
124
  :put_multipart,
32
125
  :post_multipart
33
126
 
127
+ # @return [Hash] The client configuration options
34
128
  attr_reader :options
35
129
 
36
130
  class << self
131
+ # @!attribute [w] logger
132
+ # Sets the default logger for all Base instances
133
+ # @param value [Logger] Logger instance
37
134
  attr_writer :logger
135
+
136
+ # @!attribute [rw] default_options
137
+ # Default options merged into all Base instances
138
+ # @return [Hash, nil] Default options hash
38
139
  attr_accessor :default_options
39
140
  end
40
141
 
142
+ # Returns the default logger for Base instances
143
+ #
144
+ # @return [Logger] Logger instance (defaults to NullLogger)
41
145
  def self.logger
42
146
  @logger ||= ApiAdaptor::NullLogger.new
43
147
  end
44
148
 
149
+ # Initializes a new API client
150
+ #
151
+ # @param endpoint_url [String, nil] Base URL for the API
152
+ # @param options [Hash] Configuration options (see JSONClient#initialize for details)
153
+ #
154
+ # @raise [InvalidAPIURL] If endpoint_url is invalid
155
+ #
156
+ # @example Basic initialization
157
+ # client = Base.new("https://api.example.com")
158
+ #
159
+ # @example With authentication
160
+ # client = Base.new("https://api.example.com", bearer_token: "abc123")
45
161
  def initialize(endpoint_url = nil, options = {})
46
162
  options[:endpoint_url] = endpoint_url
47
163
  raise InvalidAPIURL if !endpoint_url.nil? && endpoint_url !~ URI::RFC3986_Parser::RFC3986_URI
@@ -52,10 +168,31 @@ module ApiAdaptor
52
168
  self.endpoint = options[:endpoint_url]
53
169
  end
54
170
 
171
+ # Constructs a URL for a given slug with query parameters
172
+ #
173
+ # @param slug [String] The API endpoint slug
174
+ # @param options [Hash] Query parameters to append
175
+ #
176
+ # @return [String] Full URL with .json extension and query string
177
+ #
178
+ # @example
179
+ # url_for_slug("users/123", include: "posts")
180
+ # # => "https://api.example.com/users/123.json?include=posts"
55
181
  def url_for_slug(slug, options = {})
56
182
  "#{base_url}/#{slug}.json#{query_string(options)}"
57
183
  end
58
184
 
185
+ # Performs a GET request and wraps the response in a ListResponse
186
+ #
187
+ # @param url [String] The URL to request
188
+ #
189
+ # @return [ListResponse] Paginated response wrapper
190
+ #
191
+ # @example
192
+ # list = client.get_list("/posts?page=1")
193
+ # list.results # => Array of items
194
+ # list.current_page # => 1
195
+ # list.total_pages # => 10
59
196
  def get_list(url)
60
197
  get_json(url) do |r|
61
198
  ApiAdaptor::ListResponse.new(r, self)
@@ -1,25 +1,55 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ApiAdaptor
4
- # Abstract error class
4
+ # Base exception class for all ApiAdaptor errors
5
5
  class BaseError < StandardError; end
6
6
 
7
+ # Raised when too many redirects are followed
8
+ #
9
+ # @see JSONClient#max_redirects
7
10
  class TooManyRedirects < BaseError; end
8
11
 
12
+ # Raised when a redirect response is missing the Location header
9
13
  class RedirectLocationMissing < BaseError; end
10
14
 
15
+ # Raised when connection to the endpoint is refused (ECONNREFUSED)
11
16
  class EndpointNotFound < BaseError; end
12
17
 
18
+ # Raised when a request times out
19
+ #
20
+ # @see JSONClient#initialize for timeout configuration
13
21
  class TimedOutException < BaseError; end
14
22
 
23
+ # Raised when an invalid URL is provided
15
24
  class InvalidUrl < BaseError; end
16
25
 
26
+ # Raised when a socket error occurs during the request
17
27
  class SocketErrorException < BaseError; end
18
28
 
19
- # Superclass for all 4XX and 5XX errors
29
+ # Base class for all HTTP 4xx and 5xx error responses
30
+ #
31
+ # Provides access to the HTTP status code, error details, and response body.
32
+ #
33
+ # @example Handling HTTP errors
34
+ # begin
35
+ # client.get_json(url)
36
+ # rescue ApiAdaptor::HTTPNotFound => e
37
+ # puts "Resource not found: #{e.code}"
38
+ # rescue ApiAdaptor::HTTPServerError => e
39
+ # puts "Server error: #{e.code} - #{e.error_details}"
40
+ # end
20
41
  class HTTPErrorResponse < BaseError
42
+ # @return [Integer] HTTP status code
43
+ # @return [Hash, nil] Parsed error details from response body
44
+ # @return [String, nil] Raw HTTP response body
21
45
  attr_accessor :code, :error_details, :http_body
22
46
 
47
+ # Initializes a new HTTP error response
48
+ #
49
+ # @param code [Integer] HTTP status code
50
+ # @param message [String, nil] Error message
51
+ # @param error_details [Hash, nil] Parsed error details from JSON body
52
+ # @param http_body [String, nil] Raw HTTP response body
23
53
  def initialize(code, message = nil, error_details = nil, http_body = nil)
24
54
  super(message)
25
55
  @code = code
@@ -28,51 +58,84 @@ module ApiAdaptor
28
58
  end
29
59
  end
30
60
 
31
- # Superclass & fallback for all 4XX errors
61
+ # Base class for all HTTP 4xx client errors
32
62
  class HTTPClientError < HTTPErrorResponse; end
33
63
 
64
+ # Base class for intermittent client errors that may succeed on retry
34
65
  class HTTPIntermittentClientError < HTTPClientError; end
35
66
 
67
+ # Raised on HTTP 404 Not Found
36
68
  class HTTPNotFound < HTTPClientError; end
37
69
 
70
+ # Raised on HTTP 410 Gone
38
71
  class HTTPGone < HTTPClientError; end
39
72
 
73
+ # Raised on HTTP 413 Payload Too Large
40
74
  class HTTPPayloadTooLarge < HTTPClientError; end
41
75
 
76
+ # Raised on HTTP 401 Unauthorized
42
77
  class HTTPUnauthorized < HTTPClientError; end
43
78
 
79
+ # Raised on HTTP 403 Forbidden
44
80
  class HTTPForbidden < HTTPClientError; end
45
81
 
82
+ # Raised on HTTP 409 Conflict
46
83
  class HTTPConflict < HTTPClientError; end
47
84
 
85
+ # Raised on HTTP 422 Unprocessable Entity
48
86
  class HTTPUnprocessableEntity < HTTPClientError; end
49
87
 
88
+ # Raised on HTTP 422 Unprocessable Content (alternative name)
50
89
  class HTTPUnprocessableContent < HTTPClientError; end
51
90
 
91
+ # Raised on HTTP 400 Bad Request
52
92
  class HTTPBadRequest < HTTPClientError; end
53
93
 
94
+ # Raised on HTTP 429 Too Many Requests
54
95
  class HTTPTooManyRequests < HTTPIntermittentClientError; end
55
96
 
56
- # Superclass & fallback for all 5XX errors
97
+ # Base class for all HTTP 5xx server errors
57
98
  class HTTPServerError < HTTPErrorResponse; end
58
99
 
100
+ # Base class for intermittent server errors that may succeed on retry
59
101
  class HTTPIntermittentServerError < HTTPServerError; end
60
102
 
103
+ # Raised on HTTP 500 Internal Server Error
61
104
  class HTTPInternalServerError < HTTPServerError; end
62
105
 
106
+ # Raised on HTTP 502 Bad Gateway
63
107
  class HTTPBadGateway < HTTPIntermittentServerError; end
64
108
 
109
+ # Raised on HTTP 503 Service Unavailable
65
110
  class HTTPUnavailable < HTTPIntermittentServerError; end
66
111
 
112
+ # Raised on HTTP 504 Gateway Timeout
67
113
  class HTTPGatewayTimeout < HTTPIntermittentServerError; end
68
114
 
115
+ # Module providing HTTP error handling and exception mapping
69
116
  module ExceptionHandling
117
+ # Builds a specific HTTP error exception based on the status code
118
+ #
119
+ # @param error [RestClient::Exception] The RestClient exception
120
+ # @param url [String] The URL that was requested
121
+ # @param details [Hash, nil] Parsed error details from JSON response
122
+ #
123
+ # @return [HTTPErrorResponse] Specific exception instance
124
+ #
125
+ # @api private
70
126
  def build_specific_http_error(error, url, details = nil)
71
127
  message = "URL: #{url}\nResponse body:\n#{error.http_body}"
72
128
  code = error.http_code
73
129
  error_class_for_code(code).new(code, message, details, error.http_body)
74
130
  end
75
131
 
132
+ # Maps HTTP status codes to exception classes
133
+ #
134
+ # @param code [Integer] HTTP status code
135
+ #
136
+ # @return [Class] Exception class for the status code
137
+ #
138
+ # @api private
76
139
  def error_class_for_code(code)
77
140
  case code
78
141
  when 400
@@ -1,22 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ApiAdaptor
4
+ # Thread-safe header management for HTTP requests
5
+ #
6
+ # Headers are stored in thread-local storage, allowing different threads
7
+ # to maintain separate header contexts without interference.
8
+ #
9
+ # @example Setting custom headers
10
+ # ApiAdaptor::Headers.set_header("X-Request-ID", "12345")
11
+ # ApiAdaptor::Headers.set_header("X-Correlation-ID", "abcde")
12
+ #
13
+ # @example Getting all headers
14
+ # headers = ApiAdaptor::Headers.headers
15
+ # # => {"X-Request-ID" => "12345", "X-Correlation-ID" => "abcde"}
16
+ #
17
+ # @example Clearing headers
18
+ # ApiAdaptor::Headers.clear_headers
4
19
  class Headers
5
20
  class << self
21
+ # Sets a header value for the current thread
22
+ #
23
+ # @param header_name [String] Header name
24
+ # @param value [String] Header value
25
+ #
26
+ # @return [String] The value that was set
6
27
  def set_header(header_name, value)
7
28
  header_data[header_name] = value
8
29
  end
9
30
 
31
+ # Returns all non-empty headers for the current thread
32
+ #
33
+ # @return [Hash] Hash of header names to values, excluding nil/empty values
10
34
  def headers
11
35
  header_data.reject { |_k, v| v.nil? || v.empty? }
12
36
  end
13
37
 
38
+ # Clears all headers for the current thread
39
+ #
40
+ # @return [Hash] Empty hash
14
41
  def clear_headers
15
42
  Thread.current[:headers] = {}
16
43
  end
17
44
 
18
45
  private
19
46
 
47
+ # Returns the thread-local header storage
48
+ #
49
+ # @return [Hash] Thread-local header hash
50
+ #
51
+ # @api private
20
52
  def header_data
21
53
  Thread.current[:headers] ||= {}
22
54
  end