api_adaptor 0.0.2 → 1.0.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.
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,64 +21,177 @@ 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
+
35
+ ## Releasing
36
+
37
+ Publishing is handled by GitHub Actions when you push a version tag.
38
+
39
+ - RubyGems publishing uses **Trusted Publishing (OIDC)** via `rubygems/release-gem`
40
+ - Ensure `api_adaptor` is configured on RubyGems.org with this repository/workflow as a trusted publisher.
41
+ - Bump `ApiAdaptor::VERSION` in `lib/api_adaptor/version.rb`.
42
+ - Tag the release as `vX.Y.Z` (must match `ApiAdaptor::VERSION`) and push the tag:
43
+
44
+ ```shell
45
+ git tag v0.1.1
46
+ git push origin v0.1.1
47
+ ```
48
+
20
49
  ## Usage
21
50
 
22
51
  Use the ApiAdaptor as a base class for your API wrapper, for example:
23
52
 
24
53
  ```ruby
25
- class MyApi < ApiAdaptor::Base
26
- def base_url
27
- endpoint
28
- end
29
- end
54
+ class MyApi < ApiAdaptor::Base; end
30
55
  ```
31
56
 
32
57
  Use your new class to create a client that can make HTTP requests to JSON APIs for:
33
58
 
34
- ### GET JSON
35
-
36
59
  ```ruby
37
60
  client = MyApi.new
38
- response = client.get_json("http://some.endpoint/json")
61
+ client.get_json("http://some.endpoint/json")
62
+ client.post_json("http://some.endpoint/json", { "foo": "bar" })
63
+ client.put_json("http://some.endpoint/json", { "foo": "bar" })
64
+ client.patch_json("http://some.endpoint/json", { "foo": "bar" })
65
+ client.delete_json("http://some.endpoint/json", { "foo": "bar" })
39
66
  ```
40
67
 
41
- ### POST JSON
68
+ You can also get a raw response from the API
42
69
 
43
70
  ```ruby
44
- client = MyApi.new
45
- response = client.post_json("http://some.endpoint/json", { "foo": "bar" })
71
+ client.get_raw("http://some.endpoint/json")
72
+ ```
73
+
74
+ ### Redirects (3xx)
75
+
76
+ Some APIs return a `3xx` response (commonly `307`/`308`) with a `Location` header that points to the “real” URL.
77
+ `ApiAdaptor::JsonClient` follows these redirects in a controlled way so callers consistently receive a final JSON response.
78
+
79
+ #### Defaults
80
+
81
+ - Redirects are followed for `GET`/`HEAD` when the status is `301`, `302`, `303`, `307`, or `308`.
82
+ - `max_redirects` defaults to `3`.
83
+ - Cross-origin redirects (scheme/host/port change) are allowed by default, but credentials are **not** forwarded.
84
+ - Non-GET requests (`POST`, `PUT`, `PATCH`, `DELETE`) do **not** follow redirects by default.
85
+
86
+ #### Configuration
87
+
88
+ You can configure redirect behaviour by passing options into your `ApiAdaptor::Base` subclass (they are forwarded to `ApiAdaptor::JsonClient`):
89
+
90
+ ```ruby
91
+ client = MyApi.new(
92
+ "https://example.com",
93
+ max_redirects: 3,
94
+ allow_cross_origin_redirects: true,
95
+ forward_auth_on_cross_origin_redirects: false,
96
+ follow_non_get_redirects: false
97
+ )
98
+
99
+ client.get_json("https://example.com/some/endpoint.json")
46
100
  ```
47
101
 
48
- ### PUT JSON
102
+ Or, if you are using `ApiAdaptor::JsonClient` directly:
49
103
 
50
104
  ```ruby
51
- client = MyApi.new
52
- response = client.put_json("http://some.endpoint/json", { "foo": "bar" })
105
+ client = ApiAdaptor::JsonClient.new(
106
+ bearer_token: "SOME_BEARER_TOKEN",
107
+ max_redirects: 3,
108
+ allow_cross_origin_redirects: true,
109
+ forward_auth_on_cross_origin_redirects: false
110
+ )
111
+
112
+ client.get_json("https://example.com/some/endpoint.json")
53
113
  ```
54
114
 
55
- ### PATCH JSON
115
+ #### Security: auth and cross-origin redirects
116
+
117
+ Following redirects across origins can accidentally leak credentials (for example `Authorization` bearer tokens) to an unexpected host.
118
+ To reduce risk:
119
+
120
+ - By default, when the redirect target is cross-origin, `Authorization` is stripped and basic auth is not applied.
121
+ - To completely prevent cross-origin redirects, set `allow_cross_origin_redirects: false`.
122
+ - Only set `forward_auth_on_cross_origin_redirects: true` if you fully trust the redirect target.
123
+
124
+ #### Non-GET redirects (risk of replay)
125
+
126
+ Redirects for non-GET requests are risky because they may cause a request to be replayed (and potentially create duplicate side effects).
127
+ For that reason, redirect-following is disabled for non-GET requests by default.
128
+
129
+ If you do want to follow `307`/`308` for non-GET requests, you can opt in:
56
130
 
57
131
  ```ruby
58
- client = MyApi.new
59
- response = client.patch_json("http://some.endpoint/json", { "foo": "bar" })
132
+ client = MyApi.new(
133
+ "https://example.com",
134
+ follow_non_get_redirects: true
135
+ )
136
+
137
+ client.post_json("https://example.com/some/endpoint.json", { "a" => 1 })
60
138
  ```
61
139
 
62
- ### DELETE JSON
140
+ #### Handling redirect failures
141
+
142
+ You can rescue these redirect-specific exceptions:
143
+
144
+ - `ApiAdaptor::TooManyRedirects` (exceeded `max_redirects`)
145
+ - `ApiAdaptor::RedirectLocationMissing` (a redirect response without a usable `Location`)
146
+
147
+ Example:
63
148
 
64
149
  ```ruby
65
- client = MyApi.new
66
- response = client.delete_json("http://some.endpoint/json", { "foo": "bar" })
150
+ begin
151
+ client.get_json("https://example.com/some/endpoint.json")
152
+ rescue ApiAdaptor::TooManyRedirects, ApiAdaptor::RedirectLocationMissing => e
153
+ # handle / log / retry / surface a friendly message
154
+ raise e
155
+ end
67
156
  ```
68
157
 
69
- ### GET raw requests
158
+ ### Conventional usage
159
+
160
+ An example of how to use this repository to bootstrap an API can be found in the [WikiData REST adaptor](https://github.com/huwd/wikidata_adaptor) it was built for.
70
161
 
71
- you can also get a raw response from the API
162
+ A REST API module can be created with:
72
163
 
73
164
  ```ruby
74
- client = MyApi.new
75
- response = client.get_raw("http://some.endpoint/json")
165
+ module MyApiAdaptor
166
+ # Wikidata REST API class
167
+ class RestApi < ApiAdaptor::Base
168
+ def get_foo(foo_id)
169
+ get_json("#{endpoint}/foo/#{CGI.escape(foo_id)}")
170
+ end
171
+ end
172
+ end
76
173
  ```
77
174
 
175
+ and can be wrapped in a top level module:
176
+
177
+ ```ruby
178
+ module MyApiAdaptor
179
+ class Error < StandardError; end
180
+
181
+ def self.rest_endpoint
182
+ ENV["MYAPI_REST_ENDPOINT"] || "https://example.com"
183
+ end
184
+
185
+ def self.rest_api
186
+ MyApiAdaptor::RestApi.new(rest_endpoint)
187
+ end
188
+ end
189
+ ```
190
+
191
+ The intended convention is to have test helpers ship alongside the actual Adaptor code.
192
+ See [WikiData examples here](https://github.com/huwd/wikidata_adaptor/blob/main/lib/wikidata_adaptor/test_helpers/rest_api.rb).
193
+ This allows other applications that integrate the API Adaptor to easily mock out calls and receive representative data back.
194
+
78
195
  ## Environment variables
79
196
 
80
197
  User Agent is populated with a default string.
@@ -94,6 +211,76 @@ User agent would read
94
211
  test_app/1.0.0 (contact@example.com)
95
212
  ```
96
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
+
97
284
  ## Contributing
98
285
 
99
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]
@@ -0,0 +1,3 @@
1
+ {
2
+ "foo": "bar"
3
+ }
@@ -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,20 +124,43 @@ 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
 
45
- def initialize(endpoint_url, options = {})
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")
161
+ def initialize(endpoint_url = nil, options = {})
46
162
  options[:endpoint_url] = endpoint_url
47
- raise InvalidAPIURL unless endpoint_url =~ URI::RFC3986_Parser::RFC3986_URI
163
+ raise InvalidAPIURL if !endpoint_url.nil? && endpoint_url !~ URI::RFC3986_Parser::RFC3986_URI
48
164
 
49
165
  base_options = { logger: ApiAdaptor::Base.logger }
50
166
  default_options = base_options.merge(ApiAdaptor::Base.default_options || {})
@@ -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,21 +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
10
+ class TooManyRedirects < BaseError; end
11
+
12
+ # Raised when a redirect response is missing the Location header
13
+ class RedirectLocationMissing < BaseError; end
14
+
15
+ # Raised when connection to the endpoint is refused (ECONNREFUSED)
7
16
  class EndpointNotFound < BaseError; end
8
17
 
18
+ # Raised when a request times out
19
+ #
20
+ # @see JSONClient#initialize for timeout configuration
9
21
  class TimedOutException < BaseError; end
10
22
 
23
+ # Raised when an invalid URL is provided
11
24
  class InvalidUrl < BaseError; end
12
25
 
26
+ # Raised when a socket error occurs during the request
13
27
  class SocketErrorException < BaseError; end
14
28
 
15
- # 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
16
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
17
45
  attr_accessor :code, :error_details, :http_body
18
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
19
53
  def initialize(code, message = nil, error_details = nil, http_body = nil)
20
54
  super(message)
21
55
  @code = code
@@ -24,49 +58,84 @@ module ApiAdaptor
24
58
  end
25
59
  end
26
60
 
27
- # Superclass & fallback for all 4XX errors
61
+ # Base class for all HTTP 4xx client errors
28
62
  class HTTPClientError < HTTPErrorResponse; end
29
63
 
64
+ # Base class for intermittent client errors that may succeed on retry
30
65
  class HTTPIntermittentClientError < HTTPClientError; end
31
66
 
67
+ # Raised on HTTP 404 Not Found
32
68
  class HTTPNotFound < HTTPClientError; end
33
69
 
70
+ # Raised on HTTP 410 Gone
34
71
  class HTTPGone < HTTPClientError; end
35
72
 
73
+ # Raised on HTTP 413 Payload Too Large
36
74
  class HTTPPayloadTooLarge < HTTPClientError; end
37
75
 
76
+ # Raised on HTTP 401 Unauthorized
38
77
  class HTTPUnauthorized < HTTPClientError; end
39
78
 
79
+ # Raised on HTTP 403 Forbidden
40
80
  class HTTPForbidden < HTTPClientError; end
41
81
 
82
+ # Raised on HTTP 409 Conflict
42
83
  class HTTPConflict < HTTPClientError; end
43
84
 
85
+ # Raised on HTTP 422 Unprocessable Entity
44
86
  class HTTPUnprocessableEntity < HTTPClientError; end
45
87
 
88
+ # Raised on HTTP 422 Unprocessable Content (alternative name)
89
+ class HTTPUnprocessableContent < HTTPClientError; end
90
+
91
+ # Raised on HTTP 400 Bad Request
46
92
  class HTTPBadRequest < HTTPClientError; end
47
93
 
94
+ # Raised on HTTP 429 Too Many Requests
48
95
  class HTTPTooManyRequests < HTTPIntermittentClientError; end
49
96
 
50
- # Superclass & fallback for all 5XX errors
97
+ # Base class for all HTTP 5xx server errors
51
98
  class HTTPServerError < HTTPErrorResponse; end
52
99
 
100
+ # Base class for intermittent server errors that may succeed on retry
53
101
  class HTTPIntermittentServerError < HTTPServerError; end
54
102
 
103
+ # Raised on HTTP 500 Internal Server Error
55
104
  class HTTPInternalServerError < HTTPServerError; end
56
105
 
106
+ # Raised on HTTP 502 Bad Gateway
57
107
  class HTTPBadGateway < HTTPIntermittentServerError; end
58
108
 
109
+ # Raised on HTTP 503 Service Unavailable
59
110
  class HTTPUnavailable < HTTPIntermittentServerError; end
60
111
 
112
+ # Raised on HTTP 504 Gateway Timeout
61
113
  class HTTPGatewayTimeout < HTTPIntermittentServerError; end
62
114
 
115
+ # Module providing HTTP error handling and exception mapping
63
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
64
126
  def build_specific_http_error(error, url, details = nil)
65
127
  message = "URL: #{url}\nResponse body:\n#{error.http_body}"
66
128
  code = error.http_code
67
129
  error_class_for_code(code).new(code, message, details, error.http_body)
68
130
  end
69
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
70
139
  def error_class_for_code(code)
71
140
  case code
72
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