airwallex 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 +22 -0
- data/LICENSE.txt +21 -0
- data/README.md +377 -0
- data/Rakefile +12 -0
- data/docs/internal/20251125_iteration_1_quickstart.md +130 -0
- data/docs/internal/20251125_iteration_1_summary.md +342 -0
- data/docs/internal/20251125_sprint_1_completed.md +448 -0
- data/docs/internal/20251125_sprint_1_plan.md +389 -0
- data/docs/internal/20251125_sprint_2_completed.md +559 -0
- data/docs/internal/20251125_sprint_2_plan.md +531 -0
- data/docs/internal/20251125_sprint_2_unit_tests_completed.md +264 -0
- data/docs/research/Airwallex API Endpoint Research.md +410 -0
- data/docs/research/Airwallex API Research for Ruby Gem.md +383 -0
- data/lib/airwallex/api_operations/create.rb +16 -0
- data/lib/airwallex/api_operations/delete.rb +16 -0
- data/lib/airwallex/api_operations/list.rb +23 -0
- data/lib/airwallex/api_operations/retrieve.rb +16 -0
- data/lib/airwallex/api_operations/update.rb +44 -0
- data/lib/airwallex/api_resource.rb +96 -0
- data/lib/airwallex/client.rb +132 -0
- data/lib/airwallex/configuration.rb +67 -0
- data/lib/airwallex/errors.rb +64 -0
- data/lib/airwallex/list_object.rb +85 -0
- data/lib/airwallex/middleware/auth_refresh.rb +32 -0
- data/lib/airwallex/middleware/idempotency.rb +29 -0
- data/lib/airwallex/resources/beneficiary.rb +14 -0
- data/lib/airwallex/resources/payment_intent.rb +44 -0
- data/lib/airwallex/resources/transfer.rb +23 -0
- data/lib/airwallex/util.rb +58 -0
- data/lib/airwallex/version.rb +5 -0
- data/lib/airwallex/webhook.rb +67 -0
- data/lib/airwallex.rb +49 -0
- data/sig/airwallex.rbs +4 -0
- metadata +128 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/multipart"
|
|
5
|
+
require "faraday/retry"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Airwallex
|
|
9
|
+
class Client
|
|
10
|
+
attr_reader :config, :access_token, :token_expires_at
|
|
11
|
+
|
|
12
|
+
def initialize(config = Airwallex.configuration)
|
|
13
|
+
@config = config
|
|
14
|
+
@config.validate!
|
|
15
|
+
@access_token = nil
|
|
16
|
+
@token_expires_at = nil
|
|
17
|
+
@token_mutex = Mutex.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def connection
|
|
21
|
+
@connection ||= Faraday.new(url: config.api_url) do |conn|
|
|
22
|
+
conn.request :json
|
|
23
|
+
conn.request :multipart
|
|
24
|
+
conn.request :retry, retry_options
|
|
25
|
+
conn.response :json, content_type: /\bjson$/
|
|
26
|
+
conn.response :logger, config.logger, { headers: true, bodies: true } if config.logger
|
|
27
|
+
|
|
28
|
+
conn.headers["Content-Type"] = "application/json"
|
|
29
|
+
conn.headers["User-Agent"] = user_agent
|
|
30
|
+
conn.headers["x-api-version"] = config.api_version
|
|
31
|
+
|
|
32
|
+
conn.adapter Faraday.default_adapter
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def get(path, params = {}, headers = {})
|
|
37
|
+
request(:get, path, params, headers)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def post(path, body = {}, headers = {})
|
|
41
|
+
request(:post, path, body, headers)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def put(path, body = {}, headers = {})
|
|
45
|
+
request(:put, path, body, headers)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def patch(path, body = {}, headers = {})
|
|
49
|
+
request(:patch, path, body, headers)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def delete(path, params = {}, headers = {})
|
|
53
|
+
request(:delete, path, params, headers)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def authenticate!
|
|
57
|
+
@token_mutex.synchronize do
|
|
58
|
+
response = connection.post("/api/v1/authentication/login") do |req|
|
|
59
|
+
req.headers["x-client-id"] = config.client_id
|
|
60
|
+
req.headers["x-api-key"] = config.api_key
|
|
61
|
+
req.headers.delete("Authorization")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
handle_response_errors(response)
|
|
65
|
+
|
|
66
|
+
data = response.body
|
|
67
|
+
@access_token = data["token"]
|
|
68
|
+
@token_expires_at = Time.now + 1800 # 30 minutes
|
|
69
|
+
|
|
70
|
+
@access_token
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def token_expired?
|
|
75
|
+
return true if access_token.nil? || token_expires_at.nil?
|
|
76
|
+
|
|
77
|
+
# Refresh if token expires in less than 5 minutes
|
|
78
|
+
Time.now >= (token_expires_at - 300)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def ensure_authenticated!
|
|
82
|
+
authenticate! if token_expired?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def request(method, path, data, headers)
|
|
88
|
+
ensure_authenticated!
|
|
89
|
+
|
|
90
|
+
response = connection.public_send(method) do |req|
|
|
91
|
+
req.url(path)
|
|
92
|
+
req.headers.merge!(headers)
|
|
93
|
+
req.headers["Authorization"] = "Bearer #{access_token}"
|
|
94
|
+
|
|
95
|
+
case method
|
|
96
|
+
when :get, :delete
|
|
97
|
+
req.params = data
|
|
98
|
+
when :post, :put, :patch
|
|
99
|
+
req.body = data
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
handle_response_errors(response)
|
|
104
|
+
response.body
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def handle_response_errors(response)
|
|
108
|
+
return if response.success?
|
|
109
|
+
|
|
110
|
+
raise Error.from_response(response)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def retry_options
|
|
114
|
+
{
|
|
115
|
+
max: 3,
|
|
116
|
+
interval: 0.5,
|
|
117
|
+
interval_randomness: 0.5,
|
|
118
|
+
backoff_factor: 2,
|
|
119
|
+
methods: %i[get delete],
|
|
120
|
+
exceptions: [
|
|
121
|
+
Faraday::TimeoutError,
|
|
122
|
+
Faraday::ConnectionFailed
|
|
123
|
+
],
|
|
124
|
+
retry_statuses: [429, 500, 502, 503, 504]
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def user_agent
|
|
129
|
+
"Airwallex-Ruby/#{Airwallex::VERSION} Ruby/#{RUBY_VERSION}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airwallex
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :api_key, :client_id, :api_version, :logger, :log_level
|
|
6
|
+
attr_reader :environment
|
|
7
|
+
|
|
8
|
+
SANDBOX_API_URL = "https://api-demo.airwallex.com"
|
|
9
|
+
PRODUCTION_API_URL = "https://api.airwallex.com"
|
|
10
|
+
SANDBOX_FILES_URL = "https://files-demo.airwallex.com"
|
|
11
|
+
PRODUCTION_FILES_URL = "https://files.airwallex.com"
|
|
12
|
+
|
|
13
|
+
DEFAULT_API_VERSION = "2024-09-27"
|
|
14
|
+
VALID_ENVIRONMENTS = %i[sandbox production].freeze
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@environment = :sandbox
|
|
18
|
+
@api_version = DEFAULT_API_VERSION
|
|
19
|
+
@log_level = :info
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def api_url
|
|
23
|
+
case environment
|
|
24
|
+
when :sandbox
|
|
25
|
+
SANDBOX_API_URL
|
|
26
|
+
when :production
|
|
27
|
+
PRODUCTION_API_URL
|
|
28
|
+
else
|
|
29
|
+
raise ConfigurationError, "Invalid environment: #{environment}. Must be :sandbox or :production"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def files_url
|
|
34
|
+
case environment
|
|
35
|
+
when :sandbox
|
|
36
|
+
SANDBOX_FILES_URL
|
|
37
|
+
when :production
|
|
38
|
+
PRODUCTION_FILES_URL
|
|
39
|
+
else
|
|
40
|
+
raise ConfigurationError, "Invalid environment: #{environment}. Must be :sandbox or :production"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def environment=(env)
|
|
45
|
+
unless VALID_ENVIRONMENTS.include?(env)
|
|
46
|
+
raise ConfigurationError, "Invalid environment: #{env}. Must be one of #{VALID_ENVIRONMENTS.join(", ")}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@environment = env
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validate!
|
|
53
|
+
errors = []
|
|
54
|
+
errors << "api_key is required" if api_key.nil? || api_key.empty?
|
|
55
|
+
errors << "client_id is required" if client_id.nil? || client_id.empty?
|
|
56
|
+
errors << "environment must be :sandbox or :production" unless VALID_ENVIRONMENTS.include?(environment)
|
|
57
|
+
|
|
58
|
+
raise ConfigurationError, errors.join(", ") unless errors.empty?
|
|
59
|
+
|
|
60
|
+
true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def configured?
|
|
64
|
+
!api_key.nil? && !client_id.nil? && VALID_ENVIRONMENTS.include?(environment)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airwallex
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :code, :message, :param, :details, :http_status
|
|
6
|
+
|
|
7
|
+
def initialize(message = nil, code: nil, param: nil, details: nil, http_status: nil)
|
|
8
|
+
@code = code
|
|
9
|
+
@message = message
|
|
10
|
+
@param = param
|
|
11
|
+
@details = details
|
|
12
|
+
@http_status = http_status
|
|
13
|
+
super(message)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.from_response(response)
|
|
17
|
+
body = parse_body(response.body)
|
|
18
|
+
status = response.status
|
|
19
|
+
|
|
20
|
+
error_class = error_class_for_status(status)
|
|
21
|
+
error_class.new(
|
|
22
|
+
body["message"],
|
|
23
|
+
code: body["code"],
|
|
24
|
+
param: body["source"],
|
|
25
|
+
details: body["details"],
|
|
26
|
+
http_status: status
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.parse_body(body)
|
|
31
|
+
return {} if body.nil? || body.empty?
|
|
32
|
+
return body if body.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
JSON.parse(body)
|
|
35
|
+
rescue JSON::ParserError
|
|
36
|
+
{ "message" => body }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.error_class_for_status(status)
|
|
40
|
+
case status
|
|
41
|
+
when 400 then BadRequestError
|
|
42
|
+
when 401 then AuthenticationError
|
|
43
|
+
when 403 then PermissionError
|
|
44
|
+
when 404 then NotFoundError
|
|
45
|
+
when 429 then RateLimitError
|
|
46
|
+
when 500..599 then APIError
|
|
47
|
+
else Error
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private_class_method :parse_body, :error_class_for_status
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class ConfigurationError < Error; end
|
|
55
|
+
class BadRequestError < Error; end
|
|
56
|
+
class AuthenticationError < Error; end
|
|
57
|
+
class PermissionError < Error; end
|
|
58
|
+
class NotFoundError < Error; end
|
|
59
|
+
class RateLimitError < Error; end
|
|
60
|
+
class APIError < Error; end
|
|
61
|
+
class InsufficientFundsError < Error; end
|
|
62
|
+
class SCARequiredError < PermissionError; end
|
|
63
|
+
class SignatureVerificationError < Error; end
|
|
64
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airwallex
|
|
4
|
+
class ListObject
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
attr_reader :data, :has_more, :next_cursor
|
|
8
|
+
|
|
9
|
+
def initialize(data:, has_more:, resource_class:, next_cursor: nil, params: {})
|
|
10
|
+
@data = data.map { |item| resource_class.new(item) }
|
|
11
|
+
@has_more = has_more
|
|
12
|
+
@next_cursor = next_cursor
|
|
13
|
+
@resource_class = resource_class
|
|
14
|
+
@params = params
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def each(&)
|
|
18
|
+
@data.each(&)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def [](index)
|
|
22
|
+
@data[index]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def size
|
|
26
|
+
@data.size
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
alias length size
|
|
30
|
+
alias count size
|
|
31
|
+
|
|
32
|
+
def empty?
|
|
33
|
+
@data.empty?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def first
|
|
37
|
+
@data.first
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def last
|
|
41
|
+
@data.last
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Fetch the next page of results
|
|
45
|
+
def next_page
|
|
46
|
+
return nil unless @has_more
|
|
47
|
+
|
|
48
|
+
next_params = @params.dup
|
|
49
|
+
|
|
50
|
+
if @next_cursor
|
|
51
|
+
# Cursor-based pagination
|
|
52
|
+
next_params[:next_cursor] = @next_cursor
|
|
53
|
+
else
|
|
54
|
+
# Offset-based pagination
|
|
55
|
+
page_size = @params[:page_size] || @params[:limit] || 20
|
|
56
|
+
current_offset = @params[:offset] || 0
|
|
57
|
+
next_params[:offset] = current_offset + page_size
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@resource_class.list(next_params)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Automatically iterate through all pages
|
|
64
|
+
def auto_paging_each(&block)
|
|
65
|
+
return enum_for(:auto_paging_each) unless block_given?
|
|
66
|
+
|
|
67
|
+
page = self
|
|
68
|
+
loop do
|
|
69
|
+
page.each(&block)
|
|
70
|
+
break unless page.has_more
|
|
71
|
+
|
|
72
|
+
page = page.next_page
|
|
73
|
+
break if page.nil? || page.empty?
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_a
|
|
78
|
+
@data
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def inspect
|
|
82
|
+
"#<#{self.class}[#{@data.size}] has_more=#{@has_more}>"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airwallex
|
|
4
|
+
module Middleware
|
|
5
|
+
class AuthRefresh < Faraday::Middleware
|
|
6
|
+
def initialize(app, client)
|
|
7
|
+
super(app)
|
|
8
|
+
@client = client
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(env)
|
|
12
|
+
# Skip authentication refresh for login endpoint
|
|
13
|
+
return @app.call(env) if env[:url].path.include?("/authentication/login")
|
|
14
|
+
|
|
15
|
+
# Ensure token is valid before making request
|
|
16
|
+
@client.ensure_authenticated! unless env[:url].path.include?("/authentication/")
|
|
17
|
+
|
|
18
|
+
response = @app.call(env)
|
|
19
|
+
|
|
20
|
+
# If we get a 401, try refreshing the token and retrying once
|
|
21
|
+
if response.status == 401 && !env[:request].fetch(:auth_retry, false)
|
|
22
|
+
@client.authenticate!
|
|
23
|
+
env[:request][:auth_retry] = true
|
|
24
|
+
env[:request_headers]["Authorization"] = "Bearer #{@client.access_token}"
|
|
25
|
+
response = @app.call(env)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
response
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airwallex
|
|
4
|
+
module Middleware
|
|
5
|
+
class Idempotency < Faraday::Middleware
|
|
6
|
+
IDEMPOTENT_METHODS = %i[post put patch].freeze
|
|
7
|
+
|
|
8
|
+
def call(env)
|
|
9
|
+
inject_request_id(env) if idempotent_request?(env)
|
|
10
|
+
|
|
11
|
+
@app.call(env)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def idempotent_request?(env)
|
|
17
|
+
IDEMPOTENT_METHODS.include?(env[:method]) && env[:body].is_a?(Hash)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def inject_request_id(env)
|
|
21
|
+
body = env[:body]
|
|
22
|
+
|
|
23
|
+
return if body.key?("request_id") || body.key?(:request_id)
|
|
24
|
+
|
|
25
|
+
body[:request_id] = Airwallex::Util.generate_idempotency_key
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airwallex
|
|
4
|
+
class Beneficiary < APIResource
|
|
5
|
+
extend APIOperations::Create
|
|
6
|
+
extend APIOperations::Retrieve
|
|
7
|
+
extend APIOperations::List
|
|
8
|
+
extend APIOperations::Delete
|
|
9
|
+
|
|
10
|
+
def self.resource_path
|
|
11
|
+
"/api/v1/beneficiaries"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airwallex
|
|
4
|
+
class PaymentIntent < APIResource
|
|
5
|
+
extend APIOperations::Create
|
|
6
|
+
extend APIOperations::Retrieve
|
|
7
|
+
extend APIOperations::List
|
|
8
|
+
include APIOperations::Update
|
|
9
|
+
|
|
10
|
+
def self.resource_path
|
|
11
|
+
"/api/v1/pa/payment_intents"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Confirm the payment intent with payment method details
|
|
15
|
+
def confirm(params = {})
|
|
16
|
+
response = Airwallex.client.post(
|
|
17
|
+
"#{self.class.resource_path}/#{id}/confirm",
|
|
18
|
+
params
|
|
19
|
+
)
|
|
20
|
+
refresh_from(response)
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Cancel the payment intent
|
|
25
|
+
def cancel(params = {})
|
|
26
|
+
response = Airwallex.client.post(
|
|
27
|
+
"#{self.class.resource_path}/#{id}/cancel",
|
|
28
|
+
params
|
|
29
|
+
)
|
|
30
|
+
refresh_from(response)
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Capture an authorized payment
|
|
35
|
+
def capture(params = {})
|
|
36
|
+
response = Airwallex.client.post(
|
|
37
|
+
"#{self.class.resource_path}/#{id}/capture",
|
|
38
|
+
params
|
|
39
|
+
)
|
|
40
|
+
refresh_from(response)
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airwallex
|
|
4
|
+
class Transfer < APIResource
|
|
5
|
+
extend APIOperations::Create
|
|
6
|
+
extend APIOperations::Retrieve
|
|
7
|
+
extend APIOperations::List
|
|
8
|
+
|
|
9
|
+
def self.resource_path
|
|
10
|
+
"/api/v1/transfers"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Cancel a pending transfer
|
|
14
|
+
def cancel
|
|
15
|
+
response = Airwallex.client.post(
|
|
16
|
+
"#{self.class.resource_path}/#{id}/cancel",
|
|
17
|
+
{}
|
|
18
|
+
)
|
|
19
|
+
refresh_from(response)
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
require "bigdecimal"
|
|
6
|
+
|
|
7
|
+
module Airwallex
|
|
8
|
+
module Util
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def generate_idempotency_key
|
|
12
|
+
SecureRandom.uuid
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def format_date_time(value)
|
|
16
|
+
case value
|
|
17
|
+
when Time, DateTime
|
|
18
|
+
value.utc.iso8601
|
|
19
|
+
when Date
|
|
20
|
+
value.to_time.utc.iso8601
|
|
21
|
+
when String
|
|
22
|
+
value
|
|
23
|
+
else
|
|
24
|
+
raise ArgumentError, "Cannot format #{value.class} as ISO 8601"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def parse_date_time(value)
|
|
29
|
+
return nil if value.nil?
|
|
30
|
+
return value if value.is_a?(Time)
|
|
31
|
+
|
|
32
|
+
Time.iso8601(value)
|
|
33
|
+
rescue ArgumentError
|
|
34
|
+
Time.parse(value)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def symbolize_keys(hash)
|
|
38
|
+
return hash unless hash.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
hash.transform_keys(&:to_sym)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def deep_symbolize_keys(hash)
|
|
44
|
+
return hash unless hash.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
47
|
+
result[key.to_sym] = value.is_a?(Hash) ? deep_symbolize_keys(value) : value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_money(value)
|
|
52
|
+
return value if value.is_a?(BigDecimal)
|
|
53
|
+
return BigDecimal("0") if value.nil?
|
|
54
|
+
|
|
55
|
+
BigDecimal(value.to_s)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Airwallex
|
|
6
|
+
module Webhook
|
|
7
|
+
DEFAULT_TOLERANCE = 300 # 5 minutes
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def construct_event(payload, signature, timestamp, secret:, tolerance: DEFAULT_TOLERANCE)
|
|
12
|
+
verify_signature(payload, signature, timestamp, secret, tolerance)
|
|
13
|
+
|
|
14
|
+
data = JSON.parse(payload)
|
|
15
|
+
Event.new(data)
|
|
16
|
+
rescue JSON::ParserError => e
|
|
17
|
+
raise SignatureVerificationError, "Invalid payload: #{e.message}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def verify_signature(payload, signature, timestamp, secret, tolerance)
|
|
21
|
+
verify_timestamp(timestamp, tolerance)
|
|
22
|
+
|
|
23
|
+
expected_signature = compute_signature(timestamp, payload, secret)
|
|
24
|
+
|
|
25
|
+
unless secure_compare(expected_signature, signature)
|
|
26
|
+
raise SignatureVerificationError, "Signature verification failed"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def compute_signature(timestamp, payload, secret)
|
|
33
|
+
data = "#{timestamp}#{payload}"
|
|
34
|
+
OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, data)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def verify_timestamp(timestamp, tolerance)
|
|
38
|
+
current_time = Time.now.to_i
|
|
39
|
+
timestamp_int = timestamp.to_i
|
|
40
|
+
|
|
41
|
+
if (current_time - timestamp_int).abs > tolerance
|
|
42
|
+
raise SignatureVerificationError, "Timestamp outside tolerance (#{tolerance}s)"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def secure_compare(a, b)
|
|
49
|
+
return false if a.nil? || b.nil? || a.bytesize != b.bytesize
|
|
50
|
+
|
|
51
|
+
OpenSSL.fixed_length_secure_compare(a, b)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private_class_method :compute_signature, :verify_timestamp, :secure_compare
|
|
55
|
+
|
|
56
|
+
class Event
|
|
57
|
+
attr_reader :id, :type, :data, :created_at
|
|
58
|
+
|
|
59
|
+
def initialize(attributes = {})
|
|
60
|
+
@id = attributes["id"]
|
|
61
|
+
@type = attributes["type"]
|
|
62
|
+
@data = attributes["data"]
|
|
63
|
+
@created_at = attributes["created_at"]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/airwallex.rb
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "airwallex/version"
|
|
4
|
+
require_relative "airwallex/errors"
|
|
5
|
+
require_relative "airwallex/configuration"
|
|
6
|
+
require_relative "airwallex/util"
|
|
7
|
+
require_relative "airwallex/client"
|
|
8
|
+
require_relative "airwallex/webhook"
|
|
9
|
+
require_relative "airwallex/middleware/idempotency"
|
|
10
|
+
require_relative "airwallex/middleware/auth_refresh"
|
|
11
|
+
|
|
12
|
+
# API Operations
|
|
13
|
+
require_relative "airwallex/api_operations/create"
|
|
14
|
+
require_relative "airwallex/api_operations/retrieve"
|
|
15
|
+
require_relative "airwallex/api_operations/list"
|
|
16
|
+
require_relative "airwallex/api_operations/update"
|
|
17
|
+
require_relative "airwallex/api_operations/delete"
|
|
18
|
+
|
|
19
|
+
# Core classes
|
|
20
|
+
require_relative "airwallex/list_object"
|
|
21
|
+
require_relative "airwallex/api_resource"
|
|
22
|
+
|
|
23
|
+
# Resources
|
|
24
|
+
require_relative "airwallex/resources/payment_intent"
|
|
25
|
+
require_relative "airwallex/resources/transfer"
|
|
26
|
+
require_relative "airwallex/resources/beneficiary"
|
|
27
|
+
|
|
28
|
+
module Airwallex
|
|
29
|
+
class << self
|
|
30
|
+
attr_writer :configuration
|
|
31
|
+
|
|
32
|
+
def configuration
|
|
33
|
+
@configuration ||= Configuration.new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def configure
|
|
37
|
+
yield(configuration)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def client
|
|
41
|
+
@client ||= Client.new(configuration)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reset!
|
|
45
|
+
@configuration = Configuration.new
|
|
46
|
+
@client = nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/sig/airwallex.rbs
ADDED