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.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -0
- data/CHANGELOG.md +38 -0
- data/CLAUDE.md +9 -0
- data/README.md +45 -4
- data/docs/ACTIONS.md +329 -0
- data/docs/ADVANCED_USAGE.md +29 -45
- data/docs/API.md +19 -25
- data/docs/CONFIGURATION.md +281 -0
- data/docs/ERROR_HANDLING.md +94 -27
- data/docs/TESTING.md +21 -0
- data/lib/resteze/action.rb +99 -0
- data/lib/resteze/actions/validation.rb +56 -0
- data/lib/resteze/actions.rb +117 -0
- data/lib/resteze/api_module.rb +11 -5
- data/lib/resteze/api_resource.rb +28 -3
- data/lib/resteze/authenticating.rb +28 -0
- data/lib/resteze/authenticator/api_key.rb +45 -0
- data/lib/resteze/authenticator/basic.rb +40 -0
- data/lib/resteze/authenticator/bearer.rb +37 -0
- data/lib/resteze/authenticator/mtls.rb +73 -0
- data/lib/resteze/authenticator/strategy.rb +133 -0
- data/lib/resteze/authenticator.rb +187 -0
- data/lib/resteze/client.rb +59 -11
- data/lib/resteze/errors.rb +125 -4
- data/lib/resteze/instrumentation.rb +35 -0
- data/lib/resteze/list.rb +2 -2
- data/lib/resteze/middleware/raise_error.rb +38 -6
- data/lib/resteze/rate_limit.rb +44 -0
- data/lib/resteze/rate_limiter/fixed_window.rb +107 -0
- data/lib/resteze/rate_limiter/leaky_bucket.rb +40 -0
- data/lib/resteze/rate_limiter/sliding_window.rb +37 -0
- data/lib/resteze/rate_limiter/strategy.rb +58 -0
- data/lib/resteze/rate_limiter/token_bucket.rb +39 -0
- data/lib/resteze/rate_limiter.rb +229 -0
- data/lib/resteze/rate_limiting.rb +30 -0
- data/lib/resteze/response.rb +37 -1
- data/lib/resteze/result.rb +18 -0
- data/lib/resteze/save.rb +25 -3
- data/lib/resteze/synchronized_state.rb +29 -0
- data/lib/resteze/testing/minitest/configuration.rb +3 -1
- data/lib/resteze/testing/minitest/object.rb +30 -0
- data/lib/resteze/testing/rspec/configure.rb +3 -1
- data/lib/resteze/testing/rspec/object.rb +32 -2
- data/lib/resteze/version.rb +1 -1
- data/lib/resteze.rb +101 -11
- 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"
|
data/lib/resteze/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
data/lib/resteze/errors.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
39
|
+
action_for(:list).http_method
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
end
|