lib/artifactory/client.rb



#
# Copyright 2014 Chef Software, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

require 'cgi'
require 'json'
require 'net/http'
require 'uri'

module Artifactory
  #
  # Client for the Artifactory API.
  #
  # @see http://www.jfrog.com/confluence/display/RTF/Artifactory+REST+API
  #
  class Client
    class << self
      #
      # @private
      #
      def proxy(klass)
        namespace = klass.name.split('::').last.downcase
        klass.singleton_methods(false).each do |name|
          define_method("#{namespace}_#{name}") do |*args|
            if args.last.is_a?(Hash)
              args.last[:client] = self
            else
              args << { client: self }
            end

            klass.send(name, *args)
          end
        end
      end
    end

    include Artifactory::Configurable

    proxy Resource::Artifact
    proxy Resource::Backup
    proxy Resource::Layout
    proxy Resource::LDAPSetting
    proxy Resource::MailServer
    proxy Resource::PermissionTarget
    proxy Resource::Repository
    proxy Resource::System
    proxy Resource::URLBase
    proxy Resource::User

    #
    # Create a new Artifactory Client with the given options. Any options
    # given take precedence over the default options.
    #
    # @return [Artifactory::Client]
    #
    def initialize(options = {})
      # Use any options given, but fall back to the defaults set on the module
      Artifactory::Configurable.keys.each do |key|
        value = if options[key].nil?
          Artifactory.instance_variable_get(:"@#{key}")
        else
          options[key]
        end

        instance_variable_set(:"@#{key}", value)
      end
    end

    #
    # Determine if the given options are the same as ours.
    #
    # @return [Boolean]
    #
    def same_options?(opts)
      opts.hash == options.hash
    end

    #
    # Make a HTTP GET request
    #
    # @param path (see Client#request)
    # @param [Hash] params
    #   the list of query params
    # @param headers (see Client#request)
    #
    # @raise (see Client#request)
    # @return (see Client#request)
    #
    def get(path, params = {}, headers = {})
      request(:get, path, params, headers)
    end

    #
    # Make a HTTP POST request
    #
    # @param path (see Client#request)
    # @param [String, #read] data
    #   the body to use for the request
    # @param headers (see Client#request)
    #
    # @raise (see Client#request)
    # @return (see Client#request)
    #
    def post(path, data, headers = {})
      request(:post, path, data, headers)
    end

    #
    # Make a HTTP PUT request
    #
    # @param path (see Client#request)
    # @param data (see Client#post)
    # @param headers (see Client#request)
    #
    # @raise (see Client#request)
    # @return (see Client#request)
    #
    def put(path, data, headers = {})
      request(:put, path, data, headers)
    end

    #
    # Make a HTTP PATCH request
    #
    # @param path (see Client#request)
    # @param data (see Client#post)
    # @param headers (see Client#request)
    #
    # @raise (see Client#request)
    # @return (see Client#request)
    #
    def patch(path, data, headers = {})
      request(:patch, path, data, headers)
    end

    #
    # Make a HTTP DELETE request
    #
    # @param path (see Client#request)
    # @param params (see Client#get)
    # @param headers (see Client#request)
    #
    # @raise (see Client#request)
    # @return (see Client#request)
    #
    def delete(path, params = {}, headers = {})
      request(:delete, path, params, headers)
    end

    #
    # Make an HTTP request with the given verb, data, params, and headers. If
    # the response has a return type of JSON, the JSON is automatically parsed
    # and returned as a hash; otherwise it is returned as a string.
    #
    # @raise [Error::HTTPError]
    #   if the request is not an HTTP 200 OK
    #
    # @param [Symbol] verb
    #   the lowercase symbol of the HTTP verb (e.g. :get, :delete)
    # @param [String] path
    #   the absolute or relative path from {Defaults.endpoint} to make the
    #   request against
    # @param [#read, Hash, nil] data
    #   the data to use (varies based on the +verb+)
    # @param [Hash] headers
    #   the list of headers to use
    #
    # @return [String, Hash]
    #   the response body
    #
    def request(verb, path, data = {}, headers = {})
      # Build the URI and request object from the given information
      uri = build_uri(verb, path, data)
      request = class_for_request(verb).new(uri.request_uri)

      # Add headers
      default_headers.merge(headers).each do |key, value|
        request.add_field(key, value)
      end

      # Add basic authentication
      if username && password
        request.basic_auth(username, password)
      end

      # Setup PATCH/POST/PUT
      if [:patch, :post, :put].include?(verb)
        if data.respond_to?(:read)
          request.content_length = data.size
          request.body_stream = data
        elsif data.is_a?(Hash)
          request.form_data = data
        else
          request.body = data
        end
      end

      # Create the HTTP connection object - since the proxy information defaults
      # to +nil+, we can just pass it to the initializer method instead of doing
      # crazy strange conditionals.
      connection = Net::HTTP.new(uri.host, uri.port,
        proxy_address, proxy_port, proxy_username, proxy_password)

      # The artifacts being uploaded might be large, so there’s a good chance
      # we'll need to bump this higher than the `Net::HTTP` default of 60
      # seconds.
      connection.read_timeout = read_timeout

      # Apply SSL, if applicable
      if uri.scheme == 'https'
        require 'net/https' unless defined?(Net::HTTPS)

        # Turn on SSL
        connection.use_ssl = true

        # Custom pem files, no problem!
        if ssl_pem_file
          pem = File.read(ssl_pem_file)
          connection.cert = OpenSSL::X509::Certificate.new(pem)
          connection.key = OpenSSL::PKey::RSA.new(pem)
          connection.verify_mode = OpenSSL::SSL::VERIFY_PEER
        end

        # Naughty, naughty, naughty! Don't blame when when someone hops in
        # and executes a MITM attack!
        unless ssl_verify
          connection.verify_mode = OpenSSL::SSL::VERIFY_NONE
        end
      end

      # Create a connection using the block form, which will ensure the socket
      # is properly closed in the event of an error.
      connection.start do |http|
        response = http.request(request)

        case response
        when Net::HTTPRedirection
          redirect = URI.parse(response['location'])
          request(verb, redirect, data, headers)
        when Net::HTTPSuccess
          success(response)
        else
          error(response)
        end
      end
    rescue SocketError, Errno::ECONNREFUSED, EOFError
      raise Error::ConnectionError.new(endpoint)
    end

    #
    # The list of default headers (such as Keep-Alive and User-Agent) for the
    # client object.
    #
    # @return [Hash]
    #
    def default_headers
      {
        'Connection' => 'keep-alive',
        'Keep-Alive' => '30',
        'User-Agent' => user_agent,
      }
    end

    #
    # Construct a URL from the given verb and path. If the request is a GET or
    # DELETE request, the params are assumed to be query params are are
    # converted as such using {Client#to_query_string}.
    #
    # If the path is relative, it is merged with the {Defaults.endpoint}
    # attribute. If the path is absolute, it is converted to a URI object and
    # returned.
    #
    # @param [Symbol] verb
    #   the lowercase HTTP verb (e.g. :+get+)
    # @param [String] path
    #   the absolute or relative HTTP path (url) to get
    # @param [Hash] params
    #   the list of params to build the URI with (for GET and DELETE requests)
    #
    # @return [URI]
    #
    def build_uri(verb, path, params = {})
      # Add any query string parameters
      if [:delete, :get].include?(verb)
        path = [path, to_query_string(params)].compact.join('?')
      end

      # Parse the URI
      uri = URI.parse(path)

      # Don't merge absolute URLs
      uri = URI.parse(File.join(endpoint, path)) unless uri.absolute?

      # Return the URI object
      uri
    end

    #
    # Helper method to get the corresponding {Net::HTTP} class from the given
    # HTTP verb.
    #
    # @param [#to_s] verb
    #   the HTTP verb to create a class from
    #
    # @return [Class]
    #
    def class_for_request(verb)
      Net::HTTP.const_get(verb.to_s.capitalize)
    end

    #
    # Convert the given hash to a list of query string parameters. Each key and
    # value in the hash is URI-escaped for safety.
    #
    # @param [Hash] hash
    #   the hash to create the query string from
    #
    # @return [String, nil]
    #   the query string as a string, or +nil+ if there are no params
    #
    def to_query_string(hash)
      hash.map do |key, value|
        "#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}"
      end.join('&')[/.+/]
    end

    #
    # Parse the response object and manipulate the result based on the given
    # +Content-Type+ header. For now, this method only parses JSON, but it
    # could be expanded in the future to accept other content types.
    #
    # @param [HTTP::Message] response
    #   the response object from the request
    #
    # @return [String, Hash]
    #   the parsed response, as an object
    #
    def success(response)
      if (response.content_type || '').include?('json')
        JSON.parse(response.body)
      else
        response.body
      end
    end

    #
    # Raise a response error, extracting as much information from the server's
    # response as possible.
    #
    # @raise [Error::HTTPError]
    #
    # @param [HTTP::Message] response
    #   the response object from the request
    #
    def error(response)
      if (response.content_type || '').include?('json')
        # Attempt to parse the error as JSON
        begin
          json = JSON.parse(response.body)

          if json['errors'] && json['errors'].first
            raise Error::HTTPError.new(json['errors'].first)
          end
        rescue JSON::ParserError; end
      end

      raise Error::HTTPError.new(
        'status'  => response.code,
        'message' => response.body,
      )
    end
  end
end