client-api-builder 0.5.6 → 0.6.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 +4 -4
- data/.github/workflows/ci.yml +53 -0
- data/.rubocop.yml +79 -0
- data/ARCHITECTURE.md +161 -86
- data/CLAUDE.md +92 -0
- data/Gemfile +3 -2
- data/Gemfile.lock +46 -39
- data/README.md +427 -92
- data/client-api-builder.gemspec +20 -4
- data/examples/basic_auth_example_client.rb +6 -5
- data/examples/imdb_datasets_client.rb +2 -0
- data/examples/lorem_ipsum_client.rb +3 -1
- data/lib/client-api-builder.rb +1 -0
- data/lib/client_api_builder/active_support_log_subscriber.rb +1 -0
- data/lib/client_api_builder/active_support_notifications.rb +8 -6
- data/lib/client_api_builder/nested_router.rb +3 -3
- data/lib/client_api_builder/net_http_request.rb +37 -5
- data/lib/client_api_builder/query_params.rb +9 -3
- data/lib/client_api_builder/router.rb +210 -125
- data/lib/client_api_builder/section.rb +11 -11
- data/script/console +1 -1
- metadata +20 -10
data/README.md
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
# Client API Builder
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://badge.fury.io/rb/client-api-builder)
|
|
4
|
+
[](https://github.com/dougyouch/client-api-builder/actions/workflows/ci.yml)
|
|
5
|
+
[](https://codecov.io/gh/dougyouch/client-api-builder)
|
|
6
|
+
|
|
7
|
+
A Ruby gem for building robust, secure API clients through declarative configuration. Define your API endpoints and their behavior with minimal boilerplate while benefiting from built-in security features, automatic retries, and comprehensive error handling.
|
|
4
8
|
|
|
5
9
|
## Features
|
|
6
10
|
|
|
7
|
-
- Declarative API
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
11
|
+
- **Declarative Configuration** - Define API endpoints with a clean DSL
|
|
12
|
+
- **Security by Default** - SSL/TLS verification, path traversal protection, SSRF prevention
|
|
13
|
+
- **Automatic HTTP Method Detection** - Intelligently determines HTTP methods from route names
|
|
14
|
+
- **Flexible Request Building** - Support for JSON, query params, and custom body builders
|
|
15
|
+
- **Nested Routing** - Organize complex APIs with hierarchical route structures
|
|
16
|
+
- **Retry Logic** - Configurable automatic retries for transient network failures
|
|
17
|
+
- **Streaming Support** - Handle large payloads efficiently with streaming to files or IO
|
|
18
|
+
- **ActiveSupport Integration** - Optional logging and instrumentation
|
|
19
|
+
- **Comprehensive Error Handling** - Detailed error information for debugging
|
|
16
20
|
|
|
17
21
|
## Installation
|
|
18
22
|
|
|
@@ -28,181 +32,512 @@ And then execute:
|
|
|
28
32
|
$ bundle install
|
|
29
33
|
```
|
|
30
34
|
|
|
31
|
-
Or install it yourself
|
|
35
|
+
Or install it yourself:
|
|
32
36
|
|
|
33
37
|
```bash
|
|
34
38
|
$ gem install client-api-builder
|
|
35
39
|
```
|
|
36
40
|
|
|
37
|
-
##
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
class GitHubClient
|
|
45
|
+
include ClientApiBuilder::Router
|
|
46
|
+
|
|
47
|
+
base_url 'https://api.github.com'
|
|
48
|
+
|
|
49
|
+
header 'Accept', 'application/vnd.github.v3+json'
|
|
50
|
+
header 'User-Agent', 'MyApp/1.0'
|
|
51
|
+
|
|
52
|
+
# Authentication header from instance method
|
|
53
|
+
header 'Authorization' do
|
|
54
|
+
"Bearer #{access_token}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
attr_accessor :access_token
|
|
58
|
+
|
|
59
|
+
# GET /users/:username
|
|
60
|
+
route :get_user, '/users/:username'
|
|
61
|
+
|
|
62
|
+
# GET /users/:username/repos
|
|
63
|
+
route :get_repos, '/users/:username/repos', query: { per_page: :per_page }
|
|
64
|
+
|
|
65
|
+
# POST /user/repos
|
|
66
|
+
route :create_repo, '/user/repos', body: { name: :name, private: :private }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
client = GitHubClient.new
|
|
70
|
+
client.access_token = 'ghp_xxxxxxxxxxxx'
|
|
71
|
+
|
|
72
|
+
# Fetch a user
|
|
73
|
+
user = client.get_user(username: 'octocat')
|
|
74
|
+
|
|
75
|
+
# List repositories with pagination
|
|
76
|
+
repos = client.get_repos(username: 'octocat', per_page: 10)
|
|
77
|
+
|
|
78
|
+
# Create a new repository
|
|
79
|
+
new_repo = client.create_repo(name: 'my-new-repo', private: true)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Usage Guide
|
|
83
|
+
|
|
84
|
+
### Defining Routes
|
|
85
|
+
|
|
86
|
+
Routes are defined using the `route` class method:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
route :method_name, '/path/:param', options
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Options:**
|
|
93
|
+
|
|
94
|
+
| Option | Description |
|
|
95
|
+
|--------|-------------|
|
|
96
|
+
| `method:` | HTTP method (`:get`, `:post`, `:put`, `:patch`, `:delete`). Auto-detected if omitted. |
|
|
97
|
+
| `query:` | Hash defining query parameters. Use symbols for dynamic values. |
|
|
98
|
+
| `body:` | Hash defining request body. Use symbols for dynamic values. |
|
|
99
|
+
| `expected_response_code:` | Single expected HTTP status code |
|
|
100
|
+
| `expected_response_codes:` | Array of expected HTTP status codes |
|
|
101
|
+
| `stream:` | Enable streaming (`:file`, `:io`, `:block`, or `true`) |
|
|
102
|
+
| `return:` | Return type (`:response`, `:body`, or parsed JSON by default) |
|
|
103
|
+
|
|
104
|
+
### Automatic HTTP Method Detection
|
|
38
105
|
|
|
39
|
-
|
|
106
|
+
The Router automatically detects HTTP methods based on route names:
|
|
40
107
|
|
|
41
|
-
|
|
108
|
+
| Prefix | HTTP Method |
|
|
109
|
+
|--------|-------------|
|
|
110
|
+
| `get_`, `find_`, `fetch_`, `list_`, `search_` | GET |
|
|
111
|
+
| `post_`, `create_`, `add_`, `insert_` | POST |
|
|
112
|
+
| `put_`, `update_`, `modify_`, `change_` | PUT |
|
|
113
|
+
| `patch_` | PATCH |
|
|
114
|
+
| `delete_`, `remove_`, `destroy_` | DELETE |
|
|
42
115
|
|
|
43
116
|
```ruby
|
|
44
117
|
class MyApiClient
|
|
45
118
|
include ClientApiBuilder::Router
|
|
46
119
|
|
|
47
|
-
# Set the base URL for all requests
|
|
48
120
|
base_url 'https://api.example.com'
|
|
49
121
|
|
|
50
|
-
#
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
route :
|
|
56
|
-
route :create_user, '/users', method: :post, expected_response_code: 201, body: { name: :name, email: :email }
|
|
122
|
+
# Automatically uses appropriate HTTP methods
|
|
123
|
+
route :get_users, '/users' # GET
|
|
124
|
+
route :create_user, '/users', body: { name: :name } # POST
|
|
125
|
+
route :update_user, '/users/:id', body: { name: :name } # PUT
|
|
126
|
+
route :patch_user, '/users/:id', body: { name: :name } # PATCH
|
|
127
|
+
route :delete_user, '/users/:id' # DELETE
|
|
57
128
|
end
|
|
129
|
+
```
|
|
58
130
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
131
|
+
### Dynamic Parameters
|
|
132
|
+
|
|
133
|
+
Parameters can be defined in three ways:
|
|
134
|
+
|
|
135
|
+
**1. Path Parameters** (using `:param` or `{param}` syntax):
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
route :get_user, '/users/:id'
|
|
139
|
+
route :get_post, '/users/{user_id}/posts/{post_id}'
|
|
63
140
|
```
|
|
64
141
|
|
|
65
|
-
|
|
142
|
+
**2. Query Parameters:**
|
|
66
143
|
|
|
67
|
-
|
|
144
|
+
```ruby
|
|
145
|
+
route :search_users, '/users', query: { q: :query, page: :page, limit: :limit }
|
|
146
|
+
# Generates: GET /users?q=...&page=...&limit=...
|
|
147
|
+
```
|
|
68
148
|
|
|
69
|
-
|
|
70
|
-
- Methods starting with `put`, `update`, `modify`, or `change` → `PUT`
|
|
71
|
-
- Methods starting with `patch` → `PATCH`
|
|
72
|
-
- Methods starting with `delete` or `remove` → `DELETE`
|
|
73
|
-
- All other methods → `GET`
|
|
149
|
+
**3. Body Parameters:**
|
|
74
150
|
|
|
75
|
-
|
|
151
|
+
```ruby
|
|
152
|
+
route :create_user, '/users', body: { user: { name: :name, email: :email } }
|
|
153
|
+
# Sends JSON: {"user": {"name": "...", "email": "..."}}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Headers
|
|
157
|
+
|
|
158
|
+
Define headers at the class level or dynamically:
|
|
76
159
|
|
|
77
160
|
```ruby
|
|
78
161
|
class MyApiClient
|
|
79
162
|
include ClientApiBuilder::Router
|
|
80
|
-
|
|
163
|
+
|
|
81
164
|
base_url 'https://api.example.com'
|
|
82
|
-
|
|
83
|
-
#
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
#
|
|
90
|
-
|
|
165
|
+
|
|
166
|
+
# Static header
|
|
167
|
+
header 'Content-Type', 'application/json'
|
|
168
|
+
|
|
169
|
+
# Dynamic header from instance method
|
|
170
|
+
header 'Authorization', :auth_header
|
|
171
|
+
|
|
172
|
+
# Dynamic header from block
|
|
173
|
+
header 'X-Request-ID' do
|
|
174
|
+
SecureRandom.uuid
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
attr_accessor :api_key
|
|
178
|
+
|
|
179
|
+
def auth_header
|
|
180
|
+
"Bearer #{api_key}"
|
|
181
|
+
end
|
|
91
182
|
end
|
|
92
183
|
```
|
|
93
184
|
|
|
94
185
|
### Request Body Formats
|
|
95
186
|
|
|
96
|
-
|
|
187
|
+
Configure how request bodies are serialized:
|
|
97
188
|
|
|
98
189
|
```ruby
|
|
99
190
|
class MyApiClient
|
|
100
191
|
include ClientApiBuilder::Router
|
|
101
|
-
|
|
102
|
-
#
|
|
192
|
+
|
|
193
|
+
# Default: JSON (using to_json)
|
|
194
|
+
body_builder :to_json
|
|
195
|
+
|
|
196
|
+
# URL-encoded form data (using to_query)
|
|
197
|
+
body_builder :to_query
|
|
198
|
+
|
|
199
|
+
# Custom query params builder (no ActiveSupport dependency)
|
|
103
200
|
body_builder :query_params
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
201
|
+
|
|
202
|
+
# Custom builder method
|
|
203
|
+
body_builder :my_custom_builder
|
|
204
|
+
|
|
205
|
+
# Custom builder with block
|
|
206
|
+
body_builder do |data|
|
|
207
|
+
data.to_xml
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def my_custom_builder(data)
|
|
211
|
+
# Custom serialization logic
|
|
212
|
+
end
|
|
108
213
|
end
|
|
109
214
|
```
|
|
110
215
|
|
|
111
|
-
### Nested Routing
|
|
216
|
+
### Nested Routing (Sections)
|
|
112
217
|
|
|
113
|
-
|
|
218
|
+
Organize complex APIs with nested routes:
|
|
114
219
|
|
|
115
220
|
```ruby
|
|
116
221
|
class MyApiClient
|
|
117
222
|
include ClientApiBuilder::Router
|
|
118
|
-
|
|
223
|
+
|
|
119
224
|
base_url 'https://api.example.com'
|
|
120
|
-
|
|
225
|
+
header 'Authorization', :auth_token
|
|
226
|
+
|
|
227
|
+
attr_accessor :auth_token
|
|
228
|
+
|
|
121
229
|
section :users do
|
|
122
|
-
|
|
123
|
-
|
|
230
|
+
base_url 'https://api.example.com/v2' # Override base URL
|
|
231
|
+
|
|
232
|
+
route :list, '/users'
|
|
233
|
+
route :get, '/users/:id'
|
|
234
|
+
route :create, '/users', body: { name: :name, email: :email }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
section :posts do
|
|
238
|
+
route :list, '/posts'
|
|
239
|
+
route :get, '/posts/:id'
|
|
124
240
|
end
|
|
125
241
|
end
|
|
126
242
|
|
|
127
243
|
client = MyApiClient.new
|
|
244
|
+
client.auth_token = 'secret'
|
|
245
|
+
|
|
246
|
+
# Access nested routes
|
|
128
247
|
users = client.users.list
|
|
129
248
|
user = client.users.get(id: 123)
|
|
249
|
+
posts = client.posts.list
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### Connection Options
|
|
253
|
+
|
|
254
|
+
Configure connection settings:
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
class MyApiClient
|
|
258
|
+
include ClientApiBuilder::Router
|
|
259
|
+
|
|
260
|
+
base_url 'https://api.example.com'
|
|
261
|
+
|
|
262
|
+
# Set timeouts
|
|
263
|
+
connection_option :open_timeout, 10
|
|
264
|
+
connection_option :read_timeout, 30
|
|
265
|
+
|
|
266
|
+
# SSL options (verify_mode is enabled by default)
|
|
267
|
+
connection_option :ssl_timeout, 10
|
|
268
|
+
end
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Retry Configuration
|
|
272
|
+
|
|
273
|
+
Configure automatic retries for transient failures:
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
class MyApiClient
|
|
277
|
+
include ClientApiBuilder::Router
|
|
278
|
+
|
|
279
|
+
base_url 'https://api.example.com'
|
|
280
|
+
|
|
281
|
+
# Retry up to 3 times with 0.5 second delay between attempts
|
|
282
|
+
configure_retries 3, 0.5
|
|
283
|
+
end
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
By default, retries are performed only for network-related errors:
|
|
287
|
+
- `Net::OpenTimeout`, `Net::ReadTimeout`
|
|
288
|
+
- `Errno::ECONNRESET`, `Errno::ECONNREFUSED`, `Errno::ETIMEDOUT`
|
|
289
|
+
- `SocketError`, `EOFError`
|
|
290
|
+
|
|
291
|
+
Customize retry behavior by overriding `retry_request?`:
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
class MyApiClient
|
|
295
|
+
include ClientApiBuilder::Router
|
|
296
|
+
|
|
297
|
+
def retry_request?(exception, options)
|
|
298
|
+
case exception
|
|
299
|
+
when Net::OpenTimeout, Net::ReadTimeout
|
|
300
|
+
true
|
|
301
|
+
when ClientApiBuilder::UnexpectedResponse
|
|
302
|
+
# Retry on 503 Service Unavailable
|
|
303
|
+
exception.response.code == '503'
|
|
304
|
+
else
|
|
305
|
+
false
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Streaming Support
|
|
312
|
+
|
|
313
|
+
Handle large responses efficiently:
|
|
314
|
+
|
|
315
|
+
```ruby
|
|
316
|
+
class MyApiClient
|
|
317
|
+
include ClientApiBuilder::Router
|
|
318
|
+
|
|
319
|
+
base_url 'https://api.example.com'
|
|
320
|
+
|
|
321
|
+
# Stream directly to a file
|
|
322
|
+
route :download_file, '/files/:id/download', stream: :file
|
|
323
|
+
|
|
324
|
+
# Stream to an IO object
|
|
325
|
+
route :stream_to_io, '/files/:id/stream', stream: :io
|
|
326
|
+
|
|
327
|
+
# Stream with block processing
|
|
328
|
+
route :process_stream, '/events/stream', stream: :block
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
client = MyApiClient.new
|
|
332
|
+
|
|
333
|
+
# Download to file
|
|
334
|
+
client.download_file(id: 123, file: '/path/to/output.zip')
|
|
335
|
+
|
|
336
|
+
# Stream to IO
|
|
337
|
+
File.open('/path/to/output.dat', 'wb') do |file|
|
|
338
|
+
client.stream_to_io(id: 123, io: file)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Process stream in chunks
|
|
342
|
+
client.process_stream do |response, chunk|
|
|
343
|
+
puts "Received #{chunk.bytesize} bytes"
|
|
344
|
+
process_data(chunk)
|
|
345
|
+
end
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### Response Handling
|
|
349
|
+
|
|
350
|
+
Customize how responses are processed:
|
|
351
|
+
|
|
352
|
+
```ruby
|
|
353
|
+
class MyApiClient
|
|
354
|
+
include ClientApiBuilder::Router
|
|
355
|
+
|
|
356
|
+
base_url 'https://api.example.com'
|
|
357
|
+
|
|
358
|
+
# Return parsed JSON (default)
|
|
359
|
+
route :get_user, '/users/:id'
|
|
360
|
+
|
|
361
|
+
# Return raw response body
|
|
362
|
+
route :get_raw, '/raw/:id', return: :body
|
|
363
|
+
|
|
364
|
+
# Return Net::HTTPResponse object
|
|
365
|
+
route :get_response, '/data/:id', return: :response
|
|
366
|
+
|
|
367
|
+
# Custom response handling with block
|
|
368
|
+
route :get_token, '/auth/token' do |data|
|
|
369
|
+
self.auth_token = data['access_token']
|
|
370
|
+
data
|
|
371
|
+
end
|
|
372
|
+
end
|
|
130
373
|
```
|
|
131
374
|
|
|
132
375
|
### Error Handling
|
|
133
376
|
|
|
134
|
-
The gem provides
|
|
377
|
+
The gem provides detailed error information:
|
|
135
378
|
|
|
136
379
|
```ruby
|
|
137
380
|
begin
|
|
138
|
-
client.get_user(id:
|
|
381
|
+
client.get_user(id: 999)
|
|
139
382
|
rescue ClientApiBuilder::UnexpectedResponse => e
|
|
140
|
-
puts "
|
|
141
|
-
puts "Response
|
|
383
|
+
puts "HTTP Status: #{e.response.code}"
|
|
384
|
+
puts "Response Body: #{e.response.body}"
|
|
385
|
+
puts "Error Message: #{e.message}"
|
|
142
386
|
end
|
|
143
387
|
```
|
|
144
388
|
|
|
389
|
+
### Debugging
|
|
390
|
+
|
|
391
|
+
Access request and response details after each call:
|
|
392
|
+
|
|
393
|
+
```ruby
|
|
394
|
+
client = MyApiClient.new
|
|
395
|
+
client.get_user(id: 123)
|
|
396
|
+
|
|
397
|
+
# Response information
|
|
398
|
+
puts client.response.code # HTTP status code
|
|
399
|
+
puts client.response.body # Response body
|
|
400
|
+
puts client.response.to_hash # Response headers
|
|
401
|
+
|
|
402
|
+
# Request information
|
|
403
|
+
puts client.request_options[:method] # HTTP method used
|
|
404
|
+
puts client.request_options[:uri] # Full URI
|
|
405
|
+
puts client.request_options[:body] # Request body
|
|
406
|
+
puts client.request_options[:headers] # Request headers
|
|
407
|
+
|
|
408
|
+
# Performance metrics
|
|
409
|
+
puts client.total_request_time # Time in seconds
|
|
410
|
+
puts client.request_attempts # Number of attempts (including retries)
|
|
411
|
+
```
|
|
412
|
+
|
|
145
413
|
### ActiveSupport Integration
|
|
146
414
|
|
|
147
|
-
|
|
415
|
+
When ActiveSupport is available, the gem provides instrumentation and logging:
|
|
148
416
|
|
|
149
417
|
```ruby
|
|
150
|
-
#
|
|
418
|
+
# Set up logging
|
|
151
419
|
ClientApiBuilder.logger = Logger.new(STDOUT)
|
|
152
420
|
|
|
153
|
-
# Subscribe to
|
|
154
|
-
ActiveSupport::Notifications.subscribe('request
|
|
421
|
+
# Subscribe to request events
|
|
422
|
+
ActiveSupport::Notifications.subscribe('client_api_builder.request') do |*args|
|
|
155
423
|
event = ActiveSupport::Notifications::Event.new(*args)
|
|
156
|
-
|
|
424
|
+
client = event.payload[:client]
|
|
425
|
+
|
|
426
|
+
puts "#{client.request_options[:method]} #{client.request_options[:uri]}"
|
|
427
|
+
puts "Status: #{client.response&.code}"
|
|
428
|
+
puts "Duration: #{event.duration.round(2)}ms"
|
|
157
429
|
end
|
|
430
|
+
|
|
431
|
+
# Or use the built-in log subscriber
|
|
432
|
+
subscriber = ClientApiBuilder::ActiveSupportLogSubscriber.new(Rails.logger)
|
|
433
|
+
subscriber.subscribe!
|
|
158
434
|
```
|
|
159
435
|
|
|
160
|
-
|
|
436
|
+
## Security Features
|
|
161
437
|
|
|
162
|
-
|
|
438
|
+
Client API Builder includes several security features enabled by default:
|
|
439
|
+
|
|
440
|
+
### SSL/TLS Verification
|
|
441
|
+
|
|
442
|
+
All HTTPS connections verify SSL certificates by default using `OpenSSL::SSL::VERIFY_PEER`. Default timeouts are also configured to prevent hanging connections.
|
|
443
|
+
|
|
444
|
+
### SSRF Protection
|
|
445
|
+
|
|
446
|
+
Base URLs are validated to only allow `http` and `https` schemes, preventing Server-Side Request Forgery attacks:
|
|
163
447
|
|
|
164
448
|
```ruby
|
|
165
449
|
class MyApiClient
|
|
166
450
|
include ClientApiBuilder::Router
|
|
167
|
-
|
|
168
|
-
base_url 'https://api.example.com'
|
|
169
|
-
|
|
170
|
-
#
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
# Stream response in chunks for custom processing
|
|
174
|
-
route :stream_users, '/users', stream: true
|
|
451
|
+
|
|
452
|
+
base_url 'https://api.example.com' # Valid
|
|
453
|
+
base_url 'http://api.example.com' # Valid
|
|
454
|
+
base_url 'file:///etc/passwd' # Raises ArgumentError
|
|
455
|
+
base_url 'ftp://example.com' # Raises ArgumentError
|
|
175
456
|
end
|
|
457
|
+
```
|
|
176
458
|
|
|
177
|
-
|
|
178
|
-
client = MyApiClient.new
|
|
459
|
+
### Path Traversal Protection
|
|
179
460
|
|
|
180
|
-
|
|
181
|
-
client.download_users('users.json') # Saves response directly to users.json
|
|
461
|
+
File streaming operations validate paths to prevent directory traversal attacks:
|
|
182
462
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
463
|
+
```ruby
|
|
464
|
+
# These will raise ArgumentError
|
|
465
|
+
client.download_file(id: 1, file: '/tmp/../etc/passwd')
|
|
466
|
+
client.download_file(id: 1, file: "/tmp/file\0.txt")
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
### Safe File Modes
|
|
470
|
+
|
|
471
|
+
Only safe file modes are allowed for streaming to files: `w`, `wb`, `a`, `ab`, `w+`, `wb+`, `a+`, `ab+`.
|
|
472
|
+
|
|
473
|
+
## Thread Safety
|
|
474
|
+
|
|
475
|
+
Client instances are **not thread-safe**. Create a separate client instance per thread:
|
|
476
|
+
|
|
477
|
+
```ruby
|
|
478
|
+
# Correct: Create a new client for each thread
|
|
479
|
+
threads = 5.times.map do |i|
|
|
480
|
+
Thread.new do
|
|
481
|
+
client = MyApiClient.new
|
|
482
|
+
client.get_user(id: i)
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
threads.each(&:join)
|
|
486
|
+
|
|
487
|
+
# Incorrect: Do not share clients across threads
|
|
488
|
+
client = MyApiClient.new
|
|
489
|
+
threads = 5.times.map do |i|
|
|
490
|
+
Thread.new do
|
|
491
|
+
client.get_user(id: i) # Race condition!
|
|
492
|
+
end
|
|
187
493
|
end
|
|
188
494
|
```
|
|
189
495
|
|
|
190
|
-
|
|
496
|
+
## Configuration Reference
|
|
497
|
+
|
|
498
|
+
### Class-Level Methods
|
|
499
|
+
|
|
500
|
+
| Method | Description |
|
|
501
|
+
|--------|-------------|
|
|
502
|
+
| `base_url(url)` | Set the base URL for all requests |
|
|
503
|
+
| `header(name, value)` | Add a header to all requests |
|
|
504
|
+
| `body_builder(builder)` | Configure request body serialization |
|
|
505
|
+
| `query_builder(builder)` | Configure query string serialization |
|
|
506
|
+
| `query_param(name, value)` | Add a query parameter to all requests |
|
|
507
|
+
| `connection_option(name, value)` | Set Net::HTTP connection options |
|
|
508
|
+
| `configure_retries(max, sleep)` | Configure retry behavior |
|
|
509
|
+
| `route(name, path, options)` | Define an API endpoint |
|
|
510
|
+
| `section(name, options, &block)` | Define nested routes |
|
|
511
|
+
| `namespace(path, &block)` | Add path prefix to routes in block |
|
|
191
512
|
|
|
192
|
-
|
|
513
|
+
### Instance Methods
|
|
193
514
|
|
|
194
|
-
|
|
515
|
+
| Method | Description |
|
|
516
|
+
|--------|-------------|
|
|
517
|
+
| `response` | Last Net::HTTPResponse object |
|
|
518
|
+
| `request_options` | Options used for last request |
|
|
519
|
+
| `total_request_time` | Duration of last request in seconds |
|
|
520
|
+
| `request_attempts` | Number of attempts for last request |
|
|
521
|
+
| `root_router` | Returns the root router (for nested routers) |
|
|
195
522
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
-
|
|
199
|
-
- `
|
|
200
|
-
- `nested_route`: Define nested resource routes
|
|
523
|
+
## Requirements
|
|
524
|
+
|
|
525
|
+
- Ruby 3.0+
|
|
526
|
+
- `inheritance-helper` gem (>= 0.2.5)
|
|
201
527
|
|
|
202
528
|
## Contributing
|
|
203
529
|
|
|
204
530
|
Bug reports and pull requests are welcome on GitHub at https://github.com/dougyouch/client-api-builder.
|
|
205
531
|
|
|
532
|
+
1. Fork the repository
|
|
533
|
+
2. Create your feature branch (`git checkout -b feature/my-feature`)
|
|
534
|
+
3. Write tests for your changes
|
|
535
|
+
4. Ensure all tests pass (`bundle exec rspec`)
|
|
536
|
+
5. Ensure code style compliance (`bundle exec rubocop`)
|
|
537
|
+
6. Commit your changes (`git commit -am 'Add my feature'`)
|
|
538
|
+
7. Push to the branch (`git push origin feature/my-feature`)
|
|
539
|
+
8. Create a Pull Request
|
|
540
|
+
|
|
206
541
|
## License
|
|
207
542
|
|
|
208
543
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/client-api-builder.gemspec
CHANGED
|
@@ -2,14 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
Gem::Specification.new do |s|
|
|
4
4
|
s.name = 'client-api-builder'
|
|
5
|
-
s.version = '0.
|
|
5
|
+
s.version = '0.6.0'
|
|
6
6
|
s.licenses = ['MIT']
|
|
7
|
-
s.summary = '
|
|
8
|
-
s.description =
|
|
7
|
+
s.summary = 'Build robust, secure API clients through declarative configuration'
|
|
8
|
+
s.description = <<~DESC
|
|
9
|
+
A Ruby gem for building API clients through declarative configuration. Features include
|
|
10
|
+
automatic HTTP method detection, nested routing, streaming support, configurable retries,
|
|
11
|
+
and security features like SSL verification, SSRF protection, and path traversal prevention.
|
|
12
|
+
Define your API endpoints with a clean DSL and get comprehensive error handling, debugging
|
|
13
|
+
capabilities, and optional ActiveSupport integration for logging and instrumentation.
|
|
14
|
+
DESC
|
|
9
15
|
s.authors = ['Doug Youch']
|
|
10
16
|
s.email = 'dougyouch@gmail.com'
|
|
11
17
|
s.homepage = 'https://github.com/dougyouch/client-api-builder'
|
|
12
18
|
s.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
13
19
|
|
|
14
|
-
s.
|
|
20
|
+
s.required_ruby_version = '>= 3.0'
|
|
21
|
+
|
|
22
|
+
s.add_dependency 'inheritance-helper', '>= 0.2.5'
|
|
23
|
+
|
|
24
|
+
s.metadata = {
|
|
25
|
+
'rubygems_mfa_required' => 'true',
|
|
26
|
+
'homepage_uri' => s.homepage,
|
|
27
|
+
'source_code_uri' => 'https://github.com/dougyouch/client-api-builder',
|
|
28
|
+
'changelog_uri' => 'https://github.com/dougyouch/client-api-builder/blob/master/CHANGELOG.md',
|
|
29
|
+
'bug_tracker_uri' => 'https://github.com/dougyouch/client-api-builder/issues'
|
|
30
|
+
}
|
|
15
31
|
end
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'base64'
|
|
2
4
|
require 'securerandom'
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
BasicAuthExampleClient = Struct.new(
|
|
7
|
+
:username,
|
|
8
|
+
:password
|
|
9
|
+
) do
|
|
9
10
|
include ClientApiBuilder::Router
|
|
10
11
|
include ClientApiBuilder::Section
|
|
11
12
|
|