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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +73 -0
- data/CHANGELOG.md +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +293 -0
- data/Rakefile +12 -0
- data/lib/active_call/api/locale/en.yml +25 -0
- data/lib/active_call/api/version.rb +7 -0
- data/lib/active_call/api.rb +306 -0
- data/lib/active_call/error.rb +54 -0
- data/sig/active_call/api.rbs +6 -0
- metadata +116 -0
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
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
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,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,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
|
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: []
|