partner_api 0.11.2

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 (77) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/release.yml +18 -0
  3. data/.github/workflows/ruby.yml +28 -0
  4. data/.gitignore +18 -0
  5. data/.rspec +4 -0
  6. data/CHANGELOG.md +105 -0
  7. data/Gemfile +10 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +85 -0
  10. data/Rakefile +6 -0
  11. data/bin/console +17 -0
  12. data/bin/setup +8 -0
  13. data/docs/anz_api.md +67 -0
  14. data/docs/bnz_api.md +103 -0
  15. data/docs/fab_api.md +206 -0
  16. data/docs/gemini_api.md +184 -0
  17. data/docs/vma_api.md +167 -0
  18. data/docs/westpac_api.md +106 -0
  19. data/lib/anz_api/client.rb +24 -0
  20. data/lib/anz_api/endpoint.rb +29 -0
  21. data/lib/anz_api/endpoints/fetch_jwk.rb +35 -0
  22. data/lib/anz_api/failure_response.rb +33 -0
  23. data/lib/anz_api.rb +15 -0
  24. data/lib/bnz_api/client.rb +45 -0
  25. data/lib/bnz_api/configuration.rb +21 -0
  26. data/lib/bnz_api/endpoint.rb +56 -0
  27. data/lib/bnz_api/endpoints/fetch_id_token.rb +73 -0
  28. data/lib/bnz_api/endpoints/fetch_jwk.rb +35 -0
  29. data/lib/bnz_api/failure_response.rb +33 -0
  30. data/lib/bnz_api/httpx.rb +75 -0
  31. data/lib/bnz_api.rb +18 -0
  32. data/lib/fab_api/client.rb +36 -0
  33. data/lib/fab_api/configuration.rb +26 -0
  34. data/lib/fab_api/endpoint.rb +70 -0
  35. data/lib/fab_api/endpoints/deliver_email.rb +56 -0
  36. data/lib/fab_api/endpoints/deliver_sms.rb +51 -0
  37. data/lib/fab_api/endpoints/exchange_token.rb +49 -0
  38. data/lib/fab_api/endpoints/invalidate_token.rb +53 -0
  39. data/lib/fab_api/endpoints/refresh_token.rb +60 -0
  40. data/lib/fab_api/failure_response.rb +39 -0
  41. data/lib/fab_api/utils/id.rb +13 -0
  42. data/lib/fab_api.rb +18 -0
  43. data/lib/gemini_api/address.rb +23 -0
  44. data/lib/gemini_api/balance.rb +24 -0
  45. data/lib/gemini_api/client.rb +37 -0
  46. data/lib/gemini_api/endpoint.rb +62 -0
  47. data/lib/gemini_api/endpoints/create_address_request.rb +39 -0
  48. data/lib/gemini_api/endpoints/get_available_balances.rb +39 -0
  49. data/lib/gemini_api/endpoints/view_approved_addresses.rb +40 -0
  50. data/lib/gemini_api/endpoints/view_transfers.rb +49 -0
  51. data/lib/gemini_api/endpoints/withdraw_crypto_fund.rb +49 -0
  52. data/lib/gemini_api/failure_response.rb +47 -0
  53. data/lib/gemini_api/transaction.rb +14 -0
  54. data/lib/gemini_api/transfer.rb +44 -0
  55. data/lib/gemini_api.rb +16 -0
  56. data/lib/partner_api/endpoints/base.rb +152 -0
  57. data/lib/partner_api/errors.rb +18 -0
  58. data/lib/partner_api/utils/hash.rb +21 -0
  59. data/lib/partner_api/utils/read_cert.rb +38 -0
  60. data/lib/vma_api/client.rb +33 -0
  61. data/lib/vma_api/configuration.rb +24 -0
  62. data/lib/vma_api/endpoint.rb +29 -0
  63. data/lib/vma_api/endpoints/access_token.rb +60 -0
  64. data/lib/vma_api/endpoints/client_credentials.rb +60 -0
  65. data/lib/vma_api/endpoints/refresh_token.rb +60 -0
  66. data/lib/vma_api/endpoints/revoke_token.rb +55 -0
  67. data/lib/vma_api/failure_response.rb +42 -0
  68. data/lib/vma_api.rb +20 -0
  69. data/lib/westpac_api/client.rb +29 -0
  70. data/lib/westpac_api/configuration.rb +28 -0
  71. data/lib/westpac_api/endpoint.rb +26 -0
  72. data/lib/westpac_api/endpoints/fetch_jwk.rb +33 -0
  73. data/lib/westpac_api/endpoints/fetch_user.rb +96 -0
  74. data/lib/westpac_api/failure_response.rb +33 -0
  75. data/lib/westpac_api.rb +28 -0
  76. data/partner_api.gemspec +31 -0
  77. metadata +191 -0
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GeminiApi
4
+ Transfer = Struct.new(
5
+ :type,
6
+ :status,
7
+ :timestampms,
8
+ :eid,
9
+ :advance_eid,
10
+ :currency,
11
+ :amount,
12
+ :fee_amount,
13
+ :fee_currency,
14
+ :method,
15
+ :tx_hash,
16
+ :withdrawal_id,
17
+ :output_idx,
18
+ :destination,
19
+ :purpose,
20
+ :raw_data,
21
+ keyword_init: true
22
+ ) do
23
+ def self.build(raw_data)
24
+ new(
25
+ type: raw_data['type'],
26
+ status: raw_data['status'],
27
+ timestampms: raw_data['timestampms'],
28
+ eid: raw_data['eid'],
29
+ advance_eid: raw_data['advanceEid'],
30
+ currency: raw_data['currency'],
31
+ amount: raw_data['amount'],
32
+ fee_amount: raw_data['feeAmount'],
33
+ fee_currency: raw_data['feeCurrency'],
34
+ method: raw_data['method'],
35
+ tx_hash: raw_data['txHash'],
36
+ withdrawal_id: raw_data['withdrawalId'],
37
+ output_idx: raw_data['outputIdx'],
38
+ destination: raw_data['destination'],
39
+ purpose: raw_data['purpose'],
40
+ raw_data: raw_data
41
+ )
42
+ end
43
+ end
44
+ end
data/lib/gemini_api.rb ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-configurable'
4
+ require 'gemini_api/client'
5
+
6
+ module GeminiApi
7
+ extend Dry::Configurable
8
+
9
+ setting :instrumentation, default: -> (action:, tenant_id: nil, &block) { block.call }
10
+ setting :logger, default: Logger.new($stdout, level: :info)
11
+ setting :base_url, constructor: proc { |value| URI.parse(value) }
12
+ setting :api_key
13
+ setting :api_secret
14
+ setting :default_headers, default: -> { {} }
15
+ setting :default_parameters, default: -> { {} }
16
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-monads'
4
+ require 'hanami/utils/hash'
5
+ require 'http'
6
+ require 'openssl'
7
+
8
+ require 'partner_api/utils/hash'
9
+ require 'partner_api/utils/read_cert'
10
+ require 'partner_api/errors'
11
+
12
+ module PartnerApi
13
+ module Endpoints
14
+ class Base
15
+ include Dry::Monads[:result]
16
+
17
+ def call
18
+ config.logger.info(**logging_params.merge(class: self.class.name))
19
+
20
+ response = config.instrumentation.call(action: self.class.name) do
21
+ client.request(method, url, **request_options)
22
+ end
23
+
24
+ if successful?(response)
25
+ Success(decode(response))
26
+ else
27
+ Failure(decode_error(response))
28
+ end
29
+ rescue HTTP::Error, OpenSSL::SSL::SSLError => e
30
+ raise Errors::ConnectionError, e.message
31
+ end
32
+
33
+ private
34
+
35
+ def client
36
+ @client ||= HTTP::Client.new(**connection_options)
37
+ .use(logging: { logger: config.logger })
38
+ end
39
+
40
+ def logging_params
41
+ raise NotImplementedError
42
+ end
43
+
44
+ # To be implemented by concrete end-point class.
45
+ #
46
+ def connection_options
47
+ raise NotImplementedError
48
+ end
49
+
50
+ # To be implemented by concrete end-point class.
51
+ #
52
+ def method
53
+ raise NotImplementedError
54
+ end
55
+
56
+ def url
57
+ config.base_url.dup.tap { |uri| uri.path = path }
58
+ end
59
+
60
+ # To be implemented by concrete end-point class.
61
+ #
62
+ def path
63
+ raise NotImplementedError
64
+ end
65
+
66
+ def request_options
67
+ {
68
+ headers: combined_headers,
69
+ body: JSON.dump(combined_payload)
70
+ }
71
+ end
72
+
73
+ def default_headers
74
+ {}
75
+ end
76
+
77
+ def default_parameters
78
+ {}
79
+ end
80
+
81
+ def combined_headers
82
+ Utils::Hash.deep_merge(
83
+ default_headers,
84
+ Hanami::Utils::Hash.deep_stringify(config.default_headers.call),
85
+ Hanami::Utils::Hash.deep_stringify(headers)
86
+ )
87
+ end
88
+
89
+ def combined_payload
90
+ Utils::Hash.deep_merge(
91
+ default_parameters,
92
+ Hanami::Utils::Hash.deep_stringify(config.default_parameters.call),
93
+ Hanami::Utils::Hash.deep_stringify(parameters)
94
+ )
95
+ end
96
+
97
+ # To be implemented by concrete end-point class.
98
+ #
99
+ def headers
100
+ {}
101
+ end
102
+
103
+ # To be implemented by concrete end-point class.
104
+ #
105
+ def parameters
106
+ {}
107
+ end
108
+
109
+ # To be implemented by concrete end-point class.
110
+ #
111
+ def successful?(response)
112
+ raise NotImplementedError
113
+ end
114
+
115
+ # To be implemented by concrete end-point class.
116
+ #
117
+ def decode(response)
118
+ raise NotImplementedError
119
+ end
120
+
121
+ # To be implemented by concrete end-point class.
122
+ #
123
+ def decode_error(response)
124
+ raise NotImplementedError
125
+ end
126
+ end
127
+
128
+ module Initializer
129
+ def self.prepended(klass)
130
+ klass.class_eval do
131
+ def self.call(config, **params)
132
+ new(config, **params).call
133
+ end
134
+ end
135
+ end
136
+
137
+ def initialize(config, **params)
138
+ @config = config
139
+
140
+ if params.empty?
141
+ super()
142
+ else
143
+ super(**params)
144
+ end
145
+ end
146
+
147
+ private
148
+
149
+ attr_reader :config
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,18 @@
1
+ module PartnerApi
2
+ module Errors
3
+ class Error < StandardError; end
4
+
5
+ # Used to re-wrap connection errors from http.rb.
6
+ #
7
+ class ConnectionError < Error; end
8
+
9
+ class RequestError < Error
10
+ attr_reader :code
11
+
12
+ def initialize(code: nil, message:)
13
+ @code = code
14
+ super(message)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,21 @@
1
+ module PartnerApi
2
+ module Utils
3
+ class Hash
4
+ # This a simplify version of deep merging
5
+ #
6
+ # In case both conflict items are hash, continue to merge recursively.
7
+ # Otherwise, prioritize the hash in later order
8
+ def self.deep_merge(*hashes)
9
+ hashes.inject({}) do |result, hash|
10
+ result.merge(hash) do |_key, item_1, item_2|
11
+ if item_1.is_a?(::Hash) && item_2.is_a?(::Hash)
12
+ deep_merge(item_1, item_2)
13
+ else
14
+ item_2 || item_1
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,38 @@
1
+ module PartnerApi
2
+ module Utils
3
+ class ReadCert
4
+ # A workaround for this issue related to OpenSSL
5
+ # https://github.com/ruby/openssl/issues/254
6
+ CERT_PATTERN = /-----BEGIN CERTIFICATE-----(?:.|\n)+?-----END CERTIFICATE-----/
7
+
8
+ def initialize(public_certs, private_key)
9
+ @certs = public_certs.scan(CERT_PATTERN)
10
+ @private_key = private_key
11
+ end
12
+
13
+ def ssl_context
14
+ OpenSSL::SSL::SSLContext.new(:TLSv1_2).tap do |ctx|
15
+ ctx.set_params(
16
+ cert: cert,
17
+ key: OpenSSL::PKey::RSA.new(private_key),
18
+ extra_chain_cert: extra_chain_cert
19
+ )
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :certs, :private_key
26
+
27
+ def cert
28
+ OpenSSL::X509::Certificate.new(certs.first)
29
+ end
30
+
31
+ def extra_chain_cert
32
+ return [] if certs.length < 2
33
+
34
+ certs[1..-1].map { |cert| OpenSSL::X509::Certificate.new(cert) }
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,33 @@
1
+ require 'hanami/utils/string'
2
+ require 'hanami/utils/class'
3
+
4
+ require 'vma_api/endpoints/access_token'
5
+ require 'vma_api/endpoints/refresh_token'
6
+ require 'vma_api/endpoints/client_credentials'
7
+ require 'vma_api/endpoints/revoke_token'
8
+
9
+ module VmaApi
10
+ class Client
11
+ def self.endpoint(name)
12
+ define_method(name) do |**args|
13
+ klass_name = Hanami::Utils::String.classify(name)
14
+ endpoint_klass = Hanami::Utils::Class.load!("VmaApi::Endpoints::#{klass_name}")
15
+
16
+ endpoint_klass.(config, **args)
17
+ end
18
+ end
19
+
20
+ endpoint :access_token
21
+ endpoint :refresh_token
22
+ endpoint :client_credentials
23
+ endpoint :revoke_token
24
+
25
+ def initialize(config: VmaApi.config)
26
+ @config = config
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :config
32
+ end
33
+ end
@@ -0,0 +1,24 @@
1
+ require 'dry-configurable'
2
+
3
+ module VmaApi
4
+ class Configuration
5
+ include Dry::Configurable
6
+
7
+ setting :logger, default: Logger.new(STDOUT, level: :info)
8
+
9
+ setting :base_url
10
+ setting :client_id
11
+ setting :client_secret
12
+
13
+ setting :auth_base_url
14
+ setting :auth_client_id
15
+ setting :auth_client_secret
16
+
17
+ setting :subscription_key
18
+ setting :system_id
19
+
20
+ setting :default_parameters, default: -> { {} }
21
+ setting :default_headers, default: -> { {} }
22
+ setting :instrumentation, default: -> (action:, tenant_id: nil, &block) { block.call }
23
+ end
24
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'partner_api/endpoints/base'
4
+ require 'vma_api/failure_response'
5
+
6
+ module VmaApi
7
+ class Endpoint < PartnerApi::Endpoints::Base
8
+
9
+ private
10
+
11
+ # VMA at time of writing this does not need mTLS or proxies
12
+ #
13
+ def connection_options
14
+ {}
15
+ end
16
+
17
+ def successful?(response)
18
+ response.status.success? && response.parse["code"].nil?
19
+ end
20
+
21
+ def decode_error(response)
22
+ FailureResponse.new(response)
23
+ end
24
+
25
+ def logging_params
26
+ { request_id: request_id }
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,60 @@
1
+ require 'vma_api/endpoint'
2
+
3
+ module VmaApi
4
+ module Endpoints
5
+ class AccessToken < Endpoint
6
+ prepend PartnerApi::Endpoints::Initializer
7
+
8
+ AUTH_TOKEN = 'authToken'.freeze
9
+
10
+ def initialize(short_token:, request_id: SecureRandom.uuid)
11
+ @short_token = short_token
12
+ @request_id = request_id
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :short_token, :request_id
18
+
19
+ def url
20
+ URI.parse(config.auth_base_url).tap { |uri| uri.path = path }
21
+ end
22
+
23
+ def headers
24
+ {
25
+ 'Content-Type' => 'application/json',
26
+ 'Accept' => 'application/json',
27
+ 'Ocp-Apim-Subscription-Key': config.subscription_key,
28
+ 'Request-Id': request_id,
29
+ 'Timestamp': Time.now.utc.iso8601,
30
+ 'Sending-System-Version': SYSTEM_VERSION,
31
+ 'Sending-System-Id': config.system_id,
32
+ 'Initiating-System-Version': SYSTEM_VERSION,
33
+ 'Initiating-System-Id': config.system_id,
34
+ 'Ocp-Apim-Trace': 'true'
35
+ }
36
+ end
37
+
38
+ def method
39
+ 'POST'
40
+ end
41
+
42
+ def path
43
+ '/customerIdentity/v1.0/token'
44
+ end
45
+
46
+ def parameters
47
+ {
48
+ client_id: config.auth_client_id,
49
+ client_secret: config.auth_client_secret,
50
+ track_token: short_token,
51
+ credentials_type: AUTH_TOKEN
52
+ }
53
+ end
54
+
55
+ def decode(response)
56
+ response.parse.dig('access_token')
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,60 @@
1
+ require 'vma_api/endpoint'
2
+
3
+ module VmaApi
4
+ module Endpoints
5
+ class ClientCredentials < Endpoint
6
+ prepend PartnerApi::Endpoints::Initializer
7
+
8
+ VMA_CLIENT_CREDENTIALS = 'client_credentials'.freeze
9
+
10
+ def initialize(tenant_id:, resource:, request_id: SecureRandom.uuid)
11
+ @tenant_id = tenant_id
12
+ @resource = resource
13
+ @request_id = request_id
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :tenant_id, :resource, :request_id
19
+
20
+ def url
21
+ URI.parse(config.base_url).tap { |uri| uri.path = path }
22
+ end
23
+
24
+ def headers
25
+ {
26
+ 'Content-Type' => 'application/x-www-form-urlencoded',
27
+ 'Request-Id' => request_id
28
+ }
29
+ end
30
+
31
+ def method
32
+ 'POST'
33
+ end
34
+
35
+ def path
36
+ "/#{tenant_id}/oauth2/token"
37
+ end
38
+
39
+ def parameters
40
+ {
41
+ client_id: config.client_id,
42
+ client_secret: config.client_secret,
43
+ grant_type: VMA_CLIENT_CREDENTIALS,
44
+ resource: resource
45
+ }
46
+ end
47
+
48
+ def decode(response)
49
+ response.parse.dig('access_token')
50
+ end
51
+
52
+ def request_options
53
+ {
54
+ headers: combined_headers,
55
+ form: combined_payload
56
+ }
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,60 @@
1
+ require 'vma_api/endpoint'
2
+
3
+ module VmaApi
4
+ module Endpoints
5
+ class RefreshToken < Endpoint
6
+ prepend PartnerApi::Endpoints::Initializer
7
+
8
+ REFRESH_TOKEN = 'refreshToken'.freeze
9
+
10
+ def initialize(refresh_token:, request_id: SecureRandom.uuid)
11
+ @refresh_token = refresh_token
12
+ @request_id = request_id
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :refresh_token, :request_id
18
+
19
+ def url
20
+ URI.parse(config.auth_base_url).tap { |uri| uri.path = path }
21
+ end
22
+
23
+ def headers
24
+ {
25
+ 'Content-Type' => 'application/json',
26
+ 'Accept' => 'application/json',
27
+ 'Ocp-Apim-Subscription-Key': config.subscription_key,
28
+ 'Request-Id': request_id,
29
+ 'Timestamp': Time.now.utc.iso8601,
30
+ 'Sending-System-Version': SYSTEM_VERSION,
31
+ 'Sending-System-Id': config.system_id,
32
+ 'Initiating-System-Version': SYSTEM_VERSION,
33
+ 'Initiating-System-Id': config.system_id,
34
+ 'Ocp-Apim-Trace': 'true'
35
+ }
36
+ end
37
+
38
+ def method
39
+ 'POST'
40
+ end
41
+
42
+ def path
43
+ '/customerIdentity/v1.0/token'
44
+ end
45
+
46
+ def parameters
47
+ {
48
+ client_id: config.auth_client_id,
49
+ client_secret: config.auth_client_secret,
50
+ refresh_token: refresh_token,
51
+ credentials_type: REFRESH_TOKEN
52
+ }
53
+ end
54
+
55
+ def decode(response)
56
+ response.parse.dig('access_token')
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,55 @@
1
+ require 'vma_api/endpoint'
2
+
3
+ module VmaApi
4
+ module Endpoints
5
+ class RevokeToken < Endpoint
6
+ prepend PartnerApi::Endpoints::Initializer
7
+
8
+ def initialize(token:, request_id: SecureRandom.uuid)
9
+ @token = token
10
+ @request_id = request_id
11
+ end
12
+
13
+ private
14
+
15
+ attr_reader :token, :request_id
16
+
17
+ def url
18
+ URI.parse(config.auth_base_url).tap { |uri| uri.path = path }
19
+ end
20
+
21
+ def headers
22
+ {
23
+ 'Content-Type' => 'application/json',
24
+ 'Accept' => 'application/json',
25
+ 'Ocp-Apim-Subscription-Key': config.subscription_key,
26
+ 'Request-Id': request_id,
27
+ 'Timestamp': Time.now.utc.iso8601,
28
+ 'Sending-System-Version': SYSTEM_VERSION,
29
+ 'Sending-System-Id': config.system_id,
30
+ 'Initiating-System-Version': SYSTEM_VERSION,
31
+ 'Initiating-System-Id': config.system_id,
32
+ 'Ocp-Apim-Trace': 'true'
33
+ }
34
+ end
35
+
36
+ def method
37
+ 'POST'
38
+ end
39
+
40
+ def path
41
+ '/customerIdentity/v1.0/revoke'
42
+ end
43
+
44
+ def parameters
45
+ {
46
+ client_id: config.auth_client_id,
47
+ client_secret: config.auth_client_secret,
48
+ token: token
49
+ }
50
+ end
51
+
52
+ def decode(_response); end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,42 @@
1
+ require 'partner_api/errors'
2
+
3
+ module VmaApi
4
+ class FailureResponse
5
+ def initialize(response)
6
+ @response = response
7
+ end
8
+
9
+ def errors
10
+ if json?
11
+ parsed_response = response.parse
12
+ [PartnerApi::Errors::RequestError.new(code: parsed_response['code'], message: parsed_response['message'])]
13
+ else
14
+ [PartnerApi::Errors::RequestError.new(message: "Invalid Response: #{body}")]
15
+ end
16
+ end
17
+
18
+ def error
19
+ errors.first
20
+ end
21
+
22
+ def status
23
+ response.status
24
+ end
25
+
26
+ def body
27
+ response.body
28
+ end
29
+
30
+ def headers
31
+ response.headers.to_h
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :response
37
+
38
+ def json?
39
+ response.content_type.mime_type == 'application/json'
40
+ end
41
+ end
42
+ end
data/lib/vma_api.rb ADDED
@@ -0,0 +1,20 @@
1
+ require 'forwardable'
2
+ require 'concurrent-ruby'
3
+
4
+ require 'vma_api/client'
5
+ require 'vma_api/configuration'
6
+
7
+ module VmaApi
8
+ extend self
9
+ extend Forwardable
10
+
11
+ SYSTEM_VERSION = 'v1.0'.freeze
12
+
13
+ @configurations = Concurrent::Hash.new
14
+
15
+ def_delegators 'configuration(:default)', :config, :configure
16
+
17
+ def configuration(key)
18
+ @configurations[key] ||= Configuration.new
19
+ end
20
+ end