From 796f813ecb519efce81738d2e8a90789a4ad2831 Mon Sep 17 00:00:00 2001 From: Ryan Duguid <152749594+ryanduguid@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:34:04 +1000 Subject: [PATCH 1/2] fix: wire Payroll AU v2 client routing --- lib/xero-ruby/api_client.rb | 9 +++++++++ lib/xero-ruby/configuration.rb | 2 ++ spec/api_client_spec.rb | 13 +++++++++++++ spec/configuration_spec.rb | 1 + 4 files changed, 25 insertions(+) diff --git a/lib/xero-ruby/api_client.rb b/lib/xero-ruby/api_client.rb index dc9d679a..8e7c309f 100644 --- a/lib/xero-ruby/api_client.rb +++ b/lib/xero-ruby/api_client.rb @@ -95,6 +95,11 @@ def payroll_au_api XeroRuby::PayrollAuApi.new(self) end + def payroll_au_v2_api + @config.base_url = @config.payroll_au_v2_url + XeroRuby::PayrollAuV2Api.new(self) + end + def payroll_nz_api @config.base_url = @config.payroll_nz_url XeroRuby::PayrollNzApi.new(self) @@ -289,6 +294,8 @@ def call_api(http_method, path, api_client, opts = {}) method_base_url = @config.files_url when "PayrollAuApi" method_base_url = @config.payroll_au_url + when "PayrollAuV2Api" + method_base_url = @config.payroll_au_v2_url when "PayrollNzApi" method_base_url = @config.payroll_nz_url when "PayrollUkApi" @@ -517,6 +524,8 @@ def convert_to_type(data, return_type, api_client) XeroRuby::Files.const_get(return_type).build_from_hash(data) when 'PayrollAuApi' XeroRuby::PayrollAu.const_get(return_type).build_from_hash(data) + when 'PayrollAuV2Api' + XeroRuby::PayrollAuV2.const_get(return_type).build_from_hash(data) when 'PayrollNzApi' XeroRuby::PayrollNz.const_get(return_type).build_from_hash(data) when 'PayrollUkApi' diff --git a/lib/xero-ruby/configuration.rb b/lib/xero-ruby/configuration.rb index 7710f557..657f61cf 100644 --- a/lib/xero-ruby/configuration.rb +++ b/lib/xero-ruby/configuration.rb @@ -31,6 +31,7 @@ class Configuration attr_accessor :project_url attr_accessor :files_url attr_accessor :payroll_au_url + attr_accessor :payroll_au_v2_url attr_accessor :payroll_nz_url attr_accessor :payroll_uk_url attr_accessor :app_store_url @@ -149,6 +150,7 @@ def initialize @project_url = 'https://api.xero.com/projects.xro/2.0/' @files_url = 'https://api.xero.com/files.xro/1.0/' @payroll_au_url = 'https://api.xero.com/payroll.xro/1.0/' + @payroll_au_v2_url = 'https://api.xero.com/payroll.xro/2.0/' @payroll_nz_url = 'https://api.xero.com/payroll.xro/2.0/' @payroll_uk_url = 'https://api.xero.com/payroll.xro/2.0/' @app_store_url = 'https://api.xero.com/appstore/2.0/' diff --git a/spec/api_client_spec.rb b/spec/api_client_spec.rb index 617433b7..919baa5e 100644 --- a/spec/api_client_spec.rb +++ b/spec/api_client_spec.rb @@ -168,6 +168,9 @@ api_client.payroll_au_api expect(api_client.config.base_url).to eq('https://api.xero.com/payroll.xro/1.0/') + + expect(api_client.payroll_au_v2_api).to be_a(XeroRuby::PayrollAuV2Api) + expect(api_client.config.base_url).to eq('https://api.xero.com/payroll.xro/2.0/') api_client.payroll_nz_api expect(api_client.config.base_url).to eq('https://api.xero.com/payroll.xro/2.0/') @@ -221,6 +224,16 @@ expect(data).to be_instance_of(Hash) expect(data).to eq(:message => 'Hello') end + + it 'deserializes Payroll AU v2 models in their own namespace' do + api_client = XeroRuby::ApiClient.new + headers = { 'Content-Type' => 'application/json' } + response = double('response', headers: headers, body: '{}') + + data = api_client.deserialize(response, 'TimesheetObject', 'PayrollAuV2Api') + + expect(data).to be_instance_of(XeroRuby::PayrollAuV2::TimesheetObject) + end end describe "#object_to_hash modifies a hash from snake_case to PascalCase" do diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb index 3daa2666..f70300fb 100644 --- a/spec/configuration_spec.rb +++ b/spec/configuration_spec.rb @@ -12,6 +12,7 @@ expect(config.project_url).to eq('https://api.xero.com/projects.xro/2.0/') expect(config.files_url).to eq('https://api.xero.com/files.xro/1.0/') expect(config.payroll_au_url).to eq('https://api.xero.com/payroll.xro/1.0/') + expect(config.payroll_au_v2_url).to eq('https://api.xero.com/payroll.xro/2.0/') expect(config.payroll_nz_url).to eq('https://api.xero.com/payroll.xro/2.0/') expect(config.payroll_uk_url).to eq('https://api.xero.com/payroll.xro/2.0/') expect(config.finance_url).to eq('https://api.xero.com/finance.xro/1.0/') From 6e6c804619f0865759ddede38e796dcf5991b950 Mon Sep 17 00:00:00 2001 From: Ryan Duguid <152749594+ryanduguid@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:10:44 +1000 Subject: [PATCH 2/2] fix(payroll-au-v2): resolve the request base URL per call instead of shared state call_api picked method_base_url from the API class, but build_request_url built an absolute URL from @config.base_url, and Faraday's build_exclusive_url lets an absolute request URL win over the connection prefix. Payroll AU v1 and v2 share that single mutable base_url and expose byte-identical relative paths, so v2 = client.payroll_au_v2_api client.payroll_au_api v2.get_timesheets(tenant_id) GET payroll.xro/1.0//Timesheets and deserialised a v1 payload through XeroRuby::PayrollAuV2. The PayrollAuV2Api branch in call_api was inert. Thread method_base_url through build_request into build_request_url so the base URL comes from the API class that issued the call. The no-API-class branch now honours @config.base_url before falling back to accounting_url, which keeps connections and disconnect pointed at https://api.xero.com. Durability: the v2 wiring lives entirely in OpenAPI-Generator-owned files and regeneration has rewritten api_client.rb before (d400b016). The repo keeps no PR body file, so the "do not hand-edit, update the template" warning is in-file next to each hand-maintained addition, and spec/configuration_spec.rb and spec/api_client_spec.rb now fail if the accessor or config entry disappears. --- lib/xero-ruby/api_client.rb | 27 ++++++++++++++++++++------- lib/xero-ruby/configuration.rb | 3 +++ spec/api_client_spec.rb | 27 +++++++++++++++++++++++++++ spec/configuration_spec.rb | 16 ++++++++++++++++ 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/lib/xero-ruby/api_client.rb b/lib/xero-ruby/api_client.rb index 8e7c309f..14e3966d 100644 --- a/lib/xero-ruby/api_client.rb +++ b/lib/xero-ruby/api_client.rb @@ -95,6 +95,12 @@ def payroll_au_api XeroRuby::PayrollAuApi.new(self) end + # HAND-MAINTAINED: this accessor, the PayrollAuV2Api branches in `call_api` + # and `deserialize`, and `payroll_au_v2_url` in configuration.rb are not yet + # emitted by OpenAPI Generator. Regeneration overwrites this file (see commit + # d400b016), so any codegen bump must also update the generator templates or + # these are silently dropped and `payroll_au_v2_api` raises NoMethodError. + # spec/configuration_spec.rb and spec/api_client_spec.rb guard against that. def payroll_au_v2_api @config.base_url = @config.payroll_au_v2_url XeroRuby::PayrollAuV2Api.new(self) @@ -307,7 +313,9 @@ def call_api(http_method, path, api_client, opts = {}) when "FinanceApi" method_base_url = @config.finance_url else - method_base_url = @config.accounting_url + # `connections` and `disconnect` call through with no API class and set + # @config.base_url themselves, so honour it before falling back. + method_base_url = @config.base_url || @config.accounting_url end connection = Faraday.new(:url => method_base_url, :ssl => ssl_options) do |conn| @@ -321,7 +329,7 @@ def call_api(http_method, path, api_client, opts = {}) begin response = connection.public_send(http_method.to_sym.downcase) do |req| - build_request(http_method, path, req, opts) + build_request(http_method, path, req, opts, method_base_url) end if @config.debugging @@ -366,8 +374,8 @@ def return_error(response) # @option opts [Hash] :form_params Query parameters # @option opts [Object] :body HTTP body (JSON/XML) # @return A Faraday Request - def build_request(http_method, path, request, opts = {}) - url = build_request_url(path) + def build_request(http_method, path, request, opts = {}, base_url = nil) + url = build_request_url(path, base_url) http_method = http_method.to_sym.downcase header_params = @default_headers.merge(opts[:header_params] || {}) @@ -576,11 +584,16 @@ def sanitize_filename(filename) filename.gsub(/.*[\/\\]/, '') end - def build_request_url(path) - if @config.base_url + # `base_url` is resolved per call from the API class that issued the request. + # Falling back to @config.base_url would read shared mutable state that any + # later `client._api` call has already overwritten, which sends the + # request to the wrong API set while still deserialising into this one. + def build_request_url(path, base_url = nil) + base_url ||= @config.base_url + if base_url # Add leading and trailing slashes to path path = "/#{path}".gsub(/\/+/, '/') - @config.base_url + path + base_url + path else path end diff --git a/lib/xero-ruby/configuration.rb b/lib/xero-ruby/configuration.rb index 657f61cf..b384c1cc 100644 --- a/lib/xero-ruby/configuration.rb +++ b/lib/xero-ruby/configuration.rb @@ -31,6 +31,9 @@ class Configuration attr_accessor :project_url attr_accessor :files_url attr_accessor :payroll_au_url + # HAND-MAINTAINED: not emitted by OpenAPI Generator. Regeneration overwrites + # this file, so a codegen bump must also update the generator templates. + # Guarded by spec/configuration_spec.rb. attr_accessor :payroll_au_v2_url attr_accessor :payroll_nz_url attr_accessor :payroll_uk_url diff --git a/spec/api_client_spec.rb b/spec/api_client_spec.rb index 919baa5e..6169e549 100644 --- a/spec/api_client_spec.rb +++ b/spec/api_client_spec.rb @@ -566,4 +566,31 @@ end end end + + # Regression guards for hand-maintained Payroll AU v2 wiring. OpenAPI Generator + # does not emit these and regeneration overwrites api_client.rb, so if a codegen + # bump drops them these examples fail instead of the SDK silently losing v2. + describe 'payroll AU v2 wiring' do + let(:api_client) { XeroRuby::ApiClient.new } + + it 'exposes the payroll_au_v2_api accessor' do + expect(api_client).to respond_to(:payroll_au_v2_api) + expect(api_client.payroll_au_v2_api).to be_a(XeroRuby::PayrollAuV2Api) + end + + it 'resolves the request url from the calling API class, not the last-set base_url' do + api_client.payroll_au_v2_api + api_client.payroll_au_api + expect(api_client.config.base_url).to eq('https://api.xero.com/payroll.xro/1.0/') + + url = api_client.build_request_url('/Timesheets', api_client.config.payroll_au_v2_url) + expect(url).to start_with('https://api.xero.com/payroll.xro/2.0/') + expect(url).not_to start_with('https://api.xero.com/payroll.xro/1.0/') + end + + it 'still falls back to the shared base_url when no per-call base url is given' do + api_client.config.base_url = 'https://api.xero.com' + expect(api_client.build_request_url('/connections/')).to eq('https://api.xero.com/connections/') + end + end end diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb index f70300fb..590de41f 100644 --- a/spec/configuration_spec.rb +++ b/spec/configuration_spec.rb @@ -19,6 +19,22 @@ end end + # Regression guard for hand-maintained Payroll AU v2 wiring. OpenAPI Generator + # does not emit payroll_au_v2_url and regeneration overwrites configuration.rb, + # so a codegen bump that drops it fails here instead of only surfacing later as + # a NoMethodError from ApiClient#payroll_au_v2_api. + describe 'payroll_au_v2_url' do + it 'is exposed as a reader and a writer' do + expect(config).to respond_to(:payroll_au_v2_url) + expect(config).to respond_to(:payroll_au_v2_url=) + end + + it 'defaults to the payroll 2.0 base url and not the v1 one' do + expect(config.payroll_au_v2_url).to eq('https://api.xero.com/payroll.xro/2.0/') + expect(config.payroll_au_v2_url).not_to eq(config.payroll_au_url) + end + end + describe 'config' do it 'should apply the default configuration options' do client = XeroRuby::ApiClient.new(credentials: {})