resteze 0.3.1 → 0.4.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/CLAUDE.md +74 -0
- data/README.md +31 -0
- data/docs/ADVANCED_USAGE.md +760 -0
- data/docs/API.md +410 -0
- data/docs/CONFIGURATION.md +681 -0
- data/docs/ERROR_HANDLING.md +609 -0
- data/docs/TESTING.md +768 -0
- data/lib/resteze/client.rb +1 -0
- data/lib/resteze/instrumentation.rb +14 -0
- data/lib/resteze/version.rb +1 -1
- data/lib/resteze.rb +2 -0
- metadata +9 -2
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
# Advanced Usage Guide
|
|
2
|
+
|
|
3
|
+
## Table of Contents
|
|
4
|
+
- [List Operations](#list-operations)
|
|
5
|
+
- [Save Operations](#save-operations)
|
|
6
|
+
- [Error Handling](#error-handling)
|
|
7
|
+
- [Pagination](#pagination)
|
|
8
|
+
- [Batch Operations](#batch-operations)
|
|
9
|
+
- [Caching](#caching)
|
|
10
|
+
- [Authentication Strategies](#authentication-strategies)
|
|
11
|
+
- [Testing Your API Client](#testing-your-api-client)
|
|
12
|
+
|
|
13
|
+
## List Operations
|
|
14
|
+
|
|
15
|
+
### Including the List Module
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
module MyApi
|
|
19
|
+
class Product < ApiResource
|
|
20
|
+
include List
|
|
21
|
+
|
|
22
|
+
property :id
|
|
23
|
+
property :name
|
|
24
|
+
property :price
|
|
25
|
+
property :category
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Basic List Operations
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# List all products
|
|
34
|
+
products = MyApi::Product.list
|
|
35
|
+
products.each do |product|
|
|
36
|
+
puts "#{product.name}: $#{product.price}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# List with filters
|
|
40
|
+
products = MyApi::Product.list(
|
|
41
|
+
category: 'electronics',
|
|
42
|
+
min_price: 100,
|
|
43
|
+
sort: 'price_asc'
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Access list metadata
|
|
47
|
+
products.resteze_metadata[:total_count]
|
|
48
|
+
products.resteze_metadata[:page]
|
|
49
|
+
products.resteze_metadata[:per_page]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Custom List Methods
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
module MyApi
|
|
56
|
+
class Product < ApiResource
|
|
57
|
+
include List
|
|
58
|
+
|
|
59
|
+
# Override list behavior
|
|
60
|
+
def self.list(filters = {})
|
|
61
|
+
response = request(:get, resource_path, params: filters)
|
|
62
|
+
ListObject.new(response.data).tap do |list|
|
|
63
|
+
list.set_data(response.data[:products])
|
|
64
|
+
list.set_metadata(response.data.except(:products))
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Additional list endpoints
|
|
69
|
+
def self.featured
|
|
70
|
+
response = request(:get, "#{resource_path}/featured")
|
|
71
|
+
response.data.map { |attrs| construct_from(attrs) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.search(query, limit: 10)
|
|
75
|
+
response = request(
|
|
76
|
+
:get,
|
|
77
|
+
"#{resource_path}/search",
|
|
78
|
+
params: { q: query, limit: limit }
|
|
79
|
+
)
|
|
80
|
+
ListObject.new(response.data)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Working with ListObject
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
# ListObject extends Hashie::Array
|
|
90
|
+
products = MyApi::Product.list
|
|
91
|
+
|
|
92
|
+
# Array-like operations
|
|
93
|
+
products.count
|
|
94
|
+
products.first
|
|
95
|
+
products.last
|
|
96
|
+
products[0]
|
|
97
|
+
products.select { |p| p.price > 100 }
|
|
98
|
+
products.map(&:name)
|
|
99
|
+
|
|
100
|
+
# Access metadata
|
|
101
|
+
products.metadata[:total]
|
|
102
|
+
products.metadata[:has_more]
|
|
103
|
+
products.metadata[:url]
|
|
104
|
+
|
|
105
|
+
# Iterate with metadata
|
|
106
|
+
products.each_with_index do |product, index|
|
|
107
|
+
puts "#{index + 1}. #{product.name}"
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Save Operations
|
|
112
|
+
|
|
113
|
+
### Including the Save Module
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
module MyApi
|
|
117
|
+
class Customer < ApiResource
|
|
118
|
+
include Save
|
|
119
|
+
|
|
120
|
+
property :id
|
|
121
|
+
property :email
|
|
122
|
+
property :name
|
|
123
|
+
property :phone
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Create and Update Operations
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
# Create a new customer
|
|
132
|
+
customer = MyApi::Customer.new
|
|
133
|
+
customer.email = 'john@example.com'
|
|
134
|
+
customer.name = 'John Doe'
|
|
135
|
+
customer.save # POST to /customers
|
|
136
|
+
|
|
137
|
+
# Update existing customer
|
|
138
|
+
customer = MyApi::Customer.retrieve('123')
|
|
139
|
+
customer.phone = '+1234567890'
|
|
140
|
+
customer.save # PATCH/PUT to /customers/123
|
|
141
|
+
|
|
142
|
+
# Check if it's a new record
|
|
143
|
+
customer = MyApi::Customer.new
|
|
144
|
+
customer.persisted? # false
|
|
145
|
+
customer.save
|
|
146
|
+
customer.persisted? # true
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Custom Save Behavior
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
module MyApi
|
|
153
|
+
class Customer < ApiResource
|
|
154
|
+
include Save
|
|
155
|
+
|
|
156
|
+
# Override save method
|
|
157
|
+
def save
|
|
158
|
+
if persisted?
|
|
159
|
+
update
|
|
160
|
+
else
|
|
161
|
+
create
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Custom create endpoint
|
|
166
|
+
def create
|
|
167
|
+
response = request(:post, '/v2/customers', params: attributes_for_create)
|
|
168
|
+
initialize_from(response.data)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Custom update endpoint
|
|
172
|
+
def update
|
|
173
|
+
response = request(:put, resource_path, params: attributes_for_update)
|
|
174
|
+
initialize_from(response.data)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Validation before save
|
|
178
|
+
def save
|
|
179
|
+
validate!
|
|
180
|
+
super
|
|
181
|
+
rescue ValidationError => e
|
|
182
|
+
errors.add(:base, e.message)
|
|
183
|
+
false
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def attributes_for_create
|
|
189
|
+
attributes.except(:id, :created_at, :updated_at)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def attributes_for_update
|
|
193
|
+
attributes.slice(:name, :phone) # Only update specific fields
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def validate!
|
|
197
|
+
raise ValidationError, "Email is required" if email.blank?
|
|
198
|
+
raise ValidationError, "Invalid email format" unless email =~ /\A[^@\s]+@[^@\s]+\z/
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Batch Save Operations
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
module MyApi
|
|
208
|
+
class Customer < ApiResource
|
|
209
|
+
include Save
|
|
210
|
+
|
|
211
|
+
# Bulk create
|
|
212
|
+
def self.create_batch(customers_data)
|
|
213
|
+
response = request(
|
|
214
|
+
:post,
|
|
215
|
+
"#{resource_path}/batch",
|
|
216
|
+
params: { customers: customers_data }
|
|
217
|
+
)
|
|
218
|
+
response.data.map { |attrs| construct_from(attrs) }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Bulk update
|
|
222
|
+
def self.update_batch(updates)
|
|
223
|
+
response = request(
|
|
224
|
+
:patch,
|
|
225
|
+
"#{resource_path}/batch",
|
|
226
|
+
params: { updates: updates }
|
|
227
|
+
)
|
|
228
|
+
response.data
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Usage
|
|
234
|
+
customers = MyApi::Customer.create_batch([
|
|
235
|
+
{ email: 'user1@example.com', name: 'User 1' },
|
|
236
|
+
{ email: 'user2@example.com', name: 'User 2' }
|
|
237
|
+
])
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Error Handling
|
|
241
|
+
|
|
242
|
+
### Built-in Error Classes
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
begin
|
|
246
|
+
user = MyApi::User.retrieve('invalid-id')
|
|
247
|
+
rescue MyApi::ResourceNotFound => e
|
|
248
|
+
puts "User not found: #{e.message}"
|
|
249
|
+
rescue MyApi::InvalidRequestError => e
|
|
250
|
+
puts "Invalid request: #{e.message}"
|
|
251
|
+
puts "Field: #{e.param}" if e.param
|
|
252
|
+
rescue MyApi::ApiConnectionError => e
|
|
253
|
+
puts "Connection failed: #{e.message}"
|
|
254
|
+
rescue MyApi::ApiError => e
|
|
255
|
+
puts "API error: #{e.message}"
|
|
256
|
+
puts "Status: #{e.http_status}"
|
|
257
|
+
puts "Response: #{e.response.body}"
|
|
258
|
+
end
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Custom Error Handling
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
module MyApi
|
|
265
|
+
# Define custom errors
|
|
266
|
+
class RateLimitError < Error; end
|
|
267
|
+
class AuthenticationError < Error; end
|
|
268
|
+
|
|
269
|
+
# Custom middleware for error handling
|
|
270
|
+
class Middleware::CustomErrorHandler < Faraday::Middleware
|
|
271
|
+
def on_complete(env)
|
|
272
|
+
case env[:status]
|
|
273
|
+
when 429
|
|
274
|
+
raise RateLimitError.new(
|
|
275
|
+
"Rate limit exceeded. Retry after #{env[:response_headers]['retry-after']}",
|
|
276
|
+
http_status: 429,
|
|
277
|
+
response: env
|
|
278
|
+
)
|
|
279
|
+
when 401
|
|
280
|
+
raise AuthenticationError.new(
|
|
281
|
+
"Authentication failed",
|
|
282
|
+
http_status: 401,
|
|
283
|
+
response: env
|
|
284
|
+
)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
class Client < Resteze::Client
|
|
290
|
+
def self.default_connection
|
|
291
|
+
@default_connection ||= Faraday.new do |conn|
|
|
292
|
+
conn.use Middleware::CustomErrorHandler
|
|
293
|
+
conn.use Middleware::RaiseError
|
|
294
|
+
conn.adapter Faraday.default_adapter
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### Retry Logic
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
module MyApi
|
|
305
|
+
class Client < Resteze::Client
|
|
306
|
+
def execute_request_with_retry(method, path, **options)
|
|
307
|
+
retries = 0
|
|
308
|
+
begin
|
|
309
|
+
execute_request(method, path, **options)
|
|
310
|
+
rescue ApiConnectionError, RateLimitError => e
|
|
311
|
+
if retries < 3
|
|
312
|
+
retries += 1
|
|
313
|
+
sleep(2 ** retries) # Exponential backoff
|
|
314
|
+
retry
|
|
315
|
+
else
|
|
316
|
+
raise e
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
## Pagination
|
|
325
|
+
|
|
326
|
+
### Implementing Pagination
|
|
327
|
+
|
|
328
|
+
```ruby
|
|
329
|
+
module MyApi
|
|
330
|
+
class Order < ApiResource
|
|
331
|
+
include List
|
|
332
|
+
|
|
333
|
+
def self.list(page: 1, per_page: 25, **filters)
|
|
334
|
+
response = request(
|
|
335
|
+
:get,
|
|
336
|
+
resource_path,
|
|
337
|
+
params: filters.merge(page: page, per_page: per_page)
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
ListObject.new(response.data).tap do |list|
|
|
341
|
+
list.set_data(response.data[:orders])
|
|
342
|
+
list.set_metadata({
|
|
343
|
+
page: response.data[:page],
|
|
344
|
+
per_page: response.data[:per_page],
|
|
345
|
+
total: response.data[:total],
|
|
346
|
+
has_more: response.data[:has_more]
|
|
347
|
+
})
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Auto-pagination helper
|
|
352
|
+
def self.all_pages(**filters)
|
|
353
|
+
Enumerator.new do |yielder|
|
|
354
|
+
page = 1
|
|
355
|
+
loop do
|
|
356
|
+
list = list(page: page, **filters)
|
|
357
|
+
list.each { |item| yielder.yield(item) }
|
|
358
|
+
|
|
359
|
+
break unless list.metadata[:has_more]
|
|
360
|
+
page += 1
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Usage
|
|
368
|
+
# Get all orders across all pages
|
|
369
|
+
MyApi::Order.all_pages(status: 'completed').each do |order|
|
|
370
|
+
process_order(order)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# With lazy evaluation
|
|
374
|
+
expensive_orders = MyApi::Order.all_pages.lazy
|
|
375
|
+
.select { |order| order.total > 1000 }
|
|
376
|
+
.take(10)
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
### Cursor-based Pagination
|
|
380
|
+
|
|
381
|
+
```ruby
|
|
382
|
+
module MyApi
|
|
383
|
+
class Event < ApiResource
|
|
384
|
+
include List
|
|
385
|
+
|
|
386
|
+
def self.list(cursor: nil, limit: 100)
|
|
387
|
+
response = request(
|
|
388
|
+
:get,
|
|
389
|
+
resource_path,
|
|
390
|
+
params: { cursor: cursor, limit: limit }.compact
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
ListObject.new(response.data).tap do |list|
|
|
394
|
+
list.set_data(response.data[:events])
|
|
395
|
+
list.set_metadata({
|
|
396
|
+
next_cursor: response.data[:next_cursor],
|
|
397
|
+
has_more: response.data[:has_more]
|
|
398
|
+
})
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def self.each_batch(limit: 100, &block)
|
|
403
|
+
cursor = nil
|
|
404
|
+
loop do
|
|
405
|
+
batch = list(cursor: cursor, limit: limit)
|
|
406
|
+
yield batch
|
|
407
|
+
|
|
408
|
+
cursor = batch.metadata[:next_cursor]
|
|
409
|
+
break if cursor.nil?
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
## Batch Operations
|
|
417
|
+
|
|
418
|
+
### Parallel Requests
|
|
419
|
+
|
|
420
|
+
```ruby
|
|
421
|
+
require 'parallel'
|
|
422
|
+
|
|
423
|
+
module MyApi
|
|
424
|
+
class User < ApiResource
|
|
425
|
+
def self.fetch_multiple(ids)
|
|
426
|
+
Parallel.map(ids, in_threads: 5) do |id|
|
|
427
|
+
begin
|
|
428
|
+
retrieve(id)
|
|
429
|
+
rescue ResourceNotFound
|
|
430
|
+
nil
|
|
431
|
+
end
|
|
432
|
+
end.compact
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def self.update_multiple(updates)
|
|
436
|
+
Parallel.map(updates, in_threads: 5) do |id, attrs|
|
|
437
|
+
user = retrieve(id)
|
|
438
|
+
user.update_attributes(attrs)
|
|
439
|
+
user.save
|
|
440
|
+
user
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
### Batch API Endpoints
|
|
448
|
+
|
|
449
|
+
```ruby
|
|
450
|
+
module MyApi
|
|
451
|
+
class Product < ApiResource
|
|
452
|
+
# Batch fetch
|
|
453
|
+
def self.retrieve_batch(ids)
|
|
454
|
+
response = request(
|
|
455
|
+
:post,
|
|
456
|
+
"#{resource_path}/batch/get",
|
|
457
|
+
params: { ids: ids }
|
|
458
|
+
)
|
|
459
|
+
response.data.map { |attrs| construct_from(attrs) }
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Batch delete
|
|
463
|
+
def self.delete_batch(ids)
|
|
464
|
+
request(
|
|
465
|
+
:delete,
|
|
466
|
+
"#{resource_path}/batch",
|
|
467
|
+
params: { ids: ids }
|
|
468
|
+
)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Batch update
|
|
472
|
+
def self.update_batch(updates)
|
|
473
|
+
# updates = [{ id: 1, price: 99.99 }, { id: 2, price: 149.99 }]
|
|
474
|
+
response = request(
|
|
475
|
+
:patch,
|
|
476
|
+
"#{resource_path}/batch",
|
|
477
|
+
params: { updates: updates }
|
|
478
|
+
)
|
|
479
|
+
response.data[:updated_count]
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
## Caching
|
|
486
|
+
|
|
487
|
+
### Simple Memory Cache
|
|
488
|
+
|
|
489
|
+
```ruby
|
|
490
|
+
module MyApi
|
|
491
|
+
class CachedClient < Client
|
|
492
|
+
def initialize(connection = self.class.default_connection)
|
|
493
|
+
super
|
|
494
|
+
@cache = {}
|
|
495
|
+
@cache_ttl = 300 # 5 minutes
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def execute_request(method, path, **options)
|
|
499
|
+
if method == :get && cacheable?(path)
|
|
500
|
+
cache_key = "#{path}:#{options[:params].to_json}"
|
|
501
|
+
cached = @cache[cache_key]
|
|
502
|
+
|
|
503
|
+
if cached && cached[:expires_at] > Time.now
|
|
504
|
+
return cached[:response]
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
response = super
|
|
508
|
+
@cache[cache_key] = {
|
|
509
|
+
response: response,
|
|
510
|
+
expires_at: Time.now + @cache_ttl
|
|
511
|
+
}
|
|
512
|
+
response
|
|
513
|
+
else
|
|
514
|
+
super
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
private
|
|
519
|
+
|
|
520
|
+
def cacheable?(path)
|
|
521
|
+
path.match?(/\/(products|categories|static_content)/)
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
### Redis Cache
|
|
528
|
+
|
|
529
|
+
```ruby
|
|
530
|
+
require 'redis'
|
|
531
|
+
require 'json'
|
|
532
|
+
|
|
533
|
+
module MyApi
|
|
534
|
+
class RedisCache
|
|
535
|
+
def initialize(redis = Redis.new, ttl: 300)
|
|
536
|
+
@redis = redis
|
|
537
|
+
@ttl = ttl
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def fetch(key, &block)
|
|
541
|
+
cached = @redis.get(key)
|
|
542
|
+
return JSON.parse(cached, symbolize_names: true) if cached
|
|
543
|
+
|
|
544
|
+
result = yield
|
|
545
|
+
@redis.setex(key, @ttl, result.to_json)
|
|
546
|
+
result
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def clear(pattern = '*')
|
|
550
|
+
keys = @redis.keys("myapi:#{pattern}")
|
|
551
|
+
@redis.del(*keys) unless keys.empty?
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
class Product < ApiResource
|
|
556
|
+
def self.cache
|
|
557
|
+
@cache ||= RedisCache.new
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def self.retrieve(id)
|
|
561
|
+
cache.fetch("myapi:product:#{id}") do
|
|
562
|
+
super
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
end
|
|
566
|
+
end
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
## Authentication Strategies
|
|
570
|
+
|
|
571
|
+
### API Key Authentication
|
|
572
|
+
|
|
573
|
+
```ruby
|
|
574
|
+
module MyApi
|
|
575
|
+
configure do |config|
|
|
576
|
+
config.api_key = ENV['MY_API_KEY']
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
class Client < Resteze::Client
|
|
580
|
+
def request_headers
|
|
581
|
+
super.merge({
|
|
582
|
+
'X-API-Key' => api_module.api_key
|
|
583
|
+
})
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
### OAuth 2.0
|
|
590
|
+
|
|
591
|
+
```ruby
|
|
592
|
+
module MyApi
|
|
593
|
+
class << self
|
|
594
|
+
attr_accessor :access_token, :refresh_token, :token_expires_at
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
class Client < Resteze::Client
|
|
598
|
+
def request_headers
|
|
599
|
+
ensure_valid_token!
|
|
600
|
+
super.merge({
|
|
601
|
+
'Authorization' => "Bearer #{api_module.access_token}"
|
|
602
|
+
})
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
private
|
|
606
|
+
|
|
607
|
+
def ensure_valid_token!
|
|
608
|
+
return if api_module.token_expires_at && api_module.token_expires_at > Time.now
|
|
609
|
+
|
|
610
|
+
refresh_access_token!
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def refresh_access_token!
|
|
614
|
+
response = Faraday.post('https://api.example.com/oauth/token') do |req|
|
|
615
|
+
req.body = {
|
|
616
|
+
grant_type: 'refresh_token',
|
|
617
|
+
refresh_token: api_module.refresh_token,
|
|
618
|
+
client_id: ENV['CLIENT_ID'],
|
|
619
|
+
client_secret: ENV['CLIENT_SECRET']
|
|
620
|
+
}
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
token_data = JSON.parse(response.body)
|
|
624
|
+
api_module.access_token = token_data['access_token']
|
|
625
|
+
api_module.refresh_token = token_data['refresh_token']
|
|
626
|
+
api_module.token_expires_at = Time.now + token_data['expires_in']
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
end
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
### HMAC Signature
|
|
633
|
+
|
|
634
|
+
```ruby
|
|
635
|
+
require 'openssl'
|
|
636
|
+
require 'base64'
|
|
637
|
+
|
|
638
|
+
module MyApi
|
|
639
|
+
class Client < Resteze::Client
|
|
640
|
+
def execute_request(method, path, **options)
|
|
641
|
+
timestamp = Time.now.to_i.to_s
|
|
642
|
+
signature = generate_signature(method, path, timestamp, options[:params])
|
|
643
|
+
|
|
644
|
+
options[:headers] ||= {}
|
|
645
|
+
options[:headers].merge!({
|
|
646
|
+
'X-Timestamp' => timestamp,
|
|
647
|
+
'X-Signature' => signature,
|
|
648
|
+
'X-Client-Id' => api_module.client_id
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
super
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
private
|
|
655
|
+
|
|
656
|
+
def generate_signature(method, path, timestamp, params)
|
|
657
|
+
message = [
|
|
658
|
+
method.to_s.upcase,
|
|
659
|
+
path,
|
|
660
|
+
timestamp,
|
|
661
|
+
params.to_json
|
|
662
|
+
].join("\n")
|
|
663
|
+
|
|
664
|
+
hmac = OpenSSL::HMAC.digest('SHA256', api_module.client_secret, message)
|
|
665
|
+
Base64.strict_encode64(hmac)
|
|
666
|
+
end
|
|
667
|
+
end
|
|
668
|
+
end
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
## Testing Your API Client
|
|
672
|
+
|
|
673
|
+
### Using Minitest
|
|
674
|
+
|
|
675
|
+
```ruby
|
|
676
|
+
require 'resteze/testing/minitest'
|
|
677
|
+
|
|
678
|
+
class MyApiTest < Minitest::Test
|
|
679
|
+
include Resteze::Testing::Minitest
|
|
680
|
+
|
|
681
|
+
def setup
|
|
682
|
+
configure_api(MyApi) do |config|
|
|
683
|
+
config.api_base = 'http://test.example.com/'
|
|
684
|
+
end
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
def test_retrieve_user
|
|
688
|
+
stub_api_request(:get, '/users/123').to_return(
|
|
689
|
+
body: { id: 123, email: 'test@example.com' }.to_json,
|
|
690
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
user = MyApi::User.retrieve('123')
|
|
694
|
+
assert_equal 'test@example.com', user.email
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
def test_error_handling
|
|
698
|
+
stub_api_request(:get, '/users/999').to_return(status: 404)
|
|
699
|
+
|
|
700
|
+
assert_raises(MyApi::ResourceNotFound) do
|
|
701
|
+
MyApi::User.retrieve('999')
|
|
702
|
+
end
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
### Using RSpec
|
|
708
|
+
|
|
709
|
+
```ruby
|
|
710
|
+
require 'resteze/testing/rspec'
|
|
711
|
+
|
|
712
|
+
RSpec.describe MyApi::User do
|
|
713
|
+
include Resteze::Testing::RSpec
|
|
714
|
+
|
|
715
|
+
before do
|
|
716
|
+
configure_api(MyApi) do |config|
|
|
717
|
+
config.api_base = 'http://test.example.com/'
|
|
718
|
+
end
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
describe '.retrieve' do
|
|
722
|
+
it 'fetches a user by ID' do
|
|
723
|
+
stub_api_request(:get, '/users/123').to_return(
|
|
724
|
+
body: { id: 123, email: 'test@example.com' }.to_json
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
user = described_class.retrieve('123')
|
|
728
|
+
expect(user.email).to eq('test@example.com')
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
it 'raises an error for missing users' do
|
|
732
|
+
stub_api_request(:get, '/users/999').to_return(status: 404)
|
|
733
|
+
|
|
734
|
+
expect { described_class.retrieve('999') }
|
|
735
|
+
.to raise_error(MyApi::ResourceNotFound)
|
|
736
|
+
end
|
|
737
|
+
end
|
|
738
|
+
end
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
### VCR Integration
|
|
742
|
+
|
|
743
|
+
```ruby
|
|
744
|
+
require 'vcr'
|
|
745
|
+
|
|
746
|
+
VCR.configure do |config|
|
|
747
|
+
config.cassette_library_dir = 'test/fixtures/vcr_cassettes'
|
|
748
|
+
config.hook_into :faraday
|
|
749
|
+
config.filter_sensitive_data('<API_KEY>') { MyApi.api_key }
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
class MyApiIntegrationTest < Minitest::Test
|
|
753
|
+
def test_real_api_call
|
|
754
|
+
VCR.use_cassette('user_retrieve') do
|
|
755
|
+
user = MyApi::User.retrieve('123')
|
|
756
|
+
assert_equal 'John Doe', user.name
|
|
757
|
+
end
|
|
758
|
+
end
|
|
759
|
+
end
|
|
760
|
+
```
|