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,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
@@ -6,11 +6,17 @@ module Resteze
6
6
 
7
7
  module ClassMethods
8
8
  def api_module
9
- @api_module ||=
10
- begin
11
- parents = name.scan("::").inject([name]) { |mods, _n| mods << mods.last.deconstantize }
12
- parents.map(&:constantize).detect { |mod| mod == Resteze || mod.include?(Resteze) }
13
- end
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
@@ -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
- :get
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
- resource_path,
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