jeckle 0.6.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +311 -55
- data/lib/jeckle/api.rb +180 -1
- data/lib/jeckle/attribute_aliasing.rb +15 -0
- data/lib/jeckle/auth/credential_chain.rb +33 -0
- data/lib/jeckle/auth/oauth2.rb +74 -0
- data/lib/jeckle/auth.rb +13 -0
- data/lib/jeckle/client.rb +103 -0
- data/lib/jeckle/collection.rb +84 -0
- data/lib/jeckle/errors.rb +56 -12
- data/lib/jeckle/http.rb +19 -25
- data/lib/jeckle/middleware/instrumentation.rb +45 -0
- data/lib/jeckle/middleware/log_redactor.rb +41 -0
- data/lib/jeckle/middleware/raise_error.rb +36 -1
- data/lib/jeckle/model.rb +9 -0
- data/lib/jeckle/nested_resource.rb +49 -0
- data/lib/jeckle/operations/create.rb +29 -0
- data/lib/jeckle/operations/delete.rb +30 -0
- data/lib/jeckle/operations/find.rb +31 -0
- data/lib/jeckle/operations/instance.rb +59 -0
- data/lib/jeckle/operations/list.rb +57 -0
- data/lib/jeckle/operations/update.rb +31 -0
- data/lib/jeckle/operations.rb +27 -0
- data/lib/jeckle/pagination/cursor.rb +47 -0
- data/lib/jeckle/pagination/link_header.rb +68 -0
- data/lib/jeckle/pagination/offset.rb +39 -0
- data/lib/jeckle/pagination.rb +15 -0
- data/lib/jeckle/rate_limit.rb +65 -0
- data/lib/jeckle/request.rb +16 -0
- data/lib/jeckle/resource.rb +17 -0
- data/lib/jeckle/response_inspector.rb +22 -0
- data/lib/jeckle/rest_actions.rb +10 -20
- data/lib/jeckle/setup.rb +50 -14
- data/lib/jeckle/testing.rb +89 -0
- data/lib/jeckle/types.rb +54 -0
- data/lib/jeckle/version.rb +1 -1
- data/lib/jeckle.rb +27 -3
- metadata +66 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: acac117a0dc0c961029fb70e87d6c2f412fe108989fdfec04d3d911274ceb460
|
|
4
|
+
data.tar.gz: 9ed85573a4512a3915071479500fd22b1901e8b852c23c3490ed9f35c4dd9c66
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ce75de0db73e82f2b9f832eba3df78c9b862d812c85e98e1034d50a6b6aeb83b4db5070615fdb3c8a50239bbd9f9b8d8c1d09064c87a8ea81dda832680375eec
|
|
7
|
+
data.tar.gz: ea854a9bdf95b332be7feebe9fb6215c78a2bf3b120ad8707983c86d18e0971919220af8086ec8b14828a8341d0d76230f3bebb2ed7f9f1c74f052e75569b067
|
data/README.md
CHANGED
|
@@ -28,28 +28,133 @@ And then execute:
|
|
|
28
28
|
$ bundle
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
##
|
|
32
|
-
|
|
33
|
-
### Configuring an API
|
|
34
|
-
|
|
35
|
-
Let's say you'd like to connect your app to Dribbble.com - a community of designers sharing screenshots of their work, process, and projects.
|
|
36
|
-
|
|
37
|
-
First, you would need to configure the API:
|
|
31
|
+
## Quick Start
|
|
38
32
|
|
|
39
33
|
```ruby
|
|
34
|
+
# 1. Configure the API
|
|
40
35
|
Jeckle.configure do |config|
|
|
41
36
|
config.register :dribbble do |api|
|
|
42
37
|
api.base_uri = 'http://api.dribbble.com'
|
|
38
|
+
api.bearer_token = ENV['DRIBBBLE_TOKEN']
|
|
43
39
|
api.middlewares do
|
|
44
40
|
response :json
|
|
41
|
+
response :jeckle_raise_error
|
|
45
42
|
end
|
|
46
43
|
end
|
|
47
44
|
end
|
|
45
|
+
|
|
46
|
+
# 2. Define a resource
|
|
47
|
+
class Shot < Jeckle::Resource
|
|
48
|
+
api :dribbble
|
|
49
|
+
|
|
50
|
+
attribute :id, Jeckle::Types::Integer
|
|
51
|
+
attribute :name, Jeckle::Types::String
|
|
52
|
+
attribute :url, Jeckle::Types::String
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# 3. Use it
|
|
56
|
+
shot = Shot.find(1600459)
|
|
57
|
+
shots = Shot.list(name: 'avengers')
|
|
48
58
|
```
|
|
49
59
|
|
|
50
|
-
|
|
60
|
+
## API Configuration
|
|
51
61
|
|
|
52
|
-
|
|
62
|
+
### Basic Auth
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
Jeckle.configure do |config|
|
|
66
|
+
config.register :my_api do |api|
|
|
67
|
+
api.base_uri = 'https://api.example.com'
|
|
68
|
+
api.basic_auth = { username: 'user', password: 'pass' }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Bearer Token
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
config.register :my_api do |api|
|
|
77
|
+
api.base_uri = 'https://api.example.com'
|
|
78
|
+
api.bearer_token = 'my-oauth-token'
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### API Key (Header)
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
config.register :my_api do |api|
|
|
86
|
+
api.base_uri = 'https://api.example.com'
|
|
87
|
+
api.api_key = { value: 'secret', header: 'X-Api-Key' }
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### API Key (Query Param)
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
config.register :my_api do |api|
|
|
95
|
+
api.base_uri = 'https://api.example.com'
|
|
96
|
+
api.api_key = { value: 'secret', param: 'api_key' }
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### OAuth 2.0
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
config.register :my_api do |api|
|
|
104
|
+
api.base_uri = 'https://api.example.com'
|
|
105
|
+
api.oauth2 = {
|
|
106
|
+
client_id: 'id',
|
|
107
|
+
client_secret: 'secret',
|
|
108
|
+
token_url: 'https://auth.example.com/oauth/token'
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Environment-Based Configuration
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
# Reads MY_API_BASE_URI, MY_API_BEARER_TOKEN from ENV
|
|
117
|
+
Jeckle::Setup.register_from_env(:my_api)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Automatic Retries
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
config.register :my_api do |api|
|
|
124
|
+
api.base_uri = 'https://api.example.com'
|
|
125
|
+
api.retry = { max: 3, interval: 1, retry_statuses: [429, 503] }
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Other Options
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
config.register :my_api do |api|
|
|
133
|
+
api.base_uri = 'https://api.example.com'
|
|
134
|
+
api.namespaces = { prefix: 'api', version: 'v2' }
|
|
135
|
+
api.headers = { 'Content-Type' => 'application/json' }
|
|
136
|
+
api.params = { locale: 'en' }
|
|
137
|
+
api.open_timeout = 2
|
|
138
|
+
api.read_timeout = 5
|
|
139
|
+
api.logger = Rails.logger
|
|
140
|
+
|
|
141
|
+
api.middlewares do
|
|
142
|
+
request :json
|
|
143
|
+
response :json
|
|
144
|
+
response :jeckle_raise_error
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Extend Faraday defaults
|
|
148
|
+
api.configure_connection do |conn|
|
|
149
|
+
conn.use MyCustomMiddleware
|
|
150
|
+
conn.adapter :typhoeus
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Defining Resources
|
|
156
|
+
|
|
157
|
+
Resources inherit from `Jeckle::Resource` and use `Jeckle::Types` for attribute definitions:
|
|
53
158
|
|
|
54
159
|
```ruby
|
|
55
160
|
class Shot < Jeckle::Resource
|
|
@@ -58,101 +163,252 @@ class Shot < Jeckle::Resource
|
|
|
58
163
|
attribute :id, Jeckle::Types::Integer
|
|
59
164
|
attribute :name, Jeckle::Types::String
|
|
60
165
|
attribute :url, Jeckle::Types::String
|
|
61
|
-
attribute :
|
|
166
|
+
attribute :score, Jeckle::Types::Float
|
|
62
167
|
end
|
|
63
168
|
```
|
|
64
169
|
|
|
65
|
-
|
|
170
|
+
Available types: `Jeckle::Types::Integer`, `String`, `Float`, `Bool`, `Array`, `Hash`, `DateTime`, `Time`, `Decimal`, `UUID`, `URI`, `SymbolizedHash`, `StringArray`, and any [dry-types](https://dry-rb.org/gems/dry-types/) type.
|
|
171
|
+
|
|
172
|
+
## CRUD Operations
|
|
173
|
+
|
|
174
|
+
### Find
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
# GET /shots/1600459
|
|
178
|
+
shot = Shot.find(1600459)
|
|
179
|
+
shot.name #=> "Daryl Heckle And Jeckle Oates"
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### List
|
|
66
183
|
|
|
67
|
-
|
|
184
|
+
```ruby
|
|
185
|
+
# GET /shots?name=avengers
|
|
186
|
+
shots = Shot.list(name: 'avengers')
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Create
|
|
68
190
|
|
|
69
191
|
```ruby
|
|
70
|
-
#
|
|
71
|
-
shot = Shot.
|
|
192
|
+
# POST /shots
|
|
193
|
+
shot = Shot.create(name: 'New Shot', url: 'http://example.com')
|
|
72
194
|
```
|
|
73
195
|
|
|
74
|
-
|
|
196
|
+
### Update
|
|
75
197
|
|
|
76
198
|
```ruby
|
|
77
|
-
|
|
78
|
-
|
|
199
|
+
# PATCH /shots/123
|
|
200
|
+
shot = Shot.update(123, name: 'Updated Name')
|
|
201
|
+
```
|
|
79
202
|
|
|
80
|
-
|
|
81
|
-
=> "Daryl Heckle And Jeckle Oates"
|
|
203
|
+
### Destroy
|
|
82
204
|
|
|
83
|
-
|
|
84
|
-
|
|
205
|
+
```ruby
|
|
206
|
+
# DELETE /shots/123
|
|
207
|
+
Shot.destroy(123) #=> true
|
|
85
208
|
```
|
|
86
209
|
|
|
87
|
-
|
|
210
|
+
### Instance-Level Operations
|
|
88
211
|
|
|
89
212
|
```ruby
|
|
90
|
-
|
|
91
|
-
|
|
213
|
+
shot = Shot.find(123)
|
|
214
|
+
updated = shot.save # PATCH /shots/123
|
|
215
|
+
fresh = shot.reload # GET /shots/123
|
|
216
|
+
shot.delete # DELETE /shots/123
|
|
92
217
|
```
|
|
93
218
|
|
|
94
|
-
|
|
219
|
+
## Composable Operations
|
|
95
220
|
|
|
96
|
-
|
|
221
|
+
By default, resources get all CRUD operations. For fine-grained control, extend individual modules:
|
|
97
222
|
|
|
98
223
|
```ruby
|
|
99
|
-
|
|
224
|
+
class ReadOnlyShot < Jeckle::Resource
|
|
225
|
+
api :dribbble
|
|
226
|
+
extend Jeckle::Operations::Find
|
|
227
|
+
extend Jeckle::Operations::List
|
|
228
|
+
# No Create, Update, or Delete
|
|
229
|
+
end
|
|
100
230
|
```
|
|
101
231
|
|
|
102
|
-
|
|
232
|
+
Available modules: `Jeckle::Operations::Find`, `List`, `Create`, `Update`, `Delete`.
|
|
233
|
+
|
|
234
|
+
## Nested Resources
|
|
103
235
|
|
|
104
236
|
```ruby
|
|
105
|
-
|
|
106
|
-
|
|
237
|
+
class Comment < Jeckle::Resource
|
|
238
|
+
api :my_api
|
|
239
|
+
belongs_to :post
|
|
107
240
|
|
|
108
|
-
|
|
109
|
-
|
|
241
|
+
attribute :id, Jeckle::Types::Integer
|
|
242
|
+
attribute :body, Jeckle::Types::String
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
Comment.find(456, post_id: 123) # GET /posts/123/comments/456
|
|
246
|
+
Comment.list(post_id: 123) # GET /posts/123/comments
|
|
247
|
+
Comment.create(post_id: 123, body: 'Nice') # POST /posts/123/comments
|
|
110
248
|
```
|
|
111
249
|
|
|
112
|
-
|
|
250
|
+
## Attribute Aliasing
|
|
113
251
|
|
|
114
|
-
|
|
252
|
+
Map API attribute names to Ruby-friendly names:
|
|
115
253
|
|
|
116
254
|
```ruby
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
255
|
+
class Shot < Jeckle::Resource
|
|
256
|
+
api :dribbble
|
|
257
|
+
attribute :thumbnailSize, Jeckle::Types::String, as: :thumbnail_size
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
shot.thumbnailSize #=> "50x50"
|
|
261
|
+
shot.thumbnail_size #=> "50x50"
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## Error Handling
|
|
265
|
+
|
|
266
|
+
Enable the error middleware to get typed exceptions:
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
api.middlewares do
|
|
270
|
+
response :json
|
|
271
|
+
response :jeckle_raise_error
|
|
125
272
|
end
|
|
126
273
|
```
|
|
127
274
|
|
|
128
|
-
Then rescue specific errors
|
|
275
|
+
Then rescue specific errors:
|
|
129
276
|
|
|
130
277
|
```ruby
|
|
131
278
|
begin
|
|
132
|
-
Shot.find
|
|
279
|
+
Shot.find(999)
|
|
133
280
|
rescue Jeckle::NotFoundError => e
|
|
134
281
|
puts "Not found: #{e.message} (status: #{e.status})"
|
|
282
|
+
puts "Request ID: #{e.request_id}" if e.request_id
|
|
283
|
+
rescue Jeckle::TooManyRequestsError => e
|
|
284
|
+
puts "Rate limited! Remaining: #{e.rate_limit&.remaining}"
|
|
135
285
|
rescue Jeckle::ClientError => e
|
|
136
286
|
puts "Client error: #{e.status}"
|
|
137
287
|
rescue Jeckle::ServerError => e
|
|
138
288
|
puts "Server error: #{e.status}"
|
|
139
|
-
rescue Jeckle::HTTPError => e
|
|
140
|
-
puts "HTTP error: #{e.status}"
|
|
141
289
|
end
|
|
142
290
|
```
|
|
143
291
|
|
|
144
|
-
|
|
292
|
+
Error hierarchy:
|
|
145
293
|
|
|
146
|
-
- `Jeckle::Error`
|
|
147
|
-
- `Jeckle::ConnectionError`
|
|
148
|
-
- `Jeckle::TimeoutError`
|
|
149
|
-
- `Jeckle::HTTPError`
|
|
150
|
-
- `Jeckle::ClientError`
|
|
294
|
+
- `Jeckle::Error` -- base error
|
|
295
|
+
- `Jeckle::ConnectionError` -- network errors
|
|
296
|
+
- `Jeckle::TimeoutError` -- timeout errors
|
|
297
|
+
- `Jeckle::HTTPError` -- HTTP errors (`status`, `body`, `request_id`)
|
|
298
|
+
- `Jeckle::ClientError` -- 4xx
|
|
151
299
|
- `BadRequestError` (400), `UnauthorizedError` (401), `ForbiddenError` (403), `NotFoundError` (404), `UnprocessableEntityError` (422), `TooManyRequestsError` (429)
|
|
152
|
-
- `Jeckle::ServerError`
|
|
300
|
+
- `Jeckle::ServerError` -- 5xx
|
|
153
301
|
- `InternalServerError` (500), `ServiceUnavailableError` (503)
|
|
154
302
|
|
|
155
|
-
|
|
303
|
+
## Pagination
|
|
304
|
+
|
|
305
|
+
### Offset-Based (Default)
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
Shot.list_each(per_page: 10).each do |shot|
|
|
309
|
+
puts shot.name
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Works with Enumerable methods
|
|
313
|
+
Shot.list_each(per_page: 50).first(5)
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Cursor-Based
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
config.register :stripe do |api|
|
|
320
|
+
api.base_uri = 'https://api.stripe.com/v1'
|
|
321
|
+
api.pagination :cursor, cursor_param: :starting_after, limit_param: :limit
|
|
322
|
+
end
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### Link Header (GitHub-Style)
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
config.register :github do |api|
|
|
329
|
+
api.base_uri = 'https://api.github.com'
|
|
330
|
+
api.pagination :link_header, per_page_param: :per_page
|
|
331
|
+
end
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
## Instance-Based Client
|
|
335
|
+
|
|
336
|
+
Use `Jeckle::Client` for requests with different credentials:
|
|
337
|
+
|
|
338
|
+
```ruby
|
|
339
|
+
client = Jeckle::Client.new(:my_api, bearer_token: 'other-token')
|
|
340
|
+
client.find(Shot, 123)
|
|
341
|
+
client.list(Shot, name: 'avengers')
|
|
342
|
+
client.create(Shot, name: 'New Shot')
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
## Response Inspection
|
|
346
|
+
|
|
347
|
+
Access the raw HTTP response after API calls:
|
|
348
|
+
|
|
349
|
+
```ruby
|
|
350
|
+
shot = Shot.find(123)
|
|
351
|
+
shot._response.status #=> 200
|
|
352
|
+
shot._response.headers #=> { 'X-Request-Id' => '...' }
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
## Observability
|
|
356
|
+
|
|
357
|
+
### Instrumentation
|
|
358
|
+
|
|
359
|
+
Enable `ActiveSupport::Notifications` events:
|
|
360
|
+
|
|
361
|
+
```ruby
|
|
362
|
+
api.middlewares do
|
|
363
|
+
request :jeckle_instrumentation
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
ActiveSupport::Notifications.subscribe('request.jeckle') do |*args|
|
|
367
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
368
|
+
puts "#{event.payload[:method]} #{event.payload[:url]} => #{event.payload[:status]}"
|
|
369
|
+
end
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### Log Redaction
|
|
373
|
+
|
|
374
|
+
Redact sensitive headers in log output:
|
|
375
|
+
|
|
376
|
+
```ruby
|
|
377
|
+
redactor = Jeckle::Middleware::LogRedactor.new(
|
|
378
|
+
headers: %w[Authorization X-Api-Key],
|
|
379
|
+
patterns: [/password/i]
|
|
380
|
+
)
|
|
381
|
+
safe_headers = redactor.redact_headers(response.headers)
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
## Testing
|
|
385
|
+
|
|
386
|
+
Use test mode to stub HTTP requests without real network calls:
|
|
387
|
+
|
|
388
|
+
```ruby
|
|
389
|
+
# In your test setup
|
|
390
|
+
Jeckle.test_mode!
|
|
391
|
+
|
|
392
|
+
Jeckle.stub_request(:my_api, :get, 'shots/1', body: { id: 1, name: 'Test' })
|
|
393
|
+
|
|
394
|
+
shot = Shot.find(1) #=> uses stubbed response
|
|
395
|
+
|
|
396
|
+
# Cleanup
|
|
397
|
+
Jeckle.reset_test_stubs!
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
## Credential Providers
|
|
401
|
+
|
|
402
|
+
Chain multiple credential sources (AWS SDK pattern):
|
|
403
|
+
|
|
404
|
+
```ruby
|
|
405
|
+
chain = Jeckle::Auth::CredentialChain.new(
|
|
406
|
+
-> { ENV['MY_API_TOKEN'] },
|
|
407
|
+
-> { File.read(File.expand_path('~/.my_api/token')).strip rescue nil },
|
|
408
|
+
-> { 'fallback-token' }
|
|
409
|
+
)
|
|
410
|
+
api.bearer_token = chain.resolve
|
|
411
|
+
```
|
|
156
412
|
|
|
157
413
|
## Migration from 0.4.x
|
|
158
414
|
|
|
@@ -167,7 +423,7 @@ class Shot
|
|
|
167
423
|
attribute :id, Integer
|
|
168
424
|
end
|
|
169
425
|
|
|
170
|
-
# After (0.
|
|
426
|
+
# After (1.0.0)
|
|
171
427
|
class Shot < Jeckle::Resource
|
|
172
428
|
attribute :id, Jeckle::Types::Integer
|
|
173
429
|
end
|
data/lib/jeckle/api.rb
CHANGED
|
@@ -1,11 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Jeckle
|
|
4
|
+
# Holds configuration for a single API endpoint including base URI,
|
|
5
|
+
# authentication, headers, params, timeouts, and Faraday middlewares.
|
|
4
6
|
class API
|
|
7
|
+
# @return [Logger, nil] logger for request/response logging
|
|
5
8
|
attr_accessor :logger
|
|
9
|
+
|
|
10
|
+
# @!attribute [w] base_uri
|
|
11
|
+
# @param value [String] the base URL for the API
|
|
12
|
+
# @!attribute [w] namespaces
|
|
13
|
+
# @param value [Hash] URL path segments appended to base_uri
|
|
14
|
+
# @!attribute [w] params
|
|
15
|
+
# @param value [Hash] default query parameters for all requests
|
|
16
|
+
# @!attribute [w] headers
|
|
17
|
+
# @param value [Hash] default headers for all requests
|
|
18
|
+
# @!attribute [w] open_timeout
|
|
19
|
+
# @param value [Integer] connection open timeout in seconds
|
|
20
|
+
# @!attribute [w] read_timeout
|
|
21
|
+
# @param value [Integer] read timeout in seconds
|
|
6
22
|
attr_writer :base_uri, :namespaces, :params, :headers, :open_timeout, :read_timeout
|
|
7
|
-
attr_reader :basic_auth, :request_timeout
|
|
8
23
|
|
|
24
|
+
# @return [Hash, nil] basic auth credentials
|
|
25
|
+
# @return [Integer, nil] request timeout
|
|
26
|
+
# @return [String, nil] bearer token for Authorization header
|
|
27
|
+
# @return [Hash, nil] API key configuration
|
|
28
|
+
# @return [Hash, nil] retry configuration for faraday-retry
|
|
29
|
+
# @return [#paginate, #next_context, nil] pagination strategy for collections
|
|
30
|
+
# @return [Jeckle::Auth::OAuth2, nil] OAuth 2.0 configuration
|
|
31
|
+
attr_reader :basic_auth, :request_timeout, :bearer_token, :api_key, :retry_options,
|
|
32
|
+
:pagination_strategy, :oauth2
|
|
33
|
+
|
|
34
|
+
# Returns or builds a configured Faraday connection.
|
|
35
|
+
#
|
|
36
|
+
# @return [Faraday::Connection]
|
|
9
37
|
def connection
|
|
10
38
|
@connection ||= Faraday.new(url: base_uri, request: timeout).tap do |conn|
|
|
11
39
|
conn.headers = headers
|
|
@@ -13,10 +41,28 @@ module Jeckle
|
|
|
13
41
|
conn.response :logger, logger
|
|
14
42
|
|
|
15
43
|
conn.request :authorization, :basic, basic_auth[:username], basic_auth[:password] if basic_auth
|
|
44
|
+
conn.request :authorization, :Bearer, bearer_token if bearer_token
|
|
45
|
+
conn.request :authorization, :Bearer, -> { oauth2.token } if oauth2
|
|
46
|
+
conn.request :retry, retry_options if retry_options
|
|
47
|
+
|
|
48
|
+
if api_key
|
|
49
|
+
if api_key[:header]
|
|
50
|
+
conn.headers[api_key[:header]] = api_key[:value]
|
|
51
|
+
elsif api_key[:param]
|
|
52
|
+
conn.params[api_key[:param]] = api_key[:value]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
16
56
|
conn.instance_exec(&@middlewares_block) if @middlewares_block
|
|
57
|
+
@connection_customizer&.call(conn)
|
|
17
58
|
end
|
|
18
59
|
end
|
|
19
60
|
|
|
61
|
+
# Set basic authentication credentials.
|
|
62
|
+
#
|
|
63
|
+
# @param credential_params [Hash] must contain :username and :password
|
|
64
|
+
# @raise [Jeckle::NoUsernameOrPasswordError] if keys are missing
|
|
65
|
+
# @return [Hash]
|
|
20
66
|
def basic_auth=(credential_params)
|
|
21
67
|
%i[username password].all? do |key|
|
|
22
68
|
credential_params.key? key
|
|
@@ -25,28 +71,161 @@ module Jeckle
|
|
|
25
71
|
@basic_auth = credential_params
|
|
26
72
|
end
|
|
27
73
|
|
|
74
|
+
# Set bearer token authentication. Resets cached connection.
|
|
75
|
+
#
|
|
76
|
+
# @param token [String] the bearer token
|
|
77
|
+
# @return [String]
|
|
78
|
+
def bearer_token=(token)
|
|
79
|
+
@connection = nil
|
|
80
|
+
@bearer_token = token
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Configure automatic retries using faraday-retry. Resets cached connection.
|
|
84
|
+
#
|
|
85
|
+
# @param options [Hash] retry options passed to Faraday::Retry::Middleware
|
|
86
|
+
# @option options [Integer] :max (2) maximum number of retries
|
|
87
|
+
# @option options [Float] :interval (0.5) initial interval between retries in seconds
|
|
88
|
+
# @option options [Float] :interval_randomness (0.5) randomness factor for retry interval
|
|
89
|
+
# @option options [Integer] :backoff_factor (2) exponential backoff multiplier
|
|
90
|
+
# @option options [Array<Integer>] :retry_statuses ([429, 500, 502, 503]) HTTP status codes to retry
|
|
91
|
+
# @return [Hash]
|
|
92
|
+
#
|
|
93
|
+
# @example
|
|
94
|
+
# api.retry = { max: 3, interval: 1, retry_statuses: [429, 503] }
|
|
95
|
+
def retry=(options)
|
|
96
|
+
@connection = nil
|
|
97
|
+
@retry_options = DEFAULT_RETRY_OPTIONS.merge(options)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Default retry configuration.
|
|
101
|
+
DEFAULT_RETRY_OPTIONS = {
|
|
102
|
+
max: 2,
|
|
103
|
+
interval: 0.5,
|
|
104
|
+
interval_randomness: 0.5,
|
|
105
|
+
backoff_factor: 2,
|
|
106
|
+
retry_statuses: [429, 500, 502, 503]
|
|
107
|
+
}.freeze
|
|
108
|
+
|
|
109
|
+
# Set API key authentication. Resets cached connection.
|
|
110
|
+
#
|
|
111
|
+
# @param config [Hash] must contain :value and either :header or :param
|
|
112
|
+
# @raise [Jeckle::ArgumentError] if config is invalid
|
|
113
|
+
# @return [Hash]
|
|
114
|
+
#
|
|
115
|
+
# @example Header-based API key
|
|
116
|
+
# api.api_key = { value: 'secret', header: 'X-Api-Key' }
|
|
117
|
+
#
|
|
118
|
+
# @example Query param-based API key
|
|
119
|
+
# api.api_key = { value: 'secret', param: 'api_key' }
|
|
120
|
+
def api_key=(config)
|
|
121
|
+
unless config.is_a?(Hash) && config[:value] && (config[:header] || config[:param])
|
|
122
|
+
raise Jeckle::ArgumentError, 'api_key requires :value and either :header or :param'
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
@connection = nil
|
|
126
|
+
@api_key = config
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Returns the full base URI including namespace segments.
|
|
130
|
+
#
|
|
131
|
+
# @return [String]
|
|
28
132
|
def base_uri
|
|
29
133
|
[@base_uri, *namespaces.values].join '/'
|
|
30
134
|
end
|
|
31
135
|
|
|
136
|
+
# @return [Hash] default query parameters
|
|
32
137
|
def params
|
|
33
138
|
@params || {}
|
|
34
139
|
end
|
|
35
140
|
|
|
141
|
+
# @return [Hash] default headers
|
|
36
142
|
def headers
|
|
37
143
|
@headers || {}
|
|
38
144
|
end
|
|
39
145
|
|
|
146
|
+
# @return [Hash] URL namespace segments
|
|
40
147
|
def namespaces
|
|
41
148
|
@namespaces || {}
|
|
42
149
|
end
|
|
43
150
|
|
|
151
|
+
# Set OAuth 2.0 client credentials authentication. Resets cached connection.
|
|
152
|
+
# The token is fetched lazily on the first request.
|
|
153
|
+
#
|
|
154
|
+
# @param config [Hash] OAuth 2.0 configuration
|
|
155
|
+
# @option config [String] :client_id OAuth client ID
|
|
156
|
+
# @option config [String] :client_secret OAuth client secret
|
|
157
|
+
# @option config [String] :token_url token endpoint URL
|
|
158
|
+
# @option config [String] :scope (nil) requested scope
|
|
159
|
+
# @return [Jeckle::Auth::OAuth2]
|
|
160
|
+
#
|
|
161
|
+
# @example
|
|
162
|
+
# api.oauth2 = {
|
|
163
|
+
# client_id: 'id', client_secret: 'secret',
|
|
164
|
+
# token_url: 'https://auth.example.com/oauth/token'
|
|
165
|
+
# }
|
|
166
|
+
def oauth2=(config)
|
|
167
|
+
@connection = nil
|
|
168
|
+
@oauth2 = Jeckle::Auth::OAuth2.new(**config)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Configure the pagination strategy for this API.
|
|
172
|
+
#
|
|
173
|
+
# @param strategy [Symbol, #paginate] :offset, :cursor, :link_header, or a strategy instance
|
|
174
|
+
# @param options [Hash] options passed to the built-in strategy constructor
|
|
175
|
+
# @return [#paginate, #next_context]
|
|
176
|
+
#
|
|
177
|
+
# @example Cursor-based pagination (Stripe-style)
|
|
178
|
+
# api.pagination :cursor, cursor_param: :starting_after, limit_param: :limit
|
|
179
|
+
#
|
|
180
|
+
# @example Link header pagination (GitHub-style)
|
|
181
|
+
# api.pagination :link_header
|
|
182
|
+
#
|
|
183
|
+
# @example Custom strategy instance
|
|
184
|
+
# api.pagination MyCustomStrategy.new
|
|
185
|
+
def pagination(strategy, **options)
|
|
186
|
+
@pagination_strategy = case strategy
|
|
187
|
+
when :offset then Jeckle::Pagination::Offset.new(**options)
|
|
188
|
+
when :cursor then Jeckle::Pagination::Cursor.new(**options)
|
|
189
|
+
when :link_header then Jeckle::Pagination::LinkHeader.new(**options)
|
|
190
|
+
else strategy
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Configure Faraday middlewares for this API.
|
|
195
|
+
#
|
|
196
|
+
# @yield block evaluated in the context of the Faraday connection builder
|
|
197
|
+
# @raise [Jeckle::ArgumentError] if no block is given
|
|
198
|
+
#
|
|
199
|
+
# @example
|
|
200
|
+
# api.middlewares do
|
|
201
|
+
# request :json
|
|
202
|
+
# response :json
|
|
203
|
+
# response :jeckle_raise_error
|
|
204
|
+
# end
|
|
44
205
|
def middlewares(&block)
|
|
45
206
|
raise Jeckle::ArgumentError, 'A block is required when configuring API middlewares' unless block_given?
|
|
46
207
|
|
|
47
208
|
@middlewares_block = block
|
|
48
209
|
end
|
|
49
210
|
|
|
211
|
+
# Customize the Faraday connection after Jeckle's defaults are applied.
|
|
212
|
+
# The block receives the Faraday connection and can add middleware,
|
|
213
|
+
# override the adapter, etc.
|
|
214
|
+
#
|
|
215
|
+
# @yield [Faraday::Connection] the connection after default setup
|
|
216
|
+
#
|
|
217
|
+
# @example
|
|
218
|
+
# api.configure_connection do |conn|
|
|
219
|
+
# conn.use MyCustomMiddleware
|
|
220
|
+
# conn.adapter :typhoeus
|
|
221
|
+
# end
|
|
222
|
+
def configure_connection(&block)
|
|
223
|
+
@connection_customizer = block
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Returns timeout configuration hash for Faraday.
|
|
227
|
+
#
|
|
228
|
+
# @return [Hash]
|
|
50
229
|
def timeout
|
|
51
230
|
{}.tap do |t|
|
|
52
231
|
t[:open_timeout] = @open_timeout if @open_timeout
|