lipwa 0.1.2 → 0.1.4

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +219 -204
  4. data/lib/lipwa/auth_strategies/api_key.rb +28 -0
  5. data/lib/lipwa/auth_strategies/base.rb +4 -0
  6. data/lib/lipwa/auth_strategies/bearer_token.rb +5 -0
  7. data/lib/lipwa/auth_strategies/jenga.rb +58 -0
  8. data/lib/lipwa/auth_strategies.rb +2 -0
  9. data/lib/lipwa/capabilities/authorize.rb +26 -0
  10. data/lib/lipwa/capabilities/bank_transfer.rb +66 -0
  11. data/lib/lipwa/capabilities/c2b.rb +11 -10
  12. data/lib/lipwa/capabilities/capture.rb +25 -0
  13. data/lib/lipwa/capabilities/disbursement.rb +16 -17
  14. data/lib/lipwa/capabilities/lightning_invoice.rb +75 -0
  15. data/lib/lipwa/capabilities/lightning_payment.rb +71 -0
  16. data/lib/lipwa/capabilities/purchase.rb +26 -0
  17. data/lib/lipwa/capabilities/refund.rb +14 -14
  18. data/lib/lipwa/capabilities/status_query.rb +21 -12
  19. data/lib/lipwa/capabilities/stk_push.rb +8 -5
  20. data/lib/lipwa/capabilities/void.rb +25 -0
  21. data/lib/lipwa/configuration_snapshot.rb +50 -0
  22. data/lib/lipwa/context.rb +60 -0
  23. data/lib/lipwa/contracts/bank_transfer_contract.rb +61 -0
  24. data/lib/lipwa/contracts/card_payment_contract.rb +59 -0
  25. data/lib/lipwa/contracts/card_transaction_contract.rb +21 -0
  26. data/lib/lipwa/contracts/lightning_invoice_check_contract.rb +18 -0
  27. data/lib/lipwa/contracts/lightning_invoice_contract.rb +27 -0
  28. data/lib/lipwa/contracts/lightning_payment_check_contract.rb +18 -0
  29. data/lib/lipwa/contracts/lightning_payment_contract.rb +14 -0
  30. data/lib/lipwa/contracts/status_query_contract.rb +29 -6
  31. data/lib/lipwa/errors.rb +4 -2
  32. data/lib/lipwa/gateway.rb +24 -5
  33. data/lib/lipwa/gateways/coop_bank/auth.rb +68 -0
  34. data/lib/lipwa/gateways/coop_bank.rb +115 -0
  35. data/lib/lipwa/gateways/flutterwave.rb +160 -0
  36. data/lib/lipwa/gateways/jenga/auth.rb +76 -0
  37. data/lib/lipwa/gateways/jenga.rb +282 -0
  38. data/lib/lipwa/gateways/lnbits.rb +67 -0
  39. data/lib/lipwa/gateways/mpesa/auth.rb +18 -2
  40. data/lib/lipwa/gateways/mpesa.rb +16 -11
  41. data/lib/lipwa/gateways/paystack.rb +119 -0
  42. data/lib/lipwa/gateways/pesapal/auth.rb +72 -0
  43. data/lib/lipwa/gateways/pesapal.rb +123 -0
  44. data/lib/lipwa/gateways.rb +7 -1
  45. data/lib/lipwa/http_adapter.rb +56 -17
  46. data/lib/lipwa/logging.rb +100 -0
  47. data/lib/lipwa/money.rb +33 -4
  48. data/lib/lipwa/types.rb +3 -3
  49. data/lib/lipwa/version.rb +1 -1
  50. data/lib/lipwa/webhooks/coop_bank.rb +28 -0
  51. data/lib/lipwa/webhooks/jenga.rb +55 -0
  52. data/lib/lipwa/webhooks/lnbits.rb +46 -0
  53. data/lib/lipwa.rb +29 -0
  54. metadata +32 -4
  55. data/CODE_OF_CONDUCT.md +0 -10
  56. data/Rakefile +0 -12
  57. data/sig/lipwa.rbs +0 -4
@@ -21,7 +21,9 @@ module Lipwa
21
21
 
22
22
  CONTRACT = Lipwa::Contracts::StkPushContract.new
23
23
 
24
- def stk_push(amount:, phone_number:, account_reference:, callback_url:, transaction_desc: nil)
24
+ # rubocop:disable Metrics/ParameterLists
25
+ def stk_push(amount:, phone_number:, account_reference:, callback_url:, transaction_desc: nil,
26
+ idempotency_key: nil)
25
27
  validation = CONTRACT.call(
26
28
  amount: amount,
27
29
  phone_number: phone_number,
@@ -31,13 +33,14 @@ module Lipwa
31
33
  )
32
34
  return Failure(Lipwa::ValidationError.new(validation)) if validation.failure?
33
35
 
34
- perform_stk_push(validation.to_h)
36
+ perform_stk_push(validation.to_h, idempotency_key)
35
37
  end
38
+ # rubocop:enable Metrics/ParameterLists
36
39
 
37
40
  private
38
41
 
39
- def perform_stk_push(params)
40
- response = http.post(PATH, body: stk_push_body(params))
42
+ def perform_stk_push(params, idempotency_key)
43
+ response = http.post(PATH, body: stk_push_body(params), idempotency_key: idempotency_key)
41
44
 
42
45
  build_stk_push_response(response.body)
43
46
  rescue Lipwa::GatewayError => e
@@ -45,7 +48,7 @@ module Lipwa
45
48
  end
46
49
 
47
50
  def stk_push_body(params)
48
- config = self.class.config
51
+ config = self.config
49
52
  timestamp = Time.now.strftime("%Y%m%d%H%M%S")
50
53
 
51
54
  {
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/monads"
4
+ require_relative "../capability"
5
+ require_relative "../contracts/card_transaction_contract"
6
+
7
+ module Lipwa
8
+ module Capabilities
9
+ # Cancels an authorization which has not completed.
10
+ module Void
11
+ extend Lipwa::Capability
12
+ include Dry::Monads[:result]
13
+ self.capability_name = :void
14
+
15
+ CONTRACT = Lipwa::Contracts::CardTransactionContract.new
16
+
17
+ def void(authorization:, idempotency_key: nil)
18
+ validation = CONTRACT.call(authorization: authorization)
19
+ return Failure(Lipwa::ValidationError.new(validation)) if validation.failure?
20
+
21
+ perform_void(validation.to_h, idempotency_key)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration"
4
+ require_relative "logging"
5
+
6
+ module Lipwa
7
+ # Copies setting data while preserving service objects such as loggers.
8
+ module ConfigurationSnapshot
9
+ module_function
10
+
11
+ def finalize(config)
12
+ config.values.transform_values! { |value| freeze_value(copy_value(value)) }
13
+ config.define_singleton_method(:inspect) do
14
+ values = Lipwa::Logging::Redactor.call(to_h)
15
+ "#<#{self.class} values=#{values.inspect}>"
16
+ end
17
+ config.finalize!
18
+ config.values.freeze
19
+ config
20
+ end
21
+
22
+ def freeze_value(value)
23
+ case value
24
+ when Dry::Configurable::Config then finalize(value)
25
+ when Array then value.each { |item| freeze_value(item) }.freeze
26
+ when Hash then value.each_value { |item| freeze_value(item) }.freeze
27
+ when String then value.freeze
28
+ end
29
+ value
30
+ end
31
+
32
+ def snapshot(source)
33
+ source.dup.tap do |copy|
34
+ copy.values.transform_values! { |value| copy_value(value) }
35
+ end
36
+ end
37
+
38
+ # Copy configuration data without duplicating services such as loggers or
39
+ # callable clocks, and without running setting constructors a second time.
40
+ def copy_value(value)
41
+ case value
42
+ when Dry::Configurable::Config then snapshot(value)
43
+ when String then value.dup
44
+ when Array then value.map { |item| copy_value(item) }
45
+ when Hash then value.transform_values { |item| copy_value(item) }
46
+ else value
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+ require_relative "gateways"
5
+ require_relative "configuration_snapshot"
6
+
7
+ module Lipwa
8
+ # An explicit, immutable configuration scope with its own gateway instances.
9
+ class Context
10
+ # Delegates settings to Dry::Configurable so constructors and errors survive.
11
+ class Builder < SimpleDelegator
12
+ def initialize(config, gateway_configs)
13
+ super(config)
14
+ @gateway_configs = gateway_configs
15
+ end
16
+
17
+ def gateway(name)
18
+ config = @gateway_configs[name]
19
+ yield config if block_given?
20
+ config
21
+ end
22
+ end
23
+ private_constant :Builder
24
+
25
+ attr_reader :config
26
+
27
+ def initialize
28
+ @config = snapshot(Lipwa.config)
29
+ classes = Gateways.gateway_classes
30
+ gateway_configs = snapshot_gateways(classes)
31
+ yield Builder.new(config, gateway_configs) if block_given?
32
+ finalize(config)
33
+ @gateways = build_gateways(classes, gateway_configs)
34
+ freeze
35
+ end
36
+
37
+ def gateway(name)
38
+ @gateways[name]
39
+ end
40
+
41
+ private
42
+
43
+ include ConfigurationSnapshot
44
+
45
+ def snapshot_gateways(classes)
46
+ Dry::Container.new.tap do |configs|
47
+ classes.each { |name, klass| configs.register(name, snapshot(klass.config)) }
48
+ end
49
+ end
50
+
51
+ def build_gateways(classes, gateway_configs)
52
+ Dry::Container.new.tap do |gateways|
53
+ classes.each do |name, klass|
54
+ provider_config = finalize(gateway_configs[name])
55
+ gateways.register(name, memoize: true) { klass.new(config: provider_config, global_config: config) }
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/validation"
4
+ require_relative "../money"
5
+ module Lipwa
6
+ module Contracts
7
+ class BankTransferContract < Dry::Validation::Contract
8
+ RAILS = %i[internal rtgs pesalink swift bill_payment].freeze
9
+ schema do
10
+ required(:rail).filled(:symbol)
11
+ required(:amount).filled
12
+ required(:source_account).filled(:string)
13
+ required(:destination_account).filled(:string)
14
+ required(:reference).filled(:string)
15
+ optional(:narration).maybe(:string)
16
+ optional(:callback_url).maybe(:string)
17
+ optional(:destination_bank_code).maybe(:string)
18
+ optional(:destination_name).maybe(:string)
19
+ optional(:biller_code).maybe(:string)
20
+ end
21
+ rule(:rail) { key.failure("must be one of #{RAILS.join(", ")}") unless RAILS.include?(value) }
22
+ rule(:amount) do
23
+ next key.failure("must be a Lipwa::Money") unless value.is_a?(Lipwa::Money)
24
+
25
+ key.failure("must be greater than zero") unless value.amount.positive?
26
+ end
27
+ rule(:destination_bank_code, :rail) do
28
+ if %i[rtgs swift].include?(values[:rail]) && values[:destination_bank_code].to_s.empty?
29
+ key(:destination_bank_code).failure("is required for RTGS transfers")
30
+ end
31
+ end
32
+ rule(:biller_code, :rail) do
33
+ if values[:rail] == :bill_payment && values[:biller_code].to_s.empty?
34
+ key(:biller_code).failure("is required for bill payments")
35
+ end
36
+ end
37
+ rule(:callback_url) { key.failure("must be an https:// URL") if value && !value.start_with?("https://") }
38
+ end
39
+
40
+ class BankAccountContract < Dry::Validation::Contract
41
+ schema do
42
+ required(:account_number).filled(:string)
43
+ optional(:message_reference).maybe(:string)
44
+ end
45
+ end
46
+
47
+ class BankStatementContract < Dry::Validation::Contract
48
+ schema do
49
+ required(:account_number).filled(:string)
50
+ optional(:from_date).maybe(:date)
51
+ optional(:to_date).maybe(:date)
52
+ optional(:message_reference).maybe(:string)
53
+ end
54
+ rule(:from_date, :to_date) do
55
+ if values[:from_date] && values[:to_date] && values[:to_date] < values[:from_date]
56
+ key(:to_date).failure("must be on or after from_date")
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/validation"
4
+ require_relative "../money"
5
+
6
+ module Lipwa
7
+ module Contracts
8
+ # Validates the shared hosted-checkout request shape.
9
+ class CardPaymentContract < Dry::Validation::Contract
10
+ schema do
11
+ required(:amount).filled
12
+ required(:reference).filled(:string)
13
+ required(:description).filled(:string)
14
+ required(:callback_url).filled(:string)
15
+ required(:notification_id).filled(:string)
16
+ required(:billing_address).hash
17
+ optional(:cancellation_url).maybe(:string)
18
+ optional(:redirect_mode).maybe(:string)
19
+ optional(:branch).maybe(:string)
20
+ optional(:card_number).maybe(:string)
21
+ optional(:cvv).maybe(:string)
22
+ optional(:expiry_month).maybe(:string)
23
+ optional(:expiry_year).maybe(:string)
24
+ optional(:fullname).maybe(:string)
25
+ optional(:phone_number).maybe(:string)
26
+ optional(:usesecureauth).maybe(:bool)
27
+ end
28
+
29
+ rule(:amount) do
30
+ next key.failure("must be a Lipwa::Money") unless value.is_a?(Lipwa::Money)
31
+
32
+ key.failure("must be greater than zero") unless value.amount.positive?
33
+ end
34
+
35
+ rule(:reference) do
36
+ key.failure("must be 50 characters or fewer") if value.length > 50
37
+ key.failure("contains unsupported characters") unless value.match?(/\A[A-Za-z0-9_.:-]+\z/)
38
+ end
39
+
40
+ rule(:description) { key.failure("must be 100 characters or fewer") if value.length > 100 }
41
+
42
+ rule(:callback_url) do
43
+ key.failure("must be an https:// URL") unless value.start_with?("https://")
44
+ end
45
+
46
+ rule(:cancellation_url) do
47
+ key.failure("must be an https:// URL") if value && !value.start_with?("https://")
48
+ end
49
+
50
+ rule(:billing_address) do
51
+ address = value
52
+ unless address[:email_address] || address["email_address"] ||
53
+ address[:phone_number] || address["phone_number"]
54
+ key.failure("must contain an email_address or phone_number")
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/validation"
4
+
5
+ module Lipwa
6
+ module Contracts
7
+ # Validates operations addressing an existing provider authorization.
8
+ class CardTransactionContract < Dry::Validation::Contract
9
+ schema do
10
+ required(:authorization).filled(:string)
11
+ optional(:amount).filled
12
+ end
13
+
14
+ rule(:authorization) do
15
+ unless value.match?(/\A[A-Za-z0-9_.:-]+\z/) && !%w[. ..].include?(value)
16
+ key.failure("contains unsupported characters")
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/validation"
4
+
5
+ module Lipwa
6
+ module Contracts
7
+ # Validates the LNbits payment hash used to look up an invoice.
8
+ class LightningInvoiceCheckContract < Dry::Validation::Contract
9
+ schema do
10
+ required(:payment_hash).filled(:string)
11
+ end
12
+
13
+ rule(:payment_hash) do
14
+ key.failure("contains unsupported characters") unless value.match?(/\A[A-Za-z0-9_-]+\z/)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/validation"
4
+
5
+ module Lipwa
6
+ module Contracts
7
+ # Validates invoice creation parameters before any network call is
8
+ # made. Amounts are integer satoshis, not Lipwa::Money: Bitcoin is
9
+ # not an ISO-4217 currency and this capability does no currency math.
10
+ class LightningInvoiceContract < Dry::Validation::Contract
11
+ schema do
12
+ required(:amount_sats).filled(:integer)
13
+ optional(:memo).maybe(:string)
14
+ optional(:expiry).maybe(:integer)
15
+ optional(:webhook_url).maybe(:string)
16
+ end
17
+
18
+ rule(:amount_sats) do
19
+ key.failure("must be greater than zero") unless value.positive?
20
+ end
21
+
22
+ rule(:webhook_url) do
23
+ key.failure("must be an https:// URL") if value && !value.start_with?("https://")
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/validation"
4
+
5
+ module Lipwa
6
+ module Contracts
7
+ # Validates the payment hash used to reconcile an outbound payment.
8
+ class LightningPaymentCheckContract < Dry::Validation::Contract
9
+ schema do
10
+ required(:payment_hash).filled(:string)
11
+ end
12
+
13
+ rule(:payment_hash) do
14
+ key.failure("contains unsupported characters") unless value.match?(/\A[A-Za-z0-9_-]+\z/)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/validation"
4
+
5
+ module Lipwa
6
+ module Contracts
7
+ # Validates an outbound BOLT11 invoice before wallet funds can be spent.
8
+ class LightningPaymentContract < Dry::Validation::Contract
9
+ schema do
10
+ required(:bolt11).filled(:string)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -11,19 +11,42 @@ module Lipwa
11
11
  # per-call param.
12
12
  class StatusQueryContract < Dry::Validation::Contract
13
13
  schema do
14
- required(:transaction_id).filled(:string)
15
- required(:remarks).filled(:string)
16
- required(:result_url).filled(:string)
17
- required(:queue_timeout_url).filled(:string)
14
+ optional(:transaction_id).maybe(:string)
15
+ optional(:message_reference).maybe(:string)
16
+ optional(:remarks).maybe(:string)
17
+ optional(:result_url).maybe(:string)
18
+ optional(:queue_timeout_url).maybe(:string)
18
19
  optional(:occasion).maybe(:string)
19
20
  end
20
21
 
22
+ rule(:transaction_id, :message_reference) do
23
+ if values[:transaction_id].to_s.empty? && values[:message_reference].to_s.empty?
24
+ key(:transaction_id).failure("or message_reference must be filled")
25
+ elsif values[:transaction_id] && values[:message_reference]
26
+ key(:message_reference).failure("cannot be used with transaction_id")
27
+ end
28
+ end
29
+
30
+ rule(:remarks, :transaction_id) do
31
+ key(:remarks).failure("must be filled") if values[:transaction_id] && values[:remarks].to_s.empty?
32
+ end
33
+
34
+ rule(:result_url, :transaction_id) do
35
+ key(:result_url).failure("must be filled") if values[:transaction_id] && values[:result_url].to_s.empty?
36
+ end
37
+
38
+ rule(:queue_timeout_url, :transaction_id) do
39
+ if values[:transaction_id] && values[:queue_timeout_url].to_s.empty?
40
+ key(:queue_timeout_url).failure("must be filled")
41
+ end
42
+ end
43
+
21
44
  rule(:result_url) do
22
- key.failure("must be an https:// URL") unless value.start_with?("https://")
45
+ key.failure("must be an https:// URL") if value && !value.start_with?("https://")
23
46
  end
24
47
 
25
48
  rule(:queue_timeout_url) do
26
- key.failure("must be an https:// URL") unless value.start_with?("https://")
49
+ key.failure("must be an https:// URL") if value && !value.start_with?("https://")
27
50
  end
28
51
  end
29
52
  end
data/lib/lipwa/errors.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "logging"
4
+
3
5
  module Lipwa
4
6
  # Base class for everything the gem raises or wraps in a Failure.
5
7
  class Error < StandardError; end
@@ -41,8 +43,8 @@ module Lipwa
41
43
 
42
44
  def initialize(message, code: nil, raw: nil)
43
45
  @code = code
44
- @raw = raw
45
- super(message)
46
+ @raw = Logging::Redactor.call(raw)
47
+ super(Logging::Redactor.redact_string(message.to_s))
46
48
  end
47
49
  end
48
50
  end
data/lib/lipwa/gateway.rb CHANGED
@@ -6,7 +6,7 @@ require_relative "types"
6
6
  require_relative "capability"
7
7
  require_relative "auth_strategies"
8
8
  require_relative "http_adapter"
9
- require_relative "configuration"
9
+ require_relative "configuration_snapshot"
10
10
 
11
11
  module Lipwa
12
12
  # Abstract base every provider gateway (Mpesa, CoopBank, Jenga, ...)
@@ -41,6 +41,19 @@ module Lipwa
41
41
  end
42
42
  end
43
43
 
44
+ attr_reader :config, :global_config
45
+
46
+ # Explicit snapshots win over defaults captured at construction time.
47
+ def initialize(config: nil, global_config: nil)
48
+ @config = ConfigurationSnapshot.snapshot(config || self.class.config)
49
+ strategy = @config.auth_strategy
50
+ @config.auth_strategy = strategy.respond_to?(:apply) ? strategy.dup : strategy&.call
51
+ ConfigurationSnapshot.finalize(@config)
52
+ @global_config = ConfigurationSnapshot.finalize(
53
+ ConfigurationSnapshot.snapshot(global_config || Lipwa.config)
54
+ )
55
+ end
56
+
44
57
  def capability?(name)
45
58
  self.class.capabilities.include?(name.to_sym)
46
59
  end
@@ -49,19 +62,25 @@ module Lipwa
49
62
  @http ||= build_http_adapter
50
63
  end
51
64
 
65
+ # Configuration contains credentials, so the default object inspection is
66
+ # deliberately replaced with a diagnostic that cannot serialize them.
67
+ def inspect
68
+ "#<#{self.class}:0x#{object_id.to_s(16)}>"
69
+ end
70
+
52
71
  private
53
72
 
54
73
  def build_http_adapter
55
- config = self.class.config
74
+ config = self.config
56
75
  ensure_base_url_configured!(config)
57
76
 
58
77
  HttpAdapter.new(
59
78
  base_url: config.base_url,
60
79
  auth_strategy: config.auth_strategy || AuthStrategies::None.new,
61
- timeout: config.timeout || Lipwa.config.default_timeout,
80
+ timeout: config.timeout || global_config.default_timeout,
62
81
  open_timeout: config.open_timeout,
63
- logger: config.logger || Lipwa.config.logger,
64
- adapter: Lipwa.config.adapter
82
+ logger: config.logger || global_config.logger,
83
+ adapter: global_config.adapter
65
84
  )
66
85
  end
67
86
 
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "faraday"
5
+ require "json"
6
+ require_relative "../../errors"
7
+ module Lipwa
8
+ module Gateways
9
+ class CoopBank
10
+ class Auth
11
+ EXPIRY_BUFFER = 60
12
+ def initialize(client_id:, client_secret:, token_url:, clock: -> { Time.now })
13
+ unless client_id && client_secret
14
+ raise Lipwa::ConfigurationError,
15
+ "CoopBank::Auth requires client_id and client_secret"
16
+ end
17
+
18
+ @client_id = client_id
19
+ @client_secret = client_secret
20
+ @token_url = token_url
21
+ @clock = clock
22
+ @mutex = Mutex.new
23
+ end
24
+
25
+ def initialize_copy(source)
26
+ super
27
+ @token = nil
28
+ @expires_at = nil
29
+ @mutex = Mutex.new
30
+ end
31
+
32
+ def inspect
33
+ "#<#{self.class}:0x#{object_id.to_s(16)}>"
34
+ end
35
+
36
+ def call
37
+ return @token if fresh?
38
+
39
+ @mutex.synchronize { refresh! unless fresh? }
40
+ @token
41
+ end
42
+
43
+ private
44
+
45
+ def fresh? = @token && @expires_at && @clock.call < @expires_at
46
+
47
+ def refresh!
48
+ response = Faraday.post(@token_url) do |request|
49
+ request.headers["Authorization"] = "Basic #{Base64.strict_encode64("#{@client_id}:#{@client_secret}")}"
50
+ request.headers["Content-Type"] = "application/x-www-form-urlencoded"
51
+ request.body = "grant_type=client_credentials"
52
+ end
53
+ body = JSON.parse(response.body)
54
+ unless response.success?
55
+ raise Lipwa::GatewayError.new("Co-op OAuth failed", code: response.status.to_s,
56
+ raw: body)
57
+ end
58
+
59
+ @token = body.fetch("access_token")
60
+ @expires_at = @clock.call + body.fetch("expires_in", 3600).to_i - EXPIRY_BUFFER
61
+ rescue Faraday::Error, JSON::ParserError, KeyError => e
62
+ raise Lipwa::GatewayError.new("unexpected Co-op OAuth response: #{e.message}",
63
+ raw: defined?(body) ? body : nil)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end