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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +22 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +377 -0
  5. data/Rakefile +12 -0
  6. data/docs/internal/20251125_iteration_1_quickstart.md +130 -0
  7. data/docs/internal/20251125_iteration_1_summary.md +342 -0
  8. data/docs/internal/20251125_sprint_1_completed.md +448 -0
  9. data/docs/internal/20251125_sprint_1_plan.md +389 -0
  10. data/docs/internal/20251125_sprint_2_completed.md +559 -0
  11. data/docs/internal/20251125_sprint_2_plan.md +531 -0
  12. data/docs/internal/20251125_sprint_2_unit_tests_completed.md +264 -0
  13. data/docs/research/Airwallex API Endpoint Research.md +410 -0
  14. data/docs/research/Airwallex API Research for Ruby Gem.md +383 -0
  15. data/lib/airwallex/api_operations/create.rb +16 -0
  16. data/lib/airwallex/api_operations/delete.rb +16 -0
  17. data/lib/airwallex/api_operations/list.rb +23 -0
  18. data/lib/airwallex/api_operations/retrieve.rb +16 -0
  19. data/lib/airwallex/api_operations/update.rb +44 -0
  20. data/lib/airwallex/api_resource.rb +96 -0
  21. data/lib/airwallex/client.rb +132 -0
  22. data/lib/airwallex/configuration.rb +67 -0
  23. data/lib/airwallex/errors.rb +64 -0
  24. data/lib/airwallex/list_object.rb +85 -0
  25. data/lib/airwallex/middleware/auth_refresh.rb +32 -0
  26. data/lib/airwallex/middleware/idempotency.rb +29 -0
  27. data/lib/airwallex/resources/beneficiary.rb +14 -0
  28. data/lib/airwallex/resources/payment_intent.rb +44 -0
  29. data/lib/airwallex/resources/transfer.rb +23 -0
  30. data/lib/airwallex/util.rb +58 -0
  31. data/lib/airwallex/version.rb +5 -0
  32. data/lib/airwallex/webhook.rb +67 -0
  33. data/lib/airwallex.rb +49 -0
  34. data/sig/airwallex.rbs +4 -0
  35. 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airwallex
4
+ VERSION = "0.1.0"
5
+ 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
@@ -0,0 +1,4 @@
1
+ module Airwallex
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end