rpanachi/core_ext

View on GitHub
lib/core_ext/hash/conversions.rb

Summary

Maintainability
A
3 hrs
Test Coverage
require 'core_ext/xml_mini'
require 'core_ext/time'
require 'core_ext/object/blank'
require 'core_ext/object/to_param'
require 'core_ext/object/to_query'
require 'core_ext/array/wrap'
require 'core_ext/hash/reverse_merge'
require 'core_ext/string/inflections'

class Hash
  # Returns a string containing an XML representation of its receiver:
  #
  #   { foo: 1, bar: 2 }.to_xml
  #   # =>
  #   # <?xml version="1.0" encoding="UTF-8"?>
  #   # <hash>
  #   #   <foo type="integer">1</foo>
  #   #   <bar type="integer">2</bar>
  #   # </hash>
  #
  # To do so, the method loops over the pairs and builds nodes that depend on
  # the _values_. Given a pair +key+, +value+:
  #
  # * If +value+ is a hash there's a recursive call with +key+ as <tt>:root</tt>.
  #
  # * If +value+ is an array there's a recursive call with +key+ as <tt>:root</tt>,
  #   and +key+ singularized as <tt>:children</tt>.
  #
  # * If +value+ is a callable object it must expect one or two arguments. Depending
  #   on the arity, the callable is invoked with the +options+ hash as first argument
  #   with +key+ as <tt>:root</tt>, and +key+ singularized as second argument. The
  #   callable can add nodes by using <tt>options[:builder]</tt>.
  #
  #     'foo'.to_xml(lambda { |options, key| options[:builder].b(key) })
  #     # => "<b>foo</b>"
  #
  # * If +value+ responds to +to_xml+ the method is invoked with +key+ as <tt>:root</tt>.
  #
  #     class Foo
  #       def to_xml(options)
  #         options[:builder].bar 'fooing!'
  #       end
  #     end
  #
  #     { foo: Foo.new }.to_xml(skip_instruct: true)
  #     # =>
  #     # <hash>
  #     #   <bar>fooing!</bar>
  #     # </hash>
  #
  # * Otherwise, a node with +key+ as tag is created with a string representation of
  #   +value+ as text node. If +value+ is +nil+ an attribute "nil" set to "true" is added.
  #   Unless the option <tt>:skip_types</tt> exists and is true, an attribute "type" is
  #   added as well according to the following mapping:
  #
  #     XML_TYPE_NAMES = {
  #       "Symbol"     => "symbol",
  #       "Fixnum"     => "integer",
  #       "Bignum"     => "integer",
  #       "BigDecimal" => "decimal",
  #       "Float"      => "float",
  #       "TrueClass"  => "boolean",
  #       "FalseClass" => "boolean",
  #       "Date"       => "date",
  #       "DateTime"   => "dateTime",
  #       "Time"       => "dateTime"
  #     }
  #
  # By default the root node is "hash", but that's configurable via the <tt>:root</tt> option.
  #
  # The default XML builder is a fresh instance of <tt>Builder::XmlMarkup</tt>. You can
  # configure your own builder with the <tt>:builder</tt> option. The method also accepts
  # options like <tt>:dasherize</tt> and friends, they are forwarded to the builder.
  def to_xml(options = {})
    require 'core_ext/builder' unless defined?(Builder)

    options = options.dup
    options[:indent]  ||= 2
    options[:root]    ||= 'hash'
    options[:builder] ||= Builder::XmlMarkup.new(indent: options[:indent])

    builder = options[:builder]
    builder.instruct! unless options.delete(:skip_instruct)

    root = CoreExt::XmlMini.rename_key(options[:root].to_s, options)

    builder.tag!(root) do
      each { |key, value| CoreExt::XmlMini.to_tag(key, value, options) }
      yield builder if block_given?
    end
  end

  class << self
    # Returns a Hash containing a collection of pairs when the key is the node name and the value is
    # its content
    #
    #   xml = <<-XML
    #     <?xml version="1.0" encoding="UTF-8"?>
    #       <hash>
    #         <foo type="integer">1</foo>
    #         <bar type="integer">2</bar>
    #       </hash>
    #   XML
    #
    #   hash = Hash.from_xml(xml)
    #   # => {"hash"=>{"foo"=>1, "bar"=>2}}
    #
    # +DisallowedType+ is raised if the XML contains attributes with <tt>type="yaml"</tt> or
    # <tt>type="symbol"</tt>. Use <tt>Hash.from_trusted_xml</tt> to
    # parse this XML.
    #
    # Custom +disallowed_types+ can also be passed in the form of an
    # array.
    #
    #   xml = <<-XML
    #     <?xml version="1.0" encoding="UTF-8"?>
    #       <hash>
    #         <foo type="integer">1</foo>
    #         <bar type="string">"David"</bar>
    #       </hash>
    #   XML
    #
    #   hash = Hash.from_xml(xml, ['integer'])
    #   # => CoreExt::XMLConverter::DisallowedType: Disallowed type attribute: "integer"
    #
    # Note that passing custom disallowed types will override the default types,
    # which are Symbol and YAML.
    def from_xml(xml, disallowed_types = nil)
      CoreExt::XMLConverter.new(xml, disallowed_types).to_h
    end

    # Builds a Hash from XML just like <tt>Hash.from_xml</tt>, but also allows Symbol and YAML.
    def from_trusted_xml(xml)
      from_xml xml, []
    end
  end
end

module CoreExt
  class XMLConverter # :nodoc:
    class DisallowedType < StandardError
      def initialize(type)
        super "Disallowed type attribute: #{type.inspect}"
      end
    end

    DISALLOWED_TYPES = %w(symbol yaml)

    def initialize(xml, disallowed_types = nil)
      @xml = normalize_keys(XmlMini.parse(xml))
      @disallowed_types = disallowed_types || DISALLOWED_TYPES
    end

    def to_h
      deep_to_h(@xml)
    end

    private
      def normalize_keys(params)
        case params
          when Hash
            Hash[params.map { |k,v| [k.to_s.tr('-', '_'), normalize_keys(v)] } ]
          when Array
            params.map { |v| normalize_keys(v) }
          else
            params
        end
      end

      def deep_to_h(value)
        case value
          when Hash
            process_hash(value)
          when Array
            process_array(value)
          when String
            value
          else
            raise "can't typecast #{value.class.name} - #{value.inspect}"
        end
      end

      def process_hash(value)
        if value.include?('type') && !value['type'].is_a?(Hash) && @disallowed_types.include?(value['type'])
          raise DisallowedType, value['type']
        end

        if become_array?(value)
          _, entries = Array.wrap(value.detect { |k,v| not v.is_a?(String) })
          if entries.nil? || value['__content__'].try(:empty?)
            []
          else
            case entries
            when Array
              entries.collect { |v| deep_to_h(v) }
            when Hash
              [deep_to_h(entries)]
            else
              raise "can't typecast #{entries.inspect}"
            end
          end
        elsif become_content?(value)
          process_content(value)

        elsif become_empty_string?(value)
          ''
        elsif become_hash?(value)
          xml_value = Hash[value.map { |k,v| [k, deep_to_h(v)] }]

          # Turn { files: { file: #<StringIO> } } into { files: #<StringIO> } so it is compatible with
          # how multipart uploaded files from HTML appear
          xml_value['file'].is_a?(StringIO) ? xml_value['file'] : xml_value
        end
      end

      def become_content?(value)
        value['type'] == 'file' || (value['__content__'] && (value.keys.size == 1 || value['__content__'].present?))
      end

      def become_array?(value)
        value['type'] == 'array'
      end

      def become_empty_string?(value)
        # { "string" => true }
        # No tests fail when the second term is removed.
        value['type'] == 'string' && value['nil'] != 'true'
      end

      def become_hash?(value)
        !nothing?(value) && !garbage?(value)
      end

      def nothing?(value)
        # blank or nil parsed values are represented by nil
        value.blank? || value['nil'] == 'true'
      end

      def garbage?(value)
        # If the type is the only element which makes it then
        # this still makes the value nil, except if type is
        # an XML node(where type['value'] is a Hash)
        value['type'] && !value['type'].is_a?(::Hash) && value.size == 1
      end

      def process_content(value)
        content = value['__content__']
        if parser = CoreExt::XmlMini::PARSING[value['type']]
          parser.arity == 1 ? parser.call(content) : parser.call(content, value)
        else
          content
        end
      end

      def process_array(value)
        value.map! { |i| deep_to_h(i) }
        value.length > 1 ? value : value.first
      end

  end
end