active_call-api 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0d89661cdfe0817001170ea4c3bbcefdde885817d1083135fb2579508b0495cc
4
+ data.tar.gz: 0f5e046770d937b922e26fe222682889eef76e4493cad13b13b846e0edf5541a
5
+ SHA512:
6
+ metadata.gz: 23546750b36d58b739fa1e02cb1978df326bf7453c40ddf843b28ce17c66737b6a5d5cbd10b2fb25d8605ae0e47ff4479672497529ba74f9fcac8cc56015b5a0
7
+ data.tar.gz: 5f9459ff18547e0bcb1dc8bf2373ef628aa3e30451e7310a04c4f84a3e28095872bab603ce3b6a136d73a1b78a82a3b7d03a96f4a16ea7d2a17cfda4bf669b34
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,73 @@
1
+ plugins:
2
+ - rubocop-performance
3
+ - rubocop-rake
4
+ - rubocop-rspec
5
+
6
+ AllCops:
7
+ TargetRubyVersion: 3.1
8
+ NewCops: enable
9
+ Exclude:
10
+ - active_call-api.gemspec
11
+ - bin/*
12
+ - 'vendor/**/*'
13
+ - 'gem/**/*'
14
+
15
+ Layout/ArgumentAlignment:
16
+ EnforcedStyle: with_fixed_indentation
17
+
18
+ Layout/HashAlignment:
19
+ EnforcedHashRocketStyle: table
20
+ EnforcedColonStyle: table
21
+
22
+ Layout/LineLength:
23
+ Enabled: false
24
+
25
+ Lint/MissingSuper:
26
+ Enabled: false
27
+
28
+ Metrics/AbcSize:
29
+ Enabled: false
30
+
31
+ Metrics/BlockLength:
32
+ Exclude:
33
+ - spec/*/**.rb
34
+ - lib/active_call/api.rb
35
+
36
+ Metrics/ClassLength:
37
+ Enabled: false
38
+
39
+ Metrics/MethodLength:
40
+ Enabled: false
41
+
42
+ Metrics/ModuleLength:
43
+ Enabled: false
44
+
45
+ Naming/MemoizedInstanceVariableName:
46
+ EnforcedStyleForLeadingUnderscores: required
47
+
48
+ RSpec/ExampleLength:
49
+ Enabled: false
50
+
51
+ RSpec/MultipleExpectations:
52
+ Enabled: false
53
+
54
+ # Disabled for now. Can't get it to work with the commented rules.
55
+ Style/ClassAndModuleChildren:
56
+ Enabled: false
57
+ # EnforcedStyle: compact
58
+ # EnforcedStyleForClasses: compact
59
+ # EnforcedStyleForModules: nested
60
+ # Exclude:
61
+ # - lib/active_call/api.rb
62
+
63
+ Style/Documentation:
64
+ Enabled: false
65
+
66
+ Style/StringLiterals:
67
+ EnforcedStyle: single_quotes
68
+
69
+ Style/StringLiteralsInInterpolation:
70
+ EnforcedStyle: single_quotes
71
+
72
+ Style/SymbolArray:
73
+ Enabled: false
data/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-03-25
4
+
5
+ - Initial release.
6
+ - Initial `ActiveCall::*Error` classes with overridable `exception_mapping` and `connection` methods.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Kobus Joubert
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,293 @@
1
+ # Active Call - Api
2
+
3
+ Active Call - API is an extension of [Active Call](https://rubygems.org/gems/active_call) that provides a standardized way to create service objects for REST API endpoints.
4
+
5
+ Before proceeding, please review the [Active Call Usage](https://github.com/kobusjoubert/active_call?tab=readme-ov-file#usage) section. It takes just 55 seconds.
6
+
7
+ ## Installation
8
+
9
+ TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
10
+
11
+ Install the gem and add to the application's Gemfile by executing:
12
+
13
+ ```bash
14
+ bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
15
+ ```
16
+
17
+ If bundler is not being used to manage dependencies, install the gem by executing:
18
+
19
+ ```bash
20
+ gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ Set up an `ActiveCall::Base` base service class for the REST API and include `ActiveCall::Api`.
26
+
27
+ ```ruby
28
+ require 'active_call'
29
+ require 'active_call/api'
30
+
31
+ class YourGem::BaseService < ActiveCall::Base
32
+ include ActiveCall::Api
33
+
34
+ self.abstract_class = true
35
+
36
+ ...
37
+ ```
38
+
39
+ Implement a `connection` method to hold a [Faraday::Connection](https://lostisland.github.io/faraday/#/getting-started/quick-start?id=faraday-connection) object.
40
+
41
+ This connection instance will then be used in the `call` methods of the individual service objects.
42
+
43
+ ```ruby
44
+ class YourGem::BaseService < ActiveCall::Base
45
+ ...
46
+
47
+ config_accessor :api_key, default: ENV['API_KEY'], instance_writer: false
48
+ config_accessor :logger, default: Logger.new($stdout), instance_writer: false
49
+
50
+ def connection
51
+ @_connection ||= Faraday.new do |conn|
52
+ conn.url_prefix = 'https://example.com/api/v1'
53
+ conn.request :authorization, 'X-API-Key', api_key
54
+ conn.request :json
55
+ conn.response :json
56
+ conn.response :logger, logger, formatter: Faraday::Logging::ColorFormatter, prefix: { request: 'YourGem', response: 'YourGem' } do |logger|
57
+ logger.filter(/(Authorization:).*"(.+)."/i, '\1 [FILTERED]')
58
+ end
59
+ conn.adapter Faraday.default_adapter
60
+ end
61
+ end
62
+
63
+ ...
64
+ ```
65
+
66
+ You can now create a REST API service object like so.
67
+
68
+ ```ruby
69
+ class YourGem::SomeResource::UpdateService < YourGem::BaseService
70
+ attr_reader :id, :first_name, :last_name
71
+
72
+ validates :id, :first_name, :last_name, presence: true
73
+
74
+ def initialize(id:, first_name:, last_name:)
75
+ @id = id
76
+ @first_name = first_name
77
+ @last_name = last_name
78
+ end
79
+
80
+ # PUT /api/v1/someresource/:id
81
+ def call
82
+ connection.put("someresource/#{id}", first_name: first_name, last_name: last_name)
83
+ end
84
+ end
85
+ ```
86
+
87
+ ### Using `call`
88
+
89
+ ```ruby
90
+ service = YourGem::SomeResource::UpdateService.call(id: '1', first_name: 'Stan', last_name: 'Marsh')
91
+ service.success? # => true
92
+ service.errors # => #<ActiveModel::Errors []>
93
+ service.response # => #<Faraday::Response ...>
94
+ service.response.status # => 200
95
+ service.response.body # => {}
96
+ ```
97
+
98
+ ### Using `call!`
99
+
100
+ ```ruby
101
+ begin
102
+ service = YourGem::SomeResource::UpdateService.call!(id: '1', first_name: 'Stan', last_name: '')
103
+ rescue ActiveCall::ValidationError => exception
104
+ exception.errors # => #<ActiveModel::Errors [#<ActiveModel::Error attribute=last_name, type=blank, options={}>]>
105
+ exception.errors.full_messages # => ["Last name can't be blank"]
106
+ rescue ActiveCall::UnprocessableEntityError => exception
107
+ exception.errors # => #<ActiveModel::Errors [#<ActiveModel::Error attribute=base, type=unprocessable_entity, options={}>]>
108
+ exception.errors.full_messages # => ["Unprocessable Entity"]
109
+ exception.response # => #<Faraday::Response ...>
110
+ exception.response.status # => 422
111
+ exception.response.body # => {}
112
+ end
113
+ ```
114
+
115
+ ## Errors
116
+
117
+ The following exceptions will get raised when using `call!` and the request was unsuccessful.
118
+
119
+ | HTTP Status Code | Exception Class |
120
+ | :--------------: | ---------------------------------------------- |
121
+ | **4xx** | `ActiveCall::ClientError` |
122
+ | **400** | `ActiveCall::BadRequestError` |
123
+ | **401** | `ActiveCall::UnauthorizedError` |
124
+ | **403** | `ActiveCall::ForbiddenError` |
125
+ | **404** | `ActiveCall::NotFoundError` |
126
+ | **406** | `ActiveCall::NotAcceptableError` |
127
+ | **407** | `ActiveCall::ProxyAuthenticationRequiredError` |
128
+ | **408** | `ActiveCall::RequestTimeoutError` |
129
+ | **409** | `ActiveCall::ConflictError` |
130
+ | **422** | `ActiveCall::UnprocessableEntityError` |
131
+ | **429** | `ActiveCall::TooManyRequestsError` |
132
+ | **5xx** | `ActiveCall::ServerError` |
133
+ | **500** | `ActiveCall::InternalServerError` |
134
+ | **501** | `ActiveCall::NotImplementedError` |
135
+ | **502** | `ActiveCall::BadGatewayError` |
136
+ | **503** | `ActiveCall::ServiceUnavailableError` |
137
+ | **504** | `ActiveCall::GatewayTimeoutError` |
138
+
139
+ **400..499** errors are subclasses of `ActiveCall::ClientError`.
140
+
141
+ **500..599** errors are subclasses of `ActiveCall::ServerError`.
142
+
143
+ For any explicit HTTP status code not listed here, an `ActiveCall::ClientError` exception gets raised for **4xx** HTTP status codes and an `ActiveCall::ServerError` exception for **5xx** HTTP status codes.
144
+
145
+ ### Custom Exception Classes
146
+
147
+ If you want to use your error classes instead, override the `exception_mapping` class method.
148
+
149
+ ```ruby
150
+ class YourGem::BaseService < ActiveCall::Base
151
+ ...
152
+
153
+ class << self
154
+ def exception_mapping
155
+ {
156
+ validation_error: YourGem::ValidationError,
157
+ request_error: YourGem::RequestError,
158
+ client_error: YourGem::ClientError,
159
+ server_error: YourGem::ServerError,
160
+ bad_request: YourGem::BadRequestError,
161
+ unauthorized: YourGem::UnauthorizedError,
162
+ forbidden: YourGem::ForbiddenError,
163
+ not_found: YourGem::NotFoundError,
164
+ not_acceptable: YourGem::NotAcceptableError,
165
+ proxy_authentication_required: YourGem::ProxyAuthenticationRequiredError,
166
+ request_timeout: YourGem::RequestTimeoutError,
167
+ conflict: YourGem::ConflictError,
168
+ unprocessable_entity: YourGem::UnprocessableEntityError,
169
+ too_many_requests: YourGem::TooManyRequestsError,
170
+ internal_server_error: YourGem::InternalServerError,
171
+ bad_gateway: YourGem::BadGatewayError,
172
+ service_unavailable: YourGem::ServiceUnavailableError,
173
+ gateway_timeout: YourGem::GatewayTimeoutError
174
+ }
175
+ end
176
+ end
177
+
178
+ ...
179
+ ```
180
+
181
+ ### Error Types
182
+
183
+ The methods below determine what **type** of error gets added to the **errors** object.
184
+
185
+ ```ruby
186
+ service.errors # => #<ActiveModel::Errors [#<ActiveModel::Error attribute=base, type=bad_request, options={}>]>
187
+ ```
188
+
189
+ When using `.call!`, they map to the `exception_mapping` above, so `bad_request?` maps to `bad_request`.
190
+
191
+ ```ruby
192
+ exception.errors # => #<ActiveModel::Errors [#<ActiveModel::Error attribute=base, type=bad_request, options={}>]>
193
+ ```
194
+
195
+ ```ruby
196
+ class YourGem::BaseService < ActiveCall::Base
197
+ ...
198
+
199
+ def bad_request?
200
+ response.status == 400
201
+ end
202
+
203
+ def unauthorized?
204
+ response.status == 401
205
+ end
206
+
207
+ def forbidden?
208
+ response.status == 403
209
+ end
210
+
211
+ def not_found?
212
+ response.status == 404
213
+ end
214
+
215
+ def not_acceptable?
216
+ response.status == 406
217
+ end
218
+
219
+ def proxy_authentication_required?
220
+ response.status == 407
221
+ end
222
+
223
+ def request_timeout?
224
+ response.status == 408
225
+ end
226
+
227
+ def conflict?
228
+ response.status == 409
229
+ end
230
+
231
+ def unprocessable_entity?
232
+ response.status == 422
233
+ end
234
+
235
+ def too_many_requests?
236
+ response.status == 429
237
+ end
238
+
239
+ def internal_server_error?
240
+ response.status == 500
241
+ end
242
+
243
+ def bad_gateway?
244
+ response.status == 502
245
+ end
246
+
247
+ def service_unavailable?
248
+ response.status == 503
249
+ end
250
+
251
+ def gateway_timeout?
252
+ response.status == 504
253
+ end
254
+ ```
255
+
256
+ These methods can be overridden to add more rules when an API does not respond with the relevant HTTP status code.
257
+
258
+ A common occurrence is when an API returns an HTTP status code of 400 with an error message in the body for anything related to client errors, sometimes even for a resource that could not be found.
259
+
260
+ It is not required to override any of these methods since all **4xx** and **5xx** errors add a `client_error` or `server_error` type to the errors object, respectively.
261
+
262
+ While not required, handling specific errors based on their actual meaning makes for a happier development experience.
263
+
264
+ You have access to the full `Farady::Response` object set to the `response` attribute, so you can use `response.status` and `response.body` to determine the type of error.
265
+
266
+ Perhaps the API does not always respond with a **422** HTTP status code for unprocessable entity requests or a **404** HTTP status for resources not found.
267
+
268
+ ```ruby
269
+ class YourGem::BaseService < ActiveCall::Base
270
+ ...
271
+
272
+ def not_found?
273
+ response.status == 404 || (response.status == 400 && response.body['error_code'] == 'not_found')
274
+ end
275
+
276
+ def unprocessable_entity?
277
+ response.status == 422 || (response.status == 400 && response.body['error_code'] == 'not_processable')
278
+ end
279
+ ```
280
+
281
+ ## Development
282
+
283
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
284
+
285
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
286
+
287
+ ## Contributing
288
+
289
+ Bug reports and pull requests are welcome on GitHub at https://github.com/kobusjoubert/active_call-api.
290
+
291
+ ## License
292
+
293
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require 'rubocop/rake_task'
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,25 @@
1
+ en:
2
+ activemodel:
3
+ errors:
4
+ # The values :model, :attribute and :value are always available for interpolation.
5
+ # The value :count is available when applicable. Can be used for pluralization.
6
+ models:
7
+ active_call/base:
8
+ bad_gateway: 'Bad Gateway'
9
+ bad_request: 'Bad Request'
10
+ client_error: 'Client Error'
11
+ conflict: 'Conflict'
12
+ forbidden: 'Forbidden'
13
+ gateway_timeout: 'Gateway Timeout'
14
+ internal_server_error: 'Internal Server Error'
15
+ not_found: 'Not Found'
16
+ not_acceptable: 'Not Acceptable'
17
+ not_implemented: 'Not Implemented'
18
+ proxy_authentication_required: 'Proxy Authentication Required'
19
+ request_error: 'Request Error'
20
+ request_timeout: 'Request Timeout'
21
+ server_error: 'Server Error'
22
+ service_unavailable: 'Service Unavailable'
23
+ too_many_requests: 'Too Many Requests'
24
+ unauthorized: 'Unauthorized'
25
+ unprocessable_entity: 'Unprocessable Entity'
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveCall
4
+ module Api
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,306 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_call'
4
+ require 'faraday'
5
+ require 'faraday/retry'
6
+ require 'faraday/logging/color_formatter'
7
+
8
+ loader = Zeitwerk::Loader.for_gem
9
+ loader.ignore("#{__dir__}/error.rb")
10
+ loader.collapse("#{__dir__}/api/concerns")
11
+ loader.push_dir(__dir__, namespace: ActiveCall)
12
+ loader.setup
13
+
14
+ require_relative 'error'
15
+ require_relative 'api/version'
16
+
17
+ module ActiveCall::Api
18
+ extend ActiveSupport::Concern
19
+
20
+ included do
21
+ include ActiveModel::Validations
22
+
23
+ validate on: :response do
24
+ throw :abort if response.is_a?(Enumerable)
25
+
26
+ # ==== 5xx
27
+ errors.add(:base, :not_implemented) and throw :abort if not_implemented?
28
+ errors.add(:base, :bad_gateway) and throw :abort if bad_gateway?
29
+ errors.add(:base, :service_unavailable) and throw :abort if service_unavailable?
30
+ errors.add(:base, :gateway_timeout) and throw :abort if gateway_timeout?
31
+ errors.add(:base, :internal_server_error) and throw :abort if internal_server_error?
32
+
33
+ # We'll use `server_error` for every 5xx error that we don't have an explicit exception class for.
34
+ errors.add(:base, :server_error) and throw :abort if response.status >= 500
35
+
36
+ # ==== 4xx
37
+ errors.add(:base, :unauthorized) and throw :abort if unauthorized?
38
+ errors.add(:base, :forbidden) and throw :abort if forbidden?
39
+ errors.add(:base, :not_found) and throw :abort if not_found?
40
+ errors.add(:base, :proxy_authentication_required) and throw :abort if proxy_authentication_required?
41
+ errors.add(:base, :request_timeout) and throw :abort if request_timeout?
42
+ errors.add(:base, :conflict) and throw :abort if conflict?
43
+ errors.add(:base, :unprocessable_entity) and throw :abort if unprocessable_entity?
44
+ errors.add(:base, :too_many_requests) and throw :abort if too_many_requests?
45
+
46
+ # Check for bad_request here since some APIs will use status 400 as a general response for all errors.
47
+ errors.add(:base, :bad_request) and throw :abort if bad_request?
48
+
49
+ # We'll use `client_error` for every 4xx error that we don't have an explicit exception class for.
50
+ errors.add(:base, :client_error) and throw :abort if response.status >= 400
51
+ end
52
+
53
+ private_class_method :api_validation_error
54
+ private_class_method :api_request_error
55
+
56
+ private
57
+
58
+ # Used in Enumerable subclasses when retrieving paginated lists from an API endpoint.
59
+ delegate :exception_for, to: :class
60
+ end
61
+
62
+ class_methods do
63
+ # If you want to use your error classes instead, override the `exception_mapping` class method.
64
+ #
65
+ # ==== Examples
66
+ #
67
+ # class YourGem::BaseService < ActiveCall::Base
68
+ # class << self
69
+ # def exception_mapping
70
+ # {
71
+ # validation_error: YourGem::ValidationError,
72
+ # request_error: YourGem::RequestError,
73
+ # client_error: YourGem::ClientError,
74
+ # server_error: YourGem::ServerError,
75
+ # bad_request: YourGem::BadRequestError,
76
+ # unauthorized: YourGem::UnauthorizedError,
77
+ # ...
78
+ # }
79
+ # end
80
+ #
81
+ def exception_mapping
82
+ {
83
+ validation_error: ActiveCall::ValidationError,
84
+ request_error: ActiveCall::RequestError,
85
+ client_error: ActiveCall::ClientError,
86
+ server_error: ActiveCall::ServerError,
87
+ bad_request: ActiveCall::BadRequestError,
88
+ unauthorized: ActiveCall::UnauthorizedError,
89
+ forbidden: ActiveCall::ForbiddenError,
90
+ not_found: ActiveCall::NotFoundError,
91
+ not_acceptable: ActiveCall::NotAcceptableError,
92
+ proxy_authentication_required: ActiveCall::ProxyAuthenticationRequiredError,
93
+ request_timeout: ActiveCall::RequestTimeoutError,
94
+ conflict: ActiveCall::ConflictError,
95
+ unprocessable_entity: ActiveCall::UnprocessableEntityError,
96
+ too_many_requests: ActiveCall::TooManyRequestsError,
97
+ internal_server_error: ActiveCall::InternalServerError,
98
+ not_implemented: ActiveCall::NotImplementedError,
99
+ bad_gateway: ActiveCall::BadGatewayError,
100
+ service_unavailable: ActiveCall::ServiceUnavailableError,
101
+ gateway_timeout: ActiveCall::GatewayTimeoutError
102
+ }.freeze
103
+ end
104
+
105
+ # Using `call`.
106
+ #
107
+ # ==== Examples
108
+ #
109
+ # service = YourGem::SomeResource::UpdateService.call(id: '1', first_name: 'Stan', last_name: 'Marsh')
110
+ # service.success? # => true
111
+ # service.errors # => #<ActiveModel::Errors []>
112
+ # service.response # => #<Faraday::Response ...>
113
+ # service.response.status # => 200
114
+ # service.response.body # => {}
115
+ #
116
+ # Using `call!`.
117
+ #
118
+ # ==== Examples
119
+ #
120
+ # begin
121
+ # service = YourGem::SomeResource::UpdateService.call!(id: '1', first_name: 'Stan', last_name: '')
122
+ # rescue ActiveCall::ValidationError => exception
123
+ # exception.errors # => #<ActiveModel::Errors [#<ActiveModel::Error attribute=last_name, type=blank, options={}>]>
124
+ # exception.errors.full_messages # => ["Last name can't be blank"]
125
+ # rescue ActiveCall::UnprocessableEntityError => exception
126
+ # exception.errors # => #<ActiveModel::Errors [#<ActiveModel::Error attribute=base, type=unprocessable_entity, options={}>]>
127
+ # exception.errors.full_messages # => ["Unprocessable Entity"]
128
+ # exception.response # => #<Faraday::Response ...>
129
+ # exception.response.status # => 200
130
+ # exception.response.body # => {}
131
+ # end
132
+ #
133
+ def call!(...)
134
+ super
135
+ rescue ActiveCall::ValidationError => e
136
+ raise api_validation_error(e)
137
+ rescue ActiveCall::RequestError => e
138
+ raise api_request_error(e)
139
+ end
140
+
141
+ def exception_for(response, errors, message = nil)
142
+ exception_type = errors.details[:base].first[:error]
143
+
144
+ case exception_type
145
+ when *exception_mapping.keys
146
+ exception_mapping[exception_type].new(response, errors, message)
147
+ else
148
+ exception_mapping[:request_error].new(response, errors, message)
149
+ end
150
+ end
151
+
152
+ def api_validation_error(exception)
153
+ exception_mapping[:validation_error].new(exception.errors, exception.message)
154
+ end
155
+
156
+ def api_request_error(exception)
157
+ exception_for(exception.response, exception.errors, exception.message)
158
+ end
159
+ end
160
+
161
+ # Subclasses must implement a `connection` method to hold a `Faraday::Connection` object.
162
+ #
163
+ # This connection instance will then be used in the `call` methods of the individual service objects.
164
+ #
165
+ # ==== Examples
166
+ #
167
+ # class YourGem::BaseService < ActiveCall::Base
168
+ # config_accessor :api_key, default: ENV['API_KEY'], instance_writer: false
169
+ # config_accessor :logger, default: Logger.new($stdout), instance_writer: false
170
+ #
171
+ # def connection
172
+ # @_connection ||= Faraday.new do |conn|
173
+ # conn.url_prefix = 'https://example.com/api/v1'
174
+ # conn.request :authorization, 'X-API-Key', api_key
175
+ # conn.request :json
176
+ # conn.response :json
177
+ # conn.response :logger, logger, formatter: Faraday::Logging::ColorFormatter, prefix: { request: 'YourGem', response: 'YourGem' } do |logger|
178
+ # logger.filter(/(Authorization:).*"(.+)."/i, '\1 [FILTERED]')
179
+ # end
180
+ # conn.adapter Faraday.default_adapter
181
+ # end
182
+ # end
183
+ #
184
+ # You can now create a REST API service object like so.
185
+ #
186
+ # class YourGem::SomeResource::UpdateService < YourGem::BaseService
187
+ # attr_reader :id, :first_name, :last_name
188
+ #
189
+ # validates :id, :first_name, :last_name, presence: true
190
+ #
191
+ # def initialize(id:, first_name:, last_name:)
192
+ # @id = id
193
+ # @first_name = first_name
194
+ # @last_name = last_name
195
+ # end
196
+ #
197
+ # # PUT /api/v1/someresource/:id
198
+ # def call
199
+ # connection.put("someresource/#{id}", first_name: first_name, last_name: last_name)
200
+ # end
201
+ # end
202
+ #
203
+ def connection
204
+ raise NotImplementedError, 'Subclasses must implement a connection method. Must return a Faraday.new object.'
205
+ end
206
+
207
+ # The methods below determine what type of error gets added to the errors object.
208
+ #
209
+ # service.errors # => #<ActiveModel::Errors [#<ActiveModel::Error attribute=base, type=bad_request, options={}>]>
210
+ #
211
+ # When using `.call!`, they map to the `exception_mapping` above, so `bad_request?` maps to `bad_request`.
212
+ #
213
+ # exception.errors # => #<ActiveModel::Errors [#<ActiveModel::Error attribute=base, type=bad_request, options={}>]>
214
+ #
215
+ # These methods can be overridden to add more rules when an API does not respond with the relevant HTTP status code.
216
+ #
217
+ # A common occurrence is when an API returns an HTTP status code of 400 with an error message in the body for anything
218
+ # related to client errors, sometimes even for a resource that could not be found.
219
+ #
220
+ # It is not required to override any of these methods since all 4xx and 5xx errors add a `client_error` or
221
+ # `server_error` type to the errors object, respectively.
222
+ #
223
+ # While not required, handling specific errors based on their actual meaning makes for a happier development
224
+ # experience.
225
+ #
226
+ # You have access to the full `Farady::Response` object set to the `response` attribute, so you can use
227
+ # `response.status` and `response.body` to determine the type of error.
228
+ #
229
+ # Perhaps the API does not always respond with a 422 HTTP status code for unprocessable entity requests or a 404 HTTP
230
+ # status for resources not found.
231
+ #
232
+ # class YourGem::BaseService < ActiveCall::Base
233
+ # ...
234
+ #
235
+ # def not_found?
236
+ # response.status == 404 || (response.status == 400 && response.body['error_code'] == 'not_found')
237
+ # end
238
+ #
239
+ # def unprocessable_entity?
240
+ # response.status == 422 || (response.status == 400 && response.body['error_code'] == 'not_processable')
241
+ # end
242
+ #
243
+ def bad_request?
244
+ response.status == 400
245
+ end
246
+
247
+ def unauthorized?
248
+ response.status == 401
249
+ end
250
+
251
+ def forbidden?
252
+ response.status == 403
253
+ end
254
+
255
+ def not_found?
256
+ response.status == 404
257
+ end
258
+
259
+ def not_acceptable?
260
+ response.status == 406
261
+ end
262
+
263
+ def proxy_authentication_required?
264
+ response.status == 407
265
+ end
266
+
267
+ def request_timeout?
268
+ response.status == 408
269
+ end
270
+
271
+ def conflict?
272
+ response.status == 409
273
+ end
274
+
275
+ def unprocessable_entity?
276
+ response.status == 422
277
+ end
278
+
279
+ def too_many_requests?
280
+ response.status == 429
281
+ end
282
+
283
+ def internal_server_error?
284
+ response.status == 500
285
+ end
286
+
287
+ def not_implemented?
288
+ response.status == 501
289
+ end
290
+
291
+ def bad_gateway?
292
+ response.status == 502
293
+ end
294
+
295
+ def service_unavailable?
296
+ response.status == 503
297
+ end
298
+
299
+ def gateway_timeout?
300
+ response.status == 504
301
+ end
302
+ end
303
+
304
+ ActiveSupport.on_load(:i18n) do
305
+ I18n.load_path << File.expand_path('api/locale/en.yml', __dir__)
306
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveCall
4
+ # 400..499
5
+ class ClientError < RequestError; end
6
+
7
+ # 400
8
+ class BadRequestError < ClientError; end
9
+
10
+ # 401
11
+ class UnauthorizedError < ClientError; end
12
+
13
+ # 403
14
+ class ForbiddenError < ClientError; end
15
+
16
+ # 404
17
+ class NotFoundError < ClientError; end
18
+
19
+ # 406
20
+ class NotAcceptableError < ClientError; end
21
+
22
+ # 407
23
+ class ProxyAuthenticationRequiredError < ClientError; end
24
+
25
+ # 408
26
+ class RequestTimeoutError < ClientError; end
27
+
28
+ # 409
29
+ class ConflictError < ClientError; end
30
+
31
+ # 422
32
+ class UnprocessableEntityError < ClientError; end
33
+
34
+ # 429
35
+ class TooManyRequestsError < ClientError; end
36
+
37
+ # 500..599
38
+ class ServerError < RequestError; end
39
+
40
+ # 500
41
+ class InternalServerError < ServerError; end
42
+
43
+ # 501
44
+ class NotImplementedError < ServerError; end
45
+
46
+ # 502
47
+ class BadGatewayError < ServerError; end
48
+
49
+ # 503
50
+ class ServiceUnavailableError < ServerError; end
51
+
52
+ # 504
53
+ class GatewayTimeoutError < ServerError; end
54
+ end
@@ -0,0 +1,6 @@
1
+ module ActiveCall
2
+ module Api
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,116 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: active_call-api
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kobus Joubert
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-03-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: active_call
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: faraday-retry
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '2.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '2.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: faraday-logging-color_formatter
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.2'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.2'
69
+ description: Active Call - API is an extension of Active Call that provides a standardized
70
+ way to create service objects for REST API endpoints.
71
+ email:
72
+ - kobus@translate3d.com
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - ".rspec"
78
+ - ".rubocop.yml"
79
+ - CHANGELOG.md
80
+ - LICENSE.txt
81
+ - README.md
82
+ - Rakefile
83
+ - lib/active_call/api.rb
84
+ - lib/active_call/api/locale/en.yml
85
+ - lib/active_call/api/version.rb
86
+ - lib/active_call/error.rb
87
+ - sig/active_call/api.rbs
88
+ homepage: https://github.com/kobusjoubert/active_call-api
89
+ licenses:
90
+ - MIT
91
+ metadata:
92
+ allowed_push_host: https://rubygems.org
93
+ rubygems_mfa_required: 'false'
94
+ homepage_uri: https://github.com/kobusjoubert/active_call-api
95
+ source_code_uri: https://github.com/kobusjoubert/active_call-api
96
+ changelog_uri: https://github.com/kobusjoubert/active_call-api/blob/main/CHANGELOG.md
97
+ post_install_message:
98
+ rdoc_options: []
99
+ require_paths:
100
+ - lib
101
+ required_ruby_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: 3.1.0
106
+ required_rubygems_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ requirements: []
112
+ rubygems_version: 3.3.27
113
+ signing_key:
114
+ specification_version: 4
115
+ summary: Active Call - API
116
+ test_files: []