Class: OpenBankingIO::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/open_banking_io/client.rb

Overview

Server-to-server client for open-banking.io.

Authenticates with an API key (+X-Api-Key+) and decrypts the zero-knowledge data envelopes locally with the exported private key -- the service only ever returns ciphertext it cannot read.

Constant Summary collapse

DEFAULT_OPEN_TIMEOUT =
15
DEFAULT_READ_TIMEOUT =
60
USER_AGENT =
"open-banking-io/ruby/#{VERSION}".freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(api_base_url:, api_key:, private_key_pkcs8:, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, http_client: nil) ⇒ Client

+open_timeout+/+read_timeout+ are seconds applied to the internally built Net::HTTP. http_client, when given, is any object responding to #request (e.g. a preconfigured Net::HTTP for a proxy, custom CA/mTLS or connection pooling); it is used as-is instead of building one internally. All three are optional and default to the previous behaviour.

Raises:

  • (ArgumentError)


72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/open_banking_io/client.rb', line 72

def initialize(api_base_url:, api_key:, private_key_pkcs8:,
  open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT,
  http_client: nil)
  raise ArgumentError, "api_base_url is required" if blank?(api_base_url)
  raise ArgumentError, "api_key is required" if blank?(api_key)
  raise ArgumentError, "private_key_pkcs8 is required" if blank?(private_key_pkcs8)

  # Strip any trailing slashes without a backtracking regex (avoids ReDoS on
  # pathological input) then re-append a single one, so the base always ends in "/".
  base = api_base_url.to_s
  base = base.chomp("/") while base.end_with?("/")
  @base_uri = URI.parse(base + "/")
  @api_key = api_key
  @private_key = Envelope.load_private_key(private_key_pkcs8)
  @open_timeout = open_timeout
  @read_timeout = read_timeout
  @http_client = http_client
end

Class Method Details

.from_credentials(path_or_json, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, http_client: nil) ⇒ Object

Builds a client from a credentials-bundle JSON string or a path to a bundle file.

Optional +open_timeout+/+read_timeout+ (seconds) and a caller-supplied http_client (an object responding to #request, e.g. a preconfigured Net::HTTP) are threaded straight through to #initialize; the defaults preserve the previous behaviour.

Raises:

  • (ArgumentError)


39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/open_banking_io/client.rb', line 39

def self.from_credentials(path_or_json, open_timeout: DEFAULT_OPEN_TIMEOUT,
  read_timeout: DEFAULT_READ_TIMEOUT, http_client: nil)
  raw = if File.file?(path_or_json.to_s)
    File.read(path_or_json)
  else
    path_or_json
  end

  bundle = JSON.parse(raw)
  api_base_url = bundle["apiBaseUrl"].to_s
  api_key = bundle["apiKey"]
  raise ArgumentError, "The credentials bundle has no apiKey" if api_key.nil? || api_key.empty?

  enc_key = bundle["encryptionKey"] || {}
  private_key = enc_key["privateKey"] || enc_key["privateKeyPkcs8B64"]
  if private_key.nil? || private_key.to_s.empty?
    raise ArgumentError, "The credentials bundle has no encryption private key"
  end

  new(
    api_base_url: api_base_url,
    api_key: api_key,
    private_key_pkcs8: private_key,
    open_timeout: open_timeout,
    read_timeout: read_timeout,
    http_client: http_client
  )
end

Instance Method Details

#get_accountsObject

Lists the user's accounts with all sensitive fields decrypted.



92
93
94
# File 'lib/open_banking_io/client.rb', line 92

def get_accounts
  .map { |w| (w) }
end

#get_connectionsObject

Lists the user's bank connections.



110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/open_banking_io/client.rb', line 110

def get_connections
  get_json("api/connections").map do |c|
    Connection.new(
      session_id: c["sessionId"] || "",
      aspsp_name: c["aspspName"] || "",
      aspsp_country: c["aspspCountry"] || "",
      valid_until: c["validUntil"],
      status: c["status"] || "",
      account_count: c["accountCount"] || 0,
      last_synced_at: c["lastSyncedAt"],
      psu_type: c["psuType"]
    )
  end
end

#get_transactions(account_id, from: nil, to: nil, limit: nil, offset: nil) ⇒ Object

Returns a page of an account's statement, newest first, with decrypted fields.



97
98
99
100
101
102
103
104
105
106
107
# File 'lib/open_banking_io/client.rb', line 97

def get_transactions(, from: nil, to: nil, limit: nil, offset: nil)
  params = {}
  params["from"] = from unless from.nil?
  params["to"] = to unless to.nil?
  params["limit"] = limit unless limit.nil?
  params["offset"] = offset unless offset.nil?

  page = get_json("api/accounts/#{}/transactions", params)
  items = (page["items"] || []).map { |t| map_transaction(t) }
  TransactionPage.new(items: items, total: page["total"] || 0)
end

#sync(account_id) ⇒ Object

Triggers an online sync of one account.

Decrypts that account's Enable Banking uid and posts it, so the service can fetch fresh data without ever holding the uid in plaintext.

Raises:

  • (ArgumentError)


129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/open_banking_io/client.rb', line 129

def sync()
   = .find { |a| a["id"] ==  }
  raise ArgumentError, "Account #{} not found" if .nil?

  uid = decrypt_uid()
  if uid.nil?
    raise ArgumentError, "Account has no active session (reconnect required) -- cannot sync"
  end

  result = post_json("api/accounts/#{}/sync", {"uid" => uid})
  SyncResult.new(
    new_transactions: result["newTransactions"] || 0,
    total_fetched: result["totalFetched"] || 0
  )
end

#sync_allObject

Triggers an online sync of every account that has an active session.



146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/open_banking_io/client.rb', line 146

def sync_all
  items = []
  .each do |a|
    uid = decrypt_uid(a)
    items << {"accountId" => a["id"], "uid" => uid} unless uid.nil?
  end

  result = post_json("api/sync", {"items" => items})
  SyncAllResult.new(
    accounts: result["accounts"] || 0,
    new_transactions: result["newTransactions"] || 0
  )
end