resteze 0.4.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.
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 +9 -0
  5. data/README.md +45 -4
  6. data/docs/ACTIONS.md +329 -0
  7. data/docs/ADVANCED_USAGE.md +29 -45
  8. data/docs/API.md +19 -25
  9. data/docs/CONFIGURATION.md +281 -0
  10. data/docs/ERROR_HANDLING.md +94 -27
  11. data/docs/TESTING.md +21 -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 +59 -11
  25. data/lib/resteze/errors.rb +125 -4
  26. data/lib/resteze/instrumentation.rb +35 -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 +101 -11
  47. metadata +23 -2
@@ -1,5 +1,7 @@
1
1
  module Resteze
2
2
  class Response
3
+ include Resteze::ApiModule
4
+
3
5
  OK = 200
4
6
  attr_accessor :data, :http_body, :http_headers, :http_status, :request_id
5
7
 
@@ -15,15 +17,37 @@ module Resteze
15
17
  status: response.http_status,
16
18
  headers: response.http_headers
17
19
  )
20
+
21
+ error = body_error(response)
22
+ raise error if error
18
23
  end
19
24
  end
20
25
 
26
+ # Override in MyApi::Response for APIs that report failures inside a
27
+ # successful HTTP response. Receives the parsed Response after the
28
+ # status-based middleware has accepted it; return an exception to raise
29
+ # it, or nil to accept the response.
30
+ #
31
+ # module MyApi
32
+ # class Response
33
+ # def self.body_error(response)
34
+ # data = response.data
35
+ # return unless data.is_a?(Hash) && data[:errors].present?
36
+ #
37
+ # ApiError.from_response(response, data[:message])
38
+ # end
39
+ # end
40
+ # end
41
+ def self.body_error(_response)
42
+ nil
43
+ end
44
+
21
45
  def self.batch_response?(headers)
22
46
  headers["Content-Type".freeze].to_s.include?("boundary=batchresponse".freeze)
23
47
  end
24
48
 
25
49
  def self.parse_body(body, status: nil, headers: {})
26
- return body if unparsable_body?(status:, headers:)
50
+ return body if body.blank? || unparsable_body?(status:, headers:)
27
51
 
28
52
  type = mime_type(headers)&.symbol || :json
29
53
  case type
@@ -45,8 +69,20 @@ module Resteze
45
69
  Mime::LOOKUP[mime_type] || Mime::Type.lookup_by_extension(:json)
46
70
  end
47
71
 
72
+ # Exactly 200 OK. Use #success? for the whole 2xx range.
48
73
  def ok?
49
74
  http_status == OK
50
75
  end
76
+
77
+ # Any 2xx status, matching Faraday::Response#success?.
78
+ def success?
79
+ (200..299).cover?(http_status)
80
+ end
81
+
82
+ # Whatever the module's RateLimiter makes of this response (see
83
+ # Resteze::RateLimiter#rate_limit), or nil.
84
+ def rate_limit
85
+ @rate_limit ||= api_module.try(:rate_limiter)&.rate_limit(self)
86
+ end
51
87
  end
52
88
  end
@@ -0,0 +1,18 @@
1
+ module Resteze
2
+ # The default return value of a custom action: a loose, dot-accessible view
3
+ # of the parsed response body, with the Response it came from attached.
4
+ class Result < Hashie::Mash
5
+ include Resteze::ApiModule
6
+
7
+ disable_warnings
8
+
9
+ attr_reader :http_response
10
+
11
+ def self.from_response(response)
12
+ data = response.data
13
+ new(data.is_a?(Hash) ? data : {}).tap do |result|
14
+ result.instance_variable_set(:@http_response, response)
15
+ end
16
+ end
17
+ end
18
+ end
data/lib/resteze/save.rb CHANGED
@@ -6,24 +6,40 @@ module Resteze
6
6
  resp = request(
7
7
  save_method,
8
8
  save_resource_path,
9
- params: as_save_json,
9
+ params: save_params,
10
10
  headers: save_headers
11
11
  )
12
12
  process_save_response(resp)
13
13
  end
14
14
 
15
15
  def save_method
16
- persisted? ? :put : :post
16
+ save_action.http_method
17
17
  end
18
18
 
19
+ # While :create / :update keep their REST defaults, an instance-level
20
+ # resource_path override is honored exactly as in 0.4.0.
19
21
  def save_resource_path
20
- persisted? ? resource_path : self.class.resource_path
22
+ action = save_action
23
+ if action.rest_default?
24
+ persisted? ? resource_path : self.class.resource_path
25
+ else
26
+ persisted? ? action.path_for(id) : action.path_for
27
+ end
21
28
  end
22
29
 
23
30
  def as_save_json
24
31
  as_json
25
32
  end
26
33
 
34
+ # The body sent by #save: as_save_json, plus the id param when the
35
+ # :update action carries the id as a param rather than in the path.
36
+ def save_params
37
+ params = as_save_json
38
+ return params unless persisted? && save_action.id == :param
39
+
40
+ { save_action.id_param.to_s => id }.merge(params)
41
+ end
42
+
27
43
  def save_headers
28
44
  respond_to?(:retrieve_headers) ? retrieve_headers : {}
29
45
  end
@@ -31,5 +47,11 @@ module Resteze
31
47
  def process_save_response(response)
32
48
  merge_from(response.data)
33
49
  end
50
+
51
+ private
52
+
53
+ def save_action
54
+ self.class.action_for(persisted? ? :update : :create)
55
+ end
34
56
  end
35
57
  end
@@ -0,0 +1,29 @@
1
+ module Resteze
2
+ # A mutex-guarded state hash for objects shared by every thread in a
3
+ # process, such as a module's RateLimiter and Authenticator. Including
4
+ # classes call `initialize_state` from their constructor and keep all
5
+ # mutable state in the hash rather than in instance variables.
6
+ module SynchronizedState
7
+ def initialize_state
8
+ @state_mutex = Mutex.new
9
+ @state = {}
10
+ end
11
+
12
+ # A deep-copied snapshot of the state.
13
+ def state
14
+ synchronize(&:deep_dup)
15
+ end
16
+
17
+ def update_state(**values)
18
+ synchronize { |state| state.merge!(values) }
19
+ end
20
+
21
+ # Yields the live state hash under the lock. Re-entrant, so `state` and
22
+ # `update_state` may be called inside the block.
23
+ def synchronize
24
+ return yield(@state) if @state_mutex.owned?
25
+
26
+ @state_mutex.synchronize { yield(@state) }
27
+ end
28
+ end
29
+ end
@@ -3,10 +3,12 @@ module Resteze
3
3
  module Configuration
4
4
  extend ActiveSupport::Concern
5
5
 
6
- # Assert that a config property is supported and can be assigned
6
+ # Assert that a config property can be assigned and reads back the
7
+ # assigned value. The original value is restored afterwards.
7
8
  def assert_config_property(property, value = nil, subject: nil)
8
9
  subject ||= (defined?(@subject) && @subject) || subject()
9
10
  original_value = subject.send(property)
11
+ subject.send("#{property}=", value)
10
12
 
11
13
  assert_equal value, subject.send(property), "config property #{property} value did not match expectation"
12
14
  ensure
@@ -4,6 +4,9 @@ module Resteze
4
4
  module Object
5
5
  include ActiveSupport::Concern
6
6
 
7
+ ACTION_READERS = { method: :http_method, on: :on, id: :id, id_param: :id_param,
8
+ returns: :returns, as: :generated_name }.freeze
9
+
7
10
  private
8
11
 
9
12
  # rubocop:disable Metrics/AbcSize
@@ -18,6 +21,33 @@ module Resteze
18
21
  # rubocop:enable Metrics/AbcSize
19
22
  # rubocop:enable Naming/PredicatePrefix
20
23
 
24
+ # Assert that an action is declared, optionally checking its resolved
25
+ # options. `path:` is compared against the full routed path, using the
26
+ # id "ID" for member actions.
27
+ # rubocop:disable-next Naming/PredicatePrefix
28
+ def has_action(name, **options)
29
+ action = subject.actions[name.to_sym]
30
+
31
+ assert action, "Expected #{subject} to have action: #{name}"
32
+ assert_action_options(action, options)
33
+ end
34
+
35
+ def assert_action_options(action, options)
36
+ ACTION_READERS.each do |option, reader|
37
+ next unless options.key?(option)
38
+
39
+ assert_equal options[option], action.public_send(reader),
40
+ "Expected #{subject} action #{action.name} to have #{option}: #{options[option].inspect}"
41
+ end
42
+ assert_action_path(action, options[:path]) if options.key?(:path)
43
+ end
44
+
45
+ def assert_action_path(action, path)
46
+ actual = action.member? ? action.path_for("ID") : action.path_for
47
+
48
+ assert_equal path, actual, "Expected #{subject} action #{action.name} to route to #{path}"
49
+ end
50
+
21
51
  def assert_property_transformed(name, transformed: true)
22
52
  translation = subject.translations_hash.dig(subject.translations.key(name.to_sym), name.to_sym)
23
53
  transformation_defined = translation.is_a?(Proc) || subject.transformation_exists?(name.to_sym)
@@ -1,10 +1,12 @@
1
1
  module Resteze
2
2
  module Testing
3
3
  module Configuration
4
- # Assert that a config property is supported and can be assigned
4
+ # Assert that a config property can be assigned and reads back the
5
+ # assigned value. The original value is restored afterwards.
5
6
  def assert_config_property(property, value = nil, subject: nil)
6
7
  subject ||= (defined?(@subject) && @subject) || self.subject
7
8
  original_value = subject.send(property)
9
+ subject.send("#{property}=", value)
8
10
 
9
11
  expect(subject.send(property)).to eq(value), "config property #{property} value did not match expectation"
10
12
  ensure
@@ -4,8 +4,11 @@ module Resteze
4
4
  module Object
5
5
  extend RSpec::Matchers
6
6
 
7
+ ACTION_READERS = { method: :http_method, on: :on, id: :id, id_param: :id_param,
8
+ returns: :returns, as: :generated_name }.freeze
9
+
7
10
  # rubocop:disable Metrics/AbcSize
8
- # rubocop:disable Naming/PredicatePrefix
11
+ # rubocop:disable-next Naming/PredicatePrefix
9
12
  def has_property(name, **options)
10
13
  expect(subject.property?(name.to_sym)).to be(true), "Expected #{subject} to have property: #{name}"
11
14
  assert_property_default(name, options[:default]) if options.key?(:default)
@@ -13,7 +16,6 @@ module Resteze
13
16
  assert_property_transformed(name, transformed: options[:transformed]) if options.key?(:transformed)
14
17
  assert_property_required(name, required: options[:required]) if options.key?(:required)
15
18
  end
16
- # rubocop:enable Naming/PredicatePrefix
17
19
 
18
20
  def assert_property_transformed(name, transformed: true)
19
21
  translation = subject.translations_hash.dig(subject.translations.key(name.to_sym), name.to_sym)
@@ -27,6 +29,34 @@ module Resteze
27
29
  end
28
30
  # rubocop:enable Metrics/AbcSize
29
31
 
32
+ # Assert that an action is declared, optionally checking its resolved
33
+ # options. `path:` is compared against the full routed path, using the
34
+ # id "ID" for member actions.
35
+ # rubocop:disable-next Naming/PredicatePrefix
36
+ def has_action(name, **options)
37
+ action = subject.actions[name.to_sym]
38
+
39
+ expect(action).not_to be_nil, "Expected #{subject} to have action: #{name}"
40
+ assert_action_options(action, options)
41
+ end
42
+
43
+ def assert_action_options(action, options)
44
+ ACTION_READERS.each do |option, reader|
45
+ next unless options.key?(option)
46
+
47
+ expect(action.public_send(reader)).to eq(options[option]),
48
+ "Expected #{subject} action #{action.name} to have #{option}: " \
49
+ "#{options[option].inspect}"
50
+ end
51
+ assert_action_path(action, options[:path]) if options.key?(:path)
52
+ end
53
+
54
+ def assert_action_path(action, path)
55
+ actual = action.member? ? action.path_for("ID") : action.path_for
56
+
57
+ expect(actual).to eq(path), "Expected #{subject} action #{action.name} to route to #{path}"
58
+ end
59
+
30
60
  def assert_property_required(name, required: true)
31
61
  if required
32
62
  expect(subject.required?(name.to_sym)).to be(true), "Expected #{subject} to require property: #{name}"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Resteze
4
- VERSION = "0.4.0"
4
+ VERSION = "1.0.0"
5
5
  end
data/lib/resteze.rb CHANGED
@@ -10,6 +10,8 @@ require_relative "resteze/version"
10
10
  # API Concerns
11
11
  require "resteze/api_module"
12
12
  require "resteze/request"
13
+ require "resteze/actions/validation"
14
+ require "resteze/actions"
13
15
  require "resteze/api_resource"
14
16
  require "resteze/list"
15
17
  require "resteze/save"
@@ -17,12 +19,21 @@ require "resteze/save"
17
19
  # API resource support classes
18
20
  require "resteze/object"
19
21
 
20
- # API Classes
21
- require "resteze/instrumentation"
22
+ # API Classes (each file requires what it needs at load time, so this
23
+ # list stays alphabetical)
24
+ require "resteze/action"
25
+ require "resteze/authenticating"
26
+ require "resteze/authenticator"
22
27
  require "resteze/client"
23
28
  require "resteze/errors"
29
+ require "resteze/instrumentation"
24
30
  require "resteze/list_object"
31
+ require "resteze/rate_limit"
32
+ require "resteze/rate_limiter"
33
+ require "resteze/rate_limiting"
25
34
  require "resteze/response"
35
+ require "resteze/result"
36
+ require "resteze/synchronized_state"
26
37
  require "resteze/util"
27
38
 
28
39
  # Middlewares
@@ -59,6 +70,59 @@ module Resteze
59
70
  def default_api_key(attribute)
60
71
  attribute.to_s
61
72
  end
73
+
74
+ def default_id_param(klass)
75
+ :"#{klass.resource_slug.singularize}_id"
76
+ end
77
+
78
+ ACTION_DEFAULT_OPTIONS = %i[method id id_param].freeze
79
+
80
+ # Declares API-wide defaults for one named action; resources merge these
81
+ # beneath their own declarations.
82
+ def action(name, **options)
83
+ name = name.to_sym
84
+ Resteze::Action.validate_options!(name, options)
85
+ action_declarations[name] = options
86
+ nil
87
+ end
88
+
89
+ def action_declarations
90
+ @action_declarations ||= {}
91
+ end
92
+
93
+ # With options, merges them into the API-wide defaults applied to every
94
+ # action; without options, returns those defaults.
95
+ def action_defaults(**options)
96
+ @action_defaults ||= {}
97
+ return @action_defaults if options.empty?
98
+
99
+ Resteze::Action.validate_options!(:defaults, options, allowed: ACTION_DEFAULT_OPTIONS)
100
+ @action_defaults.merge!(options)
101
+ nil
102
+ end
103
+
104
+ # Chooses how the API authenticates, without opening the module's
105
+ # Authenticator: `authenticate_with :api_key, header: "X-Api-Key",
106
+ # value: -> { MyApi.api_key }`. Call it once per scheme to combine
107
+ # them. Reopen MyApi::Authenticator only to override its lifecycle.
108
+ def authenticate_with(name, **)
109
+ component_class(:Authenticator).strategy(name, **)
110
+ end
111
+
112
+ # Chooses how the client paces itself, without opening the module's
113
+ # RateLimiter: `rate_limit_with :fixed_window, limit: 100, window: 60`.
114
+ # Reopen MyApi::RateLimiter to override `retry?` and the rest.
115
+ def rate_limit_with(name, **)
116
+ component_class(:RateLimiter).strategy(name, **)
117
+ end
118
+
119
+ private
120
+
121
+ def component_class(name)
122
+ raise ArgumentError, "call this on a module that includes Resteze, after the include" unless include?(Resteze)
123
+
124
+ const_get(name, false)
125
+ end
62
126
  end
63
127
 
64
128
  extend ClassMethods
@@ -70,7 +134,37 @@ module Resteze
70
134
  :read_timeout,
71
135
  :logger,
72
136
  :proxy
73
- end
137
+
138
+ # One RateLimiter per API module, shared by every thread. Defaults to
139
+ # an instance of the module's own RateLimiter class; assign another to
140
+ # replace it (nil restores the default). Whatever is assigned is told
141
+ # which module it serves, so it raises this module's errors.
142
+ def rate_limiter
143
+ @rate_limiter || @component_mutex.synchronize { @rate_limiter ||= adopt(self::RateLimiter.new) }
144
+ end
145
+
146
+ def rate_limiter=(limiter)
147
+ @rate_limiter = adopt(limiter)
148
+ end
149
+
150
+ # One Authenticator per API module, shared by every thread, with the
151
+ # same defaults and replacement rules as rate_limiter.
152
+ def authenticator
153
+ @authenticator || @component_mutex.synchronize { @authenticator ||= adopt(self::Authenticator.new) }
154
+ end
155
+
156
+ def authenticator=(authenticator)
157
+ @authenticator = adopt(authenticator)
158
+ end
159
+
160
+ # Tells a shared component which module it serves, so a replacement
161
+ # of any class raises this module's errors.
162
+ def adopt(component)
163
+ component.api_module = self if component.respond_to?(:api_module=)
164
+ component
165
+ end
166
+ end
167
+ @component_mutex = Mutex.new
74
168
 
75
169
  configure do |config|
76
170
  config.api_base = "http://example.com/"
@@ -84,19 +178,15 @@ module Resteze
84
178
  const_set :Object, Class.new(Resteze::Object)
85
179
  const_set :Client, Class.new(Resteze::Client)
86
180
  const_set :Response, Class.new(Resteze::Response)
181
+ const_set :Result, Class.new(Resteze::Result)
87
182
  const_set :Error, Class.new(Resteze::Error)
88
-
89
- Resteze::Error.subclasses.select { |klass| klass.name.start_with?("Resteze::") }.each do |error_class|
90
- error_type = error_class.to_s.demodulize
91
- const_set error_type, Class.new(error_class) unless const_defined?("#{self}::#{error_type}")
92
- end
183
+ const_set :RateLimiter, Class.new(Resteze::RateLimiter)
184
+ const_set :Authenticator, Class.new(Resteze::Authenticator)
185
+ Resteze::Errors.define(self, self::Error)
93
186
 
94
187
  # Namespace other exceptions that we want to be able to catch and make sense of
95
188
  # without needing lower level implementation details
96
189
  const_set :NotImplementedError, Class.new(NotImplementedError)
97
- const_set :ResourceNotFound, Class.new(Faraday::ResourceNotFound)
98
- const_set :UnprocessableEntityError, Class.new(Faraday::UnprocessableEntityError)
99
- const_set :ConflictError, Class.new(Faraday::ConflictError)
100
190
 
101
191
  # Setup our Magic Constants
102
192
  const_set(:ListObject, Class.new(Hashie::Array)).class_eval do
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: resteze
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - JD Hendrickson
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-06-30 00:00:00.000000000 Z
10
+ date: 2026-11-13 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: actionpack
@@ -83,14 +83,25 @@ files:
83
83
  - Rakefile
84
84
  - assets/resteze.png
85
85
  - assets/resteze.svg
86
+ - docs/ACTIONS.md
86
87
  - docs/ADVANCED_USAGE.md
87
88
  - docs/API.md
88
89
  - docs/CONFIGURATION.md
89
90
  - docs/ERROR_HANDLING.md
90
91
  - docs/TESTING.md
91
92
  - lib/resteze.rb
93
+ - lib/resteze/action.rb
94
+ - lib/resteze/actions.rb
95
+ - lib/resteze/actions/validation.rb
92
96
  - lib/resteze/api_module.rb
93
97
  - lib/resteze/api_resource.rb
98
+ - lib/resteze/authenticating.rb
99
+ - lib/resteze/authenticator.rb
100
+ - lib/resteze/authenticator/api_key.rb
101
+ - lib/resteze/authenticator/basic.rb
102
+ - lib/resteze/authenticator/bearer.rb
103
+ - lib/resteze/authenticator/mtls.rb
104
+ - lib/resteze/authenticator/strategy.rb
94
105
  - lib/resteze/client.rb
95
106
  - lib/resteze/errors.rb
96
107
  - lib/resteze/instrumentation.rb
@@ -98,9 +109,19 @@ files:
98
109
  - lib/resteze/list_object.rb
99
110
  - lib/resteze/middleware/raise_error.rb
100
111
  - lib/resteze/object.rb
112
+ - lib/resteze/rate_limit.rb
113
+ - lib/resteze/rate_limiter.rb
114
+ - lib/resteze/rate_limiter/fixed_window.rb
115
+ - lib/resteze/rate_limiter/leaky_bucket.rb
116
+ - lib/resteze/rate_limiter/sliding_window.rb
117
+ - lib/resteze/rate_limiter/strategy.rb
118
+ - lib/resteze/rate_limiter/token_bucket.rb
119
+ - lib/resteze/rate_limiting.rb
101
120
  - lib/resteze/request.rb
102
121
  - lib/resteze/response.rb
122
+ - lib/resteze/result.rb
103
123
  - lib/resteze/save.rb
124
+ - lib/resteze/synchronized_state.rb
104
125
  - lib/resteze/testing/minitest.rb
105
126
  - lib/resteze/testing/minitest/configuration.rb
106
127
  - lib/resteze/testing/minitest/object.rb