http-client-generator 0.2.1 → 0.2.2
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 +4 -4
- data/LICENSE.txt +21 -0
- data/README.md +359 -19
- data/lib/http_client_generator/inflector.rb +22 -0
- data/lib/http_client_generator/key_transformer.rb +20 -0
- data/lib/http_client_generator/plugs/camelize_body.rb +2 -2
- data/lib/http_client_generator/plugs/underscore_response.rb +3 -2
- data/lib/http_client_generator/request.rb +12 -8
- data/lib/http_client_generator/resource.rb +39 -17
- data/lib/http_client_generator/resources_definition.rb +21 -6
- data/lib/http_client_generator/timeout_normalizer.rb +41 -0
- data/lib/http_client_generator/version.rb +1 -1
- data/lib/http_client_generator.rb +7 -10
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: edc63e0a164b0694ebe93647137b6b825061c57064cf851f4675c5e2955e6fd6
|
|
4
|
+
data.tar.gz: 68ee612dc6a37f278351736ab53fe8d2a85598314c570b65bb4e9f7daffc9463
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6b15241122952c07b5560047c505e5bffb0a2f6c2ff80c5626de3a152636766fd9a61e72ff01c9734b06a235aa7aaa33796af544c621e801c5088377f22f50f6
|
|
7
|
+
data.tar.gz: a5663a4c5f8f1c8daeb0e83f3cbefff6f5d6499f5f0ff5011c8324cd9a27780ed0bffce2b9902f37386ad1e91890f532f6e825d536d0e897fbaa2838a7bd4dcc
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pavel Egorov
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
CHANGED
|
@@ -1,39 +1,379 @@
|
|
|
1
|
-
#
|
|
1
|
+
# HttpClientGenerator
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`http-client-generator` is a small Ruby DSL for building API clients on top of the
|
|
4
|
+
[`http`](https://github.com/httprb/http) gem.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
You declare resources, attach request and response plugs, provide a URL helper,
|
|
7
|
+
and the gem generates class-level request methods such as `get_user`,
|
|
8
|
+
`post_issue`, or `patch_repository`.
|
|
6
9
|
|
|
7
10
|
## Installation
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
Add the gem to your application:
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
```ruby
|
|
15
|
+
gem 'http-client-generator'
|
|
16
|
+
```
|
|
12
17
|
|
|
13
|
-
|
|
18
|
+
Then run:
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
```sh
|
|
21
|
+
bundle install
|
|
22
|
+
```
|
|
18
23
|
|
|
19
24
|
## Usage
|
|
20
25
|
|
|
21
|
-
|
|
26
|
+
Define a client module, include `HttpClientGenerator`, declare resources, and
|
|
27
|
+
provide a URL helper module with methods that match each resource name.
|
|
22
28
|
|
|
23
|
-
|
|
29
|
+
This example sketches a small GitHub API client.
|
|
24
30
|
|
|
25
|
-
|
|
31
|
+
```ruby
|
|
32
|
+
require 'http_client_generator'
|
|
33
|
+
require 'zeitwerk'
|
|
26
34
|
|
|
27
|
-
|
|
35
|
+
Zeitwerk::Loader.for_gem.setup
|
|
28
36
|
|
|
29
|
-
|
|
37
|
+
module GitHub
|
|
38
|
+
include HttpClientGenerator
|
|
30
39
|
|
|
31
|
-
|
|
40
|
+
Configuration = Struct.new(:base_url, :access_token, keyword_init: true)
|
|
32
41
|
|
|
33
|
-
|
|
42
|
+
class InvalidConfigurationError < Error
|
|
43
|
+
ACCESS_TOKEN_ERROR = 'Access token should be a non-empty String.'
|
|
44
|
+
BASE_URL_ERROR = 'Base URL should be a non-empty String.'
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
resources do
|
|
48
|
+
req_plug :set_request_id, :x_request_id
|
|
49
|
+
req_plug :set_header, header: :accept, value: 'application/vnd.github+json'
|
|
50
|
+
req_plug :set_header, header: :x_github_api_version, value: '2022-11-28'
|
|
51
|
+
req_plug :set_header,
|
|
52
|
+
header: :authorization,
|
|
53
|
+
value: -> { "Bearer #{GitHub.config.access_token}" }
|
|
54
|
+
|
|
55
|
+
req_plug :camelize_body
|
|
56
|
+
|
|
57
|
+
resp_plug :enforce_json_response
|
|
58
|
+
resp_plug :underscore_response
|
|
59
|
+
|
|
60
|
+
timeout connect: 2, read: 5, write: 5
|
|
61
|
+
|
|
62
|
+
get :user
|
|
63
|
+
get :repository
|
|
64
|
+
get :repository_issues, timeout: 10
|
|
65
|
+
post :issue
|
|
66
|
+
patch :issue
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
url_helper UrlHelper
|
|
70
|
+
|
|
71
|
+
process_config do |config|
|
|
72
|
+
validation_errors = []
|
|
73
|
+
|
|
74
|
+
unless config.access_token.is_a?(String) && !config.access_token.empty?
|
|
75
|
+
validation_errors << InvalidConfigurationError::ACCESS_TOKEN_ERROR
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
unless config.base_url.is_a?(String) && !config.base_url.empty?
|
|
79
|
+
validation_errors << InvalidConfigurationError::BASE_URL_ERROR
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
raise InvalidConfigurationError, validation_errors.join(' ') if validation_errors.any?
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
require 'uri'
|
|
89
|
+
|
|
90
|
+
module GitHub
|
|
91
|
+
module UrlHelper
|
|
92
|
+
include HttpClientGenerator::UrlBuilder
|
|
93
|
+
|
|
94
|
+
def user(_options = {})
|
|
95
|
+
"#{config.base_url}/user"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def repository(options)
|
|
99
|
+
"#{config.base_url}/repos/#{options.fetch(:owner)}/#{options.fetch(:repo)}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def repository_issues(options)
|
|
103
|
+
build_url(
|
|
104
|
+
url: "#{config.base_url}/repos/#{options.fetch(:owner)}/#{options.fetch(:repo)}/issues",
|
|
105
|
+
query: options.slice(:state, :labels, :since, :per_page, :page),
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def issue(options)
|
|
110
|
+
issue_number = options[:issue_number]
|
|
111
|
+
path = "#{config.base_url}/repos/#{options.fetch(:owner)}/#{options.fetch(:repo)}/issues"
|
|
112
|
+
|
|
113
|
+
issue_number ? "#{path}/#{issue_number}" : path
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def config = GitHub.config
|
|
119
|
+
|
|
120
|
+
def build_url(url:, query: {})
|
|
121
|
+
uri = URI(url)
|
|
122
|
+
query_values = query.compact.transform_keys { |key| HttpClientGenerator::Inflector.camelize_lower(key) }
|
|
123
|
+
|
|
124
|
+
uri.query = URI.encode_www_form(query_values) if query_values.any?
|
|
125
|
+
uri.to_s
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Configure the client before making requests:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
GitHub.configure do |config|
|
|
135
|
+
config.base_url = 'https://api.github.com'
|
|
136
|
+
config.access_token = ENV.fetch('GITHUB_TOKEN')
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Then call generated methods. Method names are built from the HTTP verb and
|
|
141
|
+
resource name:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
GitHub.get_user
|
|
145
|
+
|
|
146
|
+
GitHub.get_user(timeout: 1)
|
|
147
|
+
|
|
148
|
+
GitHub.get_repository(owner: 'rails', repo: 'rails')
|
|
149
|
+
|
|
150
|
+
GitHub.get_repository_issues(
|
|
151
|
+
{ owner: 'rails', repo: 'rails', state: 'open', per_page: 10 },
|
|
152
|
+
request_id: SecureRandom.uuid,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
GitHub.post_issue(
|
|
156
|
+
{ owner: 'octo-org', repo: 'octo-repo' },
|
|
157
|
+
body: {
|
|
158
|
+
title: 'Bug report',
|
|
159
|
+
body: 'Steps to reproduce...',
|
|
160
|
+
labels: ['bug'],
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
GitHub.patch_issue(
|
|
165
|
+
{ owner: 'octo-org', repo: 'octo-repo', issue_number: 42 },
|
|
166
|
+
body: {
|
|
167
|
+
state: 'closed',
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Resources
|
|
173
|
+
|
|
174
|
+
Declare resources inside a `resources` block:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
resources do
|
|
178
|
+
get :user
|
|
179
|
+
post :issue
|
|
180
|
+
put :repository
|
|
181
|
+
patch :issue
|
|
182
|
+
end
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Each resource generates a singleton method on the client module:
|
|
186
|
+
|
|
187
|
+
| Declaration | Generated method |
|
|
188
|
+
| --- | --- |
|
|
189
|
+
| `get :user` | `get_user` |
|
|
190
|
+
| `post :issue` | `post_issue` |
|
|
191
|
+
| `put :repository` | `put_repository` |
|
|
192
|
+
| `patch :issue` | `patch_issue` |
|
|
193
|
+
|
|
194
|
+
Generated methods accept URL options as the first positional argument, optional
|
|
195
|
+
`body:` and `timeout:` keywords, and any extra keyword arguments consumed by
|
|
196
|
+
plugs:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
Client.post_resource(
|
|
200
|
+
{ id: 1 },
|
|
201
|
+
body: { name: 'Example' },
|
|
202
|
+
timeout: 2,
|
|
203
|
+
request_id: 'abc-123',
|
|
204
|
+
)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
JSON is the default content type. Use `content_type: :text` for plain text
|
|
208
|
+
request bodies:
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
resources do
|
|
212
|
+
post :webhook_test, content_type: :text
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Timeouts
|
|
217
|
+
|
|
218
|
+
Timeouts mirror the `http` gem API. Use a numeric value for a global timeout,
|
|
219
|
+
per-operation values for connect/read/write limits, or `:null` to explicitly
|
|
220
|
+
disable timeout handling.
|
|
34
221
|
|
|
35
|
-
|
|
222
|
+
```ruby
|
|
223
|
+
resources do
|
|
224
|
+
timeout 5
|
|
36
225
|
|
|
37
|
-
|
|
226
|
+
get :user
|
|
227
|
+
get :repository_issues, timeout: { read: 10 }
|
|
228
|
+
|
|
229
|
+
namespace do
|
|
230
|
+
timeout connect: 1, read: 3, write: 3
|
|
231
|
+
|
|
232
|
+
post :issue
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
get :stream, timeout: :null
|
|
236
|
+
end
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Timeout precedence, from highest to lowest:
|
|
240
|
+
|
|
241
|
+
1. Per-call `timeout:`
|
|
242
|
+
2. Resource-level `timeout:`
|
|
243
|
+
3. Current scoped `timeout`
|
|
244
|
+
4. No timeout configuration
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
Client.get_user(timeout: 1)
|
|
248
|
+
Client.post_issue({ id: 1 }, body: { title: 'Bug' }, timeout: :null)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## URL Helpers
|
|
252
|
+
|
|
253
|
+
Register a URL helper with `url_helper`. The helper must respond to every
|
|
254
|
+
resource name and return a complete URL.
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
module Client
|
|
258
|
+
module UrlHelper
|
|
259
|
+
include HttpClientGenerator::UrlBuilder
|
|
260
|
+
|
|
261
|
+
def resource(options)
|
|
262
|
+
"#{Client.config.base_url}/resources/#{options.fetch(:id)}"
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
Client.url_helper Client::UrlHelper
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`HttpClientGenerator::UrlBuilder` extends the helper module so resource URLs can
|
|
271
|
+
be called by the generated client methods.
|
|
272
|
+
|
|
273
|
+
## Plugs
|
|
274
|
+
|
|
275
|
+
Plugs are objects that respond to `call(request)`. Request plugs run before the
|
|
276
|
+
HTTP request. Response plugs run after the response body is read.
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
resources do
|
|
280
|
+
req_plug :set_request_id, :x_request_id
|
|
281
|
+
req_plug :set_bearer_token, from_arg: :access_token
|
|
282
|
+
|
|
283
|
+
resp_plug :enforce_json_response
|
|
284
|
+
resp_plug :underscore_response
|
|
285
|
+
|
|
286
|
+
get :profile
|
|
287
|
+
end
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Built-in request plugs:
|
|
291
|
+
|
|
292
|
+
| Plug | Purpose |
|
|
293
|
+
| --- | --- |
|
|
294
|
+
| `:set_request_id` | Sets a generated or provided request id header. |
|
|
295
|
+
| `:set_header` | Sets a static, dynamic, or argument-derived header. |
|
|
296
|
+
| `:set_bearer_token` | Sets `Authorization: Bearer ...` from an extra method argument. |
|
|
297
|
+
| `:camelize_body` | Camelizes JSON request body keys. |
|
|
298
|
+
| `:validate_request` | Validates request bodies with a schema helper. |
|
|
299
|
+
|
|
300
|
+
Built-in response plugs:
|
|
301
|
+
|
|
302
|
+
| Plug | Purpose |
|
|
303
|
+
| --- | --- |
|
|
304
|
+
| `:enforce_json_response` | Parses the response as JSON and raises on invalid JSON. |
|
|
305
|
+
| `:encode_json_response` | Parses JSON responses when possible and leaves invalid JSON unchanged. |
|
|
306
|
+
| `:underscore_response` | Underscores parsed response keys. |
|
|
307
|
+
| `:validate_response` | Validates parsed response bodies with a schema helper. |
|
|
308
|
+
|
|
309
|
+
Limit plugs to specific resources with `only:` or `except:`:
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
resources do
|
|
313
|
+
req_plug :camelize_body, only: %i[create_issue update_issue]
|
|
314
|
+
resp_plug :enforce_json_response, except: :download_archive
|
|
315
|
+
|
|
316
|
+
post :create_issue
|
|
317
|
+
patch :update_issue
|
|
318
|
+
get :download_archive, content_type: :text
|
|
319
|
+
end
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
You can also pass a callable plug:
|
|
323
|
+
|
|
324
|
+
```ruby
|
|
325
|
+
resources do
|
|
326
|
+
req_plug ->(request) {
|
|
327
|
+
request.headers[:user_agent] = 'my-client/1.0'
|
|
328
|
+
request
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
get :user
|
|
332
|
+
end
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
## Configuration
|
|
336
|
+
|
|
337
|
+
The client module owns its configuration type. Define `Configuration`, then call
|
|
338
|
+
`configure`:
|
|
339
|
+
|
|
340
|
+
```ruby
|
|
341
|
+
module Client
|
|
342
|
+
include HttpClientGenerator
|
|
343
|
+
|
|
344
|
+
Configuration = Struct.new(:base_url, :access_token, keyword_init: true)
|
|
345
|
+
|
|
346
|
+
process_config do |config|
|
|
347
|
+
raise Error, 'Missing base URL' unless config.base_url.is_a?(String)
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
Client.configure do |config|
|
|
352
|
+
config.base_url = 'https://api.example.com'
|
|
353
|
+
config.access_token = ENV.fetch('ACCESS_TOKEN')
|
|
354
|
+
end
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
The final configuration object is frozen after `process_config` runs.
|
|
358
|
+
|
|
359
|
+
## Development
|
|
360
|
+
|
|
361
|
+
After checking out the repo, run:
|
|
362
|
+
|
|
363
|
+
```sh
|
|
364
|
+
bin/setup
|
|
365
|
+
bundle exec rake spec
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
You can also run `bin/console` for an interactive prompt.
|
|
369
|
+
|
|
370
|
+
To install this gem locally:
|
|
371
|
+
|
|
372
|
+
```sh
|
|
373
|
+
bundle exec rake install
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
## License
|
|
38
377
|
|
|
39
|
-
|
|
378
|
+
The gem is available as open source under the terms of the
|
|
379
|
+
[MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HttpClientGenerator
|
|
4
|
+
module Inflector # :nodoc:
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def camelize_lower(value)
|
|
8
|
+
value.to_s.split('_').then do |parts|
|
|
9
|
+
[parts.first, *parts.drop(1).map(&:capitalize)].join
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def underscore(value)
|
|
14
|
+
value.to_s
|
|
15
|
+
.gsub('::', '/')
|
|
16
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
17
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
18
|
+
.tr('-', '_')
|
|
19
|
+
.downcase
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HttpClientGenerator
|
|
4
|
+
module KeyTransformer # :nodoc:
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def deep_transform_keys(value, &block)
|
|
8
|
+
case value
|
|
9
|
+
when Hash
|
|
10
|
+
value.each_with_object({}) do |(key, hash_value), transformed|
|
|
11
|
+
transformed[yield(key)] = deep_transform_keys(hash_value, &block)
|
|
12
|
+
end
|
|
13
|
+
when Array
|
|
14
|
+
value.map { |item| deep_transform_keys(item, &block) }
|
|
15
|
+
else
|
|
16
|
+
value
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module HttpClientGenerator
|
|
4
4
|
class Plugs
|
|
5
|
-
class CamelizeBody
|
|
5
|
+
class CamelizeBody # :nodoc:
|
|
6
6
|
Plugs.register :camelize_body, self
|
|
7
7
|
|
|
8
8
|
def call(req)
|
|
9
9
|
body =
|
|
10
10
|
if req.json? && req.body.is_a?(Hash)
|
|
11
|
-
req.body
|
|
11
|
+
KeyTransformer.deep_transform_keys(req.body) { |key| Inflector.camelize_lower(key).to_sym }
|
|
12
12
|
else
|
|
13
13
|
req.body
|
|
14
14
|
end
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module HttpClientGenerator
|
|
4
4
|
class Plugs
|
|
5
|
-
class UnderscoreResponse
|
|
5
|
+
class UnderscoreResponse # :nodoc:
|
|
6
6
|
Plugs.register :underscore_response, self
|
|
7
7
|
|
|
8
8
|
def call(req)
|
|
9
9
|
return req unless req.response_body.is_a?(Hash)
|
|
10
10
|
|
|
11
|
-
req.response_body =
|
|
11
|
+
req.response_body =
|
|
12
|
+
KeyTransformer.deep_transform_keys(req.response_body) { |key| Inflector.underscore(key).to_sym }
|
|
12
13
|
|
|
13
14
|
req
|
|
14
15
|
end
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module HttpClientGenerator
|
|
4
|
-
class Request
|
|
4
|
+
class Request # :nodoc:
|
|
5
5
|
CONTENT_TYPES = %i[json text].freeze
|
|
6
6
|
|
|
7
7
|
DEFAULT_HEADERS_BY_CONTENT_TYPE = {
|
|
8
8
|
json: { accept: 'application/json', content_type: 'application/json' },
|
|
9
|
-
text: { content_type: 'text/plain' }
|
|
9
|
+
text: { content_type: 'text/plain' }
|
|
10
10
|
}.freeze
|
|
11
11
|
|
|
12
12
|
CONTENT_TYPES.each do |type|
|
|
@@ -15,19 +15,23 @@ module HttpClientGenerator
|
|
|
15
15
|
end
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
attr_accessor :verb, :name, :content_type, :url, :headers, :body, :rest_args, :response_body,
|
|
18
|
+
attr_accessor :verb, :name, :content_type, :timeout, :url, :headers, :body, :rest_args, :response_body,
|
|
19
|
+
:base, :extra
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
# rubocop:disable Metrics/ParameterLists
|
|
22
|
+
def initialize(base:, name:, verb:, content_type:, timeout:, url:, body:, rest_args:)
|
|
21
23
|
@base = base
|
|
22
24
|
@name = name
|
|
23
25
|
@verb = verb
|
|
24
26
|
@content_type = content_type
|
|
27
|
+
@timeout = timeout
|
|
25
28
|
@url = url
|
|
26
29
|
@rest_args = rest_args
|
|
27
30
|
@body = body
|
|
28
31
|
@headers = {}
|
|
29
32
|
@extra = {}
|
|
30
33
|
end
|
|
34
|
+
# rubocop:enable Metrics/ParameterLists
|
|
31
35
|
|
|
32
36
|
def current_headers
|
|
33
37
|
default_headers.merge(headers)
|
|
@@ -35,15 +39,15 @@ module HttpClientGenerator
|
|
|
35
39
|
|
|
36
40
|
def raw_body
|
|
37
41
|
if json?
|
|
38
|
-
body
|
|
42
|
+
body&.to_json
|
|
39
43
|
else
|
|
40
44
|
body
|
|
41
45
|
end
|
|
42
46
|
end
|
|
43
47
|
|
|
44
|
-
def raise_error(
|
|
45
|
-
Sentry.capture_exception(
|
|
46
|
-
raise base::RequestError,
|
|
48
|
+
def raise_error(error)
|
|
49
|
+
Sentry.capture_exception(error, extra: { request_id: extra[:request_id] }) if Object.const_defined?(:Sentry)
|
|
50
|
+
raise base::RequestError, error.message, error.backtrace, cause: nil
|
|
47
51
|
end
|
|
48
52
|
|
|
49
53
|
def raise_message(message)
|
|
@@ -3,28 +3,25 @@
|
|
|
3
3
|
require 'http'
|
|
4
4
|
|
|
5
5
|
module HttpClientGenerator
|
|
6
|
-
class Resource
|
|
7
|
-
attr_reader :verb, :content_type, :name, :req_plugs, :resp_plugs, :base
|
|
6
|
+
class Resource # :nodoc:
|
|
7
|
+
attr_reader :verb, :content_type, :name, :req_plugs, :resp_plugs, :base, :timeout
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
# rubocop:disable Metrics/ParameterLists
|
|
10
|
+
def initialize(verb:, content_type:, timeout:, name:, base:, req_plugs:, resp_plugs:)
|
|
10
11
|
@verb = verb
|
|
11
12
|
@base = base
|
|
12
13
|
@content_type = content_type
|
|
14
|
+
@timeout = timeout
|
|
13
15
|
@name = name
|
|
14
16
|
@req_plugs = select_plugs(req_plugs)
|
|
15
17
|
@resp_plugs = select_plugs(resp_plugs)
|
|
16
18
|
end
|
|
19
|
+
# rubocop:enable Metrics/ParameterLists
|
|
17
20
|
|
|
18
|
-
def perform_request(url_helper, url_options, body, rest_args)
|
|
19
|
-
request = prepare_request(url_helper, url_options, body, rest_args)
|
|
21
|
+
def perform_request(url_helper, url_options, body, rest_args, timeout_override)
|
|
22
|
+
request = prepare_request(url_helper, url_options, body, rest_args, timeout_override)
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if request.body
|
|
24
|
-
request_attributes << { body: request.raw_body }
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
request.response_body = HTTP[request.current_headers].public_send(*request_attributes).to_s
|
|
24
|
+
request.response_body = perform_http_request(request).to_s
|
|
28
25
|
|
|
29
26
|
process_response(request)
|
|
30
27
|
rescue HTTP::Error => e
|
|
@@ -33,14 +30,37 @@ module HttpClientGenerator
|
|
|
33
30
|
|
|
34
31
|
private
|
|
35
32
|
|
|
36
|
-
def prepare_request(url_helper, url_options, body, rest_args)
|
|
33
|
+
def prepare_request(url_helper, url_options, body, rest_args, timeout_override)
|
|
37
34
|
url = url_helper.public_send(:"#{name}", url_options)
|
|
35
|
+
request = build_request(url, body, rest_args, timeout_override)
|
|
36
|
+
|
|
37
|
+
req_plugs.reduce(request) { |req, plug| plug.call(req) }
|
|
38
|
+
end
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
def build_request(url, body, rest_args, timeout_override)
|
|
41
|
+
Request.new(
|
|
42
|
+
name: name,
|
|
43
|
+
verb: verb,
|
|
44
|
+
url: url,
|
|
45
|
+
content_type: content_type,
|
|
46
|
+
timeout: timeout_override.nil? ? timeout : TimeoutNormalizer.call(timeout_override),
|
|
47
|
+
body: body,
|
|
48
|
+
rest_args: rest_args,
|
|
49
|
+
base: base
|
|
41
50
|
)
|
|
51
|
+
end
|
|
42
52
|
|
|
43
|
-
|
|
53
|
+
def perform_http_request(request)
|
|
54
|
+
http_client = HTTP[request.current_headers]
|
|
55
|
+
http_client = http_client.timeout(request.timeout) unless request.timeout.nil?
|
|
56
|
+
|
|
57
|
+
http_client.public_send(*request_attributes(request))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def request_attributes(request)
|
|
61
|
+
attributes = [request.verb, request.url]
|
|
62
|
+
attributes << { body: request.raw_body } if request.body
|
|
63
|
+
attributes
|
|
44
64
|
end
|
|
45
65
|
|
|
46
66
|
def process_response(request)
|
|
@@ -51,7 +71,9 @@ module HttpClientGenerator
|
|
|
51
71
|
|
|
52
72
|
def select_plugs(plug_entries)
|
|
53
73
|
plug_entries.filter_map do |entry|
|
|
54
|
-
|
|
74
|
+
only = entry[:only]
|
|
75
|
+
except = entry[:except]
|
|
76
|
+
plug = entry[:plug]
|
|
55
77
|
|
|
56
78
|
next if only.any? && !only.include?(name)
|
|
57
79
|
next if except.include?(name)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module HttpClientGenerator
|
|
4
|
-
class ResourcesDefinition
|
|
4
|
+
class ResourcesDefinition # :nodoc:
|
|
5
5
|
attr_reader :resources
|
|
6
6
|
|
|
7
7
|
DEFAULT_OPTIONS = {
|
|
@@ -10,11 +10,12 @@ module HttpClientGenerator
|
|
|
10
10
|
|
|
11
11
|
HTTP_VERBS = %i[get post put patch].freeze
|
|
12
12
|
|
|
13
|
-
def initialize(base, req_plugs = [], resp_plugs = [])
|
|
13
|
+
def initialize(base, req_plugs = [], resp_plugs = [], timeout = nil)
|
|
14
14
|
@base = base
|
|
15
15
|
@resources = []
|
|
16
16
|
@req_plugs = req_plugs
|
|
17
17
|
@resp_plugs = resp_plugs
|
|
18
|
+
@timeout = timeout
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
HTTP_VERBS.each do |verb|
|
|
@@ -31,8 +32,12 @@ module HttpClientGenerator
|
|
|
31
32
|
@resp_plugs << build_plug_entry(plug, *args, only: only, except: except, **kwargs)
|
|
32
33
|
end
|
|
33
34
|
|
|
34
|
-
def
|
|
35
|
-
|
|
35
|
+
def timeout(value = nil, **options)
|
|
36
|
+
@timeout = TimeoutNormalizer.call(value, **options)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def namespace(_name = nil, &block)
|
|
40
|
+
namespaced_definition = ResourcesDefinition.new(@base, @req_plugs.dup, @resp_plugs.dup, @timeout)
|
|
36
41
|
namespaced_definition.instance_eval(&block)
|
|
37
42
|
@resources += namespaced_definition.resources
|
|
38
43
|
end
|
|
@@ -58,18 +63,28 @@ module HttpClientGenerator
|
|
|
58
63
|
end
|
|
59
64
|
|
|
60
65
|
def build_resource(verb, name, options)
|
|
66
|
+
resource_options = with_defaults(options)
|
|
67
|
+
|
|
61
68
|
Resource.new(
|
|
62
69
|
verb: verb,
|
|
63
70
|
name: name,
|
|
64
71
|
base: @base,
|
|
65
72
|
req_plugs: @req_plugs,
|
|
66
73
|
resp_plugs: @resp_plugs,
|
|
67
|
-
**
|
|
74
|
+
**resource_options
|
|
68
75
|
)
|
|
69
76
|
end
|
|
70
77
|
|
|
71
78
|
def with_defaults(options)
|
|
72
|
-
DEFAULT_OPTIONS.merge(options)
|
|
79
|
+
resource_options = DEFAULT_OPTIONS.merge(options)
|
|
80
|
+
resource_options[:timeout] =
|
|
81
|
+
if options.key?(:timeout)
|
|
82
|
+
TimeoutNormalizer.call(options[:timeout])
|
|
83
|
+
else
|
|
84
|
+
@timeout
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
resource_options
|
|
73
88
|
end
|
|
74
89
|
end
|
|
75
90
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HttpClientGenerator
|
|
4
|
+
module TimeoutNormalizer # :nodoc:
|
|
5
|
+
VALID_KEYS = %i[connect read write].freeze
|
|
6
|
+
|
|
7
|
+
ERROR_MESSAGE = 'Use timeout(global), timeout(:null), or timeout(connect: x, read: y, write: z).'
|
|
8
|
+
|
|
9
|
+
def self.call(value = nil, **options)
|
|
10
|
+
timeout_value = build_timeout_value(value, options)
|
|
11
|
+
case timeout_value
|
|
12
|
+
when Numeric, :null
|
|
13
|
+
timeout_value
|
|
14
|
+
when Hash
|
|
15
|
+
normalize_hash(timeout_value)
|
|
16
|
+
else
|
|
17
|
+
raise ArgumentError, ERROR_MESSAGE
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.build_timeout_value(value, options)
|
|
22
|
+
if options.any?
|
|
23
|
+
raise ArgumentError, ERROR_MESSAGE unless value.nil?
|
|
24
|
+
|
|
25
|
+
options
|
|
26
|
+
else
|
|
27
|
+
value
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
private_class_method :build_timeout_value
|
|
31
|
+
|
|
32
|
+
def self.normalize_hash(timeout_value)
|
|
33
|
+
invalid_keys = timeout_value.keys - VALID_KEYS
|
|
34
|
+
|
|
35
|
+
raise ArgumentError, "Unknown timeout option(s): #{invalid_keys.join(', ')}." if invalid_keys.any?
|
|
36
|
+
|
|
37
|
+
timeout_value.dup
|
|
38
|
+
end
|
|
39
|
+
private_class_method :normalize_hash
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -2,14 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require 'zeitwerk'
|
|
4
4
|
|
|
5
|
-
require 'active_support/core_ext/string/inflections'
|
|
6
|
-
require 'active_support/core_ext/hash/keys'
|
|
7
|
-
|
|
8
5
|
Zeitwerk::Loader.for_gem.setup
|
|
9
6
|
|
|
10
|
-
Dir["#{__dir__}/http_client_generator/plugs/*.rb"].each { |f| require f }
|
|
7
|
+
Dir["#{__dir__}/http_client_generator/plugs/*.rb"].sort.each { |f| require f }
|
|
11
8
|
|
|
12
|
-
module HttpClientGenerator
|
|
9
|
+
module HttpClientGenerator # :nodoc:
|
|
13
10
|
def self.included(base)
|
|
14
11
|
super
|
|
15
12
|
|
|
@@ -30,8 +27,8 @@ module HttpClientGenerator
|
|
|
30
27
|
resources.each do |resource|
|
|
31
28
|
method_name = :"#{resource.verb}_#{resource.name}"
|
|
32
29
|
|
|
33
|
-
define_singleton_method(method_name) do |url_options = {}, body: nil, **rest_args|
|
|
34
|
-
resource.perform_request(@url_helper, url_options, body, rest_args)
|
|
30
|
+
define_singleton_method(method_name) do |url_options = {}, body: nil, timeout: nil, **rest_args|
|
|
31
|
+
resource.perform_request(@url_helper, url_options, body, rest_args, timeout)
|
|
35
32
|
end
|
|
36
33
|
end
|
|
37
34
|
end
|
|
@@ -40,9 +37,9 @@ module HttpClientGenerator
|
|
|
40
37
|
|
|
41
38
|
def configure(&configure)
|
|
42
39
|
@config = self::Configuration.new
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
.tap(&configure)
|
|
41
|
+
.tap { |c| user_process_config(c) if respond_to?(:user_process_config) }
|
|
42
|
+
.tap(&:freeze)
|
|
46
43
|
end
|
|
47
44
|
|
|
48
45
|
def process_config(&block)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: http-client-generator
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Pavel Egorov
|
|
@@ -44,8 +44,11 @@ executables: []
|
|
|
44
44
|
extensions: []
|
|
45
45
|
extra_rdoc_files: []
|
|
46
46
|
files:
|
|
47
|
+
- LICENSE.txt
|
|
47
48
|
- README.md
|
|
48
49
|
- lib/http_client_generator.rb
|
|
50
|
+
- lib/http_client_generator/inflector.rb
|
|
51
|
+
- lib/http_client_generator/key_transformer.rb
|
|
49
52
|
- lib/http_client_generator/plugs.rb
|
|
50
53
|
- lib/http_client_generator/plugs/camelize_body.rb
|
|
51
54
|
- lib/http_client_generator/plugs/encode_json_response.rb
|
|
@@ -60,6 +63,7 @@ files:
|
|
|
60
63
|
- lib/http_client_generator/resource.rb
|
|
61
64
|
- lib/http_client_generator/resources_definition.rb
|
|
62
65
|
- lib/http_client_generator/schema_builder.rb
|
|
66
|
+
- lib/http_client_generator/timeout_normalizer.rb
|
|
63
67
|
- lib/http_client_generator/url_builder.rb
|
|
64
68
|
- lib/http_client_generator/version.rb
|
|
65
69
|
licenses: []
|