airwallex-ruby 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/.gitignore +15 -0
- data/.rspec +1 -0
- data/.rubocop.yml +67 -0
- data/CHANGELOG.md +22 -0
- data/Gemfile +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +433 -0
- data/Rakefile +12 -0
- data/airwallex-ruby.gemspec +54 -0
- data/docs/airwallex-research.md +78 -0
- data/docs/release.md +66 -0
- data/examples/basic_configuration.rb +21 -0
- data/examples/payment_intent_create.rb +28 -0
- data/examples/rails_webhook_controller.rb +38 -0
- data/examples/refund_create.rb +29 -0
- data/examples/webhook_verification.rb +23 -0
- data/lib/airwallex/client.rb +203 -0
- data/lib/airwallex/configuration.rb +38 -0
- data/lib/airwallex/errors.rb +77 -0
- data/lib/airwallex/railtie.rb +6 -0
- data/lib/airwallex/resources/authentication.rb +28 -0
- data/lib/airwallex/resources/base_resource.rb +47 -0
- data/lib/airwallex/resources/payment_intents.rb +33 -0
- data/lib/airwallex/resources/refunds.rb +22 -0
- data/lib/airwallex/version.rb +5 -0
- data/lib/airwallex/webhook.rb +94 -0
- data/lib/airwallex.rb +38 -0
- data/lib/generators/airwallex/install/install_generator.rb +13 -0
- data/lib/generators/airwallex/install/templates/airwallex.rb +10 -0
- metadata +195 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Airwallex API Research
|
|
2
|
+
|
|
3
|
+
Notes for building the unofficial `airwallex-ruby` SDK. Phase 1 uses this document only — no live API calls yet.
|
|
4
|
+
|
|
5
|
+
## Official documentation
|
|
6
|
+
|
|
7
|
+
- API reference: https://www.airwallex.com/docs/api
|
|
8
|
+
- Authentication: https://www.airwallex.com/docs/api/authentication/api_access/login
|
|
9
|
+
- Developer tools: https://www.airwallex.com/docs/developer-tools/api/quickstart-with-postman
|
|
10
|
+
- Manage API keys: https://www.airwallex.com/docs/developer-tools/api/manage-api-keys
|
|
11
|
+
|
|
12
|
+
## Base URLs
|
|
13
|
+
|
|
14
|
+
| Environment | Base URL |
|
|
15
|
+
|-------------|----------|
|
|
16
|
+
| Sandbox (demo) | `https://api-demo.airwallex.com/api/v1` |
|
|
17
|
+
| Production | `https://api.airwallex.com/api/v1` |
|
|
18
|
+
|
|
19
|
+
## Authentication
|
|
20
|
+
|
|
21
|
+
**Endpoint:** `POST /authentication/login`
|
|
22
|
+
|
|
23
|
+
**Required headers:**
|
|
24
|
+
|
|
25
|
+
- `x-client-id` — Airwallex Client ID
|
|
26
|
+
- `x-api-key` — Airwallex API key
|
|
27
|
+
|
|
28
|
+
**Response:** JSON with `token` and `expires_at`. Use the token as `Authorization: Bearer <token>` on subsequent requests.
|
|
29
|
+
|
|
30
|
+
**Implementation notes:**
|
|
31
|
+
|
|
32
|
+
- Do not call the login endpoint before every request.
|
|
33
|
+
- Cache the token in memory and reuse it until `expires_at`.
|
|
34
|
+
- Refresh only when expired or after a 401 response.
|
|
35
|
+
|
|
36
|
+
Optional header for scoped keys:
|
|
37
|
+
|
|
38
|
+
- `x-login-as` — target account ID when the API key is scoped to multiple accounts
|
|
39
|
+
|
|
40
|
+
## Current implemented resources
|
|
41
|
+
|
|
42
|
+
**Implemented:**
|
|
43
|
+
|
|
44
|
+
- Authentication
|
|
45
|
+
- PaymentIntents
|
|
46
|
+
- Refunds
|
|
47
|
+
- Webhook verification
|
|
48
|
+
- Rails initializer generator
|
|
49
|
+
|
|
50
|
+
**Planned:**
|
|
51
|
+
|
|
52
|
+
- PaymentIntent confirm/capture
|
|
53
|
+
- Customers
|
|
54
|
+
- PaymentConsents
|
|
55
|
+
- Transfers
|
|
56
|
+
- Balances
|
|
57
|
+
- Transactions
|
|
58
|
+
|
|
59
|
+
## Planned resources (initial scope)
|
|
60
|
+
|
|
61
|
+
| Resource | Purpose |
|
|
62
|
+
|----------|---------|
|
|
63
|
+
| Authentication | Obtain and manage access tokens |
|
|
64
|
+
| PaymentIntents | Payment acceptance — create, confirm, capture, cancel |
|
|
65
|
+
| Refunds | Refund captured payments |
|
|
66
|
+
| Webhooks | Verify webhook signatures and parse events |
|
|
67
|
+
| Balances | Query multi-currency account balances |
|
|
68
|
+
| Transfers | Payouts to beneficiaries |
|
|
69
|
+
|
|
70
|
+
## Phase roadmap
|
|
71
|
+
|
|
72
|
+
Phases 1–10 are complete (foundation, HTTP client, authentication, PaymentIntents, Refunds, idempotency, webhooks, Rails integration). Phase 11 covers documentation and developer experience. Future phases add more API resources and integration test mode.
|
|
73
|
+
|
|
74
|
+
## References
|
|
75
|
+
|
|
76
|
+
- Postman collection uses API version header `x-api-version` (e.g. `2025-11-11`)
|
|
77
|
+
- Write operations typically require a `request_id` (UUID) for idempotency
|
|
78
|
+
- Sandbox file uploads use `https://files-demo.airwallex.com`; production uses `https://files.airwallex.com`
|
data/docs/release.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Release Checklist
|
|
2
|
+
|
|
3
|
+
## Before release
|
|
4
|
+
|
|
5
|
+
- bundle install
|
|
6
|
+
- bundle exec rspec
|
|
7
|
+
- bundle exec rubocop
|
|
8
|
+
- bundle exec rake build
|
|
9
|
+
- gem install ./pkg/airwallex-ruby-0.1.0.gem
|
|
10
|
+
- irb
|
|
11
|
+
- require "airwallex"
|
|
12
|
+
- Airwallex::VERSION
|
|
13
|
+
|
|
14
|
+
## Git release
|
|
15
|
+
|
|
16
|
+
- git status
|
|
17
|
+
- git add .
|
|
18
|
+
- git commit -m "Prepare v0.1.0 release"
|
|
19
|
+
- git push origin develop
|
|
20
|
+
|
|
21
|
+
Publishing a GitHub Release from **develop** triggers the [Release workflow](../.github/workflows/release.yml), which:
|
|
22
|
+
|
|
23
|
+
1. Merges `develop` into `master`
|
|
24
|
+
2. Runs RuboCop and RSpec
|
|
25
|
+
3. Builds the gem
|
|
26
|
+
4. Publishes to RubyGems
|
|
27
|
+
5. Attaches the `.gem` file to the GitHub Release
|
|
28
|
+
|
|
29
|
+
### Create the release on GitHub
|
|
30
|
+
|
|
31
|
+
1. Open **Releases → Draft a new release**
|
|
32
|
+
2. Set **Target** to `develop`
|
|
33
|
+
3. Create tag `v0.1.0` (must match `Airwallex::VERSION`)
|
|
34
|
+
4. Add release notes and click **Publish release**
|
|
35
|
+
|
|
36
|
+
### GitHub Actions setup
|
|
37
|
+
|
|
38
|
+
Preferred: configure [RubyGems trusted publishing](https://guides.rubygems.org/trusted-publishing/) for this repository with workflow file `release.yml`.
|
|
39
|
+
|
|
40
|
+
Fallback: add a repository secret:
|
|
41
|
+
|
|
42
|
+
| Secret | Description |
|
|
43
|
+
|--------|-------------|
|
|
44
|
+
| `RUBYGEMS_API_KEY` | RubyGems API key with push access |
|
|
45
|
+
|
|
46
|
+
Because this gem sets `rubygems_mfa_required`, create the API key at [rubygems.org](https://rubygems.org/sign_in) after MFA is enabled. The key must include push permission for `airwallex-ruby`.
|
|
47
|
+
|
|
48
|
+
Manual release retry (without publishing a new GitHub Release):
|
|
49
|
+
|
|
50
|
+
1. Open **Actions → Release → Run workflow**
|
|
51
|
+
2. Enter the tag (for example `v0.1.0`)
|
|
52
|
+
|
|
53
|
+
The workflow verifies that the tag matches `Airwallex::VERSION` before publishing.
|
|
54
|
+
|
|
55
|
+
## RubyGems release
|
|
56
|
+
|
|
57
|
+
- gem push pkg/airwallex-ruby-0.1.0.gem
|
|
58
|
+
|
|
59
|
+
Only publish after final review.
|
|
60
|
+
|
|
61
|
+
## Post-release verification
|
|
62
|
+
|
|
63
|
+
- gem install airwallex-ruby
|
|
64
|
+
- irb
|
|
65
|
+
- require "airwallex"
|
|
66
|
+
- Airwallex::VERSION
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Basic global configuration and client access.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# AIRWALLEX_CLIENT_ID=... AIRWALLEX_API_KEY=... ruby examples/basic_configuration.rb
|
|
7
|
+
|
|
8
|
+
require "airwallex"
|
|
9
|
+
|
|
10
|
+
Airwallex.configure do |config|
|
|
11
|
+
config.client_id = ENV["AIRWALLEX_CLIENT_ID"]
|
|
12
|
+
config.api_key = ENV["AIRWALLEX_API_KEY"]
|
|
13
|
+
config.login_as = ENV["AIRWALLEX_LOGIN_AS"]
|
|
14
|
+
config.environment = :demo
|
|
15
|
+
config.timeout = 30
|
|
16
|
+
config.open_timeout = 10
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
client = Airwallex.client
|
|
20
|
+
|
|
21
|
+
puts "Airwallex client ready (#{client.environment} environment)"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Create a PaymentIntent with an idempotency key.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# AIRWALLEX_CLIENT_ID=... AIRWALLEX_API_KEY=... ruby examples/payment_intent_create.rb
|
|
7
|
+
|
|
8
|
+
require "airwallex"
|
|
9
|
+
|
|
10
|
+
client = Airwallex::Client.new(
|
|
11
|
+
client_id: ENV["AIRWALLEX_CLIENT_ID"],
|
|
12
|
+
api_key: ENV["AIRWALLEX_API_KEY"],
|
|
13
|
+
login_as: ENV["AIRWALLEX_LOGIN_AS"],
|
|
14
|
+
environment: :demo
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
payment_intent = client.payment_intents.create(
|
|
18
|
+
{
|
|
19
|
+
amount: 1000,
|
|
20
|
+
currency: "PHP",
|
|
21
|
+
merchant_order_id: "ORDER-1001",
|
|
22
|
+
return_url: "https://example.com/return"
|
|
23
|
+
},
|
|
24
|
+
idempotency_key: "order-1001-create"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
puts payment_intent["id"]
|
|
28
|
+
puts payment_intent["client_secret"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Example Airwallex webhook controller for Rails.
|
|
4
|
+
# Copy into app/controllers/airwallex_webhooks_controller.rb
|
|
5
|
+
#
|
|
6
|
+
# Route (config/routes.rb):
|
|
7
|
+
# post "/webhooks/airwallex", to: "airwallex_webhooks#create"
|
|
8
|
+
#
|
|
9
|
+
# class AirwallexWebhooksController < ApplicationController
|
|
10
|
+
# skip_before_action :verify_authenticity_token
|
|
11
|
+
#
|
|
12
|
+
# def create
|
|
13
|
+
# event = Airwallex::Webhook.construct_event(
|
|
14
|
+
# payload: request.body.read,
|
|
15
|
+
# signature: request.headers["x-signature"],
|
|
16
|
+
# timestamp: request.headers["x-timestamp"],
|
|
17
|
+
# secret: webhook_secret
|
|
18
|
+
# )
|
|
19
|
+
#
|
|
20
|
+
# case event["name"]
|
|
21
|
+
# when "payment_intent.succeeded"
|
|
22
|
+
# # handle payment success
|
|
23
|
+
# when "refund.accepted"
|
|
24
|
+
# # handle refund accepted
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# head :ok
|
|
28
|
+
# rescue Airwallex::WebhookSignatureError, Airwallex::InvalidResponseError
|
|
29
|
+
# head :bad_request
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# private
|
|
33
|
+
#
|
|
34
|
+
# def webhook_secret
|
|
35
|
+
# Rails.application.credentials.dig(:airwallex, :webhook_secret) ||
|
|
36
|
+
# ENV.fetch("AIRWALLEX_WEBHOOK_SECRET")
|
|
37
|
+
# end
|
|
38
|
+
# end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Create a refund with an idempotency key.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# AIRWALLEX_CLIENT_ID=... AIRWALLEX_API_KEY=... ruby examples/refund_create.rb
|
|
7
|
+
|
|
8
|
+
require "airwallex"
|
|
9
|
+
|
|
10
|
+
client = Airwallex::Client.new(
|
|
11
|
+
client_id: ENV["AIRWALLEX_CLIENT_ID"],
|
|
12
|
+
api_key: ENV["AIRWALLEX_API_KEY"],
|
|
13
|
+
login_as: ENV["AIRWALLEX_LOGIN_AS"],
|
|
14
|
+
environment: :demo
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
refund = client.refunds.create(
|
|
18
|
+
{
|
|
19
|
+
payment_intent_id: "int_123",
|
|
20
|
+
amount: 500,
|
|
21
|
+
reason: "requested_by_customer",
|
|
22
|
+
metadata: {
|
|
23
|
+
order_id: "ORDER-1001"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
idempotency_key: "order-1001-refund-1"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
puts refund["id"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Verify an Airwallex webhook payload and parse the event.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# AIRWALLEX_WEBHOOK_SECRET=... ruby examples/webhook_verification.rb
|
|
7
|
+
|
|
8
|
+
require "airwallex"
|
|
9
|
+
require "openssl"
|
|
10
|
+
|
|
11
|
+
secret = ENV.fetch("AIRWALLEX_WEBHOOK_SECRET")
|
|
12
|
+
payload = '{"name":"payment_intent.succeeded","data":{}}'
|
|
13
|
+
timestamp = Time.now.to_i.to_s
|
|
14
|
+
signature = OpenSSL::HMAC.hexdigest("SHA256", secret, timestamp + payload)
|
|
15
|
+
|
|
16
|
+
event = Airwallex::Webhook.construct_event(
|
|
17
|
+
payload: payload,
|
|
18
|
+
signature: signature,
|
|
19
|
+
timestamp: timestamp,
|
|
20
|
+
secret: secret
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
puts event["name"]
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "faraday"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Airwallex
|
|
8
|
+
class Client
|
|
9
|
+
DEFAULT_HEADERS = {
|
|
10
|
+
"Content-Type" => "application/json",
|
|
11
|
+
"Accept" => "application/json"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
TOKEN_EXPIRY_BUFFER = 60
|
|
15
|
+
|
|
16
|
+
attr_reader :client_id, :api_key, :login_as, :environment, :timeout, :open_timeout, :logger,
|
|
17
|
+
:access_token, :token_expires_at
|
|
18
|
+
|
|
19
|
+
def initialize(**options)
|
|
20
|
+
config = Airwallex.configuration
|
|
21
|
+
|
|
22
|
+
@client_id = options.fetch(:client_id, config.client_id)
|
|
23
|
+
@api_key = options.fetch(:api_key, config.api_key)
|
|
24
|
+
@login_as = options.key?(:login_as) ? options[:login_as] : config.login_as
|
|
25
|
+
@environment = Configuration.validate_environment!(options.fetch(:environment, config.environment))
|
|
26
|
+
@timeout = options.fetch(:timeout, config.timeout)
|
|
27
|
+
@open_timeout = options.fetch(:open_timeout, config.open_timeout)
|
|
28
|
+
@logger = options.fetch(:logger, config.logger)
|
|
29
|
+
@access_token = nil
|
|
30
|
+
@token_expires_at = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def base_url
|
|
34
|
+
Configuration::ENVIRONMENTS.fetch(environment)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def authentication
|
|
38
|
+
@authentication ||= Resources::Authentication.new(self)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def payment_intents
|
|
42
|
+
@payment_intents ||= Resources::PaymentIntents.new(self)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def refunds
|
|
46
|
+
@refunds ||= Resources::Refunds.new(self)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def authenticate
|
|
50
|
+
authentication.login
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def authenticated?
|
|
54
|
+
!access_token.nil? && !token_expired?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def auth_headers
|
|
58
|
+
{ "Authorization" => "Bearer #{access_token}" }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def get(path, params = {}, headers = {}, authenticated: true)
|
|
62
|
+
request(:get, path, params: params, headers: headers, authenticated: authenticated)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def post(path, body = {}, headers = {}, authenticated: true, idempotency_key: nil)
|
|
66
|
+
validate_idempotency_key!(idempotency_key)
|
|
67
|
+
request(:post, path, body: body, headers: headers, authenticated: authenticated,
|
|
68
|
+
idempotency_key: idempotency_key)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def patch(path, body = {}, headers = {}, authenticated: true, idempotency_key: nil)
|
|
72
|
+
validate_idempotency_key!(idempotency_key)
|
|
73
|
+
request(:patch, path, body: body, headers: headers, authenticated: authenticated,
|
|
74
|
+
idempotency_key: idempotency_key)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def delete(path, params = {}, headers = {}, authenticated: true)
|
|
78
|
+
request(:delete, path, params: params, headers: headers, authenticated: authenticated)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate_credentials!
|
|
82
|
+
raise ConfigurationError, "client_id is required" if client_id.nil? || client_id.to_s.empty?
|
|
83
|
+
raise ConfigurationError, "api_key is required" if api_key.nil? || api_key.to_s.empty?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def store_token!(response)
|
|
87
|
+
token = response["token"]
|
|
88
|
+
raise AuthenticationError, "Authentication response missing token" if token.nil? || token.to_s.empty?
|
|
89
|
+
|
|
90
|
+
@access_token = token
|
|
91
|
+
@token_expires_at = parse_expires_at(response["expires_at"])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def request(method, path, params: nil, body: nil, headers: {}, authenticated: true,
|
|
97
|
+
idempotency_key: nil)
|
|
98
|
+
ensure_authenticated! if authenticated
|
|
99
|
+
|
|
100
|
+
response = connection.run_request(
|
|
101
|
+
method,
|
|
102
|
+
request_url(path),
|
|
103
|
+
body,
|
|
104
|
+
merge_headers(headers, authenticated: authenticated, idempotency_key: idempotency_key)
|
|
105
|
+
) do |req|
|
|
106
|
+
req.params.update(params) if params && !params.empty?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
handle_response(response)
|
|
110
|
+
rescue Faraday::TimeoutError => e
|
|
111
|
+
raise TimeoutError, e.message
|
|
112
|
+
rescue Faraday::ConnectionFailed => e
|
|
113
|
+
raise TimeoutError, e.message if timeout_error?(e)
|
|
114
|
+
|
|
115
|
+
raise
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def ensure_authenticated!
|
|
119
|
+
authenticate unless authenticated?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def token_expired?
|
|
123
|
+
return true if access_token.nil? || token_expires_at.nil?
|
|
124
|
+
|
|
125
|
+
Time.now >= (token_expires_at - TOKEN_EXPIRY_BUFFER)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def parse_expires_at(value)
|
|
129
|
+
if value.nil? || value.to_s.strip.empty?
|
|
130
|
+
raise AuthenticationError, "Authentication response has invalid expires_at"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
case value
|
|
134
|
+
when Time
|
|
135
|
+
value
|
|
136
|
+
when Integer, Float
|
|
137
|
+
Time.at(value)
|
|
138
|
+
when String
|
|
139
|
+
Time.parse(value)
|
|
140
|
+
else
|
|
141
|
+
raise AuthenticationError, "Authentication response has invalid expires_at"
|
|
142
|
+
end
|
|
143
|
+
rescue ::ArgumentError, ::TypeError
|
|
144
|
+
raise AuthenticationError, "Authentication response has invalid expires_at"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def timeout_error?(error)
|
|
148
|
+
cause = error.wrapped_exception
|
|
149
|
+
cause.is_a?(Net::OpenTimeout) ||
|
|
150
|
+
cause.is_a?(Net::ReadTimeout) ||
|
|
151
|
+
cause.is_a?(Timeout::Error) ||
|
|
152
|
+
error.message.match?(/timeout|timed out/i)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def request_url(path)
|
|
156
|
+
path = path.to_s
|
|
157
|
+
return path if path.start_with?("http://", "https://")
|
|
158
|
+
|
|
159
|
+
path = "/#{path}" unless path.start_with?("/")
|
|
160
|
+
"#{base_url}#{path}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def connection
|
|
164
|
+
@connection ||= Faraday.new do |conn|
|
|
165
|
+
conn.options.timeout = timeout
|
|
166
|
+
conn.options.open_timeout = open_timeout
|
|
167
|
+
conn.request :json
|
|
168
|
+
conn.adapter Faraday.default_adapter
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def merge_headers(headers, authenticated:, idempotency_key: nil)
|
|
173
|
+
merged = DEFAULT_HEADERS.merge(headers)
|
|
174
|
+
merged = merged.merge(auth_headers) if authenticated
|
|
175
|
+
merged["x-idempotency-key"] = idempotency_key if idempotency_key
|
|
176
|
+
merged
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def validate_idempotency_key!(idempotency_key)
|
|
180
|
+
return if idempotency_key.nil?
|
|
181
|
+
|
|
182
|
+
return if idempotency_key.is_a?(String) && !idempotency_key.strip.empty?
|
|
183
|
+
|
|
184
|
+
raise ArgumentError, "idempotency_key must be a non-empty String"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def handle_response(response)
|
|
188
|
+
parsed_body = parse_body(response.body)
|
|
189
|
+
|
|
190
|
+
return parsed_body if response.status.between?(200, 299)
|
|
191
|
+
|
|
192
|
+
HTTPError.raise_for_response!(response.status, parsed_body, response.body)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def parse_body(body)
|
|
196
|
+
return {} if body.nil? || body.to_s.strip.empty?
|
|
197
|
+
|
|
198
|
+
JSON.parse(body)
|
|
199
|
+
rescue JSON::ParserError => e
|
|
200
|
+
raise InvalidResponseError, "Invalid JSON response: #{e.message}"
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airwallex
|
|
4
|
+
class Configuration
|
|
5
|
+
ENVIRONMENTS = {
|
|
6
|
+
demo: "https://api-demo.airwallex.com/api/v1",
|
|
7
|
+
production: "https://api.airwallex.com/api/v1"
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
10
|
+
attr_accessor :client_id, :api_key, :login_as, :timeout, :open_timeout, :logger
|
|
11
|
+
attr_reader :environment
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@environment = :demo
|
|
15
|
+
@timeout = 30
|
|
16
|
+
@open_timeout = 10
|
|
17
|
+
@logger = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def environment=(value)
|
|
21
|
+
self.class.validate_environment!(value)
|
|
22
|
+
@environment = value
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def base_url
|
|
26
|
+
ENVIRONMENTS.fetch(environment)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
alias api_base_url base_url
|
|
30
|
+
|
|
31
|
+
def self.validate_environment!(environment)
|
|
32
|
+
return environment if ENVIRONMENTS.key?(environment)
|
|
33
|
+
|
|
34
|
+
valid = ENVIRONMENTS.keys.map(&:inspect).join(", ")
|
|
35
|
+
raise ConfigurationError, "Invalid environment: #{environment.inspect}. Valid environments are: #{valid}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airwallex
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class ConfigurationError < Error
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class ArgumentError < Error
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class AuthenticationError < Error
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class HTTPError < Error
|
|
17
|
+
attr_reader :status, :code, :source, :details, :response_body
|
|
18
|
+
|
|
19
|
+
def initialize(message = nil, **attrs)
|
|
20
|
+
super(message)
|
|
21
|
+
@status = attrs[:status]
|
|
22
|
+
@code = attrs[:code]
|
|
23
|
+
@source = attrs[:source]
|
|
24
|
+
@details = attrs[:details]
|
|
25
|
+
@response_body = attrs[:response_body]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.raise_for_response!(status, parsed_body, raw_body)
|
|
29
|
+
fields = parsed_body.is_a?(Hash) ? parsed_body : {}
|
|
30
|
+
raise error_class_for(status).new(
|
|
31
|
+
extract_message(parsed_body, status),
|
|
32
|
+
status: status,
|
|
33
|
+
code: fields["code"],
|
|
34
|
+
source: fields["source"],
|
|
35
|
+
details: fields["details"],
|
|
36
|
+
response_body: raw_body
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.error_class_for(status)
|
|
41
|
+
return ServerError if status.between?(500, 599)
|
|
42
|
+
|
|
43
|
+
status_error_map.fetch(status, HTTPError)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.extract_message(parsed_body, status)
|
|
47
|
+
return parsed_body["message"] if parsed_body.is_a?(Hash) && parsed_body["message"]
|
|
48
|
+
return parsed_body["code"] if parsed_body.is_a?(Hash) && parsed_body["code"]
|
|
49
|
+
|
|
50
|
+
"HTTP #{status}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.status_error_map
|
|
54
|
+
@status_error_map ||= {
|
|
55
|
+
400 => BadRequestError,
|
|
56
|
+
401 => UnauthorizedError,
|
|
57
|
+
403 => ForbiddenError,
|
|
58
|
+
404 => NotFoundError,
|
|
59
|
+
409 => ConflictError,
|
|
60
|
+
429 => RateLimitError
|
|
61
|
+
}.freeze
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private_class_method :error_class_for, :extract_message, :status_error_map
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class BadRequestError < HTTPError; end
|
|
68
|
+
class UnauthorizedError < HTTPError; end
|
|
69
|
+
class ForbiddenError < HTTPError; end
|
|
70
|
+
class NotFoundError < HTTPError; end
|
|
71
|
+
class ConflictError < HTTPError; end
|
|
72
|
+
class RateLimitError < HTTPError; end
|
|
73
|
+
class ServerError < HTTPError; end
|
|
74
|
+
class TimeoutError < HTTPError; end
|
|
75
|
+
class InvalidResponseError < HTTPError; end
|
|
76
|
+
class WebhookSignatureError < Error; end
|
|
77
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airwallex
|
|
4
|
+
module Resources
|
|
5
|
+
class Authentication < BaseResource
|
|
6
|
+
LOGIN_PATH = "/authentication/login"
|
|
7
|
+
|
|
8
|
+
def login
|
|
9
|
+
client.validate_credentials!
|
|
10
|
+
|
|
11
|
+
response = post(LOGIN_PATH, {}, authentication_headers, authenticated: false)
|
|
12
|
+
client.store_token!(response)
|
|
13
|
+
response
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def authentication_headers
|
|
19
|
+
headers = {
|
|
20
|
+
"x-client-id" => client.client_id,
|
|
21
|
+
"x-api-key" => client.api_key
|
|
22
|
+
}
|
|
23
|
+
headers["x-login-as"] = client.login_as if client.login_as
|
|
24
|
+
headers
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airwallex
|
|
4
|
+
module Resources
|
|
5
|
+
class BaseResource
|
|
6
|
+
attr_reader :client
|
|
7
|
+
|
|
8
|
+
def initialize(client)
|
|
9
|
+
@client = client
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def get(path, params = {}, headers = {}, **options)
|
|
15
|
+
client.get(path, params, headers, **options)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def post(path, body = {}, headers = {}, **options)
|
|
19
|
+
client.post(path, body, headers, **options)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def patch(path, body = {}, headers = {}, **options)
|
|
23
|
+
client.patch(path, body, headers, **options)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def delete(path, params = {}, headers = {}, **options)
|
|
27
|
+
client.delete(path, params, headers, **options)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_idempotency_key!(idempotency_key)
|
|
31
|
+
return if idempotency_key.nil?
|
|
32
|
+
|
|
33
|
+
return if idempotency_key.is_a?(String) && !idempotency_key.strip.empty?
|
|
34
|
+
|
|
35
|
+
raise Airwallex::ArgumentError, "idempotency_key must be a non-empty String"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def validate_id!(id, name = "id")
|
|
39
|
+
raise Airwallex::ArgumentError, "#{name} is required" if id.nil? || id.to_s.strip.empty?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def validate_params!(params)
|
|
43
|
+
raise Airwallex::ArgumentError, "params must be a Hash" unless params.is_a?(Hash)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|