util.rb 7.11 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

    class << self
      def 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 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 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 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 encode_parameters(params)
        Util.flatten_params(params)
            .map { |k, v| "#{url_encode(k)}=#{url_encode(v)}" }.join('&')
      end

      def 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 log_error(message, data = {})
        if !logger.nil? || !log_level.nil? && 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 log_info(message, data = {})
        if !logger.nil? || !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: logger,
            out: $stdout
          )
        end
      end

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

      def 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

      private

      # TODO: Make these named required arguments when we drop support for Ruby
      # 2.0.
      def 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

      def logger
        Syspro.configuration.logger
      end

      def log_level
        Syspro.configuration.log_level
      end
    end
  end
end