pluggy-rb 0.1.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 +7 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE.txt +21 -0
- data/README.md +349 -0
- data/VERSION +1 -0
- data/lib/pluggy/api_key.rb +57 -0
- data/lib/pluggy/api_requestor.rb +268 -0
- data/lib/pluggy/api_resource.rb +23 -0
- data/lib/pluggy/client.rb +61 -0
- data/lib/pluggy/configuration.rb +114 -0
- data/lib/pluggy/connection_manager.rb +49 -0
- data/lib/pluggy/credential_store.rb +65 -0
- data/lib/pluggy/errors.rb +125 -0
- data/lib/pluggy/lists/array_list.rb +23 -0
- data/lib/pluggy/lists/base_list.rb +69 -0
- data/lib/pluggy/lists/cursor_list.rb +57 -0
- data/lib/pluggy/lists/offset_list.rb +46 -0
- data/lib/pluggy/lists.rb +36 -0
- data/lib/pluggy/pluggy_object.rb +274 -0
- data/lib/pluggy/resources/account.rb +84 -0
- data/lib/pluggy/resources/bill.rb +149 -0
- data/lib/pluggy/resources/connector.rb +70 -0
- data/lib/pluggy/resources/item.rb +101 -0
- data/lib/pluggy/resources/loan.rb +88 -0
- data/lib/pluggy/resources/merchant.rb +33 -0
- data/lib/pluggy/resources/misc.rb +48 -0
- data/lib/pluggy/resources/transaction.rb +108 -0
- data/lib/pluggy/services/base_service.rb +42 -0
- data/lib/pluggy/services/other_services.rb +212 -0
- data/lib/pluggy/services/transaction_service.rb +128 -0
- data/lib/pluggy/util.rb +166 -0
- data/lib/pluggy/version.rb +5 -0
- data/lib/pluggy-rb.rb +5 -0
- data/lib/pluggy.rb +55 -0
- metadata +96 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "zlib"
|
|
6
|
+
require "stringio"
|
|
7
|
+
require "bigdecimal"
|
|
8
|
+
|
|
9
|
+
module Pluggy
|
|
10
|
+
# Builds, sends and interprets every HTTP request.
|
|
11
|
+
#
|
|
12
|
+
# Owns three things worth understanding: the apiKey lifecycle, the 403 fork,
|
|
13
|
+
# and the retry policy.
|
|
14
|
+
class APIRequestor
|
|
15
|
+
IDEMPOTENT_METHODS = %i[get head delete].freeze
|
|
16
|
+
|
|
17
|
+
# POST endpoints that are safe to retry because they create no durable
|
|
18
|
+
# resource. POST /items is NOT here on purpose: the API offers no
|
|
19
|
+
# Idempotency-Key, so retrying it risks opening a duplicate bank
|
|
20
|
+
# connection, which is user-visible and awkward to undo.
|
|
21
|
+
RETRYABLE_POST_PATHS = ["/auth", "/connect_token"].freeze
|
|
22
|
+
|
|
23
|
+
RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
24
|
+
|
|
25
|
+
RETRY_ERRORS = [
|
|
26
|
+
Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EPIPE, Errno::EHOSTUNREACH,
|
|
27
|
+
Errno::ETIMEDOUT, Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout,
|
|
28
|
+
EOFError, SocketError, IOError, OpenSSL::SSL::SSLError
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
attr_reader :config, :credentials
|
|
32
|
+
|
|
33
|
+
def initialize(config, credentials = nil)
|
|
34
|
+
@config = config
|
|
35
|
+
@credentials = credentials || CredentialStore.new(config)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# --- surface used by services ------------------------------------------
|
|
39
|
+
|
|
40
|
+
def request(method, path, params: nil, body: nil, opaque_body_keys: [])
|
|
41
|
+
url = build_path(path, params)
|
|
42
|
+
payload = body && Util.encode_body(body, opaque: opaque_body_keys)
|
|
43
|
+
execute(method, url, payload, authenticated: true)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# For cursor pagination: `path_with_query` already carries the server's
|
|
47
|
+
# "?..." string, so no query building happens at all.
|
|
48
|
+
def request_raw(method, path_with_query)
|
|
49
|
+
execute(method, path_with_query, nil, authenticated: true)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# POST /auth is the only unauthenticated operation in the API.
|
|
53
|
+
def execute_unauthenticated(method, path, body: nil)
|
|
54
|
+
execute(method, path, body, authenticated: false)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def list(path, params:, klass:, client:, paginated: false)
|
|
58
|
+
Lists.wrap(
|
|
59
|
+
request(:get, path, params: params),
|
|
60
|
+
klass: klass, requestor: self, path: path,
|
|
61
|
+
filters: params || {}, client: client, paginated: paginated
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def list_raw(path_with_query, klass:, client:)
|
|
66
|
+
Lists.wrap(
|
|
67
|
+
request_raw(:get, path_with_query),
|
|
68
|
+
klass: klass, requestor: self, path: path_with_query.split("?").first, client: client
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# --- internals ---------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def execute(method, path, payload, authenticated:)
|
|
77
|
+
attempts = 0
|
|
78
|
+
reauthed = false
|
|
79
|
+
|
|
80
|
+
loop do
|
|
81
|
+
key = authenticated ? @credentials.fetch(self) : nil
|
|
82
|
+
|
|
83
|
+
response = attempt(method, path, payload, key) do |error|
|
|
84
|
+
attempts += 1
|
|
85
|
+
raise wrap_connection_error(error) unless retryable?(method, path, attempts)
|
|
86
|
+
|
|
87
|
+
backoff(attempts, nil)
|
|
88
|
+
end
|
|
89
|
+
next if response.nil? # transport failure, already backed off
|
|
90
|
+
|
|
91
|
+
status = response.code.to_i
|
|
92
|
+
parsed = parse_body(response)
|
|
93
|
+
|
|
94
|
+
return parsed if status < 300
|
|
95
|
+
|
|
96
|
+
if renew_and_retry?(status, response, parsed, authenticated: authenticated, reauthed: reauthed)
|
|
97
|
+
@config.log(:info, "apiKey rejected, renewing", path: path)
|
|
98
|
+
@credentials.refresh!(self, stale: key)
|
|
99
|
+
reauthed = true
|
|
100
|
+
next
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
attempts += 1
|
|
104
|
+
if RETRY_STATUSES.include?(status) && retryable?(method, path, attempts)
|
|
105
|
+
backoff(attempts, response["retry-after"])
|
|
106
|
+
next
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
raise build_error(status, response, parsed, reauthed: reauthed)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Returns the response, or nil when the request died at the transport level
|
|
114
|
+
# and should be retried. The block decides whether a retry is allowed (it
|
|
115
|
+
# raises if not) and applies the backoff.
|
|
116
|
+
def attempt(method, path, payload, key)
|
|
117
|
+
perform_logged(method, path, payload, key)
|
|
118
|
+
rescue *RETRY_ERRORS => e
|
|
119
|
+
# A half-dead keep-alive socket would poison the retry.
|
|
120
|
+
ConnectionManager.current.clear!
|
|
121
|
+
yield e
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The 403 fork.
|
|
126
|
+
#
|
|
127
|
+
# Only a 403 with NO codeDescription means the apiKey aged out. One that has
|
|
128
|
+
# a codeDescription (e.g. BALANCE_CONSENT_ERROR) is a domain denial: renewing
|
|
129
|
+
# would mask it and double every affected call.
|
|
130
|
+
def renew_and_retry?(status, response, parsed, authenticated:, reauthed:)
|
|
131
|
+
return false unless status == 403 && authenticated
|
|
132
|
+
return false unless expired_key?(parsed)
|
|
133
|
+
|
|
134
|
+
# Nothing to renew with: say so, rather than reporting it as a permission
|
|
135
|
+
# problem the caller cannot act on.
|
|
136
|
+
raise static_key_rejected(status, response, parsed) if @credentials.static?
|
|
137
|
+
|
|
138
|
+
!reauthed
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# NotAuthenticatedResponse is {code, message}; GlobalErrorResponse adds
|
|
142
|
+
# codeDescription. That difference is the only signal the API gives us.
|
|
143
|
+
def expired_key?(parsed)
|
|
144
|
+
parsed.is_a?(Hash) && parsed["codeDescription"].nil?
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def perform_logged(method, path, payload, key)
|
|
148
|
+
started = now
|
|
149
|
+
response = perform(method, path, payload, key)
|
|
150
|
+
@config.log(:debug, "#{method.to_s.upcase} #{path}",
|
|
151
|
+
status: response.code, ms: ((now - started) * 1000).round)
|
|
152
|
+
response
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def perform(method, path, payload, key)
|
|
156
|
+
http = ConnectionManager.current.connection_for(@config)
|
|
157
|
+
request_class = Net::HTTP.const_get(method.to_s.capitalize)
|
|
158
|
+
req = request_class.new(path, headers(key, payload))
|
|
159
|
+
req.body = JSON.generate(payload) if payload
|
|
160
|
+
http.request(req)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def headers(key, payload)
|
|
164
|
+
h = {
|
|
165
|
+
"Accept" => "application/json",
|
|
166
|
+
"Accept-Encoding" => "gzip",
|
|
167
|
+
"User-Agent" => user_agent
|
|
168
|
+
}
|
|
169
|
+
h["X-API-KEY"] = key.to_s if key
|
|
170
|
+
h["Content-Type"] = "application/json" if payload
|
|
171
|
+
h
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def user_agent
|
|
175
|
+
@user_agent ||= [
|
|
176
|
+
"pluggy-rb/#{Pluggy::VERSION}",
|
|
177
|
+
"ruby/#{RUBY_VERSION}",
|
|
178
|
+
"(#{RUBY_PLATFORM})",
|
|
179
|
+
@config.user_agent_suffix
|
|
180
|
+
].compact.join(" ")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def build_path(path, params)
|
|
184
|
+
query = Util.encode_query(params)
|
|
185
|
+
query.empty? ? path : "#{path}?#{query}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def parse_body(response)
|
|
189
|
+
raw = response.body.to_s
|
|
190
|
+
raw = Zlib::GzipReader.new(StringIO.new(raw)).read if response["content-encoding"] == "gzip"
|
|
191
|
+
return nil if raw.empty?
|
|
192
|
+
|
|
193
|
+
# decimal_class reads the lexical digits straight off the wire, so money
|
|
194
|
+
# never passes through a Float. Integers stay Integer.
|
|
195
|
+
options = @config.decimal_amounts ? { decimal_class: BigDecimal } : {}
|
|
196
|
+
JSON.parse(raw, **options)
|
|
197
|
+
rescue JSON::ParserError, Zlib::Error
|
|
198
|
+
# A proxy or CDN error page; hand it back for the error builder to show.
|
|
199
|
+
raw
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def retryable?(method, path, attempts)
|
|
203
|
+
return false if attempts > @config.max_network_retries
|
|
204
|
+
|
|
205
|
+
idempotent?(method, path)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def idempotent?(method, path)
|
|
209
|
+
return true if IDEMPOTENT_METHODS.include?(method)
|
|
210
|
+
return RETRYABLE_POST_PATHS.any? { |p| path.start_with?(p) } if method == :post
|
|
211
|
+
|
|
212
|
+
# PATCH /items/{id} triggers a re-sync: harmless to repeat, but it queues
|
|
213
|
+
# redundant work at the institution, so leave it alone.
|
|
214
|
+
false
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def backoff(attempts, retry_after)
|
|
218
|
+
base = @config.initial_network_retry_delay * (2**(attempts - 1))
|
|
219
|
+
delay = [base, @config.max_network_retry_delay].min
|
|
220
|
+
delay *= (0.5 + (rand * 0.5)) # jitter: 50-100% of the interval
|
|
221
|
+
|
|
222
|
+
# Undocumented in the spec, but honour it when a proxy or the
|
|
223
|
+
# institution sends one.
|
|
224
|
+
delay = [retry_after.to_f, delay].max if retry_after.to_s.match?(/\A\d+(\.\d+)?\z/)
|
|
225
|
+
|
|
226
|
+
@config.log(:info, "retrying", attempt: attempts, delay: delay.round(3))
|
|
227
|
+
sleep(delay)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def build_error(status, response, parsed, reauthed:)
|
|
231
|
+
json = parsed.is_a?(Hash) ? parsed : nil
|
|
232
|
+
|
|
233
|
+
klass =
|
|
234
|
+
if status == 403 && reauthed
|
|
235
|
+
# It survived a renewal, so the key is not the problem.
|
|
236
|
+
AuthenticationError
|
|
237
|
+
else
|
|
238
|
+
Pluggy.error_class_for(status)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
klass.new(
|
|
242
|
+
json&.fetch("message", nil),
|
|
243
|
+
http_status: status,
|
|
244
|
+
http_body: response.body,
|
|
245
|
+
http_headers: response.each_header.to_h,
|
|
246
|
+
json_body: json
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def static_key_rejected(status, response, parsed)
|
|
251
|
+
AuthenticationError.new(
|
|
252
|
+
"the supplied apiKey was rejected by Pluggy and there are no credentials to renew it " \
|
|
253
|
+
"with; construct Pluggy::Client with client_id: and client_secret: for automatic renewal",
|
|
254
|
+
http_status: status,
|
|
255
|
+
http_body: response.body,
|
|
256
|
+
http_headers: response.each_header.to_h,
|
|
257
|
+
json_body: parsed.is_a?(Hash) ? parsed : nil
|
|
258
|
+
)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def wrap_connection_error(error)
|
|
262
|
+
klass = error.is_a?(Net::OpenTimeout) || error.is_a?(Net::ReadTimeout) ? TimeoutError : ConnectionError
|
|
263
|
+
klass.new("#{error.class}: #{error.message} (#{@config.api_base})")
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluggy
|
|
4
|
+
# Base for the top-level API resources. Adds the client back-reference that
|
|
5
|
+
# makes navigation (account.transactions, bill.transactions, item.accounts)
|
|
6
|
+
# possible.
|
|
7
|
+
#
|
|
8
|
+
# Objects built by a requestor always carry a client. Objects a caller
|
|
9
|
+
# constructs by hand do not, so ensure_client! explains that rather than
|
|
10
|
+
# letting a NoMethodError on nil surface.
|
|
11
|
+
class APIResource < PluggyObject
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def ensure_client!
|
|
15
|
+
return @client if @client
|
|
16
|
+
|
|
17
|
+
raise Error,
|
|
18
|
+
"#{self.class.name}##{caller_locations(1, 1)[0].label} needs a client. This object was " \
|
|
19
|
+
"built without one; fetch it through Pluggy::Client (e.g. client.accounts.retrieve(id)) " \
|
|
20
|
+
"to use navigation methods."
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluggy
|
|
4
|
+
# The entry point.
|
|
5
|
+
#
|
|
6
|
+
# client = Pluggy::Client.new(
|
|
7
|
+
# client_id: ENV["PLUGGY_CLIENT_ID"],
|
|
8
|
+
# client_secret: ENV["PLUGGY_CLIENT_SECRET"]
|
|
9
|
+
# )
|
|
10
|
+
# client.accounts.list(item_id: item_id)
|
|
11
|
+
#
|
|
12
|
+
# Authentication is handled for you: the apiKey is fetched lazily on the first
|
|
13
|
+
# request, cached until the expiry in its own JWT, and renewed transparently
|
|
14
|
+
# if the API rejects it mid-session.
|
|
15
|
+
#
|
|
16
|
+
# One client holds one credential set and is safe to share across threads.
|
|
17
|
+
class Client
|
|
18
|
+
attr_reader :config, :requestor
|
|
19
|
+
|
|
20
|
+
def initialize(client_id: nil, client_secret: nil, api_key: nil, **options)
|
|
21
|
+
@config = Pluggy.config.merge(
|
|
22
|
+
client_id: client_id,
|
|
23
|
+
client_secret: client_secret,
|
|
24
|
+
api_key: api_key,
|
|
25
|
+
**options
|
|
26
|
+
)
|
|
27
|
+
@config.validate!
|
|
28
|
+
@requestor = APIRequestor.new(@config)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def accounts = @accounts ||= Services::AccountService.new(self)
|
|
32
|
+
def transactions = @transactions ||= Services::TransactionService.new(self)
|
|
33
|
+
def bills = @bills ||= Services::BillService.new(self)
|
|
34
|
+
def loans = @loans ||= Services::LoanService.new(self)
|
|
35
|
+
def items = @items ||= Services::ItemService.new(self)
|
|
36
|
+
def connectors = @connectors ||= Services::ConnectorService.new(self)
|
|
37
|
+
def categories = @categories ||= Services::CategoryService.new(self)
|
|
38
|
+
def merchants = @merchants ||= Services::MerchantService.new(self)
|
|
39
|
+
def connect_tokens = @connect_tokens ||= Services::ConnectTokenService.new(self)
|
|
40
|
+
|
|
41
|
+
# The first call most integrations make.
|
|
42
|
+
def create_connect_token(**kwargs) = connect_tokens.create(**kwargs)
|
|
43
|
+
|
|
44
|
+
# The current apiKey, authenticating first if needed. Rarely useful directly
|
|
45
|
+
# -- mostly for debugging and for the live smoke test.
|
|
46
|
+
def api_key = @requestor.credentials.fetch(@requestor).to_s
|
|
47
|
+
|
|
48
|
+
# Escape hatches for the endpoints this gem deliberately does not model
|
|
49
|
+
# (payments, smart transfers, boletos, consents, webhooks, investments,
|
|
50
|
+
# identity). Returns parsed JSON, not resource objects.
|
|
51
|
+
def get(path, **params) = @requestor.request(:get, path, params: params)
|
|
52
|
+
def post(path, **body) = @requestor.request(:post, path, body: body)
|
|
53
|
+
def patch(path, **body) = @requestor.request(:patch, path, body: body)
|
|
54
|
+
def delete(path, **params) = @requestor.request(:delete, path, params: params)
|
|
55
|
+
|
|
56
|
+
def inspect
|
|
57
|
+
"#<Pluggy::Client client_id=#{@config.client_id.to_s[0, 8]}... api_base=#{@config.api_base}>"
|
|
58
|
+
end
|
|
59
|
+
alias to_s inspect
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Pluggy
|
|
6
|
+
class Configuration
|
|
7
|
+
DEFAULTS = {
|
|
8
|
+
api_base: "https://api.pluggy.ai",
|
|
9
|
+
client_id: nil,
|
|
10
|
+
client_secret: nil,
|
|
11
|
+
# A pre-existing apiKey. Disables automatic renewal: there are no
|
|
12
|
+
# credentials to renew with, so an expiry raises AuthenticationError.
|
|
13
|
+
api_key: nil,
|
|
14
|
+
open_timeout: 30,
|
|
15
|
+
# Generous because GET /accounts/{id}/balance proxies to the financial
|
|
16
|
+
# institution in real time -- it is the one endpoint in scope that
|
|
17
|
+
# documents both a 429 and a 502.
|
|
18
|
+
read_timeout: 80,
|
|
19
|
+
write_timeout: 30,
|
|
20
|
+
max_network_retries: 2,
|
|
21
|
+
initial_network_retry_delay: 0.5,
|
|
22
|
+
max_network_retry_delay: 4.0,
|
|
23
|
+
logger: nil,
|
|
24
|
+
log_level: :info,
|
|
25
|
+
# Parse JSON numbers as BigDecimal. Turning this off yields plain Floats
|
|
26
|
+
# and makes the gem dependency-free.
|
|
27
|
+
decimal_amounts: true,
|
|
28
|
+
coerce_times: true,
|
|
29
|
+
user_agent_suffix: nil,
|
|
30
|
+
verify_ssl_certs: true,
|
|
31
|
+
# :v2 (cursor, current) or :v1 (offset, deprecated, sunset 2026-12-31).
|
|
32
|
+
transactions_api_version: :v2
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
LOG_LEVELS = { debug: 0, info: 1, error: 2 }.freeze
|
|
36
|
+
|
|
37
|
+
SECRET_OPTIONS = %i[client_secret api_key].freeze
|
|
38
|
+
|
|
39
|
+
DEFAULTS.each_key { |key| attr_accessor key }
|
|
40
|
+
|
|
41
|
+
def self.setup
|
|
42
|
+
new.tap { |config| yield config if block_given? }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(**overrides)
|
|
46
|
+
DEFAULTS.each { |key, value| instance_variable_set(:"@#{key}", value) }
|
|
47
|
+
apply(overrides)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Per-client options win over the globals they were merged from, but only
|
|
51
|
+
# where explicitly given -- Stripe's reverse_duplicate_merge.
|
|
52
|
+
def merge(**overrides)
|
|
53
|
+
dup.tap { |config| config.send(:apply, overrides.compact) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def uri
|
|
57
|
+
@uri ||= URI.parse(api_base)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Net::HTTP pool key: two configs pointing at the same endpoint share a
|
|
61
|
+
# connection.
|
|
62
|
+
def connection_key
|
|
63
|
+
[uri.host, uri.port, verify_ssl_certs]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def credentials?
|
|
67
|
+
!(client_id.nil? || client_secret.nil?)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate!
|
|
71
|
+
return if credentials? || api_key
|
|
72
|
+
|
|
73
|
+
raise ConfigurationError,
|
|
74
|
+
"provide client_id: and client_secret: (or api_key:) to Pluggy::Client.new, " \
|
|
75
|
+
"or set Pluggy.client_id / Pluggy.client_secret"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def log(level, message, **context)
|
|
79
|
+
return unless logger
|
|
80
|
+
return unless LOG_LEVELS.fetch(level, 1) >= LOG_LEVELS.fetch(log_level, 1)
|
|
81
|
+
|
|
82
|
+
suffix = context.map { |k, v| "#{k}=#{v}" }.join(" ")
|
|
83
|
+
logger.public_send(level, "[pluggy] #{message}#{" #{suffix}" unless suffix.empty?}")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Never leak secrets into a log, a console session or an exception.
|
|
87
|
+
def inspect
|
|
88
|
+
shown = DEFAULTS.keys.map do |key|
|
|
89
|
+
value = public_send(key)
|
|
90
|
+
value = redact(value) if SECRET_OPTIONS.include?(key)
|
|
91
|
+
"#{key}=#{value.inspect}"
|
|
92
|
+
end
|
|
93
|
+
"#<Pluggy::Configuration #{shown.join(" ")}>"
|
|
94
|
+
end
|
|
95
|
+
alias to_s inspect
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def apply(overrides)
|
|
100
|
+
overrides.each do |key, value|
|
|
101
|
+
raise ArgumentError, "unknown Pluggy configuration option: #{key}" unless DEFAULTS.key?(key)
|
|
102
|
+
|
|
103
|
+
public_send(:"#{key}=", value)
|
|
104
|
+
end
|
|
105
|
+
@uri = nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def redact(value)
|
|
109
|
+
return nil if value.nil?
|
|
110
|
+
|
|
111
|
+
"***#{value.to_s[-4..]}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module Pluggy
|
|
7
|
+
# Keep-alive Net::HTTP pool, one instance per thread (Net::HTTP objects are
|
|
8
|
+
# not thread-safe), keyed by Configuration#connection_key.
|
|
9
|
+
#
|
|
10
|
+
# After a fork, sockets inherited from the parent are unusable. Call
|
|
11
|
+
# Pluggy::ConnectionManager.current.clear! in your after_fork hook
|
|
12
|
+
# (Puma, Unicorn, Sidekiq).
|
|
13
|
+
class ConnectionManager
|
|
14
|
+
def self.current
|
|
15
|
+
Thread.current[:pluggy_connection_manager] ||= new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@pool = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def connection_for(config)
|
|
23
|
+
@pool[config.connection_key] ||= build(config)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def clear!
|
|
27
|
+
@pool.each_value do |http|
|
|
28
|
+
http.finish if http.started?
|
|
29
|
+
rescue IOError, SystemCallError
|
|
30
|
+
# Already dead; nothing to close.
|
|
31
|
+
end
|
|
32
|
+
@pool.clear
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def build(config)
|
|
38
|
+
uri = config.uri
|
|
39
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
40
|
+
http.use_ssl = uri.scheme == "https"
|
|
41
|
+
http.verify_mode = config.verify_ssl_certs ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
|
42
|
+
http.open_timeout = config.open_timeout
|
|
43
|
+
http.read_timeout = config.read_timeout
|
|
44
|
+
http.write_timeout = config.write_timeout
|
|
45
|
+
http.keep_alive_timeout = 30
|
|
46
|
+
http
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluggy
|
|
4
|
+
# Holds the current apiKey for one client and renews it as needed.
|
|
5
|
+
#
|
|
6
|
+
# One store per Client, so a key is fetched at most once per lifetime
|
|
7
|
+
# regardless of how many services or threads use it.
|
|
8
|
+
class CredentialStore
|
|
9
|
+
def initialize(config)
|
|
10
|
+
@config = config
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
@key = config.api_key ? ApiKey.new(config.api_key) : nil
|
|
13
|
+
# A caller-supplied key with no credentials behind it cannot be renewed.
|
|
14
|
+
@static = !config.api_key.nil? && !config.credentials?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def static? = @static
|
|
18
|
+
|
|
19
|
+
def fetch(requestor)
|
|
20
|
+
@mutex.synchronize do
|
|
21
|
+
@key = authenticate(requestor) if @key.nil? || (!@static && @key.expired?)
|
|
22
|
+
@key
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Called after a 403 whose body carried no codeDescription -- see
|
|
27
|
+
# APIRequestor#expired_key?.
|
|
28
|
+
#
|
|
29
|
+
# `stale` is the key that just failed. If another thread has already
|
|
30
|
+
# rotated it, reuse theirs rather than authenticating again: N threads
|
|
31
|
+
# hitting expiry together produce exactly one POST /auth.
|
|
32
|
+
def refresh!(requestor, stale:)
|
|
33
|
+
if @static
|
|
34
|
+
raise AuthenticationError,
|
|
35
|
+
"the supplied apiKey was rejected and there are no credentials to renew it with; " \
|
|
36
|
+
"construct Pluggy::Client with client_id: and client_secret: for automatic renewal"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
return @key if @key && !@key.equal?(stale)
|
|
41
|
+
|
|
42
|
+
@key = authenticate(requestor)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def authenticate(requestor)
|
|
49
|
+
@config.validate!
|
|
50
|
+
|
|
51
|
+
body = requestor.execute_unauthenticated(
|
|
52
|
+
:post, "/auth",
|
|
53
|
+
body: { "clientId" => @config.client_id, "clientSecret" => @config.client_secret }
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
unless body.is_a?(Hash) && body["apiKey"]
|
|
57
|
+
raise AuthenticationError.new("POST /auth succeeded but returned no apiKey", json_body: body)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
ApiKey.new(body["apiKey"]).tap do |key|
|
|
61
|
+
@config.log(:info, "authenticated", expires_at: key.expires_at.iso8601)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|