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,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluggy
|
|
4
|
+
# Flat, one-level hierarchy off a single base carrying the whole HTTP
|
|
5
|
+
# transcript. Unlike Stripe there is no request id: the Pluggy spec documents
|
|
6
|
+
# zero response headers, so nothing reliable exists to capture.
|
|
7
|
+
class Error < StandardError
|
|
8
|
+
attr_reader :http_status, :http_body, :http_headers, :json_body,
|
|
9
|
+
:code, :code_description, :data, :api_message
|
|
10
|
+
|
|
11
|
+
def initialize(message = nil, http_status: nil, http_body: nil, http_headers: nil, json_body: nil)
|
|
12
|
+
@http_status = http_status
|
|
13
|
+
@http_body = http_body
|
|
14
|
+
@http_headers = http_headers || {}
|
|
15
|
+
@json_body = json_body
|
|
16
|
+
|
|
17
|
+
if json_body.is_a?(Hash)
|
|
18
|
+
@code = json_body["code"]
|
|
19
|
+
@code_description = json_body["codeDescription"]
|
|
20
|
+
@data = json_body["data"]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The API's own text, undecorated. #message and #to_s append the
|
|
24
|
+
# codeDescription because that is what you want in a log line; this is
|
|
25
|
+
# here for when you want to render just the message.
|
|
26
|
+
@api_message = message || @json_body&.fetch("message", nil) || default_message
|
|
27
|
+
|
|
28
|
+
super(@api_message)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_s
|
|
32
|
+
@code_description ? "#{@api_message} (#{@code_description})" : @api_message
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def default_message
|
|
38
|
+
@http_status ? "Pluggy API returned HTTP #{@http_status}" : "Pluggy API error"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Raised locally, before any request goes out.
|
|
43
|
+
class ConfigurationError < Error; end
|
|
44
|
+
|
|
45
|
+
# Transport-level: nothing came back, or the socket died.
|
|
46
|
+
class ConnectionError < Error; end
|
|
47
|
+
class TimeoutError < ConnectionError; end
|
|
48
|
+
|
|
49
|
+
# POST /auth rejected the client keys (CLIENT_KEYS_UNAUTHORIZED,
|
|
50
|
+
# CLIENT_DISABLED), or an apiKey was rejected and could not be renewed --
|
|
51
|
+
# either because it survived its one automatic retry, or because the client
|
|
52
|
+
# was built with a static api_key: and has no credentials to renew with.
|
|
53
|
+
class AuthenticationError < Error; end
|
|
54
|
+
|
|
55
|
+
# A 403 that carries a codeDescription: the request authenticated fine but
|
|
56
|
+
# was denied on the merits, e.g. BALANCE_CONSENT_ERROR when the institution
|
|
57
|
+
# refuses to share a balance. Distinct from AuthenticationError because these
|
|
58
|
+
# must never trigger a re-auth or a retry.
|
|
59
|
+
class PermissionError < Error; end
|
|
60
|
+
|
|
61
|
+
class InvalidRequestError < Error
|
|
62
|
+
ParameterError = Struct.new(:parameter, :code, :message)
|
|
63
|
+
|
|
64
|
+
# POST /items 400 declares `errors: ParameterValidationError[]` in the
|
|
65
|
+
# schema but every example in the spec emits `details` instead. Read both.
|
|
66
|
+
def parameter_errors
|
|
67
|
+
raw = json_body&.values_at("errors", "details")&.compact&.first || []
|
|
68
|
+
raw.filter_map do |e|
|
|
69
|
+
next unless e.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
ParameterError.new(e["parameter"], e["code"], e["message"])
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# GET /v2/transactions rejects a malformed `after` cursor with a 400.
|
|
76
|
+
def invalid_cursor?
|
|
77
|
+
code_description == "invalidCursor" || message.to_s.downcase.include?("cursor")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class NotFoundError < Error; end
|
|
82
|
+
|
|
83
|
+
class ConflictError < Error
|
|
84
|
+
# The ITEM_USER_ALREADY_EXISTS response carries the ids of the items that
|
|
85
|
+
# already exist for this clientUserId (and omits `code` entirely).
|
|
86
|
+
def duplicate_item_ids
|
|
87
|
+
json_body&.fetch("items", nil) || []
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class RateLimitError < Error
|
|
92
|
+
# Undocumented in the spec, so read it opportunistically and never depend
|
|
93
|
+
# on it. GET /accounts/{id}/balance is the endpoint that documents a 429,
|
|
94
|
+
# and that limit belongs to the financial institution, not to Pluggy.
|
|
95
|
+
def retry_after
|
|
96
|
+
value = http_headers["retry-after"]
|
|
97
|
+
value&.to_i
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# 500
|
|
102
|
+
class APIError < Error; end
|
|
103
|
+
|
|
104
|
+
# 502 -- the financial institution is temporarily unavailable.
|
|
105
|
+
class BadGatewayError < Error; end
|
|
106
|
+
|
|
107
|
+
ERROR_CLASSES = {
|
|
108
|
+
400 => InvalidRequestError,
|
|
109
|
+
401 => AuthenticationError,
|
|
110
|
+
403 => PermissionError,
|
|
111
|
+
404 => NotFoundError,
|
|
112
|
+
409 => ConflictError,
|
|
113
|
+
429 => RateLimitError,
|
|
114
|
+
500 => APIError,
|
|
115
|
+
502 => BadGatewayError
|
|
116
|
+
}.freeze
|
|
117
|
+
|
|
118
|
+
# By the time this is consulted the requestor has already resolved the
|
|
119
|
+
# expired-apiKey case, so a surviving 403 really is a permission problem.
|
|
120
|
+
def self.error_class_for(status)
|
|
121
|
+
ERROR_CLASSES.fetch(status) do
|
|
122
|
+
status >= 500 ? APIError : Error
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluggy
|
|
4
|
+
module Lists
|
|
5
|
+
# A bare JSON array with no envelope at all.
|
|
6
|
+
#
|
|
7
|
+
# GET /categories declares `{type: array}` in its schema while its own
|
|
8
|
+
# `example` shows an offset envelope -- the spec contradicts itself. The
|
|
9
|
+
# shape sniffer in Pluggy::Lists.wrap resolves it at runtime, so a caller
|
|
10
|
+
# gets a working list either way and never has to care which shipped.
|
|
11
|
+
class ArrayList < BaseList
|
|
12
|
+
def initialize(payload, klass:, requestor:, path: nil, filters: {}, client: nil)
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def more? = false
|
|
17
|
+
def next_page = nil
|
|
18
|
+
|
|
19
|
+
# Lets an ArrayList be splatted or passed to Array().
|
|
20
|
+
def to_ary = @results
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluggy
|
|
4
|
+
module Lists
|
|
5
|
+
# Shared iteration for all three of Pluggy's pagination shapes. Subclasses
|
|
6
|
+
# differ only in `more?` and `next_page`.
|
|
7
|
+
class BaseList
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
attr_reader :results, :filters, :path, :raw
|
|
11
|
+
|
|
12
|
+
def initialize(payload, klass:, requestor:, path:, filters: {}, client: nil)
|
|
13
|
+
@raw = payload
|
|
14
|
+
@klass = klass
|
|
15
|
+
@requestor = requestor
|
|
16
|
+
@path = path
|
|
17
|
+
@filters = filters || {}
|
|
18
|
+
@client = client
|
|
19
|
+
@results = extract(payload).map { |item| build(item) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def each(&) = @results.each(&)
|
|
23
|
+
def empty? = @results.empty?
|
|
24
|
+
def length = @results.length
|
|
25
|
+
alias size length
|
|
26
|
+
alias count length
|
|
27
|
+
|
|
28
|
+
def more? = false
|
|
29
|
+
def next_page = nil
|
|
30
|
+
|
|
31
|
+
# Walks every page. Returns an Enumerator when called without a block, so
|
|
32
|
+
# `.lazy`, `.first(n)` and `.to_a` all work and only fetch the pages they
|
|
33
|
+
# actually need.
|
|
34
|
+
def auto_paging_each(&block)
|
|
35
|
+
return enum_for(:auto_paging_each) unless block_given?
|
|
36
|
+
|
|
37
|
+
page = self
|
|
38
|
+
loop do
|
|
39
|
+
page.each(&block)
|
|
40
|
+
break unless page.more?
|
|
41
|
+
|
|
42
|
+
page = page.next_page
|
|
43
|
+
break if page.nil? || page.empty?
|
|
44
|
+
end
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Every page, eagerly. Convenient, but unbounded -- prefer
|
|
49
|
+
# auto_paging_each for large histories.
|
|
50
|
+
def auto_paging_to_a = auto_paging_each.to_a
|
|
51
|
+
|
|
52
|
+
def inspect
|
|
53
|
+
"#<#{self.class.name} results=#{@results.length} more=#{more?}>"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def extract(payload)
|
|
59
|
+
return payload if payload.is_a?(Array)
|
|
60
|
+
|
|
61
|
+
payload["results"] || []
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build(item)
|
|
65
|
+
item.is_a?(Hash) ? @klass.new(item, client: @client) : item
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Pluggy
|
|
6
|
+
module Lists
|
|
7
|
+
# The {results, next} envelope -- GET /v2/transactions only, the single
|
|
8
|
+
# cursor-paginated endpoint in the entire API.
|
|
9
|
+
#
|
|
10
|
+
# `next` is a ready-to-use query string including the leading "?" and every
|
|
11
|
+
# filter, e.g. "?accountId=562b...&after=MjAyMC0x...==", or null when
|
|
12
|
+
# exhausted. We append it VERBATIM and never parse it back apart:
|
|
13
|
+
#
|
|
14
|
+
# 1. The spec says to. It also says that building the request manually
|
|
15
|
+
# requires the URL-*decoded* `after` value -- and Ruby cannot decode it
|
|
16
|
+
# losslessly, because URI.decode_www_form and CGI.parse both turn a "+"
|
|
17
|
+
# into a space. (In practice Pluggy's date|uuid cursors don't seem to
|
|
18
|
+
# produce a "+", but there is no reason to stand near that.)
|
|
19
|
+
# 2. The server has already baked every filter into the string, so
|
|
20
|
+
# appending it makes filter propagation correct by construction -- our
|
|
21
|
+
# `filters` hash cannot drift from the server's notion of the query.
|
|
22
|
+
class CursorList < BaseList
|
|
23
|
+
# The resume token. Persist THIS -- the whole "?..." string -- not a
|
|
24
|
+
# parsed cursor. See TransactionService#resume.
|
|
25
|
+
def next_query = @raw.is_a?(Hash) ? @raw["next"] : nil
|
|
26
|
+
alias next_token next_query
|
|
27
|
+
|
|
28
|
+
def more?
|
|
29
|
+
query = next_query
|
|
30
|
+
!query.nil? && !query.empty?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def next_page
|
|
34
|
+
return nil unless more?
|
|
35
|
+
|
|
36
|
+
query = next_query
|
|
37
|
+
unless query.start_with?("?")
|
|
38
|
+
raise Error, "malformed pagination cursor from Pluggy (expected a leading '?'): #{query.inspect}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@requestor.list_raw("#{@path}#{query}", klass: @klass, client: @client)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The bare `after` value, for logging and debugging only. Never used to
|
|
45
|
+
# build a request -- see the class comment.
|
|
46
|
+
def next_cursor
|
|
47
|
+
return nil unless more?
|
|
48
|
+
|
|
49
|
+
encoded = next_query.delete_prefix("?")
|
|
50
|
+
.split("&")
|
|
51
|
+
.find { |pair| pair.start_with?("after=") }
|
|
52
|
+
&.delete_prefix("after=")
|
|
53
|
+
encoded && URI.decode_www_form_component(encoded)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluggy
|
|
4
|
+
module Lists
|
|
5
|
+
# The {results, page, total, totalPages} envelope.
|
|
6
|
+
#
|
|
7
|
+
# Used by /accounts, /bills, /loans, /connectors, /accounts/{id}/statements
|
|
8
|
+
# and /transactions (v1) -- but only /transactions and /connectors actually
|
|
9
|
+
# accept `page`/`pageSize`. The rest return a paged envelope with no
|
|
10
|
+
# documented way to ask for page 2, so services pass `paginated: false` and
|
|
11
|
+
# `more?` stays false. auto_paging_each then yields the single page and
|
|
12
|
+
# stops, rather than issuing a request that would return the same rows.
|
|
13
|
+
class OffsetList < BaseList
|
|
14
|
+
# Prose-only default in the spec (no JSON-Schema `default` exists
|
|
15
|
+
# anywhere), so the SDK has to supply it.
|
|
16
|
+
DEFAULT_PAGE_SIZE = 500
|
|
17
|
+
MAX_PAGE_SIZE = 500
|
|
18
|
+
|
|
19
|
+
def initialize(payload, paginated: false, **kwargs)
|
|
20
|
+
@paginated = paginated
|
|
21
|
+
super(payload, **kwargs)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def page = @raw.is_a?(Hash) ? (@raw["page"]&.to_i || 1) : 1
|
|
25
|
+
def total = @raw.is_a?(Hash) ? @raw["total"]&.to_i : nil
|
|
26
|
+
def total_pages = @raw.is_a?(Hash) ? (@raw["totalPages"]&.to_i || 1) : 1
|
|
27
|
+
def paginated? = @paginated
|
|
28
|
+
|
|
29
|
+
def more?
|
|
30
|
+
@paginated && !empty? && page < total_pages
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def next_page
|
|
34
|
+
return nil unless more?
|
|
35
|
+
|
|
36
|
+
@requestor.list(
|
|
37
|
+
@path,
|
|
38
|
+
params: @filters.merge(page: page + 1),
|
|
39
|
+
klass: @klass,
|
|
40
|
+
client: @client,
|
|
41
|
+
paginated: true
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/pluggy/lists.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lists/base_list"
|
|
4
|
+
require_relative "lists/offset_list"
|
|
5
|
+
require_relative "lists/cursor_list"
|
|
6
|
+
require_relative "lists/array_list"
|
|
7
|
+
|
|
8
|
+
module Pluggy
|
|
9
|
+
module Lists
|
|
10
|
+
# Pick a list type by looking at the payload rather than by trusting the
|
|
11
|
+
# endpoint's declared schema.
|
|
12
|
+
#
|
|
13
|
+
# This is deliberate: GET /categories declares a bare array but its own
|
|
14
|
+
# example is an offset envelope, so any per-endpoint assumption is a coin
|
|
15
|
+
# flip against the live API. Sniffing costs one hash lookup and makes every
|
|
16
|
+
# list endpoint present the same interface.
|
|
17
|
+
def self.wrap(payload, klass:, requestor:, path:, filters: {}, client: nil, paginated: false)
|
|
18
|
+
common = { klass: klass, requestor: requestor, path: path, filters: filters, client: client }
|
|
19
|
+
|
|
20
|
+
case payload
|
|
21
|
+
when Array
|
|
22
|
+
ArrayList.new(payload, **common)
|
|
23
|
+
when Hash
|
|
24
|
+
if payload.key?("next")
|
|
25
|
+
CursorList.new(payload, **common)
|
|
26
|
+
elsif payload.key?("results")
|
|
27
|
+
OffsetList.new(payload, paginated: paginated, **common)
|
|
28
|
+
else
|
|
29
|
+
raise Error, "unrecognized list envelope from #{path} (keys: #{payload.keys.inspect})"
|
|
30
|
+
end
|
|
31
|
+
else
|
|
32
|
+
raise Error, "expected an array or object from #{path}, got #{payload.class}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require "date"
|
|
7
|
+
|
|
8
|
+
module Pluggy
|
|
9
|
+
# Base value object wrapping an API JSON payload.
|
|
10
|
+
#
|
|
11
|
+
# The access contract, which is the one thing worth memorising:
|
|
12
|
+
#
|
|
13
|
+
# txn.date / txn[:date] => Time (coerced Ruby view)
|
|
14
|
+
# txn["date"] / txn["createdAt"] => String (verbatim wire value)
|
|
15
|
+
# txn.to_h => wire values, nested objects flattened
|
|
16
|
+
#
|
|
17
|
+
# Symbol keys and readers give you the coerced Ruby value; String keys read
|
|
18
|
+
# the original payload and accept the original camelCase spelling.
|
|
19
|
+
#
|
|
20
|
+
# Fields are declared once per class with the `fields` DSL rather than
|
|
21
|
+
# defined per instance (Stripe's approach), because a 500-transaction page
|
|
22
|
+
# would otherwise mean ~11,500 define_method calls per response. Undeclared
|
|
23
|
+
# keys are still kept and still reachable -- via method_missing and via []
|
|
24
|
+
# -- so fields Pluggy adds after this gem ships are not lost.
|
|
25
|
+
#
|
|
26
|
+
# Deliberately does NOT include Enumerable: iterating a Transaction and
|
|
27
|
+
# getting its own field values is a confusing API, and it would collide with
|
|
28
|
+
# ICountResponse#count. Only the list objects are Enumerable.
|
|
29
|
+
class PluggyObject
|
|
30
|
+
# Overriding any of these would break the object itself.
|
|
31
|
+
RESERVED = Set.new(%w[
|
|
32
|
+
class send __send__ public_send object_id __id__ method methods
|
|
33
|
+
respond_to? respond_to_missing? instance_variable_get instance_variable_set
|
|
34
|
+
instance_variables singleton_class is_a? kind_of? instance_of? nil? tap
|
|
35
|
+
then itself extend display equal? to_h to_hash to_s to_json as_json inspect
|
|
36
|
+
keys values each_pair [] == eql? hash dup clone freeze frozen? initialize
|
|
37
|
+
method_missing client read key?
|
|
38
|
+
]).freeze
|
|
39
|
+
|
|
40
|
+
# Time coercion is keyed on the wire field name, guarded by the value's
|
|
41
|
+
# shape. A blanket ISO-8601 sniff would be dangerous -- a descriptionRaw
|
|
42
|
+
# reading "2020-10-15" would silently become a Time.
|
|
43
|
+
TEMPORAL_KEYS = Set.new(%w[
|
|
44
|
+
date createdAt updatedAt lastUpdatedAt nextAutoSyncAt consentExpiresAt
|
|
45
|
+
expiresAt dueDate billClosingDate contractDate settlementDate
|
|
46
|
+
firstInstallmentDueDate paymentDate purchaseDate balanceCloseDate
|
|
47
|
+
balanceDueDate updateDateTime issueDate expirationDate paidDate
|
|
48
|
+
]).freeze
|
|
49
|
+
|
|
50
|
+
# Anchored, and requires a full date. This is what keeps `monthYear` and
|
|
51
|
+
# `billForecastDate` ("2024-03") as Strings, and `installmentPeriodicity`
|
|
52
|
+
# ("MES") untouched.
|
|
53
|
+
ISO8601 = /\A\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?\z/
|
|
54
|
+
|
|
55
|
+
class << self
|
|
56
|
+
# Wire field name => nested class, inherited by subclasses.
|
|
57
|
+
def nested_types
|
|
58
|
+
@nested_types ||= superclass.respond_to?(:nested_types) ? superclass.nested_types.dup : {}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def declared_fields
|
|
62
|
+
@declared_fields ||=
|
|
63
|
+
superclass.respond_to?(:declared_fields) ? superclass.declared_fields.dup : Set.new
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# fields :id, :descriptionRaw, "CET"
|
|
67
|
+
#
|
|
68
|
+
# Pass wire names (camelCase or otherwise); readers are generated in
|
|
69
|
+
# snake_case, with a camelCase alias so code transliterated from Pluggy's
|
|
70
|
+
# own docs also works.
|
|
71
|
+
def fields(*names)
|
|
72
|
+
names.flatten.each { |n| define_field(n.to_s) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# nested connector: Connector, financeCharges: BillFinanceCharge
|
|
76
|
+
def nested(map)
|
|
77
|
+
map.each do |wire, klass|
|
|
78
|
+
nested_types[Util.wire_key(wire)] = klass
|
|
79
|
+
define_field(Util.wire_key(wire))
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def define_field(wire)
|
|
84
|
+
declared_fields << wire
|
|
85
|
+
ruby = Util.snake_case(wire)
|
|
86
|
+
|
|
87
|
+
# Genuinely unusable names ("2fa", "foo-bar") stay reachable via [].
|
|
88
|
+
return unless ruby.match?(/\A[a-z_][a-zA-Z0-9_]*\z/)
|
|
89
|
+
return if RESERVED.include?(ruby)
|
|
90
|
+
|
|
91
|
+
define_method(ruby) { read(wire) }
|
|
92
|
+
|
|
93
|
+
# camelCase alias, e.g. loan.CET alongside loan.cet
|
|
94
|
+
define_method(wire) { read(wire) } unless wire == ruby || RESERVED.include?(wire)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
attr_reader :client
|
|
99
|
+
|
|
100
|
+
# Accepts the payload either braced or as a bare trailing hash. Ruby routes
|
|
101
|
+
# an unbraced hash into **extra (String keys included), so
|
|
102
|
+
# `Transaction.new("id" => "x")` and `Transaction.new({"id" => "x"})` and
|
|
103
|
+
# `Transaction.new(payload, client: c)` all work.
|
|
104
|
+
#
|
|
105
|
+
# Keys are wire names -- "createdAt", not :created_at.
|
|
106
|
+
def initialize(values = {}, client: nil, **extra)
|
|
107
|
+
@client = client
|
|
108
|
+
@values = {}
|
|
109
|
+
@coerced = {}
|
|
110
|
+
|
|
111
|
+
source = values.nil? || values.empty? ? extra : values.merge(extra)
|
|
112
|
+
|
|
113
|
+
source.each do |key, value|
|
|
114
|
+
k = key.to_s
|
|
115
|
+
@values[k] = convert(k, value)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Coerced read, memoised. Used by every generated accessor.
|
|
120
|
+
def read(wire)
|
|
121
|
+
return @coerced[wire] if @coerced.key?(wire)
|
|
122
|
+
|
|
123
|
+
raw = @values[wire]
|
|
124
|
+
@coerced[wire] = TEMPORAL_KEYS.include?(wire) ? coerce_time(raw) : raw
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# String key => verbatim wire value (accepts the camelCase spelling).
|
|
128
|
+
# Symbol key => coerced value, same as the reader.
|
|
129
|
+
def [](key)
|
|
130
|
+
if key.is_a?(Symbol)
|
|
131
|
+
wire = @values.key?(key.to_s) ? key.to_s : Util.camel_case(key)
|
|
132
|
+
read(wire)
|
|
133
|
+
else
|
|
134
|
+
k = key.to_s
|
|
135
|
+
@values.key?(k) ? @values[k] : @values[Util.camel_case(k)]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def key?(key)
|
|
140
|
+
k = key.to_s
|
|
141
|
+
@values.key?(k) || @values.key?(Util.camel_case(k))
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def keys = @values.keys
|
|
145
|
+
def values = @values.values
|
|
146
|
+
def each_pair(&) = @values.each_pair(&)
|
|
147
|
+
|
|
148
|
+
# Wire-shaped hash: nested objects flattened back to plain hashes. Values
|
|
149
|
+
# are the parsed ones (so amounts are BigDecimal), which is what `as_json`
|
|
150
|
+
# then renders correctly.
|
|
151
|
+
def to_h
|
|
152
|
+
@values.transform_values { |v| unwrap(v) }
|
|
153
|
+
end
|
|
154
|
+
alias to_hash to_h
|
|
155
|
+
|
|
156
|
+
# JSON.generate serialises a BigDecimal as a *quoted string*
|
|
157
|
+
# ('{"amount":"-0.21245e3"}'), which would break round-tripping. Wrap them
|
|
158
|
+
# so they render as unquoted numbers matching the original wire literal.
|
|
159
|
+
def as_json(*)
|
|
160
|
+
deep_render(to_h)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def to_json(*args)
|
|
164
|
+
as_json.to_json(*args)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def ==(other)
|
|
168
|
+
other.is_a?(self.class) && other.to_h == to_h
|
|
169
|
+
end
|
|
170
|
+
alias eql? ==
|
|
171
|
+
|
|
172
|
+
def hash = to_h.hash
|
|
173
|
+
|
|
174
|
+
def inspect
|
|
175
|
+
id = @values["id"]
|
|
176
|
+
"#<#{self.class.name}#{":#{id}" if id} #{@values.keys.join(" ")}>"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Forward-compat path for fields Pluggy adds after this gem ships, and for
|
|
180
|
+
# the undeclared-but-real ones (Bill#accountId, Item#clientUserId,
|
|
181
|
+
# Connector#isSandbox).
|
|
182
|
+
def method_missing(name, *args)
|
|
183
|
+
n = name.to_s
|
|
184
|
+
return super if n.end_with?("=", "!") || !args.empty?
|
|
185
|
+
|
|
186
|
+
if n.end_with?("?")
|
|
187
|
+
base = n.delete_suffix("?")
|
|
188
|
+
wire = resolve_wire(base)
|
|
189
|
+
return !read(wire).nil? && read(wire) != false if wire
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
wire = resolve_wire(n)
|
|
193
|
+
return read(wire) if wire
|
|
194
|
+
|
|
195
|
+
super
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def respond_to_missing?(name, include_private = false)
|
|
199
|
+
n = name.to_s.sub(/[?]\z/, "")
|
|
200
|
+
!resolve_wire(n).nil? || super
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
# Map a Ruby method name back onto a present wire key.
|
|
206
|
+
def resolve_wire(name)
|
|
207
|
+
return name if @values.key?(name)
|
|
208
|
+
|
|
209
|
+
camel = Util.camel_case(name)
|
|
210
|
+
return camel if @values.key?(camel)
|
|
211
|
+
|
|
212
|
+
# Last resort: an acronym-bearing key our camelizer cannot reproduce
|
|
213
|
+
# ("issuerCNPJ" from :issuer_cnpj).
|
|
214
|
+
@values.keys.find { |k| Util.snake_case(k) == name }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def convert(wire, value)
|
|
218
|
+
case value
|
|
219
|
+
when Hash
|
|
220
|
+
(self.class.nested_types[wire] || PluggyObject).new(value, client: @client)
|
|
221
|
+
when Array
|
|
222
|
+
item_class = self.class.nested_types[wire]
|
|
223
|
+
value.map do |e|
|
|
224
|
+
e.is_a?(Hash) ? (item_class || PluggyObject).new(e, client: @client) : e
|
|
225
|
+
end
|
|
226
|
+
else
|
|
227
|
+
value
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def unwrap(value)
|
|
232
|
+
case value
|
|
233
|
+
when PluggyObject then value.to_h
|
|
234
|
+
when Array then value.map { |e| unwrap(e) }
|
|
235
|
+
else value
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def coerce_time(raw)
|
|
240
|
+
return raw unless raw.is_a?(String)
|
|
241
|
+
return raw unless Pluggy.config.coerce_times
|
|
242
|
+
return raw unless raw.match?(ISO8601)
|
|
243
|
+
|
|
244
|
+
# A bare "2024-03-15" is a Date; anything with a time part is a Time.
|
|
245
|
+
raw.length == 10 ? Date.iso8601(raw) : Time.iso8601(raw)
|
|
246
|
+
rescue ArgumentError, TypeError
|
|
247
|
+
# Never raise on a malformed date -- too much of this spec disagrees
|
|
248
|
+
# with the live API to be strict here.
|
|
249
|
+
raw
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def deep_render(value)
|
|
253
|
+
case value
|
|
254
|
+
when BigDecimal then RawNumber.new(value)
|
|
255
|
+
when Hash then value.transform_values { |v| deep_render(v) }
|
|
256
|
+
when Array then value.map { |v| deep_render(v) }
|
|
257
|
+
else value
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Renders a BigDecimal as an unquoted JSON number.
|
|
262
|
+
class RawNumber
|
|
263
|
+
def initialize(decimal)
|
|
264
|
+
# "F" gives "-212.45" rather than "-0.21245e3".
|
|
265
|
+
@literal = decimal.to_s("F")
|
|
266
|
+
# Trim the trailing ".0" BigDecimal adds to integral values.
|
|
267
|
+
@literal = @literal.sub(/\.0\z/, "")
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def to_json(*) = @literal
|
|
271
|
+
def to_s = @literal
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|