lib/ovirtsdk4/service.rb



#
# Copyright (c) 2015-2017 Red Hat, 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.
#

module OvirtSDK4
  #
  # Instances of this class are returned for operatoins that specify the `wait: false` parameter.
  #
  class Future
    #
    # Creates a new future result.
    #
    # @param service [Service] The service that created this future.
    # @param request [HttpRequest] The request that this future will wait for when the `wait` method is called.
    # @param block [Block] The block that will be executed to check the response, and to convert its body into the
    #   right type of object.
    #
    # @api private
    #
    def initialize(service, request, &block)
      @service = service
      @request = request
      @block = block
    end

    #
    # Waits till the result of the operation that created this future is available.
    #
    # @return [Object] The result of the operation that created this future.
    #
    def wait
      response = @service.connection.wait(@request)
      raise response if response.is_a?(Exception)

      @block.call(response)
    end

    #
    # Returns a string representation of the future.
    #
    # @return [String] The string representation.
    #
    def inspect
      "#<#{self.class.name}:#{@request.method} #{@request.url}>"
    end

    #
    # Returns a string representation of the future.
    #
    # @return [String] The string representation.
    #
    def to_s
      inspect
    end
  end

  #
  # This is the base class for all the services of the SDK. It contains the utility methods used by all of them.
  #
  class Service
    #
    # Creates a new implementation of the service.
    #
    # @param parent [Service, Connection] The parent of this service. For most services the parent will be another
    #   service. For example, for the `vm` service that manages virtual machine `123` the parent will be the `vms`
    #   service that manages the collection of virtual machines. For the root of the services tree the parent will
    #   be the connection.
    #
    # @param path [String] The path of this service, relative to its parent. For example, the path of the `vm`
    #   service that manages virtual machine `123` will be `vm/123`.
    #
    # @api private
    #
    def initialize(parent, path)
      @parent = parent
      @path = path
    end

    #
    # Reads the response body, checks if it is a fault and if so converts it to an Error and raises it.
    #
    # This method is intended for internal use by other components of the SDK. Refrain from using it directly, as
    # backwards compatibility isn't guaranteed.
    #
    # @api private
    #
    def check_fault(response)
      body = internal_read_body(response)
      connection.raise_error(response, body) if body.is_a?(Fault)
      raise Error, "Expected a fault, but got '#{body.class.name.split('::').last}'"
    end

    #
    # Reads the response body and checks if it is an action or a fault. If it is an action it checks if the action
    # contains a nested fault. If there is a fault then converts it to an `Error` and raises it. If there is no fault
    # then the action object is returned.
    #
    # This method is intended for internal use by other components of the SDK. Refrain from using it directly, as
    # backwards compatibility isn't guaranteed.
    #
    # @api private
    #
    def check_action(response)
      body = internal_read_body(response)
      connection.raise_error(response, body) if body.is_a?(Fault)
      if body.is_a?(Action)
        return body if body.fault.nil?

        connection.raise_error(response, body.fault)
      end
      raise Error, "Expected an action or a fault, but got '#{body.class.name.split('::').last}'"
    end

    #
    # Returns the connection used by this service.
    #
    # This method is intended for internal use by other components of the SDK. Refrain from using it directly, as
    # backwards compatibility isn't guaranteed.
    #
    # @return [Connection] The connection used by this service.
    #
    # @api private
    #
    def connection
      return @parent if @parent.is_a? Connection

      @parent.connection
    end

    #
    # Returns a string representation of the service.
    #
    # @return [String] The string representation.
    #
    def inspect
      "#<#{self.class.name}:#{absolute_path}>"
    end

    #
    # Returns a string representation of the service.
    #
    # @return [String] The string representation.
    #
    def to_s
      inspect
    end

    protected

    #
    # Executes a `get` method.
    #
    # @param specs [Array<Array<Symbol, Class>>] An array of arrays containing the names and types of the parameters.
    # @param opts [Hash] The hash containing the values of the parameters.
    #
    # @api private
    #
    def internal_get(specs, opts)
      # Get the values of the built-in options:
      headers = opts.delete(:headers) || {}
      query = opts.delete(:query) || {}
      timeout = opts.delete(:timeout)
      wait = opts.delete(:wait)
      wait = true if wait.nil?

      # Get the values of the options specific to this operation:
      specs.each do |name, kind|
        value = opts.delete(name)
        query[name] = Writer.render(value, kind) unless value.nil?
      end

      # Check the remaining options:
      check_bad_opts(specs, opts)

      # Create and send the request:
      request = HttpRequest.new
      request.method = :GET
      request.url = absolute_path
      request.headers = headers
      request.query = query
      request.timeout = timeout
      connection.send(request)
      result = Future.new(self, request) do |response|
        raise response if response.is_a?(Exception)

        case response.code
        when 200
          internal_read_body(response)
        else
          check_fault(response)
        end
      end
      result = result.wait if wait
      result
    end

    #
    # Executes an `add` method.
    #
    # @param object [Object] The added object.
    # @param type [Class] Type type of the added object.
    # @param specs [Array<Array<Symbol, Class>>] An array of arrays containing the names and types of the parameters.
    # @param opts [Hash] The hash containing the values of the parameters.
    #
    # @api private
    #
    def internal_add(object, type, specs, opts)
      # Get the values of the built-in options:
      object = type.new(object) if object.is_a?(Hash)
      headers = opts.delete(:headers) || {}
      query = opts.delete(:query) || {}
      timeout = opts.delete(:timeout)
      wait = opts.delete(:wait)
      wait = true if wait.nil?

      # Get the values of the options specific to this operation:
      specs.each do |name, kind|
        value = opts.delete(name)
        query[name] = Writer.render(value, kind) unless value.nil?
      end

      # Check the remaining options:
      check_bad_opts(specs, opts)

      # Create and send the request:
      request = HttpRequest.new
      request.method = :POST
      request.url = absolute_path
      request.headers = headers
      request.query = query
      request.body = Writer.write(object, indent: true)
      request.timeout = timeout
      connection.send(request)
      result = Future.new(self, request) do |response|
        raise response if response.is_a?(Exception)

        case response.code
        when 200, 201, 202
          internal_read_body(response)
        else
          check_fault(response)
        end
      end
      result = result.wait if wait
      result
    end

    #
    # Executes an `update` method.
    #
    # @param object [Object] The updated object.
    # @param type [Class] Type type of the updated object.
    # @param specs [Array<Array<Symbol, Class>>] An array of tuples containing the names and types of the parameters.
    # @param opts [Hash] The hash containing the values of the parameters.
    #
    # @api private
    #
    def internal_update(object, type, specs, opts)
      # get the values of the built-in options:
      object = type.new(object) if object.is_a?(Hash)
      headers = opts.delete(:headers) || {}
      query = opts.delete(:query) || {}
      timeout = opts.delete(:timeout)
      wait = opts.delete(:wait)
      wait = true if wait.nil?

      # Get the values of the options specific to this operation:
      specs.each do |name, kind|
        value = opts.delete(name)
        query[name] = Writer.render(value, kind) unless value.nil?
      end

      # Check the remaining options:
      check_bad_opts(specs, opts)

      # Create and send the request:
      request = HttpRequest.new
      request.method = :PUT
      request.url = absolute_path
      request.headers = headers
      request.query = query
      request.body = Writer.write(object, indent: true)
      request.timeout = timeout
      connection.send(request)
      result = Future.new(self, request) do |response|
        raise response if response.is_a?(Exception)

        case response.code
        when 200
          internal_read_body(response)
        else
          check_fault(response)
        end
      end
      result = result.wait if wait
      result
    end

    #
    # Executes a `remove` method.
    #
    # @param specs [Array<Array<Symbol, Class>>] An array of tuples containing the names and types of the  parameters.
    # @param opts [Hash] The hash containing the values of the parameters.
    #
    # @api private
    #
    def internal_remove(specs, opts)
      # Get the values of the built-in options:
      headers = opts.delete(:headers) || {}
      query = opts.delete(:query) || {}
      timeout = opts.delete(:timeout)
      wait = opts.delete(:wait)
      wait = true if wait.nil?

      # Get the values of the options specific to this operation:
      specs.each do |name, kind|
        value = opts.delete(name)
        query[name] = Writer.render(value, kind) unless value.nil?
      end

      # Check the remaining options:
      check_bad_opts(specs, opts)

      # Create and send the request:
      request = HttpRequest.new
      request.method = :DELETE
      request.url = absolute_path
      request.headers = headers
      request.query = query
      request.timeout = timeout
      connection.send(request)
      result = Future.new(self, request) do |response|
        raise response if response.is_a?(Exception)

        check_fault(response) unless response.code == 200
      end
      result = result.wait if wait
      result
    end

    #
    # Executes an action method.
    #
    # @param name [Symbol] The name of the action, for example `:start`.
    # @param member [Symbol] The name of the action member that contains the result. For example `:is_attached`. Can
    #   be `nil` if the action doesn't return any value.
    # @param specs [Array<Array<Symbol, Class>>] An array of tuples containing the names and types of the parameters.
    # @param opts [Hash] The hash containing the parameters of the action.
    #
    # @api private
    #
    def internal_action(name, member, specs, opts)
      # Get the values of the built-in options:
      headers = opts.delete(:headers) || {}
      query = opts.delete(:query) || {}
      timeout = opts.delete(:timeout)
      wait = opts.delete(:wait)
      wait = true if wait.nil?

      # Create the action:
      action = Action.new(opts)

      # The constructor of the action doesn't remove the options that it uses, so we need to remove them explicitly
      # before checking for bad options.
      specs.each_entry do |key, _|
        opts.delete(key)
      end
      check_bad_opts(specs, opts)

      # Create and send the request:
      request = HttpRequest.new
      request.method = :POST
      request.url = "#{absolute_path}/#{name}"
      request.headers = headers
      request.query = query
      request.body = Writer.write(action, indent: true)
      request.timeout = timeout
      connection.send(request)
      result = Future.new(self, request) do |response|
        raise response if response.is_a?(Exception)

        case response.code
        when 200, 201, 202
          action = check_action(response)
          action.send(member) if member
        else
          check_action(response)
        end
      end
      result = result.wait if wait
      result
    end

    #
    # Checks the content type of the given response, and if it is XML, as expected, reads the body and converts it
    # to an object. If it isn't XML, then it raises an exception.
    #
    # @param response [HttpResponse] The HTTP response to check.
    # @return [Object] The result of converting the HTTP response body from XML to an SDK object.
    #
    # @api private
    #
    def internal_read_body(response)
      # First check if the response body is empty, as it makes no sense to check the content type if there is
      # no body:
      connection.raise_error(response, 'The response body is empty') if response.body.nil? || response.body.length.zero?

      # Check the content type, as otherwise the parsing will fail, and the resulting error message won't be explicit
      # about the cause of the problem:
      connection.check_xml_content_type(response)

      # Parse the XML and generate the SDK object:
      Reader.read(response.body)
    end

    #
    # Returns the absolute path of this service.
    #
    # @return [String] The absolute path of this service. For example, the path of the `vm` service that manages
    #   virtual machine `123` will be `vms/123`. Note that this absolute path doesn't include the `/ovirt-engine/api/'
    #   prefix.
    #
    # @api private
    #
    def absolute_path
      return @path if @parent.is_a? Connection

      prefix = @parent.absolute_path
      return @path if prefix.empty?

      "#{prefix}/#{@path}"
    end

    private

    #
    # Checks if the given hash contains any value, and if it does raises an exception indicating that they are not
    # supported.
    #
    # @param specs [Array<Array<Symbol, Class>>] An array of tuples containing the names and types of the parameters.
    # @param opts [Hash] The hash containing the values of the parameters.
    #
    def check_bad_opts(specs, opts)
      return if opts.empty?

      bad_names = opts.keys
      bad_text = nice_list(bad_names)
      if bad_names.length > 1
        message = "The options #{bad_text} aren't supported."
      else
        message = "The option #{bad_text} isn't supported."
      end
      good_names = specs.map(&:first)
      unless good_names.empty?
        good_text = nice_list(good_names)
        if good_names.length > 1
          message << " The supported options are #{good_text}."
        else
          message << " The only supported option is #{good_text}."
        end
      end
      raise Error, message
    end

    #
    # Generates a human readable list containing the names of the given symbols.
    #
    # @param items [Array<Symbol>]
    # @return [String] An string containing the names of the symbols, sorted, quoted, and in a gramatically correct
    #   format.
    #
    def nice_list(items)
      return nil if items.empty?

      items = items.sort
      items = items.map { |item| "'#{item}'" }
      return items.first if items.length == 1

      head = items[0, items.length - 1].join(', ')
      tail = items.last
      "#{head} and #{tail}"
    end
  end
end