Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: Ruby
on:
push:
branches:
- master
- main

pull_request:

Expand All @@ -14,7 +14,7 @@ jobs:
strategy:
matrix:
ruby:
- '3.3.4'
- "3.3.4"

steps:
- uses: actions/checkout@v4
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
## [Unreleased]

### Fixed
- `Idempotency` and `AuthRefresh` middleware are now actually registered on the Faraday connection.
Previously both classes existed but were never wired in, so the "automatic `request_id` generation"
and "retry once after a 401" behavior documented in the README did not happen at runtime.
- Fixed a bug in `AuthRefresh` where the 401-retry guard used `env[:request][:auth_retry]`, a key that
doesn't exist on `Faraday::RequestOptions` and would have raised `NoMethodError` the first time it ran.
- CI now triggers on pushes to `main` (previously configured for `master`, so pushes never ran CI).

### Changed
- `Client#request` no longer manually manages the `Authorization` header or calls
`ensure_authenticated!` directly; this is now owned by the `AuthRefresh` middleware.

## [0.3.0] - 2025-11-25

### Added
Expand Down
77 changes: 39 additions & 38 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -9,61 +9,63 @@ PATH
GEM
remote: https://rubygems.org/
specs:
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
ast (2.4.3)
bigdecimal (3.3.1)
bigdecimal (4.1.2)
crack (1.0.1)
bigdecimal
rexml
date (3.5.0)
diff-lcs (1.6.2)
docile (1.4.1)
erb (6.0.0)
faraday (2.14.0)
erb (6.0.7)
faraday (2.14.3)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-multipart (1.1.1)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.2)
faraday-net_http (3.4.4)
net-http (~> 0.5)
faraday-retry (2.3.2)
faraday-retry (2.4.0)
faraday (~> 2.0)
hashdiff (1.2.1)
io-console (0.8.1)
irb (1.15.3)
io-console (0.9.2)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
json (2.16.0)
language_server-protocol (3.17.0.5)
json (2.21.2)
language_server-protocol (3.17.0.6)
lint_roller (1.1.0)
logger (1.7.0)
multipart-post (2.4.1)
net-http (0.8.0)
net-http (0.9.1)
uri (>= 0.11.1)
parallel (1.27.0)
parser (3.3.10.0)
parallel (1.28.0)
parser (3.3.12.0)
ast (~> 2.4.1)
racc
pp (0.6.3)
pp (0.6.4)
prettyprint
prettyprint (0.2.0)
prism (1.6.0)
psych (5.2.6)
date
stringio
public_suffix (6.0.2)
prism (1.9.0)
public_suffix (7.0.5)
racc (1.8.1)
rainbow (3.1.1)
rake (13.3.1)
rdoc (6.15.1)
rake (13.4.2)
rbs (4.1.3)
logger
prism (>= 1.6.0)
tsort
rdoc (8.0.0)
erb
psych (>= 4.0.0)
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
regexp_parser (2.11.3)
reline (0.6.3)
regexp_parser (2.12.0)
reline (0.7.0)
io-console (~> 0.5)
rexml (3.4.4)
rspec (3.13.2)
Expand All @@ -75,38 +77,37 @@ GEM
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.7)
rspec-mocks (3.13.8)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-support (3.13.6)
rubocop (1.81.7)
json (~> 2.3)
rspec-support (3.13.7)
rubocop (1.90.0)
json (>= 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parallel (>= 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.48.0)
rubocop-ast (1.50.0)
parser (>= 3.3.7.2)
prism (~> 1.4)
prism (~> 1.7)
ruby-progressbar (1.13.0)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
stringio (3.1.8)
tsort (0.2.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
unicode-emoji (4.2.0)
uri (1.1.1)
webmock (3.26.1)
webmock (3.26.3)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
Expand Down
24 changes: 14 additions & 10 deletions lib/airwallex/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

module Airwallex
class Client
LOGIN_PATH = "/api/v1/authentication/login"

attr_reader :config, :access_token, :token_expires_at

def initialize(config = Airwallex.configuration)
Expand All @@ -19,11 +21,7 @@ def initialize(config = Airwallex.configuration)

def connection
@connection ||= Faraday.new(url: config.api_url) do |conn|
conn.request :json
conn.request :multipart
conn.request :retry, retry_options
conn.response :json, content_type: /\bjson$/
conn.response :logger, config.logger, { headers: true, bodies: true } if config.logger
configure_middleware(conn)

conn.headers["Content-Type"] = "application/json"
conn.headers["User-Agent"] = user_agent
Expand Down Expand Up @@ -55,10 +53,9 @@ def delete(path, params = {}, headers = {})

def authenticate!
@token_mutex.synchronize do
response = connection.post("/api/v1/authentication/login") do |req|
response = connection.post(LOGIN_PATH) do |req|
req.headers["x-client-id"] = config.client_id
req.headers["x-api-key"] = config.api_key
req.headers.delete("Authorization")
end

handle_response_errors(response)
Expand All @@ -85,12 +82,9 @@ def ensure_authenticated!
private

def request(method, path, data, headers)
ensure_authenticated!

response = connection.public_send(method) do |req|
req.url(path)
req.headers.merge!(headers)
req.headers["Authorization"] = "Bearer #{access_token}"

case method
when :get, :delete
Expand All @@ -104,6 +98,16 @@ def request(method, path, data, headers)
response.body
end

def configure_middleware(conn)
conn.use Airwallex::Middleware::Idempotency
conn.request :json
conn.request :multipart
conn.request :retry, retry_options
conn.use Airwallex::Middleware::AuthRefresh, self
conn.response :json, content_type: /\bjson$/
conn.response :logger, config.logger, { headers: true, bodies: true } if config.logger
end

def handle_response_errors(response)
return if response.success?

Expand Down
28 changes: 21 additions & 7 deletions lib/airwallex/middleware/auth_refresh.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,38 @@ def initialize(app, client)
end

def call(env)
# Skip authentication refresh for login endpoint
return @app.call(env) if env[:url].path.include?("/authentication/login")
# Skip authentication entirely for the login endpoint itself
return @app.call(env) if login_request?(env)

# Ensure token is valid before making request
@client.ensure_authenticated! unless env[:url].path.include?("/authentication/")
# Ensure token is valid before making the request, then attach it
@client.ensure_authenticated! unless authentication_request?(env)
authorize!(env)

response = @app.call(env)

# If we get a 401, try refreshing the token and retrying once
if response.status == 401 && !env[:request].fetch(:auth_retry, false)
if response.status == 401
@client.authenticate!
env[:request][:auth_retry] = true
env[:request_headers]["Authorization"] = "Bearer #{@client.access_token}"
authorize!(env)
response = @app.call(env)
end

response
end

private

def login_request?(env)
env[:url].path.include?(Client::LOGIN_PATH)
end

def authentication_request?(env)
env[:url].path.include?("/authentication/")
end

def authorize!(env)
env[:request_headers]["Authorization"] = "Bearer #{@client.access_token}"
end
end
end
end
15 changes: 2 additions & 13 deletions spec/airwallex/api_operations/create_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,7 @@ def self.resource_path
end
end

let(:auth_response) do
{
status: 200,
body: { token: "test_token" }.to_json,
headers: { "Content-Type" => "application/json" }
}
end

before do
stub_request(:post, "https://api-demo.airwallex.com/api/v1/authentication/login")
.to_return(auth_response)

stub_const("TestResource", test_class)
end

Expand All @@ -46,7 +35,7 @@ def self.resource_path
end

before do
stub_request(:post, "https://api-demo.airwallex.com/api/v1/test_resources/create")
stub_request(:post, "#{BASE_URL}/api/v1/test_resources/create")
.with(body: hash_including(create_params))
.to_return(
status: 200,
Expand All @@ -67,7 +56,7 @@ def self.resource_path
it "sends POST request to create endpoint" do
TestResource.create(create_params)

expect(WebMock).to have_requested(:post, "https://api-demo.airwallex.com/api/v1/test_resources/create")
expect(WebMock).to have_requested(:post, "#{BASE_URL}/api/v1/test_resources/create")
.with(body: hash_including(create_params))
end

Expand Down
15 changes: 2 additions & 13 deletions spec/airwallex/api_operations/delete_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,13 @@ def self.resource_path
end
end

let(:auth_response) do
{
status: 200,
body: { token: "test_token" }.to_json,
headers: { "Content-Type" => "application/json" }
}
end

before do
stub_request(:post, "https://api-demo.airwallex.com/api/v1/authentication/login")
.to_return(auth_response)

stub_const("TestResource", test_class)
end

describe ".delete" do
before do
stub_request(:delete, "https://api-demo.airwallex.com/api/v1/test_resources/test_123")
stub_request(:delete, "#{BASE_URL}/api/v1/test_resources/test_123")
.to_return(
status: 200,
body: {}.to_json,
Expand All @@ -47,7 +36,7 @@ def self.resource_path
it "sends DELETE request" do
TestResource.delete("test_123")

expect(WebMock).to have_requested(:delete, "https://api-demo.airwallex.com/api/v1/test_resources/test_123")
expect(WebMock).to have_requested(:delete, "#{BASE_URL}/api/v1/test_resources/test_123")
end
end
end
Loading
Loading