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
@@ -0,0 +1,133 @@
1
+ module Resteze
2
+ class Authenticator
3
+ # An authentication scheme. Strategies hold only their configuration;
4
+ # anything mutable, such as a cached credential, goes in the `state`
5
+ # hash the authenticator hands them, already under its lock. Because
6
+ # that hash is shared by every strategy on the authenticator, prefix
7
+ # the keys you store with the scheme's name.
8
+ #
9
+ # Credential options name module settings. A Symbol such as `:api_key`
10
+ # reads `MyApi.api_key`, which an initializer fills through
11
+ # `MyApi.configure`, on every request. Each built-in defaults to the
12
+ # obvious setting (see its DEFAULTS), and declaring the scheme declares
13
+ # the setting on the module, so `config.api_key = ...` needs no
14
+ # `attr_accessor`. A callable or a literal works in place of a Symbol.
15
+ #
16
+ # To add a scheme, subclass this, implement `apply`, declare DEFAULTS
17
+ # for any options that should read settings, and pass the class to
18
+ # `authenticate_with` with its options.
19
+ class Strategy
20
+ # Option defaults; Symbol values are settings read from the module.
21
+ DEFAULTS = {}.freeze
22
+
23
+ # Options the scheme works without (a passphrase for a key that is
24
+ # not encrypted). Their settings are declared on the module like any
25
+ # other but never reported by `missing_settings`.
26
+ OPTIONAL = [].freeze
27
+
28
+ attr_accessor :api_module
29
+
30
+ # Names a scheme for `authenticate_with :name`. Each built-in registers
31
+ # itself; a gem providing a scheme can do the same.
32
+ def self.register(name, klass)
33
+ registry[name.to_sym] = klass
34
+ end
35
+
36
+ def self.registry
37
+ @registry ||= {}
38
+ end
39
+
40
+ def self.lookup(name)
41
+ registry.fetch(name.to_sym) do
42
+ raise ArgumentError, "unknown authentication strategy #{name.inspect}; known: #{registry.keys.join(', ')}"
43
+ end
44
+ end
45
+
46
+ # The module settings these options (over the defaults) read.
47
+ def self.settings(options = {})
48
+ effective_options(options).values.grep(Symbol)
49
+ end
50
+
51
+ # Those of them the scheme cannot work without.
52
+ def self.required_settings(options = {})
53
+ effective_options(options).except(*self::OPTIONAL).values.grep(Symbol)
54
+ end
55
+
56
+ def self.effective_options(options)
57
+ self::DEFAULTS.merge(options)
58
+ end
59
+ private_class_method :effective_options
60
+
61
+ # The module settings this instance reads: DEFAULTS as overridden by
62
+ # the credential options it was built with.
63
+ def settings
64
+ self.class.settings(credential_options)
65
+ end
66
+
67
+ def required_settings
68
+ self.class.required_settings(credential_options)
69
+ end
70
+
71
+ # The credential options as given to the constructor, so `settings`
72
+ # reflects a renamed setting; schemes implement it.
73
+ def credential_options
74
+ {}
75
+ end
76
+
77
+ # Faraday connection options this scheme needs, merged into the
78
+ # connection when it is built. Most schemes need none.
79
+ def connection_options
80
+ {}
81
+ end
82
+
83
+ # Where `apply` puts the credential, so the client's debug log can
84
+ # mask it there: header names under :headers, query keys under
85
+ # :query, body keys under :params. Only the logged copy is masked;
86
+ # the request goes out intact. A scheme that adds a credential and
87
+ # leaves this empty writes it to the log at DEBUG.
88
+ def sensitive_keys
89
+ {}
90
+ end
91
+
92
+ # Add the credential to `request.headers`, `request.params` or
93
+ # `request.query`.
94
+ def apply(request, state); end
95
+
96
+ # After a MyApi::AuthenticationError: renew the credential and return
97
+ # true to have the request sent once more, or return false.
98
+ def refresh?(_error, _attempt, _state)
99
+ false
100
+ end
101
+
102
+ private
103
+
104
+ # For constructors: rejects nil for an option the scheme needs.
105
+ def required!(name, value)
106
+ raise ArgumentError, "#{name}: is required" if value.nil?
107
+
108
+ value
109
+ end
110
+
111
+ # A Symbol reads the named setting from the module (nil when there is
112
+ # no module or no such setting), a callable is called, anything else
113
+ # is used as is.
114
+ def resolve(value)
115
+ return value.call if value.respond_to?(:call)
116
+ return value unless value.is_a?(Symbol)
117
+
118
+ api_module.try(value)
119
+ end
120
+
121
+ # As `resolve`, but a nil result is a configuration error that names
122
+ # what to set.
123
+ def resolve!(option, value)
124
+ resolved = resolve(value)
125
+ return resolved unless resolved.nil?
126
+
127
+ where = value.is_a?(Symbol) ? "#{api_module || 'the API module'}.#{value} is not set" : "#{option}: resolved to nil"
128
+ scheme = self.class.name&.demodulize&.underscore || "custom"
129
+ raise ArgumentError, "#{where}; the #{scheme} authentication strategy needs it"
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,187 @@
1
+ require "resteze/synchronized_state"
2
+
3
+ module Resteze
4
+ # The interface through which a client describes how its API
5
+ # authenticates requests. Resteze ships no credentials and assumes no
6
+ # scheme: the default does nothing, and `include Resteze` gives each API
7
+ # module its own MyApi::Authenticator subclass to fill in. One instance
8
+ # per module, shared by every thread, is reached as MyApi.authenticator.
9
+ #
10
+ # The client calls, for every request:
11
+ #
12
+ # connection_options
13
+ # When the per-thread Faraday connection is built. Mutual TLS puts
14
+ # its certificate here.
15
+ # apply(request)
16
+ # Before sending. `request` exposes `headers`, `params` and `query`
17
+ # to mutate; header and query-parameter schemes add themselves here.
18
+ # refresh?(error, attempt)
19
+ # After a MyApi::AuthenticationError (401 or 403). Return true to
20
+ # send the request once more, for schemes whose credential can be
21
+ # renewed; `attempt` counts failures so far, starting at 1.
22
+ # sensitive_keys
23
+ # When logging at DEBUG, which prints the whole request. The header,
24
+ # query and body keys named here are masked in the logged copy.
25
+ #
26
+ # A scheme is chosen with `authenticate_with` on the module (or `strategy`
27
+ # inside this class), by the name a built-in registered through
28
+ # Strategy.register or by a Strategy subclass; several may be combined.
29
+ # Each scheme expects its credential in a module setting named by its
30
+ # DEFAULTS, which an initializer fills through `configure`, and declaring
31
+ # the scheme declares the setting:
32
+ #
33
+ # module MyApi
34
+ # include Resteze
35
+ #
36
+ # authenticate_with :api_key, header: "X-Api-Key" # expects MyApi.api_key
37
+ # end
38
+ #
39
+ # MyApi.configure { |config| config.api_key = ENV["MY_API_KEY"] }
40
+ #
41
+ # Any credential option also takes a Symbol naming another setting, a
42
+ # callable for a credential that comes from somewhere other than
43
+ # configuration, or a literal value. `MyApi.authenticator.missing_settings`
44
+ # lists expected settings still nil, for a boot-time check.
45
+ #
46
+ # Every scheme is a subclass of Resteze::Authenticator::Strategy: subclass
47
+ # it, implement `apply`, declare DEFAULTS, and pass the class to
48
+ # `authenticate_with`.
49
+ #
50
+ # State lives in a mutex-guarded hash (`state`, `update_state`,
51
+ # `synchronize`) shared by the strategies, so a strategy that caches a
52
+ # credential need not manage threads; `apply` and `refresh?` run under
53
+ # the lock, which is what keeps concurrent threads from all renewing a
54
+ # credential at once.
55
+ class Authenticator
56
+ include Resteze::ApiModule
57
+ include Resteze::SynchronizedState
58
+
59
+ # What `apply` receives. `headers`, `params` and `query` are the hashes
60
+ # the request will be sent with; `query` always goes in the query
61
+ # string, whatever the HTTP method, unlike `params`.
62
+ Request = Struct.new(:http_method, :path, :headers, :params, :query, keyword_init: true)
63
+
64
+ class << self
65
+ # Adds a built-in strategy (by name) or a custom Strategy class with
66
+ # its options. Call it more than once to combine schemes. Without
67
+ # arguments, returns the [class, options] pairs in effect for this
68
+ # class, inherited from a superclass when it declares none.
69
+ def strategy(name = nil, **options)
70
+ return strategy_specs if name.nil? && options.empty?
71
+ raise ArgumentError, "strategy needs a name or class before its options" if name.nil?
72
+
73
+ klass = name.is_a?(Class) ? name : Strategy.lookup(name)
74
+ declare_settings(klass.settings(options))
75
+ own_strategy_specs << [klass, options]
76
+ nil
77
+ end
78
+
79
+ # The module settings every configured strategy reads, in order.
80
+ def settings
81
+ strategy_specs.flat_map { |klass, options| klass.settings(options) }.uniq
82
+ end
83
+
84
+ # Those of them the strategies cannot work without.
85
+ def required_settings
86
+ strategy_specs.flat_map { |klass, options| klass.required_settings(options) }.uniq
87
+ end
88
+
89
+ # Declares those settings on the API module (`api_key`, `username`,
90
+ # ...) so an initializer can set them through `configure` with no
91
+ # `attr_accessor` of its own. Existing accessors are left alone.
92
+ def declare_settings(names)
93
+ mod = api_module
94
+ return if mod.nil? || mod == Resteze
95
+
96
+ names.each { |name| mod.singleton_class.attr_accessor(name) unless mod.respond_to?(name) }
97
+ end
98
+
99
+ def strategy_specs
100
+ own = own_strategy_specs
101
+ return own unless own.empty?
102
+
103
+ superclass.respond_to?(:strategy_specs) ? superclass.strategy_specs : []
104
+ end
105
+
106
+ def strategy_instances
107
+ strategy_specs.map { |klass, options| klass.new(**options) }
108
+ end
109
+
110
+ private
111
+
112
+ def own_strategy_specs
113
+ @own_strategy_specs ||= []
114
+ end
115
+ end
116
+
117
+ attr_reader :strategies
118
+
119
+ def initialize(strategies: self.class.strategy_instances)
120
+ initialize_state
121
+ @strategies = Array(strategies).freeze
122
+ self.api_module = self.class.api_module
123
+ end
124
+
125
+ # The module this authenticator serves, and whose settings its
126
+ # strategies read. Set by MyApi.authenticator=.
127
+ def api_module
128
+ @api_module || self.class.api_module
129
+ end
130
+
131
+ def api_module=(mod)
132
+ @api_module = mod
133
+ strategies.each { |strategy| strategy.api_module = mod if strategy.respond_to?(:api_module=) }
134
+ end
135
+
136
+ # The module settings this instance's strategies read.
137
+ def settings
138
+ strategies.flat_map(&:settings).uniq
139
+ end
140
+
141
+ # Those of them the strategies cannot work without (a strategy's
142
+ # OPTIONAL settings, such as a key passphrase, are left out).
143
+ def required_settings
144
+ strategies.flat_map(&:required_settings).uniq
145
+ end
146
+
147
+ # The required settings that are still nil, for checking configuration
148
+ # at boot:
149
+ #
150
+ # raise "MyApi needs #{MyApi.authenticator.missing_settings.join(', ')}" if MyApi.authenticator.missing_settings.any?
151
+ def missing_settings
152
+ required_settings.select { |name| api_module.try(name).nil? }
153
+ end
154
+
155
+ # Faraday connection options merged from every strategy, such as `ssl:`.
156
+ def connection_options
157
+ strategies.map(&:connection_options).reduce({}) { |merged, options| merged.deep_merge(options) }
158
+ end
159
+
160
+ # The request keys the strategies put credentials under, merged, for
161
+ # the client to mask in its debug log.
162
+ def sensitive_keys
163
+ strategies.map(&:sensitive_keys).reduce({}) { |merged, keys| merged.merge(keys) { |_, mine, theirs| mine | theirs } }
164
+ end
165
+
166
+ # Lets every strategy add its credential to the request. Returns it.
167
+ def apply(request)
168
+ return request if strategies.empty?
169
+
170
+ synchronize { |state| strategies.each { |strategy| strategy.apply(request, state) } }
171
+ request
172
+ end
173
+
174
+ # True when any strategy renewed its credential and wants one retry.
175
+ def refresh?(error, attempt)
176
+ return false if strategies.empty?
177
+
178
+ synchronize { |state| strategies.any? { |strategy| strategy.refresh?(error, attempt, state) } }
179
+ end
180
+ end
181
+ end
182
+
183
+ require "resteze/authenticator/strategy"
184
+ require "resteze/authenticator/api_key"
185
+ require "resteze/authenticator/basic"
186
+ require "resteze/authenticator/bearer"
187
+ require "resteze/authenticator/mtls"
@@ -1,7 +1,13 @@
1
+ require "resteze/authenticating"
2
+ require "resteze/instrumentation"
3
+ require "resteze/rate_limiting"
4
+
1
5
  module Resteze
2
6
  class Client
3
7
  include Resteze::ApiModule
4
8
  prepend Resteze::Instrumentation
9
+ prepend Resteze::Authenticating
10
+ prepend Resteze::RateLimiting
5
11
 
6
12
  def self.user_agent
7
13
  [
@@ -30,18 +36,30 @@ module Resteze
30
36
  Thread.current["#{client_name}_default_client"] ||= new(default_connection)
31
37
  end
32
38
 
39
+ # The Authenticator's connection options (mutual TLS, say) are applied
40
+ # here; an override of this method must pass them to Faraday itself.
33
41
  def self.default_connection
34
- Thread.current["#{client_name}_default_connection"] ||= Faraday.new do |conn|
42
+ Thread.current["#{client_name}_default_connection"] ||= Faraday.new(connection_options) do |conn|
35
43
  conn.use Faraday::Request::UrlEncoded
36
44
  conn.use api_module::Middleware::RaiseError
37
45
  conn.adapter Faraday.default_adapter
38
46
  end
39
47
  end
40
48
 
49
+ def self.connection_options
50
+ api_module.try(:authenticator)&.connection_options || {}
51
+ end
52
+
41
53
  def self.faraday_adapter
42
54
  @faraday_adapter ||= default_connection.builder.adapter.name.demodulize.underscore
43
55
  end
44
56
 
57
+ REDACTED = "[REDACTED]".freeze
58
+
59
+ # Headers masked in the DEBUG log whatever the authenticator declares,
60
+ # for a credential added through `request_headers` instead.
61
+ SENSITIVE_HEADERS = %w[Authorization Proxy-Authorization].freeze
62
+
45
63
  # This can be overriden to customize
46
64
  def self.api_url(path = "")
47
65
  [api_module.api_base.chomp("/".freeze), path].join
@@ -71,12 +89,14 @@ module Resteze
71
89
  end
72
90
 
73
91
  # TODO: Look at refactoring this if possible to improve the Abc size
74
- # rubocop:disable Metrics/AbcSize
75
- def execute_request(method, path, headers: {}, params: {})
92
+ # rubocop:disable-next Metrics/AbcSize
93
+ # `query:` always goes in the query string, whatever the method, where
94
+ # `params:` becomes the body for anything but GET, HEAD and DELETE.
95
+ def execute_request(method, path, headers: {}, params: {}, query: {})
76
96
  params = util.objects_to_ids(params)
77
- body, query_params = process_params(method, params)
97
+ body, query_params = process_params(method, params, query)
78
98
  headers = request_headers.merge(util.normalize_headers(headers))
79
- context = request_log_context(body:, method:, path:, query_params:, headers:)
99
+ context = request_log_context(body: redacted_body(body, params), method:, path:, query_params:, headers:)
80
100
 
81
101
  http_resp = execute_request_with_rescues(context) do
82
102
  connection.run_request(method, api_url(path), body, headers) do |req|
@@ -91,7 +111,6 @@ module Resteze
91
111
  @last_response = response
92
112
  end
93
113
  end
94
- # rubocop:enable Metrics/AbcSize
95
114
 
96
115
  protected
97
116
 
@@ -106,7 +125,7 @@ module Resteze
106
125
 
107
126
  private
108
127
 
109
- def process_params(method, params)
128
+ def process_params(method, params, query)
110
129
  body = nil
111
130
  query_params = nil
112
131
  case method.to_s.downcase.to_sym
@@ -115,6 +134,7 @@ module Resteze
115
134
  else
116
135
  body = params.to_json
117
136
  end
137
+ query_params = (query_params || {}).merge(util.objects_to_ids(query)) if query.present?
118
138
  [body, query_params]
119
139
  end
120
140
 
@@ -122,16 +142,45 @@ module Resteze
122
142
  self.class.default_connection.options.params_encoder || Faraday::Utils.default_params_encoder
123
143
  end
124
144
 
145
+ # The DEBUG log prints the whole request, so what goes in here is a
146
+ # copy with the credentials masked; the request itself is untouched.
125
147
  def request_log_context(method: nil, path: nil, query_params: nil, headers: nil, body: nil)
126
148
  RequestLogContext.new.tap do |context|
127
149
  context.method = method
128
150
  context.path = path
129
- context.query_params = params_encoder.encode(query_params)
130
- context.headers = headers
151
+ context.query_params = params_encoder.encode(redacted(query_params, :query, :params))
152
+ context.headers = redacted(headers, :headers)
131
153
  context.body = body
132
154
  end
133
155
  end
134
156
 
157
+ # Masks the values under the keys the authenticator's strategies put a
158
+ # credential in (`parts` are :headers, :query and :params), matched
159
+ # without regard to case. The query string takes :params keys too,
160
+ # since params become the query string for GET, HEAD and DELETE.
161
+ # Returns the hash itself when there is nothing to mask.
162
+ def redacted(hash, *parts)
163
+ return hash if hash.nil?
164
+
165
+ keys = parts.flat_map { |part| sensitive_keys.fetch(part, []) }.map { |key| key.to_s.downcase }
166
+ return hash if keys.empty?
167
+
168
+ hash.to_h { |key, value| [key, keys.include?(key.to_s.downcase) ? REDACTED : value] }
169
+ end
170
+
171
+ # The body for the log: the serialised one as is, rebuilt from the
172
+ # masked params only when a strategy put a credential in the body.
173
+ def redacted_body(body, params)
174
+ return body if body.nil? || sensitive_keys.fetch(:params, []).empty?
175
+
176
+ redacted(params, :params).to_json
177
+ end
178
+
179
+ def sensitive_keys
180
+ keys = api_module.try(:authenticator)&.sensitive_keys || {}
181
+ keys.merge(headers: SENSITIVE_HEADERS | keys.fetch(:headers, []))
182
+ end
183
+
135
184
  def execute_request_with_rescues(context)
136
185
  begin
137
186
  request_start = Time.now
@@ -202,7 +251,7 @@ module Resteze
202
251
  end
203
252
 
204
253
  # TODO: Look into refactoring to improve Abc Size
205
- # rubocop:disable Metrics/AbcSize
254
+ # rubocop:disable-next Metrics/AbcSize
206
255
  def response_details_in_http_syntax(response)
207
256
  status = response.respond_to?(:status) ? response.status : response[:status]
208
257
  body = response.respond_to?(:body) ? response.body : response[:body]
@@ -215,7 +264,6 @@ module Resteze
215
264
  s << "\n"
216
265
  end
217
266
  end
218
- # rubocop:enable Metrics/AbcSize
219
267
 
220
268
  def request_in_http_syntax(context)
221
269
  method = context.method.to_s.upcase
@@ -1,8 +1,54 @@
1
1
  module Resteze
2
2
  class Error < StandardError
3
+ include Resteze::ApiModule
4
+
3
5
  attr_accessor :response
4
6
  attr_reader :message, :code, :http_body, :http_headers, :http_status, :json_body
5
7
 
8
+ # Faraday-style readers, for code written against the Faraday-derived
9
+ # ResourceNotFound, ConflictError and UnprocessableEntityError of 0.4.0.
10
+ alias response_status http_status
11
+ alias response_headers http_headers
12
+ alias response_body http_body
13
+
14
+ # Builds an error from a Faraday error, carrying over its HTTP response.
15
+ # The Faraday error becomes `cause` when raised from a rescue block.
16
+ def self.from_faraday(error, message = error.message)
17
+ build(message, response_attributes(error)).tap { |e| e.response = error.response }
18
+ end
19
+
20
+ # Builds an error from a Resteze::Response, for APIs that report failures
21
+ # inside a successful HTTP response (see Response.body_error).
22
+ def self.from_response(response, message = nil)
23
+ data = response.data
24
+ attributes = { http_status: response.http_status, http_body: response.http_body,
25
+ http_headers: response.http_headers || {},
26
+ json_body: (data if data.is_a?(Hash) || data.is_a?(Array)) }
27
+
28
+ build(message, attributes).tap { |e| e.response = response }
29
+ end
30
+
31
+ # Types with a different constructor signature override this.
32
+ def self.build(message, attributes)
33
+ new(message, **attributes)
34
+ end
35
+
36
+ def self.response_attributes(error)
37
+ headers = error.response_headers || {}
38
+ body = error.response_body
39
+
40
+ { http_status: error.response_status, http_body: body, http_headers: headers,
41
+ json_body: parsed_body(body, status: error.response_status, headers: headers) }
42
+ end
43
+
44
+ def self.parsed_body(body, status:, headers:)
45
+ data = Resteze::Response.parse_body(body, status:, headers:)
46
+ data if data.is_a?(Hash) || data.is_a?(Array)
47
+ rescue StandardError
48
+ nil
49
+ end
50
+ private_class_method :response_attributes, :parsed_body
51
+
6
52
  # def initialize(message = nil, http_status: nil, http_body: nil, json_body: nil,
7
53
  # http_headers: nil, code: nil)
8
54
  def initialize(message = nil, **kwargs)
@@ -19,20 +65,95 @@ module Resteze
19
65
  status = "HTTP #{@http_status}:" if @http_status.present?
20
66
  [status, message].compact.join(" ")
21
67
  end
68
+
69
+ # Case-insensitive header lookup; http_headers may be a plain Hash or a
70
+ # Faraday::Utils::Headers.
71
+ def header(name)
72
+ RateLimit.header_value(http_headers, name)
73
+ end
74
+
75
+ # Whatever the module's RateLimiter makes of this error (see
76
+ # Resteze::RateLimiter#rate_limit), or nil.
77
+ def rate_limit
78
+ @rate_limit ||= api_module.try(:rate_limiter)&.rate_limit(self)
79
+ end
22
80
  end
23
81
 
24
- class InvalidRequestError < Error
82
+ # Behaviour for the error types that add more than a name. Every API module
83
+ # gets its own subclasses of its own Error (see Errors.define), so type
84
+ # behaviour lives in modules that each of those subclasses includes.
85
+ module InvalidRequest
25
86
  attr_accessor :param
26
87
 
88
+ module ClassMethods
89
+ def build(message, attributes)
90
+ new(message, nil, **attributes)
91
+ end
92
+ end
93
+
94
+ def self.included(base)
95
+ base.extend(ClassMethods)
96
+ end
97
+
27
98
  def initialize(message, param, **keyword_args)
28
99
  super(message, **keyword_args)
29
100
  @param = param
30
101
  end
31
102
  end
32
103
 
33
- class AuthenticationError < Error; end
104
+ module RateLimited
105
+ # `retry_after:` sets the wait outright, for errors raised before a
106
+ # request is sent (see RateLimiter's on_wait: :raise).
107
+ def initialize(message = nil, retry_after: nil, **keyword_args)
108
+ super(message, **keyword_args)
109
+ @retry_after = retry_after
110
+ end
111
+
112
+ # Seconds to wait before retrying: the value given at construction, else
113
+ # the HTTP Retry-After header, accepting delay-seconds or an HTTP-date;
114
+ # nil when absent or unparseable. Redefine on MyApi::RateLimitError for
115
+ # an API that signals the wait some other way.
116
+ def retry_after
117
+ @retry_after || retry_after_header
118
+ end
119
+
120
+ private
34
121
 
35
- class ApiConnectionError < Error; end
122
+ def retry_after_header
123
+ value = header("Retry-After")
124
+ return if value.blank?
125
+ return Integer(value) if value.to_s.match?(/\A\d+\z/)
126
+
127
+ [(Time.httpdate(value.to_s) - Time.now).ceil, 0].max
128
+ rescue ArgumentError
129
+ nil
130
+ end
131
+ end
132
+
133
+ module Errors
134
+ # Error type name => behaviour module. Resteze and every API module that
135
+ # includes it define one subclass of their own Error per entry, so
136
+ # `rescue MyApi::Error` catches every error the client raises and errors
137
+ # from one API are never caught by rescue clauses written for another.
138
+ TYPES = {
139
+ InvalidRequestError: InvalidRequest,
140
+ AuthenticationError: nil,
141
+ ApiConnectionError: nil,
142
+ ApiError: nil,
143
+ RateLimitError: RateLimited,
144
+ ResourceNotFound: nil,
145
+ ConflictError: nil,
146
+ UnprocessableEntityError: nil
147
+ }.freeze
148
+
149
+ def self.define(namespace, base)
150
+ TYPES.each do |name, behaviour|
151
+ next if namespace.const_defined?(name, false)
152
+
153
+ namespace.const_set(name, Class.new(base) { include behaviour if behaviour })
154
+ end
155
+ end
156
+ end
36
157
 
37
- class ApiError < Error; end
158
+ Errors.define(self, Error)
38
159
  end
@@ -7,7 +7,42 @@ module Resteze
7
7
  super.tap do |response|
8
8
  payload[:status] = response.http_status
9
9
  payload[:request_id] = response.request_id
10
+ instrument_rate_limit(payload, response)
10
11
  end
12
+ rescue StandardError => e
13
+ instrument_error_response(payload, e)
14
+ raise
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ # The limiter's view of the quota after this exchange, when it has one,
21
+ # so subscribers can gauge remaining calls from the events they already
22
+ # receive.
23
+ def instrument_rate_limit(payload, response_or_error)
24
+ return unless response_or_error.respond_to?(:rate_limit)
25
+
26
+ view = response_or_error.rate_limit
27
+ payload[:rate_limit] = view.to_h if !view.nil? && view.respond_to?(:to_h)
28
+ end
29
+
30
+ # An error raised by the middleware stack usually carries the HTTP response
31
+ # that produced it. Surface its status and request id the same way a
32
+ # successful response would, so subscribers can bucket by status code.
33
+ def instrument_error_response(payload, error)
34
+ instrument_rate_limit(payload, error)
35
+ status, headers = error_response_details(error)
36
+ return if status.nil?
37
+
38
+ payload[:status] = status
39
+ payload[:request_id] = headers&.[]("Request-Id")
40
+ end
41
+
42
+ def error_response_details(error)
43
+ case error
44
+ when Faraday::Error then [error.response_status, error.response_headers]
45
+ when Resteze::Error then [error.http_status, error.http_headers]
11
46
  end
12
47
  end
13
48
  end
data/lib/resteze/list.rb CHANGED
@@ -28,7 +28,7 @@ module Resteze
28
28
  end
29
29
 
30
30
  def list_resource_path(_params = {})
31
- resource_path
31
+ action_for(:list).path_for
32
32
  end
33
33
 
34
34
  def list_key
@@ -36,7 +36,7 @@ module Resteze
36
36
  end
37
37
 
38
38
  def list_method
39
- :get
39
+ action_for(:list).http_method
40
40
  end
41
41
  end
42
42
  end