util.rb 6.77 KB
# frozen_string_literal: true

module Syspro
  # Utillity class
  class Util # rubocop:disable Metrics/ClassLength
    # Options that a user is allowed to specify.
    OPTS_USER_SPECIFIED = Set[
      :user_id
    ].freeze

    # Options that should be copyable from one StripeObject to another
    # including options that may be internal.
    OPTS_COPYABLE = (
      OPTS_USER_SPECIFIED + Set[:api_base]
    ).freeze

    # Options that should be persisted between API requests. This includes
    # client, which is an object containing an HTTP client to reuse.
    OPTS_PERSISTABLE = (
      OPTS_USER_SPECIFIED + Set[:client]
    ).freeze

    def self.objects_to_ids(h) # rubocop:disable Metrics/MethodLength, Metrics/LineLength, Naming/UncommunicativeMethodParamName
      case h
      when ApiResource
        h.id
      when Hash
        res = {}
        h.each { |k, v| res[k] = objects_to_ids(v) unless v.nil? }
        res
      when Array
        h.map { |v| objects_to_ids(v) }
      else
        h
      end
    end

    # Converts a hash of fields or an array of hashes into a +SysproObject+ or
    # array of +SysproObject+s. These new objects will be created as a concrete
    # type as dictated by their `object` field (e.g. an `object` value of
    # `charge` would create an instance of +Charge+), but if `object` is not
    # present or of an unknown type, the newly created instance will fall back
    # to being a +SysproObject+.
    #
    # ==== Attributes
    #
    # * +data+ - Hash of fields and values to be converted into a SysproObject.
    # * +opts+ - Options for +SysproObject+ like an API key that will be reused
    #   on subsequent API calls.
    def self.convert_to_syspro_object(data, opts = {}) # rubocop:disable Metrics/LineLength, Metrics/MethodLength
      case data
      when Array
        data.map { |i| convert_to_syspro_object(i, opts) }
      when Hash
        # Try converting to a known object class.
        # If none available, fall back to generic SysproObject
        object_classes.fetch(
          data[:object],
          SysproObject
        ).construct_from(data, opts)
      else
        data
      end
    end

    # The secondary opts argument can either be a string or hash
    # Turn this value into an api_key and a set of headers
    def self.normalize_opts(opts)
      case opts
      when String
        opts
      when Hash
        opts.clone
      else
        raise TypeError, 'normalize_opts expects a string or a hash'
      end
    end

    # Normalizes header keys so that they're all lower case and each
    # hyphen-delimited section starts with a single capitalized letter. For
    # example, `request-id` becomes `Request-Id`. This is useful for extracting
    # certain key values when the user could have set them with a variety of
    # diffent naming schemes.
    def self.normalize_headers(headers)
      headers.each_with_object({}) do |(k, v), new_headers|
        if k.is_a?(Symbol)
          k = titlecase_parts(k.to_s.tr('_', '-'))
        elsif k.is_a?(String)
          k = titlecase_parts(k)
        end

        new_headers[k] = v
      end
    end

    def self.encode_parameters(params)
      Util.flatten_params(params)
          .map { |k, v| "#{url_encode(k)}=#{url_encode(v)}" }.join('&')
    end

    def self.flatten_params(params, parent_key = nil) # rubocop:disable Metrics/LineLength, Metrics/MethodLength
      result = []

      # do not sort the final output because arrays (and arrays of hashes
      # especially) can be order sensitive, but do sort incoming parameters
      params.each do |key, value|
        calculated_key = parent_key ? "#{parent_key}[#{key}]" : key.to_s
        if value.is_a?(Hash)
          result += flatten_params(value, calculated_key)
        elsif value.is_a?(Array)
          check_array_of_maps_start_keys!(value)
          result += flatten_params_array(value, calculated_key)
        else
          result << [calculated_key, value]
        end
      end

      result
    end

    def self.log_error(message, data = {})
      if !Syspro.logger.nil? || !Syspro.log_level.nil? && Syspro.log_level <= Syspro::LEVEL_ERROR # rubocop:disable Style/GuardClause, Metrics/LineLength
        log_internal(
          message,
          data,
          color: :cyan,
          level: Syspro::LEVEL_ERROR,
          logger: Syspro.logger,
          out: $stderr
        )
      end
    end

    def self.log_info(message, data = {})
      if !Syspro.logger.nil? || !Syspro.log_level.nil? && Syspro.log_level <= Syspro::LEVEL_INFO # rubocop:disable Style/GuardClause, Metrics/LineLength
        log_internal(
          message,
          data,
          color: :cyan,
          level: Syspro::LEVEL_INFO,
          logger: Syspro.logger,
          out: $stdout
        )
      end
    end

    def self.log_debug(message, data = {})
      if !Syspro.logger.nil? || !Syspro.log_level.nil? && Syspro.log_level <= Syspro::LEVEL_DEBUG # rubocop:disable Style/GuardClause, Metrics/LineLength
        log_internal(
          message,
          data,
          color: :blue,
          level: Syspro::LEVEL_DEBUG,
          logger: Syspro.logger,
          out: $stdout
        )
      end
    end

    def self.url_encode(key)
      CGI.escape(key.to_s).
        # Don't use strict form encoding by changing the square bracket control
        # characters back to their literals. This is fine by the server, and
        # makes these parameter strings easier to read.
        gsub('%5B', '[').gsub('%5D', ']')
    end

    # TODO: Make these named required arguments when we drop support for Ruby
    # 2.0.
    def self.log_internal(message, data = {}, color: nil, level: nil, logger: nil, out: nil) # rubocop:disable Metrics/LineLength, Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists
      data_str = data.reject { |_k, v| v.nil? }.map do |(k, v)|
        format(
          '%s=%s', # rubocop:disable Style/FormatStringToken
          colorize(k, color, !out.nil? && out.isatty),
          wrap_logfmt_value(v)
        )
      end.join(' ')

      if !logger.nil?
        # the library's log levels are mapped to the same values as the
        # standard library's logger
        logger.log(
          level,
          format(
            'message=%s %s', # rubocop:disable Style/FormatStringToken
            wrap_logfmt_value(message),
            data_str
          )
        )
      elsif out.isatty
        out.puts format(
          '%s %s %s', # rubocop:disable Style/FormatStringToken
          colorize(level_name(level)[0, 4].upcase, color, out.isatty),
          message,
          data_str
        )
      else
        out.puts format(
          'message=%s level=%s %s', # rubocop:disable Style/FormatStringToken
          wrap_logfmt_value(message),
          level_name(level),
          data_str
        )
      end
    end
    private_class_method :log_internal
  end
end