lib/travis/client/session.rb



# frozen_string_literal: true

require 'travis/client'
require 'travis/version'

require 'faraday'
require 'travis/tools/system'
require 'travis/tools/assets'
require 'faraday/rack'

begin
  require 'faraday/typhoeus' unless Travis::Tools::System.windows?
rescue LoadError
end

require 'json'

module Travis
  module Client
    class Session
      PRIMITIVE   = [nil, false, true].freeze
      SSL_OPTIONS = { ca_file: Tools::Assets['cacert.pem'] }

      include Methods
      attr_reader :connection, :headers, :access_token, :instruments, :faraday_adapter, :agent_info, :ssl
      attr_accessor :debug_http

      def initialize(options = Travis::Client::COM_URI)
        @headers         = {}
        @cache           = {}
        @instruments     = []
        @agent_info      = []
        @config          = nil
        @faraday_adapter = defined?(Typhoeus) ? :typhoeus : :net_http
        @ssl             = SSL_OPTIONS

        options = { uri: options } unless options.respond_to? :each_pair
        options.each_pair { |key, value| public_send("#{key}=", value) }

        raise ArgumentError, 'neither :uri nor :connection specified' unless connection

        headers['Accept'] = 'application/vnd.travis-ci.2+json'
        set_user_agent
        check_ssl
      end

      def uri
        connection.url_prefix.to_s if connection
      end

      def agent_info=(info)
        @agent_info = [info].flatten.freeze
        set_user_agent
      end

      def ssl=(options)
        @ssl     = options.dup.freeze
        self.uri = uri if uri
      end

      def uri=(uri)
        clear_cache!
        self.connection = Faraday.new(url: uri, ssl:) do |faraday|
          faraday.request :url_encoded
          faraday.request :retry
          faraday.response :logger if debug_http
          faraday.adapter(*faraday_adapter)
        end
      end

      def faraday_adapter=(adapter)
        @faraday_adapter = adapter
        self.uri &&= uri
        set_user_agent
      end

      def access_token=(token)
        clear_cache!
        @access_token = token
        headers['Authorization'] = "token #{token}"
        headers.delete('Authorization') unless token
      end

      def connection=(connection)
        clear_cache!
        connection.headers.merge! headers
        @config     = nil
        @connection = connection
        @headers    = connection.headers
      end

      def headers=(headers)
        clear_cache!
        connection.headers = headers if connection
        @headers = headers
      end

      def find_one(entity, id = nil)
        raise Travis::Client::Error, "cannot fetch #{entity}" unless entity.respond_to?(:many) && entity.many
        return create_entity(entity, entity.id_field => id) if entity.id? id

        cached(entity, :by, id) { fetch_one(entity, id) }
      end

      def find_many(entity, args = {})
        raise Travis::Client::Error, "cannot fetch #{entity}" unless entity.respond_to?(:many) && entity.many

        cached(entity, :many, args) { fetch_many(entity, args) }
      end

      def find_one_or_many(entity, args = nil)
        raise Travis::Client::Error, "cannot fetch #{entity}" unless entity.respond_to?(:many) && entity.many

        cached(entity, :one_or_many, args) do
          path = "/#{entity.many}"
          unless args.is_a? Hash
            path = "#{path}/#{args}"
            args = {}
          end
          result     = get(path, args)
          one        = result[entity.one]

          if result.include? entity.many
            Array(one) + Array(result[entity.many])
          else
            one
          end
        end
      end

      def reset(entity)
        entity.attributes.clear
        entity
      end

      def reload(entity)
        reset(entity)
        result = fetch_one(entity.class, entity.id)
        entity.update_attributes(result.attributes) if result.attributes != entity.attributes
        result
      end

      def config
        @config ||= get_raw('/config')['config'] || {}
      end

      def load(data)
        result = {}
        (data || {}).each_pair do |key, value|
          entity      = load_entity(key, value)
          result[key] = entity if entity
        end
        result
      end

      def load_entity(key, value)
        type = Entity.subclass_for(key)
        if value.respond_to? :to_ary
          value.to_ary.map { |e| create_entity(type, e) }
        else
          create_entity(type, value)
        end
      rescue IndexError
      end

      def preload(list)
        list.group_by(&:class).each do |type, instances|
          next unless type.preloadable?

          ids = instances.map { |e| e.id unless e.complete? }.compact
          find_many(type, ids:) if ids.any?
        end
        list
      end

      def get(*args)
        load get_raw(*args)
      end

      def delete(*args)
        load delete_raw(*args)
      end

      def patch(*args)
        load patch_raw(*args)
      end

      def post(*args)
        load post_raw(*args)
      end

      def put(*args)
        load put_raw(*args)
      end

      def get_raw(*args)
        raw(:get, *args)
      end

      def post_raw(*args)
        raw(:post, *args)
      end

      def put_raw(*args)
        raw(:put, *args)
      end

      def patch_raw(*args)
        raw(:patch, *args)
      end

      def delete_raw(*args)
        raw(:delete, *args)
      end

      def raw(verb, url, *args)
        url     = url.sub(%r{^/}, '')
        result  = instrumented(verb.to_s.upcase, url, *args) do
          if url !~ (/^https?:/) || url.start_with?(api_endpoint)
            connection.public_send(verb, url, *args)
          else
            Faraday.public_send(verb, url, *args) { |r| r.headers.delete('Authorization') }
          end
        end

        case result.status
        when 0             then raise Travis::Client::SSLError, 'SSL error: could not verify peer'
        when 200..299      then begin
          JSON.parse(result.body)
        rescue StandardError
          result.body
        end
        when 301, 303      then raw(:get, result.headers['Location'])
        when 302, 307, 308 then raw(verb, result.headers['Location'])
        when 401           then raise Travis::Client::NotLoggedIn, 'not logged in'
        when 403
          body = begin
            JSON.parse(result.body)
          rescue StandardError
            {}
          end
          raise Travis::Client::RepositoryMigrated, body['error_message'] if body['error_type'] == 'migrated_repository'

          raise Travis::Client::NotLoggedIn, 'invalid access token'

        when 404           then raise Travis::Client::NotFound,         result.body
        when 422           then raise Travis::Client::ValidationFailed, result.body
        when 400..499      then raise Travis::Client::Error,            format('%s: %p', result.status, result.body)
        when 500..599      then raise Travis::Client::Error,
                                      format('server error (%s: %p)', result.status, result.body)
        else raise Travis::Client::Error, "unhandled status code #{result.status}"
        end
      end

      def inspect
        "#<#{self.class}: #{uri}>"
      end

      def clear_cache
        reset_entities
        clear_find_cache
        self
      end

      def clear_cache!
        reset_entities
        @cache.clear
        self
      end

      def session
        self
      end

      def instrument(&block)
        instruments << block
      end

      def private_channels?
        !!config['pusher']['private']
      end

      private

      def set_user_agent
        adapter = faraday_adapter.is_a?(Array) ? faraday_adapter.first : faraday_adapter
        adapter = adapter.to_s.capitalize.gsub(/_http_(.)/) do
          "::HTTP::#{::Regexp.last_match(1).upcase}"
        end.gsub(/_http/, '::HTTP')
        headers['User-Agent'] =
          "Travis/#{Travis::VERSION} (#{Travis::Tools::System.description(agent_info)}) Faraday/#{Faraday::VERSION} #{adapter}/#{adapter_version(adapter)}"
      end

      def adapter_version(adapter)
        version = Object.const_get(adapter).const_get('VERSION')
        [*version].join('.')
      rescue Exception
        'unknown'
      end

      def instrumented(name, *args)
        name   = [name, *args.map(&:inspect)].join(' ') if args.any?
        result = nil
        chain  = instruments + [proc { |_n, _l| result = yield }]
        lift   = proc { chain.shift.call(name, lift) }
        lift.call
        result
      end

      def create_entity(type, data)
        return data if primitive?(data)

        data   = { type.id_field => data } if type.id? data
        id     = type.cast_id(data.fetch(type.id_field)) unless type.weak?
        entity = id ? cached(type, :id, id) { type.new(self, id) } : type.new(self, nil)
        entity.update_attributes(data)
        entity
      end

      def primitive?(data)
        PRIMITIVE.include? data
      end

      def error_message(e)
        message = begin
          e.response[:body].to_str
        rescue StandardError
          e.message
        end
        begin
          JSON.parse(message).fetch('error').fetch('message')
        rescue StandardError
          message
        end
      end

      def reset_entities
        subcaches do |subcache|
          subcache[:id].each_value { |e| e.attributes.clear } if subcache.include? :id
        end
      end

      def clear_find_cache
        subcaches do |subcache|
          subcache.delete_if { |k, _v| k != :id }
        end
      end

      def subcaches
        @cache.each_value do |subcache|
          yield subcache if subcache.is_a? Hash
        end
      end

      def fetch_one(entity, id = nil)
        get("/#{entity.base_path}/#{id}")[entity.one]
      end

      def fetch_many(entity, params = {})
        get("/#{entity.base_path}/", params)[entity.many]
      end

      def cached(*keys)
        last  = keys.pop
        cache = keys.inject(@cache) { |store, key| store[key] ||= {} }
        cache[last] ||= yield
      end

      def check_ssl
        raw(:head, '/') if ssl == SSL_OPTIONS
      rescue Exception => e
        self.ssl = {} if e.instance_of?(Travis::Client::SSLError)
      end
    end
  end
end