resteze 0.3.0 → 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.
@@ -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
+ ```