Skip to content

Commit 2867b7d

Browse files
Merge pull request #25 from Investec-Developer-Community/refactor/quick-wins
refactor: implement quick wins for code modernization
2 parents 162eed3 + e985ac1 commit 2867b7d

11 files changed

Lines changed: 178 additions & 14 deletions

Gemfile.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ PATH
22
remote: .
33
specs:
44
investec_open_api (2.1.0)
5+
base64
56
faraday
67
money
78

@@ -10,6 +11,7 @@ GEM
1011
specs:
1112
addressable (2.8.6)
1213
public_suffix (>= 2.0.2, < 6.0)
14+
base64 (0.3.0)
1315
bigdecimal (3.1.7)
1416
coderay (1.1.3)
1517
concurrent-ruby (1.2.3)
@@ -58,6 +60,7 @@ GEM
5860

5961
PLATFORMS
6062
arm64-darwin-23
63+
x86_64-linux
6164

6265
DEPENDENCIES
6366
faker (~> 3.4)

README.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ A simple client wrapper for the [Investec Open API](https://developer.investec.c
1616
- Retrieve balances per account
1717
- Transfer between accounts
1818

19+
## Requirements
20+
21+
- **Ruby 2.7.0 or higher**
22+
1923
## Installation
2024

2125
Add this line to your application's Gemfile:
@@ -123,6 +127,83 @@ client.transfer_multiple(
123127
)
124128
```
125129

130+
## Error Handling
131+
132+
The client raises specific exceptions for different error scenarios. Always wrap API calls in error handling:
133+
134+
### Custom Exception Classes
135+
136+
- `InvestecOpenApi::AuthenticationError` - Raised when OAuth authentication fails
137+
- `InvestecOpenApi::ValidationError` - Raised when required parameters are missing or invalid
138+
- `InvestecOpenApi::NotFoundError` - Raised when a requested resource is not found
139+
- `InvestecOpenApi::APIError` - Raised for general API errors
140+
- `InvestecOpenApi::RateLimitError` - Raised when rate limits are exceeded
141+
142+
### Example: Handling Errors
143+
144+
```ruby
145+
client = InvestecOpenApi::Client.new
146+
147+
begin
148+
client.authenticate!
149+
rescue InvestecOpenApi::AuthenticationError => e
150+
puts "Authentication failed: #{e.message}"
151+
# Handle authentication error
152+
end
153+
154+
begin
155+
transactions = client.transactions(account_id)
156+
rescue InvestecOpenApi::ValidationError => e
157+
puts "Invalid parameters: #{e.message}"
158+
# Handle validation error
159+
rescue InvestecOpenApi::NotFoundError => e
160+
puts "Account not found: #{e.message}"
161+
# Handle not found error
162+
rescue InvestecOpenApi::APIError => e
163+
puts "API error occurred: #{e.message}"
164+
# Handle general API error
165+
end
166+
167+
begin
168+
transfer = InvestecOpenApi::Models::Transfer.new(
169+
beneficiary_id,
170+
1000.00,
171+
"my ref",
172+
"their ref"
173+
)
174+
rescue InvestecOpenApi::ValidationError => e
175+
puts "Invalid transfer parameters: #{e.message}"
176+
# Handle validation error
177+
end
178+
```
179+
180+
## Thread Safety
181+
182+
**Important:** The client instance caches a Faraday connection object. If you're using this client in a multi-threaded environment (such as a Rails application), ensure that each thread has its own instance of `InvestecOpenApi::Client`:
183+
184+
```ruby
185+
# ✅ Correct: Each thread gets its own client
186+
threads = 5.times.map do
187+
Thread.new do
188+
client = InvestecOpenApi::Client.new
189+
client.authenticate!
190+
# Use the client...
191+
end
192+
end
193+
threads.each(&:join)
194+
195+
# ❌ Incorrect: Sharing a single client across threads
196+
client = InvestecOpenApi::Client.new
197+
client.authenticate!
198+
threads = 5.times.map do
199+
Thread.new do
200+
# Don't do this - connection caching is not thread-safe
201+
client.accounts
202+
end
203+
end
204+
threads.each(&:join)
205+
```
206+
126207
## Running in Sandbox mode
127208

128209
To run in sandbox mode, use the following configuration:

investec_open_api.gemspec

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Gem::Specification.new do |spec|
1010
spec.description = %q{A small wrapper client for accessing Investec's Open API}
1111
spec.homepage = "https://github.com/programmable-banking-community/investec_open_api"
1212
spec.license = "MIT"
13-
spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
13+
spec.required_ruby_version = Gem::Requirement.new(">= 2.7.0")
1414

1515
spec.metadata["allowed_push_host"] = "https://rubygems.org"
1616

@@ -30,6 +30,7 @@ Gem::Specification.new do |spec|
3030
# add runtime dependencies
3131
spec.add_runtime_dependency 'faraday'
3232
spec.add_runtime_dependency 'money'
33+
spec.add_runtime_dependency 'base64'
3334

3435
# add development dependencies
3536
spec.add_development_dependency 'rake'

lib/investec_open_api.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1+
# frozen_string_literal: true
2+
13
require "investec_open_api/version"
24
require "investec_open_api/models/base"
35
require "investec_open_api/camel_case_refinement"
46
require "investec_open_api/client"
57

68
module InvestecOpenApi
79
class Error < StandardError; end
10+
class AuthenticationError < Error; end
11+
class NotFoundError < Error; end
12+
class ValidationError < Error; end
13+
class APIError < Error; end
14+
class RateLimitError < Error; end
815

916
class Configuration
1017
DEFAULT_BASE_URL = "https://openapi.investec.com/"

lib/investec_open_api/camel_case_refinement.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# frozen_string_literal: true
2+
13
module InvestecOpenApi
24
module CamelCaseRefinement
35
refine Hash do

lib/investec_open_api/client.rb

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,108 @@
1+
# frozen_string_literal: true
2+
13
require "faraday"
24
require "investec_open_api/models/account"
35
require "investec_open_api/models/transaction"
46
require "investec_open_api/models/balance"
57
require "investec_open_api/models/transfer"
68
require "investec_open_api/camel_case_refinement"
7-
require 'base64'
9+
require "base64"
810

911
class InvestecOpenApi::Client
1012
using InvestecOpenApi::CamelCaseRefinement
1113

1214
def authenticate!
1315
@token = get_oauth_token["access_token"]
16+
rescue StandardError => e
17+
raise InvestecOpenApi::AuthenticationError, "Failed to authenticate: #{e.message}"
1418
end
1519

20+
# Get all accounts for the authenticated user
21+
# @return [Array<InvestecOpenApi::Models::Account>]
22+
# @raise [InvestecOpenApi::APIError] if the request fails
1623
def accounts
1724
response = connection.get("za/pb/v1/accounts")
1825
response.body["data"]["accounts"].map do |account_raw|
1926
InvestecOpenApi::Models::Account.from_api(account_raw)
2027
end
28+
rescue StandardError => e
29+
raise InvestecOpenApi::APIError, "Failed to fetch accounts: #{e.message}"
2130
end
2231

23-
## Get cleared transactions for an account
32+
# Get cleared transactions for an account
2433
# @param [String] account_id The id of the account to get transactions for
25-
# @param [Hash] options
34+
# @param [Hash] options Optional query parameters
2635
# @option options [String] :fromDate Start date from which to get transactions
2736
# @option options [String] :toDate End date for transactions
28-
# @option options [String] :transactionType Type of transaction to filter by eg: CardPurchases, Deposits
37+
# @option options [String] :transactionType Type of transaction to filter by (e.g., CardPurchases, Deposits)
38+
# @return [Array<InvestecOpenApi::Models::Transaction>]
39+
# @raise [InvestecOpenApi::ValidationError] if account_id is blank
40+
# @raise [InvestecOpenApi::APIError] if the request fails
2941
def transactions(account_id, options = {})
42+
raise InvestecOpenApi::ValidationError, "account_id cannot be blank" if account_id.to_s.strip.empty?
43+
3044
endpoint_url = "za/pb/v1/accounts/#{account_id}/transactions"
3145
perform_transaction_request(endpoint_url, options)
46+
rescue InvestecOpenApi::ValidationError
47+
raise
48+
rescue StandardError => e
49+
raise InvestecOpenApi::APIError, "Failed to fetch transactions: #{e.message}"
3250
end
3351

34-
## Get pending transactions for an account
52+
# Get pending transactions for an account
3553
# @param [String] account_id The id of the account to get pending transactions for
36-
# @param [Hash] options
54+
# @param [Hash] options Optional query parameters
3755
# @option options [String] :fromDate Start date from which to get pending transactions
3856
# @option options [String] :toDate End date for pending transactions
57+
# @return [Array<InvestecOpenApi::Models::Transaction>]
58+
# @raise [InvestecOpenApi::ValidationError] if account_id is blank
59+
# @raise [InvestecOpenApi::APIError] if the request fails
3960
def pending_transactions(account_id, options = {})
61+
raise InvestecOpenApi::ValidationError, "account_id cannot be blank" if account_id.to_s.strip.empty?
62+
4063
endpoint_url = "za/pb/v1/accounts/#{account_id}/pending-transactions"
4164
perform_transaction_request(endpoint_url, options)
65+
rescue InvestecOpenApi::ValidationError
66+
raise
67+
rescue StandardError => e
68+
raise InvestecOpenApi::APIError, "Failed to fetch pending transactions: #{e.message}"
4269
end
4370

71+
# Get balance for an account
72+
# @param [String] account_id The id of the account to get balance for
73+
# @return [InvestecOpenApi::Models::Balance]
74+
# @raise [InvestecOpenApi::ValidationError] if account_id is blank
75+
# @raise [InvestecOpenApi::NotFoundError] if account not found or balance data unavailable
76+
# @raise [InvestecOpenApi::APIError] if the request fails
4477
def balance(account_id)
78+
raise InvestecOpenApi::ValidationError, "account_id cannot be blank" if account_id.to_s.strip.empty?
79+
4580
endpoint_url = "za/pb/v1/accounts/#{account_id}/balance"
4681
response = connection.get(endpoint_url)
47-
raise "Error fetching balance" if response.body["data"].nil?
82+
raise InvestecOpenApi::NotFoundError, "Balance data not found for account #{account_id}" if response.body["data"].nil?
83+
4884
InvestecOpenApi::Models::Balance.from_api(response.body["data"])
85+
rescue InvestecOpenApi::ValidationError, InvestecOpenApi::NotFoundError
86+
raise
87+
rescue StandardError => e
88+
raise InvestecOpenApi::APIError, "Failed to fetch balance: #{e.message}"
4989
end
5090

51-
# @param [String] account_id
52-
# @param [Array<InvestecOpenApi::Models::Transfer>] transfers
91+
# Transfer funds between accounts
92+
# @param [String] account_id The id of the account to transfer from
93+
# @param [Array<InvestecOpenApi::Models::Transfer>] transfers List of transfers to perform
94+
# @param [String, nil] profile_id Optional profile ID for the transfer
95+
# @return [Hash] The response body from the API
96+
# @raise [InvestecOpenApi::ValidationError] if parameters are invalid
97+
# @raise [InvestecOpenApi::APIError] if the request fails
5398
def transfer_multiple(
5499
account_id,
55100
transfers,
56101
profile_id = nil
57102
)
103+
raise InvestecOpenApi::ValidationError, "account_id cannot be blank" if account_id.to_s.strip.empty?
104+
raise InvestecOpenApi::ValidationError, "transfers cannot be empty" if transfers.nil? || transfers.empty?
105+
58106
endpoint_url = "za/pb/v1/accounts/#{account_id}/transfermultiple"
59107
data = {
60108
transferList: transfers.map(&:to_h),
@@ -65,6 +113,10 @@ def transfer_multiple(
65113
JSON.generate(data)
66114
)
67115
response.body
116+
rescue InvestecOpenApi::ValidationError
117+
raise
118+
rescue StandardError => e
119+
raise InvestecOpenApi::APIError, "Failed to process transfers: #{e.message}"
68120
end
69121

70122
private
@@ -85,6 +137,10 @@ def get_oauth_token
85137
end
86138

87139
def connection
140+
# NOTE: This connection is cached in an instance variable. If you use this client
141+
# in a multi-threaded environment, ensure each thread has its own client instance.
142+
# The connection itself is thread-safe (Faraday uses thread-safe adapters),
143+
# but the caching mechanism is not.
88144
@_connection ||= Faraday.new(url: InvestecOpenApi.config.base_url) do |builder|
89145
if @token
90146
builder.headers["Authorization"] = "Bearer #{@token}"

lib/investec_open_api/models/account.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# frozen_string_literal: true
2+
13
module InvestecOpenApi::Models
24
class Account < Base
35
attr_reader :id,

lib/investec_open_api/models/base.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# frozen_string_literal: true
2+
13
require_relative "../string_utilities"
24

35
module InvestecOpenApi::Models

lib/investec_open_api/models/transaction.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# frozen_string_literal: true
2+
13
require "money"
24

35
module InvestecOpenApi::Models

lib/investec_open_api/models/transfer.rb

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,22 @@ module InvestecOpenApi::Models
44
class Transfer
55
attr_reader :beneficiary_account_id, :amount, :my_reference, :their_reference
66

7-
# @param [String] beneficiary_account_id
8-
# @param [Float] amount
9-
# @param [String] my_reference
10-
# @param [String] their_reference
7+
# @param [String] beneficiary_account_id The ID of the beneficiary account
8+
# @param [Float] amount The amount to transfer
9+
# @param [String] my_reference Reference visible to the sender
10+
# @param [String] their_reference Reference visible to the recipient
11+
# @raise [InvestecOpenApi::ValidationError] if required parameters are blank
1112
def initialize(
1213
beneficiary_account_id,
1314
amount,
1415
my_reference,
1516
their_reference
1617
)
18+
raise InvestecOpenApi::ValidationError, "beneficiary_account_id cannot be blank" if beneficiary_account_id.to_s.strip.empty?
19+
raise InvestecOpenApi::ValidationError, "amount cannot be nil or zero" if amount.nil? || amount.to_f.zero?
20+
raise InvestecOpenApi::ValidationError, "my_reference cannot be blank" if my_reference.to_s.strip.empty?
21+
raise InvestecOpenApi::ValidationError, "their_reference cannot be blank" if their_reference.to_s.strip.empty?
22+
1723
@beneficiary_account_id = beneficiary_account_id
1824
@amount = amount.to_s
1925
@my_reference = my_reference

0 commit comments

Comments
 (0)