# frozen_string_literal: true
require "active_support/core_ext/object/blank"
require "active_support/core_ext/object/to_param"
require "active_support/core_ext/object/to_query"
require "active_support/core_ext/object/try"
require "active_support/core_ext/array/wrap"
require "active_support/core_ext/hash/reverse_merge"
require "active_support/core_ext/string/inflections"
class Hash
# Returns a string containing an XML representation of its receiver:
#
# { foo: 1, bar: 2 }.to_xml
# # =>
# #
# #
# # 1
# # 2
# #
#
# 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 :root.
#
# * If +value+ is an array there's a recursive call with +key+ as :root,
# and +key+ singularized as :children.
#
# * 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 :root, and +key+ singularized as second argument. The
# callable can add nodes by using options[:builder].
#
# {foo: lambda { |options, key| options[:builder].b(key) }}.to_xml
# # => "foo"
#
# * If +value+ responds to +to_xml+ the method is invoked with +key+ as :root.
#
# class Foo
# def to_xml(options)
# options[:builder].bar 'fooing!'
# end
# end
#
# { foo: Foo.new }.to_xml(skip_instruct: true)
# # =>
# #
# # fooing!
# #
#
# * 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 :skip_types exists and is true, an attribute "type" is
# added as well according to the following mapping:
#
# XML_TYPE_NAMES = {
# "Symbol" => "symbol",
# "Integer" => "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 :root option.
#
# The default XML builder is a fresh instance of +Builder::XmlMarkup+. You can
# configure your own builder with the :builder option. The method also accepts
# options like :dasherize and friends, they are forwarded to the builder.
def to_xml(options = {})
require "active_support/builder" unless defined?(Builder::XmlMarkup)
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 = ActiveSupport::XmlMini.rename_key(options[:root].to_s, options)
builder.tag!(root) do
each { |key, value| ActiveSupport::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
#
#
# 1
# 2
#
# XML
#
# hash = Hash.from_xml(xml)
# # => {"hash"=>{"foo"=>1, "bar"=>2}}
#
# +DisallowedType+ is raised if the XML contains attributes with type="yaml" or
# type="symbol". Use Hash.from_trusted_xml to
# parse this XML.
#
# Custom +disallowed_types+ can also be passed in the form of an
# array.
#
# xml = <<-XML
#
#
# 1
# "David"
#
# XML
#
# hash = Hash.from_xml(xml, ['integer'])
# # => ActiveSupport::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)
ActiveSupport::XMLConverter.new(xml, disallowed_types).to_h
end
# Builds a Hash from XML just like Hash.from_xml, but also allows Symbol and YAML.
def from_trusted_xml(xml)
from_xml xml, []
end
end
end
module ActiveSupport
class XMLConverter # :nodoc:
# Raised if the XML contains attributes with type="yaml" or
# type="symbol". Read Hash#from_xml for more details.
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 = value.transform_values { |v| deep_to_h(v) }
# Turn { files: { file: # } } into { files: # } 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 = ActiveSupport::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