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