diff --git a/lib/app_store_connect_api/api_error.rb b/lib/app_store_connect_api/api_error.rb index c4aaff4..3688d25 100644 --- a/lib/app_store_connect_api/api_error.rb +++ b/lib/app_store_connect_api/api_error.rb @@ -6,7 +6,7 @@ class ApiError < StandardError def initialize(errors) @errors = errors - super 'App Store Connect API request failed' + super('App Store Connect API request failed') end def code diff --git a/lib/app_store_connect_api/authorization.rb b/lib/app_store_connect_api/authorization.rb index 6166898..9baebcd 100644 --- a/lib/app_store_connect_api/authorization.rb +++ b/lib/app_store_connect_api/authorization.rb @@ -3,11 +3,14 @@ module AppStoreConnectApi class Authorization ALGORITHM = 'ES256' + TOKEN_AUDIENCE = "appstoreconnect-v1" + TOKEN_ENTERPRISE_AUDIENCE = "apple-developer-enterprise-v1" - def initialize(issuer_id, key_id, private_key) + def initialize(issuer_id, key_id, private_key, is_enterprise_account: false) @issuer_id = issuer_id @key_id = key_id @private_key = private_key + @is_enterprise_account = is_enterprise_account end def token @@ -21,7 +24,11 @@ def payload iss: @issuer_id, iat: Time.now.to_i, exp: Time.now.to_i + (20 * 60), - aud: 'appstoreconnect-v1' + aud: if @is_enterprise_account + TOKEN_ENTERPRISE_AUDIENCE + else + TOKEN_AUDIENCE + end } end diff --git a/lib/app_store_connect_api/client.rb b/lib/app_store_connect_api/client.rb index 23e4289..df2b6e1 100644 --- a/lib/app_store_connect_api/client.rb +++ b/lib/app_store_connect_api/client.rb @@ -12,10 +12,12 @@ class Client include Domain APP_STORE_CONNECT_API_ROOT_URL = 'https://api.appstoreconnect.apple.com' + APP_STORE_CONNECT_ENTERPRISE_API_ROOT_URL = 'https://api.enterprise.developer.apple.com/' - def initialize(issuer_id, key_id, private_key, request_timeout = 30) - @authorization = Authorization.new issuer_id, key_id, private_key + def initialize(issuer_id, key_id, private_key, request_timeout = 30, is_enterprise_account = false) + @authorization = Authorization.new issuer_id, key_id, private_key, is_enterprise_account: is_enterprise_account @request_timeout = request_timeout + @is_enterprise_account = is_enterprise_account end def get(path, options = {}) @@ -64,10 +66,9 @@ def camel_case(params) end def connection - @connection ||= Faraday.new(url: APP_STORE_CONNECT_API_ROOT_URL, + @connection ||= Faraday.new(url: base_url, request: { timeout: @request_timeout }, headers: { 'Authorization' => "Bearer #{@authorization.token}" }) do |f| - f.request :retry, max: 3, interval: 1, @@ -80,5 +81,13 @@ def connection f.response :json, content_type: /\bjson$/ end end + + def base_url + if @is_enterprise_account + APP_STORE_CONNECT_ENTERPRISE_API_ROOT_URL + else + APP_STORE_CONNECT_API_ROOT_URL + end + end end end diff --git a/spec/app_store_connect_api/client_spec.rb b/spec/app_store_connect_api/client_spec.rb index c1057e4..7c7355c 100644 --- a/spec/app_store_connect_api/client_spec.rb +++ b/spec/app_store_connect_api/client_spec.rb @@ -1,12 +1,13 @@ # frozen_string_literal: true RSpec.describe AppStoreConnectApi::Client do - subject(:client) { described_class.new 'issuer-id', 'key-id', 'private-key' } + subject(:client) { described_class.new 'issuer-id', 'key-id', 'private-key', 30, is_enterprise_account } let(:authorization) { instance_double AppStoreConnectApi::Authorization, token: 'bearer-token' } + let(:is_enterprise_account) { false } before do - allow(AppStoreConnectApi::Authorization).to receive(:new).with('issuer-id', 'key-id', 'private-key').and_return authorization + allow(AppStoreConnectApi::Authorization).to receive(:new).with('issuer-id', 'key-id', 'private-key', is_enterprise_account: is_enterprise_account).and_return authorization end shared_examples 'it raises an error if the request failed' do @@ -27,8 +28,14 @@ context 'when the request fails and the response has no details' do before do - stub_request(:any, /api\.appstoreconnect\.apple\.com/) - .to_return(status: 500, body: 'Internal server error') + if is_enterprise_account + stub_request(:any, /api\.enterprise\.developer\.apple\.com/) + .to_return(status: 500, body: 'Internal server error') + else + stub_request(:any, /api\.appstoreconnect\.apple\.com/) + .to_return(status: 500, body: 'Internal server error') + + end end it 'raises an error' do @@ -307,4 +314,219 @@ include_examples 'it raises an error if the request failed' end + + describe 'using Enterprise API' do + let(:is_enterprise_account) { true } + + describe '#get' do + subject(:perform_request) { client.get '/test/endpoint' } + + let(:query_params) { {} } + let(:api_response_status) { 200 } + let(:api_response_body) { { data: 'response' } } + + before do + stub_request(:get, 'https://api.enterprise.developer.apple.com/test/endpoint') + .with(headers: { authorization: 'Bearer bearer-token' }, + query: query_params) + .to_return(status: api_response_status, + body: JSON.dump(api_response_body), + headers: { content_type: 'application/json' }) + end + + it 'executes a GET request on the App Store Connect API and returns the response body with symbolized keys' do + expect(perform_request).to eq 'response' + end + + context 'when there are query parameters' do + subject(:perform_request) { client.get '/test/endpoint', review_submission: { data: 'id' } } + + let(:query_params) { { reviewSubmission: { data: 'id' } } } + + it 'passes in the query parameters, transformed into camelCase format' do + expect(perform_request).to eq 'response' + end + end + + context 'when the response contains camelCase fields' do + let(:api_response_body) { { data: { id: 'some-review-id', attributes: { reviewSubmission: 'valid' } } } } + + it 'transforms camelCase keys into snake_case' do + expect(perform_request).to eq({ id: 'some-review-id', review_submission: 'valid' }) + end + end + + include_examples 'it raises an error if the request failed' + end + + describe '#post' do + subject(:perform_request) { client.post '/test/endpoint', attributes } + + let(:attributes) { { attribute: 'value' } } + let(:body) { { attribute: 'value' } } + let(:api_response_status) { 200 } + let(:api_response_body) { { data: 'response' } } + + before do + stub_request(:post, 'https://api.enterprise.developer.apple.com/test/endpoint') + .with(headers: { authorization: 'Bearer bearer-token', + content_type: 'application/json' }, + body: JSON.dump(body)) + .to_return(status: api_response_status, + body: JSON.dump(api_response_body), + headers: { content_type: 'application/json' }) + end + + it 'executes a CREATE request on the App Store Connect API and returns the response body with symbolized keys' do + expect(perform_request).to eq 'response' + end + + context 'when the body contains snake case attributes' do + let(:attributes) { { whats_new: 'value' } } + let(:body) { { whatsNew: 'value' } } + + it 'transforms the attributes into camelCase format' do + expect(perform_request).to eq 'response' + end + end + + context 'when the response contains camelCase fields' do + let(:api_response_body) { { data: { id: 'some-review-id', attributes: { reviewSubmission: 'valid' } } } } + + it 'transforms camelCase keys into snake_case' do + expect(perform_request).to eq({ id: 'some-review-id', review_submission: 'valid' }) + end + end + + include_examples 'it raises an error if the request failed' + end + + describe '#patch' do + subject(:perform_request) { client.patch '/test/endpoint', attributes } + + let(:attributes) { { attribute: 'value' } } + let(:body) { { attribute: 'value' } } + let(:api_response_status) { 200 } + let(:api_response_body) { { data: 'response' } } + + before do + stub_request(:patch, 'https://api.enterprise.developer.apple.com/test/endpoint') + .with(headers: { authorization: 'Bearer bearer-token', + content_type: 'application/json' }, + body: JSON.dump(body)) + .to_return(status: api_response_status, + body: JSON.dump(api_response_body), + headers: { content_type: 'application/json' }) + end + + it 'executes a PATCH request on the App Store Connect API and returns the response body with symbolized keys' do + expect(perform_request).to eq 'response' + end + + context 'when the body contains snake case attributes' do + let(:attributes) { { whats_new: 'value' } } + let(:body) { { whatsNew: 'value' } } + + it 'transforms the attributes into camelCase format' do + expect(perform_request).to eq 'response' + end + end + + context 'when the response contains camelCase fields' do + let(:api_response_body) { { data: { id: 'some-review-id', attributes: { reviewSubmission: 'valid' } } } } + + it 'transforms camelCase keys into snake_case' do + expect(perform_request).to eq({ id: 'some-review-id', review_submission: 'valid' }) + end + end + + include_examples 'it raises an error if the request failed' + end + + describe '#delete' do + subject(:perform_request) { client.delete '/test/endpoint' } + + let(:api_response_status) { 200 } + let(:api_response_body) { { data: 'response' } } + + before do + stub_request(:delete, 'https://api.enterprise.developer.apple.com/test/endpoint') + .with(headers: { authorization: 'Bearer bearer-token' }) + .to_return(status: api_response_status, + body: JSON.dump(api_response_body), + headers: { content_type: 'application/json' }) + end + + it 'executes a DELETE request on the App Store Connect API and returns the response body with symbolized keys' do + expect(perform_request).to eq 'response' + end + + context 'when the response contains camelCase fields' do + let(:api_response_body) { { data: { id: 'some-review-id', attributes: { reviewSubmission: 'valid' } } } } + + it 'transforms camelCase keys into snake_case' do + expect(client.delete('/test/endpoint')).to eq({ id: 'some-review-id', review_submission: 'valid' }) + end + end + + context 'when the request has a body' do + let(:perform_request) { client.delete '/test/endpoint', attributes } + + let(:attributes) { { app_store_version: 'app-store-version-id' } } + let(:body) { { appStoreVersin: 'app-store-version-id' } } + + before do + stub_request(:patch, 'https://api.enterprise.developer.apple.com/test/endpoint') + .with(headers: { authorization: 'Bearer bearer-token', + content_type: 'application/json' }, + body: JSON.dump(body)) + .to_return(status: api_response_status, + body: JSON.dump(api_response_body), + headers: { content_type: 'application/json' }) + end + + it 'executes the DELETE request including the body' do + expect(perform_request).to eq 'response' + end + end + + include_examples 'it raises an error if the request failed' + end + + describe '#more?' do + subject { client.more? resource } + + let(:resource) { { links: { next: 'https://api.enterprise.developer.apple.com/link-to-next-page' } } } + + it { is_expected.to be true } + + context 'when there is no "next" link in the resource' do + let(:resource) { { links: {} } } + + it { is_expected.to be false } + end + end + + describe '#next' do + subject(:next_page) { client.next resource } + + let(:resource) { { links: { next: 'https://api.enterprise.developer.apple.com/link-to-next-page?page=2' } } } + let(:api_response_status) { 200 } + let(:api_response_body) { { data: 'response' } } + + before do + stub_request(:get, 'https://api.enterprise.developer.apple.com/link-to-next-page?page=2') + .with(headers: { authorization: 'Bearer bearer-token' }) + .to_return(status: api_response_status, + body: JSON.dump(api_response_body), + headers: { content_type: 'application/json' }) + end + + it 'fetches the next page of results based on the "next" link in the resource' do + expect(next_page).to eq 'response' + end + + include_examples 'it raises an error if the request failed' + end + end end diff --git a/spec/support/api_requests.rb b/spec/support/api_requests.rb index 3f51f3e..1a9fa61 100644 --- a/spec/support/api_requests.rb +++ b/spec/support/api_requests.rb @@ -5,7 +5,7 @@ let(:authorization) { instance_double AppStoreConnectApi::Authorization, token: 'bearer-token' } before do - allow(AppStoreConnectApi::Authorization).to receive(:new).with('issuer-id', 'key-id', 'private-key').and_return authorization + allow(AppStoreConnectApi::Authorization).to receive(:new).with('issuer-id', 'key-id', 'private-key', is_enterprise_account: false).and_return authorization end shared_examples_for 'a GET endpoint' do |url:, query_params: {}|