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.
@@ -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