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.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -0
- data/CHANGELOG.md +38 -0
- data/CLAUDE.md +83 -0
- data/README.md +75 -3
- data/docs/ACTIONS.md +329 -0
- data/docs/ADVANCED_USAGE.md +744 -0
- data/docs/API.md +404 -0
- data/docs/CONFIGURATION.md +962 -0
- data/docs/ERROR_HANDLING.md +676 -0
- data/docs/TESTING.md +789 -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 +60 -11
- data/lib/resteze/errors.rb +125 -4
- data/lib/resteze/instrumentation.rb +49 -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 +102 -10
- metadata +30 -2
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
module Resteze
|
|
2
|
+
# A resolved action on a resource class: the HTTP method, route and return
|
|
3
|
+
# type for one named operation such as :retrieve or :track.
|
|
4
|
+
class Action
|
|
5
|
+
OPTIONS = %i[method on path id id_param returns as].freeze
|
|
6
|
+
RESERVED = %i[retrieve list create update].freeze
|
|
7
|
+
ON_VALUES = %i[collection member].freeze
|
|
8
|
+
ID_VALUES = %i[path param].freeze
|
|
9
|
+
BUILT_IN = {
|
|
10
|
+
retrieve: { method: :get, on: :member, path: nil, id: :path },
|
|
11
|
+
list: { method: :get, on: :collection, path: nil },
|
|
12
|
+
create: { method: :post, on: :collection, path: nil },
|
|
13
|
+
update: { method: :put, on: :member, path: nil, id: :path }
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :name, :owner, :on, :path, :id, :returns, :as
|
|
17
|
+
|
|
18
|
+
# Declaration-time checks shared by the resource DSL and the module-level
|
|
19
|
+
# DSL: option keys, enumerated values, and the fixed scope of built-ins.
|
|
20
|
+
def self.validate_options!(name, options, allowed: OPTIONS)
|
|
21
|
+
unknown = options.keys - allowed
|
|
22
|
+
raise ArgumentError, "Unknown action option(s): #{unknown.join(', ')}" if unknown.any?
|
|
23
|
+
|
|
24
|
+
validate_option_value!(name, options, :on, ON_VALUES)
|
|
25
|
+
validate_option_value!(name, options, :id, ID_VALUES)
|
|
26
|
+
return unless RESERVED.include?(name) && options.key?(:on)
|
|
27
|
+
|
|
28
|
+
raise ArgumentError, "on: cannot be changed for the built-in action :#{name}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.validate_option_value!(name, options, key, values)
|
|
32
|
+
return unless options.key?(key) && !values.include?(options[key])
|
|
33
|
+
|
|
34
|
+
raise ArgumentError, "#{key}: must be one of #{values.inspect} (action :#{name})"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(name, owner, options = {})
|
|
38
|
+
@name = name.to_sym
|
|
39
|
+
@owner = owner
|
|
40
|
+
@options = options.slice(*OPTIONS)
|
|
41
|
+
@on = @options[:on] || BUILT_IN.dig(@name, :on) || :collection
|
|
42
|
+
@options = @options.except(:id, :id_param) if collection?
|
|
43
|
+
@path = @options.fetch(:path) { reserved? ? nil : @name.to_s }
|
|
44
|
+
@id = member? ? @options.fetch(:id, :path) : nil
|
|
45
|
+
@returns = @options[:returns]
|
|
46
|
+
@as = @options[:as]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def reserved?
|
|
50
|
+
RESERVED.include?(name)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# True when a built-in action still has its 0.4.0 REST route, so callers
|
|
54
|
+
# can keep honoring an instance-level resource_path override.
|
|
55
|
+
def rest_default?
|
|
56
|
+
reserved? && path.nil? && (collection? || id == :path)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def member?
|
|
60
|
+
on == :member
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def collection?
|
|
64
|
+
!member?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def generated_name
|
|
68
|
+
(as || name).to_sym
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def http_method
|
|
72
|
+
@options[:method] or raise ArgumentError,
|
|
73
|
+
"Action :#{name} on #{owner} has no HTTP method; " \
|
|
74
|
+
"declare it with method: on the resource or at module level"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def id_param
|
|
78
|
+
@options[:id_param] ||
|
|
79
|
+
(owner.id_param if owner.respond_to?(:id_param)) ||
|
|
80
|
+
owner.api_module.default_id_param(owner)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def path_segments
|
|
84
|
+
path.to_s.split("/").reject(&:blank?).map { |part| CGI.escape(part) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def path_for(id = nil)
|
|
88
|
+
base = member? && self.id == :path ? owner.resource_path(id) : owner.resource_path
|
|
89
|
+
[base, *path_segments].join("/").squeeze("/")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def to_h
|
|
93
|
+
{
|
|
94
|
+
name:, method: @options[:method], on:, path:, id:,
|
|
95
|
+
id_param: (id_param if member?), returns:, as: generated_name
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module Resteze
|
|
2
|
+
module Actions
|
|
3
|
+
# Declaration-time checks for the action DSL. The declaration is resolved
|
|
4
|
+
# through the lookup chain (without being stored) so scope and generated
|
|
5
|
+
# name reflect inherited and module-level options, not just the options
|
|
6
|
+
# given on this line.
|
|
7
|
+
module Validation
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
# Returns the resolved candidate action, or raises ArgumentError.
|
|
11
|
+
def validate_action!(name, options)
|
|
12
|
+
Resteze::Action.validate_options!(name, options)
|
|
13
|
+
candidate = resolve_action(name, options)
|
|
14
|
+
validate_id_options!(candidate, options)
|
|
15
|
+
validate_returns!(candidate, options)
|
|
16
|
+
check_action_collision!(candidate) unless candidate.reserved?
|
|
17
|
+
candidate
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def validate_id_options!(candidate, options)
|
|
21
|
+
return unless candidate.collection?
|
|
22
|
+
return unless options.key?(:id) || options.key?(:id_param)
|
|
23
|
+
|
|
24
|
+
raise ArgumentError, "id: and id_param: only apply to member actions (action :#{candidate.name})"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def validate_returns!(candidate, options)
|
|
28
|
+
return unless options[:returns] == :list && !(self <= Resteze::List)
|
|
29
|
+
|
|
30
|
+
raise ArgumentError, "returns: :list requires #{self} to include List (action :#{candidate.name})"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def check_action_collision!(candidate)
|
|
34
|
+
method_name = candidate.generated_name
|
|
35
|
+
return if inherited_action_method?(candidate)
|
|
36
|
+
|
|
37
|
+
clash = respond_to?(method_name, true) ||
|
|
38
|
+
(candidate.member? && (method_defined?(method_name) || private_method_defined?(method_name)))
|
|
39
|
+
return unless clash
|
|
40
|
+
|
|
41
|
+
raise ArgumentError, "action :#{candidate.name} would define #{self}.#{method_name}, which already exists; " \
|
|
42
|
+
"pass as: to choose another method name"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# True when the method this declaration would define is the one an
|
|
46
|
+
# ancestor's declaration of the same action already generated, so
|
|
47
|
+
# redefining it is an override, not a collision.
|
|
48
|
+
def inherited_action_method?(candidate)
|
|
49
|
+
action_ancestors.drop(1).any? do |klass|
|
|
50
|
+
klass.declared_actions.key?(candidate.name) &&
|
|
51
|
+
klass.actions[candidate.name].generated_name == candidate.generated_name
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
module Resteze
|
|
2
|
+
# The action routing DSL for resources. Each class stores only its own
|
|
3
|
+
# declarations; resolution walks the chain lazily on every lookup:
|
|
4
|
+
# REST built-ins, module action_defaults, module declarations, ancestor
|
|
5
|
+
# classes (farthest first), then this class.
|
|
6
|
+
module Actions
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
module ClassMethods
|
|
10
|
+
include Resteze::Actions::Validation
|
|
11
|
+
|
|
12
|
+
def action(name, **options)
|
|
13
|
+
name = name.to_sym
|
|
14
|
+
candidate = validate_action!(name, options)
|
|
15
|
+
declared_actions[name] = options
|
|
16
|
+
define_action_methods(candidate) unless candidate.reserved?
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def declared_actions
|
|
21
|
+
@declared_actions ||= {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def actions
|
|
25
|
+
action_names.to_h { |name| [name, resolve_action(name)] }.freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Resolves one action by name. The built-ins and generated methods go
|
|
29
|
+
# through this rather than #actions, so a consumer who already defines
|
|
30
|
+
# a class method named `actions` is unaffected.
|
|
31
|
+
def action_for(name)
|
|
32
|
+
name = name.to_sym
|
|
33
|
+
raise KeyError, "#{self} has no action :#{name}" unless action_names.include?(name)
|
|
34
|
+
|
|
35
|
+
resolve_action(name)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Override to transform params before an action request is sent.
|
|
39
|
+
def action_params(_name, params)
|
|
40
|
+
params
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Override to supply headers for an action request.
|
|
44
|
+
def action_headers(_name, _params)
|
|
45
|
+
{}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def invoke_action(name, params = {}, id: nil)
|
|
49
|
+
action = action_for(name)
|
|
50
|
+
raise_invalid_action_id!(id) if action.member? && id.blank?
|
|
51
|
+
|
|
52
|
+
params = action_params(name, params)
|
|
53
|
+
params = { action.id_param => id }.merge(params) if action.member? && action.id == :param
|
|
54
|
+
response = request(action.http_method, action.path_for(id), params:, headers: action_headers(name, params))
|
|
55
|
+
build_action_result(action, response)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def action_names
|
|
61
|
+
(Resteze::Action::RESERVED + action_ancestors.flat_map { |klass| klass.declared_actions.keys }).uniq
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Classes in the ancestry that hold declarations, nearest first.
|
|
65
|
+
def action_ancestors
|
|
66
|
+
ancestors.select { |ancestor| ancestor.is_a?(Class) && ancestor.respond_to?(:declared_actions) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Resolves one action through the full chain. `own` stands in for this
|
|
70
|
+
# class's stored declaration, so a declaration can be resolved before it
|
|
71
|
+
# is stored.
|
|
72
|
+
def resolve_action(name, own = declared_actions.fetch(name, {}))
|
|
73
|
+
options = Resteze::Action::BUILT_IN.fetch(name, {}).merge(api_module.action_defaults)
|
|
74
|
+
options.merge!(api_module.action_declarations.fetch(name, {}))
|
|
75
|
+
action_ancestors.drop(1).reverse_each { |klass| options.merge!(klass.declared_actions.fetch(name, {})) }
|
|
76
|
+
Resteze::Action.new(name, self, options.merge(own))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def raise_invalid_action_id!(id)
|
|
80
|
+
raise api_module::InvalidRequestError.new(
|
|
81
|
+
"Could not determine which PATH to request: #{self} instance has invalid ID: #{id.inspect}", "id"
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_action_result(action, response)
|
|
86
|
+
case action.returns
|
|
87
|
+
when nil then api_module::Result.from_response(response)
|
|
88
|
+
when :self then convert_action_data(response.data, self)
|
|
89
|
+
when :list then construct_list_from(response.data)
|
|
90
|
+
when Class then convert_action_data(response.data, action.returns)
|
|
91
|
+
else raise ArgumentError, "Unsupported returns: #{action.returns.inspect} (action :#{action.name})"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# A Hash is an envelope (object_key is unwrapped, as in #refresh); an
|
|
96
|
+
# Array holds bare items (each is wrapped, as in ListObject).
|
|
97
|
+
def convert_action_data(data, klass)
|
|
98
|
+
case data
|
|
99
|
+
when Hash then klass.construct_from(data)
|
|
100
|
+
when Array then util.convert_to_object(data, klass)
|
|
101
|
+
else data
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def define_action_methods(action)
|
|
106
|
+
name = action.name
|
|
107
|
+
method_name = action.generated_name
|
|
108
|
+
if action.member?
|
|
109
|
+
define_method(method_name) { |**params| self.class.invoke_action(name, params, id:) }
|
|
110
|
+
define_singleton_method(method_name) { |id, **params| new(id).public_send(method_name, **params) }
|
|
111
|
+
else
|
|
112
|
+
define_singleton_method(method_name) { |**params| invoke_action(name, params) }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
data/lib/resteze/api_module.rb
CHANGED
|
@@ -6,11 +6,17 @@ module Resteze
|
|
|
6
6
|
|
|
7
7
|
module ClassMethods
|
|
8
8
|
def api_module
|
|
9
|
-
@api_module ||=
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
@api_module ||= name.nil? ? inherited_api_module : named_api_module
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# An anonymous subclass belongs where its parent does.
|
|
13
|
+
def inherited_api_module
|
|
14
|
+
superclass.api_module if respond_to?(:superclass) && superclass.respond_to?(:api_module)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def named_api_module
|
|
18
|
+
parents = name.scan("::").inject([name]) { |mods, _n| mods << mods.last.deconstantize }
|
|
19
|
+
parents.map(&:constantize).detect { |mod| mod == Resteze || mod.include?(Resteze) }
|
|
14
20
|
end
|
|
15
21
|
|
|
16
22
|
def logger
|
data/lib/resteze/api_resource.rb
CHANGED
|
@@ -2,6 +2,7 @@ module Resteze
|
|
|
2
2
|
module ApiResource
|
|
3
3
|
extend ActiveSupport::Concern
|
|
4
4
|
include Resteze::Request
|
|
5
|
+
include Resteze::Actions
|
|
5
6
|
|
|
6
7
|
def initialize(id = nil, values: {})
|
|
7
8
|
super(values)
|
|
@@ -18,12 +19,30 @@ module Resteze
|
|
|
18
19
|
self.class.resource_path(id)
|
|
19
20
|
end
|
|
20
21
|
|
|
22
|
+
# The path #refresh requests: the routed :retrieve action. Unlike
|
|
23
|
+
# #resource_path, this follows re-routing (e.g. /campaigns/details). While
|
|
24
|
+
# :retrieve keeps its REST default, an instance-level resource_path
|
|
25
|
+
# override is honored exactly as in 0.4.0.
|
|
26
|
+
def retrieve_resource_path
|
|
27
|
+
return resource_path if retrieve_action.rest_default?
|
|
28
|
+
|
|
29
|
+
unless id.present?
|
|
30
|
+
raise api_module::InvalidRequestError.new("Could not determine which PATH to request: #{self.class} instance has " \
|
|
31
|
+
"invalid ID: #{id.inspect}", "id")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
retrieve_action.path_for(id)
|
|
35
|
+
end
|
|
36
|
+
|
|
21
37
|
def retrieve_method
|
|
22
|
-
|
|
38
|
+
retrieve_action.http_method
|
|
23
39
|
end
|
|
24
40
|
|
|
25
41
|
def retrieve_params
|
|
26
|
-
@retrieve_params || {}
|
|
42
|
+
params = @retrieve_params || {}
|
|
43
|
+
return params unless retrieve_action.id == :param
|
|
44
|
+
|
|
45
|
+
{ retrieve_action.id_param => id }.merge(params)
|
|
27
46
|
end
|
|
28
47
|
|
|
29
48
|
def retrieve_headers
|
|
@@ -33,7 +52,7 @@ module Resteze
|
|
|
33
52
|
def refresh
|
|
34
53
|
resp = request(
|
|
35
54
|
retrieve_method,
|
|
36
|
-
|
|
55
|
+
retrieve_resource_path,
|
|
37
56
|
params: retrieve_params,
|
|
38
57
|
headers: retrieve_headers
|
|
39
58
|
)
|
|
@@ -66,5 +85,11 @@ module Resteze
|
|
|
66
85
|
new(id).refresh
|
|
67
86
|
end
|
|
68
87
|
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def retrieve_action
|
|
92
|
+
self.class.action_for(:retrieve)
|
|
93
|
+
end
|
|
69
94
|
end
|
|
70
95
|
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Resteze
|
|
2
|
+
# Drives the API module's Authenticator through the request lifecycle
|
|
3
|
+
# (see Resteze::Authenticator). Prepended on Client inside RateLimiting
|
|
4
|
+
# and outside Instrumentation, so a retry with a renewed credential is
|
|
5
|
+
# one attempt to the limiter and two events to instrumentation.
|
|
6
|
+
module Authenticating
|
|
7
|
+
def execute_request(method, path, **options)
|
|
8
|
+
authenticator = api_module.authenticator
|
|
9
|
+
attempt = 0
|
|
10
|
+
begin
|
|
11
|
+
request = Authenticator::Request.new(http_method: method, path:, **mutable_copies(options))
|
|
12
|
+
authenticator.apply(request)
|
|
13
|
+
super(method, path, **options, headers: request.headers, params: request.params, query: request.query)
|
|
14
|
+
rescue api_module::AuthenticationError => e
|
|
15
|
+
raise unless authenticator.refresh?(e, attempt += 1)
|
|
16
|
+
|
|
17
|
+
retry
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# The strategies get copies to add to, so the caller's hashes survive.
|
|
24
|
+
def mutable_copies(options)
|
|
25
|
+
%i[headers params query].to_h { |key| [key, options.fetch(key, {}).dup] }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Resteze
|
|
2
|
+
class Authenticator
|
|
3
|
+
# An API key sent in a header (default X-Api-Key) or, with `query:`, as
|
|
4
|
+
# a query-string parameter on every request regardless of HTTP method.
|
|
5
|
+
# The key is `MyApi.api_key` unless `value:` names another setting.
|
|
6
|
+
#
|
|
7
|
+
# authenticate_with :api_key # X-Api-Key: <MyApi.api_key>
|
|
8
|
+
# authenticate_with :api_key, header: "X-Partner-Key", value: :partner_key
|
|
9
|
+
# authenticate_with :api_key, query: "api_key"
|
|
10
|
+
# authenticate_with :api_key, header: "Authorization", value: -> { "Token #{MyApi.api_key}" }
|
|
11
|
+
class ApiKey < Strategy
|
|
12
|
+
DEFAULTS = { value: :api_key }.freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :header, :query
|
|
15
|
+
|
|
16
|
+
def initialize(value: DEFAULTS[:value], header: nil, query: nil)
|
|
17
|
+
super()
|
|
18
|
+
raise ArgumentError, "api_key takes header: or query:, not both" if header && query
|
|
19
|
+
|
|
20
|
+
@value = required!(:value, value)
|
|
21
|
+
@query = query
|
|
22
|
+
@header = query ? nil : (header || "X-Api-Key")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def credential_options
|
|
26
|
+
{ value: @value }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def sensitive_keys
|
|
30
|
+
query ? { query: [query] } : { headers: [header] }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def apply(request, _state)
|
|
34
|
+
key = resolve!(:value, @value).to_s
|
|
35
|
+
if query
|
|
36
|
+
request.query[query] = key
|
|
37
|
+
else
|
|
38
|
+
request.headers[header] = key
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Strategy.register(:api_key, self)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Resteze
|
|
2
|
+
class Authenticator
|
|
3
|
+
# HTTP Basic: an Authorization header carrying the base64 of
|
|
4
|
+
# "username:password", read from `MyApi.username` and `MyApi.password`
|
|
5
|
+
# unless told otherwise.
|
|
6
|
+
#
|
|
7
|
+
# authenticate_with :basic
|
|
8
|
+
# authenticate_with :basic, username: :api_user, password: :api_secret
|
|
9
|
+
class Basic < Strategy
|
|
10
|
+
DEFAULTS = { username: :username, password: :password }.freeze
|
|
11
|
+
|
|
12
|
+
def initialize(username: DEFAULTS[:username], password: DEFAULTS[:password])
|
|
13
|
+
super()
|
|
14
|
+
@username = required!(:username, username)
|
|
15
|
+
@password = required!(:password, password)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def credential_options
|
|
19
|
+
{ username: @username, password: @password }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def sensitive_keys
|
|
23
|
+
{ headers: ["Authorization"] }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A username cannot contain a colon (RFC 7617): the server splits on
|
|
27
|
+
# the first one, so "acme:ops" would log in as "acme" and fail with a
|
|
28
|
+
# bare 401. Better to say so here.
|
|
29
|
+
def apply(request, _state)
|
|
30
|
+
username = resolve!(:username, @username).to_s
|
|
31
|
+
raise ArgumentError, "the basic authentication username #{username.inspect} contains ':'" if username.include?(":")
|
|
32
|
+
|
|
33
|
+
credentials = "#{username}:#{resolve!(:password, @password)}"
|
|
34
|
+
request.headers["Authorization"] = "Basic #{[credentials].pack('m0')}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
Strategy.register(:basic, self)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Resteze
|
|
2
|
+
class Authenticator
|
|
3
|
+
# A token in the Authorization header, "Bearer <token>" by default,
|
|
4
|
+
# read from `MyApi.api_key` unless `token:` names another setting. Use
|
|
5
|
+
# `scheme:` for APIs that want another prefix, such as "Token". A token
|
|
6
|
+
# the application obtained some other way fits here through a callable.
|
|
7
|
+
#
|
|
8
|
+
# authenticate_with :bearer # Bearer <MyApi.api_key>
|
|
9
|
+
# authenticate_with :bearer, token: :access_token, scheme: "Token"
|
|
10
|
+
# authenticate_with :bearer, token: -> { TokenStore.current }
|
|
11
|
+
class Bearer < Strategy
|
|
12
|
+
DEFAULTS = { token: :api_key }.freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :scheme
|
|
15
|
+
|
|
16
|
+
def initialize(token: DEFAULTS[:token], scheme: "Bearer")
|
|
17
|
+
super()
|
|
18
|
+
@token = required!(:token, token)
|
|
19
|
+
@scheme = required!(:scheme, scheme)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def credential_options
|
|
23
|
+
{ token: @token }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def sensitive_keys
|
|
27
|
+
{ headers: ["Authorization"] }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def apply(request, _state)
|
|
31
|
+
request.headers["Authorization"] = "#{scheme} #{resolve!(:token, @token)}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Strategy.register(:bearer, self)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
|
|
3
|
+
module Resteze
|
|
4
|
+
class Authenticator
|
|
5
|
+
# Mutual TLS: the client presents a certificate when the connection is
|
|
6
|
+
# made. The certificate and key are `MyApi.client_cert` and
|
|
7
|
+
# `MyApi.client_key` unless told otherwise, each a PEM string or a path
|
|
8
|
+
# to a PEM file; `MyApi.client_key_passphrase` decrypts an encrypted
|
|
9
|
+
# key and `MyApi.ca_file` names a CA bundle that verifies the server
|
|
10
|
+
# when the system store is not enough. Both are optional. Paths are
|
|
11
|
+
# read as given when the connection is first built, so a relative one
|
|
12
|
+
# resolves against the process's working directory at that moment; use
|
|
13
|
+
# absolute paths (Rails.root.join, File.expand_path) or supply PEM
|
|
14
|
+
# text. Nothing is added to requests; the credential lives in the
|
|
15
|
+
# connection, so a client that overrides `default_connection` must
|
|
16
|
+
# pass `connection_options` to Faraday itself.
|
|
17
|
+
#
|
|
18
|
+
# `verify: false` turns server verification off, which defeats the
|
|
19
|
+
# trust mutual TLS is for; it is logged every time the connection is
|
|
20
|
+
# built so it does not survive from a development initializer unseen.
|
|
21
|
+
#
|
|
22
|
+
# authenticate_with :mtls # MyApi.client_cert and MyApi.client_key
|
|
23
|
+
# authenticate_with :mtls, cert: Rails.root.join("config/certs/client.pem"),
|
|
24
|
+
# key: Rails.root.join("config/certs/client-key.pem")
|
|
25
|
+
class Mtls < Strategy
|
|
26
|
+
DEFAULTS = { cert: :client_cert, key: :client_key, passphrase: :client_key_passphrase, ca_file: :ca_file }.freeze
|
|
27
|
+
OPTIONAL = %i[passphrase ca_file].freeze
|
|
28
|
+
|
|
29
|
+
def initialize(cert: DEFAULTS[:cert], key: DEFAULTS[:key], passphrase: DEFAULTS[:passphrase],
|
|
30
|
+
ca_file: DEFAULTS[:ca_file], verify: true)
|
|
31
|
+
super()
|
|
32
|
+
@cert = required!(:cert, cert)
|
|
33
|
+
@key = required!(:key, key)
|
|
34
|
+
@passphrase = passphrase
|
|
35
|
+
@ca_file = ca_file
|
|
36
|
+
@verify = verify
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def credential_options
|
|
40
|
+
{ cert: @cert, key: @key, passphrase: @passphrase, ca_file: @ca_file }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def connection_options
|
|
44
|
+
warn_unverified unless @verify
|
|
45
|
+
|
|
46
|
+
{ ssl: { client_cert: certificate, client_key: private_key, ca_file: resolve(@ca_file), verify: @verify }.compact }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def certificate
|
|
50
|
+
OpenSSL::X509::Certificate.new(pem(resolve!(:cert, @cert)))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def private_key
|
|
54
|
+
OpenSSL::PKey.read(pem(resolve!(:key, @key)), resolve(@passphrase))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
Strategy.register(:mtls, self)
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def pem(value)
|
|
62
|
+
value = value.to_s
|
|
63
|
+
value.include?("-----BEGIN") ? value : File.read(value)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def warn_unverified
|
|
67
|
+
message = "#{api_module || 'This API'} uses mTLS with verify: false, so the server's certificate is not checked"
|
|
68
|
+
logger = api_module.try(:logger)
|
|
69
|
+
logger ? logger.warn(message) : warn(message)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|