spaceship/lib/spaceship/connect_api/client.rb in fastlane-2.156.1 vs spaceship/lib/spaceship/connect_api/client.rb in fastlane-2.157.0

- old
+ new

@@ -1,245 +1,171 @@ -require_relative '../client' -require_relative './response' +require_relative './token' +require_relative './provisioning/provisioning' +require_relative './testflight/testflight' +require_relative './tunes/tunes' +require_relative './users/users' module Spaceship class ConnectAPI - class Client < Spaceship::Client + class Client attr_accessor :token + attr_accessor :tunes_client + attr_accessor :portal_client - ##################################################### - # @!group Client Init - ##################################################### - - # Instantiates a client with cookie session or a JWT token. - def initialize(cookie: nil, current_team_id: nil, token: nil) - if token.nil? - super(cookie: cookie, current_team_id: current_team_id, timeout: 1200) - else - options = { - request: { - timeout: (ENV["SPACESHIP_TIMEOUT"] || 300).to_i, - open_timeout: (ENV["SPACESHIP_TIMEOUT"] || 300).to_i - } - } - @token = token - @current_team_id = current_team_id - - hostname = "https://api.appstoreconnect.apple.com/v1/" - - @client = Faraday.new(hostname, options) do |c| - c.response(:json, content_type: /\bjson$/) - c.response(:plist, content_type: /\bplist$/) - c.use(FaradayMiddleware::RelsMiddleware) - c.adapter(Faraday.default_adapter) - c.headers["Authorization"] = "Bearer #{token.text}" - - if ENV['SPACESHIP_DEBUG'] - # for debugging only - # This enables tracking of networking requests using Charles Web Proxy - c.proxy = "https://127.0.0.1:8888" - c.ssl[:verify_mode] = OpenSSL::SSL::VERIFY_NONE - elsif ENV["SPACESHIP_PROXY"] - c.proxy = ENV["SPACESHIP_PROXY"] - c.ssl[:verify_mode] = OpenSSL::SSL::VERIFY_NONE if ENV["SPACESHIP_PROXY_SSL_VERIFY_NONE"] - end - - if ENV["DEBUG"] - puts("To run spaceship through a local proxy, use SPACESHIP_DEBUG") - end - end - end + # Initializes client with Apple's App Store Connect JWT auth key. + # + # This method will automatically use the key id, issuer id, and filepath from environment + # variables if not given. + # + # All three parameters are needed to authenticate. + # + # @param key_id (String) (optional): The key id + # @param issuer_id (String) (optional): The issuer id + # @param filepath (String) (optional): The filepath + # + # @raise InvalidUserCredentialsError: raised if authentication failed + # + # @return (Spaceship::ConnectAPI::Client) The client the login method was called for + def self.auth(key_id: nil, issuer_id: nil, filepath: nil) + token = Spaceship::ConnectAPI::Token.create(key_id: key_id, issuer_id: issuer_id, filepath: filepath) + return ConnectAPI::Client.new(token: token) end - def self.hostname - return nil - end - + # Authenticates with Apple's web services. This method has to be called once + # to generate a valid session. # - # Helpers + # This method will automatically use the username from the Appfile (if available) + # and fetch the password from the Keychain (if available) # + # @param user (String) (optional): The username (usually the email address) + # @param password (String) (optional): The password + # @param team_id (String) (optional): The team id + # @param team_name (String) (optional): The team name + # + # @raise InvalidUserCredentialsError: raised if authentication failed + # + # @return (Spaceship::ConnectAPI::Client) The client the login method was called for + def self.login(user = nil, password = nil, team_id: nil, team_name: nil) + tunes_client = TunesClient.login(user, password) + portal_client = PortalClient.login(user, password) - def web_session? - return @token.nil? + # The clients will automatically select the first team if none is given + if !team_id.nil? || !team_name.nil? + tunes_client.select_team(team_id: team_id, team_name: team_name) + portal_client.select_team(team_id: team_id, team_name: team_name) + end + + return ConnectAPI::Client.new(tunes_client: tunes_client, portal_client: portal_client) end - def build_params(filter: nil, includes: nil, limit: nil, sort: nil, cursor: nil) - params = {} + def initialize(cookie: nil, current_team_id: nil, token: nil, tunes_client: nil, portal_client: nil) + @token = token - filter = filter.delete_if { |k, v| v.nil? } if filter + # If using web session... + # Spaceship::Tunes is needed for TestFlight::API, Tunes::API, and Users::API + # Spaceship::Portal is needed for Provisioning::API + @tunes_client = tunes_client + @portal_client = portal_client - params[:filter] = filter if filter && !filter.empty? - params[:include] = includes if includes - params[:limit] = limit if limit - params[:sort] = sort if sort - params[:cursor] = cursor if cursor + # Extending this instance to add API endpoints from these modules + # Each of these modules adds a new setter method for an instance + # of an ConnectAPI::APIClient + # These get set in set_indvidual_clients + self.extend(Spaceship::ConnectAPI::TestFlight::API) + self.extend(Spaceship::ConnectAPI::Tunes::API) + self.extend(Spaceship::ConnectAPI::Provisioning::API) + self.extend(Spaceship::ConnectAPI::Users::API) - return params + set_indvidual_clients( + cookie: cookie, + current_team_id: current_team_id, + token: token, + tunes_client: @tunes_client, + portal_client: @portal_client + ) end - def get(url_or_path, params = nil) - response = with_asc_retry do - request(:get) do |req| - req.url(url_or_path) - req.options.params_encoder = Faraday::NestedParamsEncoder - req.params = params if params - req.headers['Content-Type'] = 'application/json' + def in_house? + if token + if token.in_house.nil? + message = [ + "Cannot determine if team is App Store or Enterprise via the App Store Connect API (yet)", + "Set 'in_house' on your Spaceship::ConnectAPI::Token", + "Or set 'in_house' in your App Store Connect API key JSON file", + "Or set the 'SPACESHIP_CONNECT_API_IN_HOUSE' environment variable to 'true'", + "View more info in the docs at https://docs.fastlane.tools/app-store-connect-api/" + ] + raise message.join('\n') end + return !!token.in_house + elsif @portal_client + return @portal_client.in_house? + else + raise "No App Store Connect API token or Portal Client set" end - handle_response(response) end - def post(url_or_path, body, tries: 5) - response = with_asc_retry(tries) do - request(:post) do |req| - req.url(url_or_path) - req.body = body.to_json - req.headers['Content-Type'] = 'application/json' - end - end - handle_response(response) - end + def select_team(team_id: nil, team_name: nil) + @tunes_client.select_team(team_id: team_id, team_name: team_name) + @portal_client.select_team(team_id: team_id, team_name: team_name) - def patch(url_or_path, body) - response = with_asc_retry do - request(:patch) do |req| - req.url(url_or_path) - req.body = body.to_json - req.headers['Content-Type'] = 'application/json' - end - end - handle_response(response) + # Updating the tunes and portal clients requires resetting + # of the clients in the API modules + set_indvidual_clients( + cookie: nil, + current_team_id: nil, + token: nil, + tunes_client: tunes_client, + portal_client: portal_client + ) end - def delete(url_or_path, params = nil, body = nil) - response = with_asc_retry do - request(:delete) do |req| - req.url(url_or_path) - req.options.params_encoder = Faraday::NestedParamsEncoder if params - req.params = params if params - req.body = body.to_json if body - req.headers['Content-Type'] = 'application/json' if body - end - end - handle_response(response) - end + private - protected - - def with_asc_retry(tries = 5, &_block) - tries = 1 if Object.const_defined?("SpecHelper") - response = yield - - status = response.status if response - - if [500, 504].include?(status) - msg = "Timeout received! Retrying after 3 seconds (remaining: #{tries})..." - raise msg + def set_indvidual_clients(cookie: nil, current_team_id: nil, token: nil, tunes_client: nil, portal_client: nil) + # This was added by Spaceship::ConnectAPI::TestFlight::API and is required + # to be set for API methods to have a client to send request on + if cookie || token || tunes_client + self.test_flight_request_client = Spaceship::ConnectAPI::TestFlight::Client.new( + cookie: cookie, + current_team_id: current_team_id, + token: token, + another_client: tunes_client + ) end - return response - rescue => error - tries -= 1 - puts(error) if Spaceship::Globals.verbose? - if tries.zero? - return response - else - retry + # This was added by Spaceship::ConnectAPI::Tunes::API and is required + # to be set for API methods to have a client to send request on + if cookie || token || tunes_client + self.tunes_request_client = Spaceship::ConnectAPI::Tunes::Client.new( + cookie: cookie, + current_team_id: current_team_id, + token: token, + another_client: tunes_client + ) end - end - def handle_response(response) - if (200...300).cover?(response.status) && (response.body.nil? || response.body.empty?) - return + # This was added by Spaceship::ConnectAPI::Provisioning::API and is required + # to be set for API methods to have a client to send request on + if cookie || token || portal_client + self.provisioning_request_client = Spaceship::ConnectAPI::Provisioning::Client.new( + cookie: cookie, + current_team_id: current_team_id, + token: token, + another_client: portal_client + ) end - raise InternalServerError, "Server error got #{response.status}" if (500...600).cover?(response.status) - - unless response.body.kind_of?(Hash) - raise UnexpectedResponse, response.body + # This was added by Spaceship::ConnectAPI::Users::API and is required + # to be set for API methods to have a client to send request on + if cookie || token || tunes_client + self.users_request_client = Spaceship::ConnectAPI::Users::Client.new( + cookie: cookie, + current_team_id: current_team_id, + token: token, + another_client: tunes_client + ) end - - raise UnexpectedResponse, response.body['error'] if response.body['error'] - - raise UnexpectedResponse, handle_errors(response) if response.body['errors'] - - raise UnexpectedResponse, "Temporary App Store Connect error: #{response.body}" if response.body['statusCode'] == 'ERROR' - - store_csrf_tokens(response) - - return Spaceship::ConnectAPI::Response.new(body: response.body, status: response.status, client: self) end - - def handle_errors(response) - # Example error format - # { - # "errors":[ - # { - # "id":"cbfd8674-4802-4857-bfe8-444e1ea36e32", - # "status":"409", - # "code":"STATE_ERROR", - # "title":"The request cannot be fulfilled because of the state of another resource.", - # "detail":"Submit for review errors found.", - # "meta":{ - # "associatedErrors":{ - # "/v1/appScreenshots/":[ - # { - # "id":"23d1734f-b81f-411a-98e4-6d3e763d54ed", - # "status":"409", - # "code":"STATE_ERROR.SCREENSHOT_REQUIRED.APP_WATCH_SERIES_4", - # "title":"App screenshot missing (APP_WATCH_SERIES_4)." - # }, - # { - # "id":"db993030-0a93-48e9-9fd7-7e5676633431", - # "status":"409", - # "code":"STATE_ERROR.SCREENSHOT_REQUIRED.APP_WATCH_SERIES_4", - # "title":"App screenshot missing (APP_WATCH_SERIES_4)." - # } - # ], - # "/v1/builds/d710b6fa-5235-4fe4-b791-2b80d6818db0":[ - # { - # "id":"e421fe6f-0e3b-464b-89dc-ba437e7bb77d", - # "status":"409", - # "code":"ENTITY_ERROR.ATTRIBUTE.REQUIRED", - # "title":"The provided entity is missing a required attribute", - # "detail":"You must provide a value for the attribute 'usesNonExemptEncryption' with this request", - # "source":{ - # "pointer":"/data/attributes/usesNonExemptEncryption" - # } - # } - # ] - # } - # } - # } - # ] - # } - - return response.body['errors'].map do |error| - messages = [[error['title'], error['detail']].compact.join(" - ")] - - meta = error["meta"] || {} - associated_errors = meta["associatedErrors"] || {} - - messages + associated_errors.values.flatten.map do |associated_error| - [[associated_error["title"], associated_error["detail"]].compact.join(" - ")] - end - end.flatten.join("\n") - end - - private - - def local_variable_get(binding, name) - if binding.respond_to?(:local_variable_get) - binding.local_variable_get(name) - else - binding.eval(name.to_s) - end - end - - def provider_id - return team_id if self.provider.nil? - self.provider.provider_id - end end end - # rubocop:enable Metrics/ClassLength end