Larry’s Blog

Convert Hash to XML in Ruby

| Comments

How to convert a Hash instance to XML? This is a pretty simple question, actually. Implementing it by yourself won’t take you too many lines of code actually.

Let’s say we would like to construct a XML with no nodes having attributes on them. Here are some simple codes.

(converter_simple.rb) download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
require "minitest/autorun"
require "builder"

class Converter
  XML_DECLARATION = %q(<?xml version="1.0" encoding="UTF-8"?>)

  def self.to_xml(h)
    raise unless h.kind_of? Hash

    h.inject("") { |s, (k, v)| s += node_with(k, v) }
  end

  private

  def self.node_with(k, v)
    case v
    when Hash then node_with_hash(k, v)
    when Array then node_with_array(k, v)
    else node_with_string(k, v)
    end
  end

  def self.node_with_hash(k, v)
    node_with_string(k, to_xml(v))
  end

  def self.node_with_array(k, v)
    v.map { |elt| node_with(k, elt) }.join("")
  end

  def self.node_with_string(k, v)
    "<#{k}>#{v}</#{k}>"
  end
end

class TestConverter < Minitest::Test
  def setup
    @profile = {:name => "Larry", :twitter => "@larrylv", :github => "@larrylv"}
  end

  def test_string_values
    expected_value = <<-XML
      <name>Larry</name>
      <twitter>@larrylv</twitter>
      <github>@larrylv</github>
    XML
    assert_equal format_xml_string(expected_value), Converter.to_xml(@profile)
  end

  def test_hash_values
    @profile[:blog] = {
      :tech     => "blog.larrylv.com",
      :journey  => "journey.larrylv.com",
      :homepage => "larrylv.com"
    }
    expected_value = <<-XML
      <name>Larry</name>
      <twitter>@larrylv</twitter>
      <github>@larrylv</github>
      <blog>
        <tech>blog.larrylv.com</tech>
        <journey>journey.larrylv.com</journey>
        <homepage>larrylv.com</homepage>
      </blog>
    XML
    assert_equal format_xml_string(expected_value), Converter.to_xml(@profile)
  end

  def test_array_values
    @profile[:blogs] = {:blog => %w(blog.larrylv.com journey.larrylv.com larrylv.com)}

    expected_value = <<-XML
      <name>Larry</name>
      <twitter>@larrylv</twitter>
      <github>@larrylv</github>
      <blogs>
        <blog>blog.larrylv.com</blog>
        <blog>journey.larrylv.com</blog>
        <blog>larrylv.com</blog>
      </blogs>
    XML
    assert_equal format_xml_string(expected_value), Converter.to_xml(@profile)
  end

  private

  def format_xml_string(s)
    s.split("\n").map(&:strip).join("")
  end
end

The implementation is enough in many cases, but there are some obviously not good aspects.

  • It doesn’t support indentation, so the generated string is basically unreadable.
  • It doesn’t support adding attributes for nodes.

There is a gem called builder by Jim Weirich (RIP) which provides a simple way to create XML markup and data structures. With which the previous two problems would all be solved. But still, our goal is to convert a Hash instance to XML, so we should do some efforts to convert Hash’s key and value to Builder’s DSL.

First, let’s adapt Builder’s DSL to fix indentation problem.

(converter_with_builder.rb) download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
require "minitest/autorun"
require "active_support/core_ext/string/strip"
require "builder"

class Converter
  def self.hash_to_xml(hash, options = {})
    raise unless hash.kind_of? Hash

    if options[:builder]
      builder = options[:builder]
    else
      builder = Builder::XmlMarkup.new(indent: 2)
      builder.instruct!
    end

    hash.each do |k, v|
      node_with(builder, k, v)
    end

    builder.target!
  end

  def self.array_to_xml(array, key, options = {})
    raise unless array.kind_of? Array

    builder = options[:builder] || Builder::XmlMarkup.new(indent: 2)
    array.each do |elt|
      node_with(builder, key, elt)
    end

    builder.target!
  end

  private

  def self.node_with(builder, k, v)
    case v
    when Hash
      builder.tag!(k) { hash_to_xml(v, :builder => builder) }
    when Array
      array_to_xml(v, k, :builder => builder)
    else
      builder.tag!(k, v)
    end
  end
end

class TestConverter < Minitest::Test
  def setup
    @profile = {:name => "Larry", :twitter => "@larrylv", :github => "@larrylv"}
  end

  def test_string_values
    expected_value = <<-XML.strip_heredoc
      <?xml version=\"1.0\" encoding=\"UTF-8\"?>
      <name>Larry</name>
      <twitter>@larrylv</twitter>
      <github>@larrylv</github>
    XML
    assert_equal expected_value, Converter.hash_to_xml(@profile)
  end

  def test_hash_values
    @profile[:blog] = {
      :tech     => "blog.larrylv.com",
      :journey  => "journey.larrylv.com",
      :homepage => "larrylv.com"
    }
    expected_value = <<-XML.strip_heredoc
      <?xml version=\"1.0\" encoding=\"UTF-8\"?>
      <name>Larry</name>
      <twitter>@larrylv</twitter>
      <github>@larrylv</github>
      <blog>
        <tech>blog.larrylv.com</tech>
        <journey>journey.larrylv.com</journey>
        <homepage>larrylv.com</homepage>
      </blog>
    XML
    assert_equal expected_value, Converter.hash_to_xml(@profile)
  end

  def test_array_values
    @profile[:blogs] = {:blog => %w(blog.larrylv.com journey.larrylv.com larrylv.com)}

    expected_value = <<-XML.strip_heredoc
      <?xml version=\"1.0\" encoding=\"UTF-8\"?>
      <name>Larry</name>
      <twitter>@larrylv</twitter>
      <github>@larrylv</github>
      <blogs>
        <blog>blog.larrylv.com</blog>
        <blog>journey.larrylv.com</blog>
        <blog>larrylv.com</blog>
      </blogs>
    XML
    assert_equal expected_value, Converter.hash_to_xml(@profile)
  end
end

As to support attributes for nodes, we need to define Hash’s structure since current implementation will always convert key to a tag and value to node’s value.

Here, I’m gonna use format defined by Gyoku gem which does the exactly same thing with this post, Gyoku translates Ruby Hashes to XML.

1
2
3
4
5
6
7
8
Gyoku.xml({
  :subtitle => {
    :@lang => "en",
    :content! => "It's Godzilla!"
  },
  :attributes! => { :subtitle => { "lang" => "jp" } }
}
# => "<subtitle lang=\"en\">It's Godzilla!</subtitle>"

If you would like to add attributes to a XML node, you should let its value be a Hash with attributes key prefixed with @, and the node’s real value is :content!.

With this defination, we could add attributes to XML nodes, ugly format though.

So we need to update last implementation of constructing Hash nodes.

(converter_with_attributes_support.rb) download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
require "minitest/autorun"
require "active_support/core_ext/string/strip"
require "builder"

class Converter
  def self.hash_to_xml(hash, options = {})
    raise unless hash.kind_of? Hash

    if options[:builder]
      builder = options[:builder]
    else
      builder = Builder::XmlMarkup.new(indent: 2)
      builder.instruct!
    end

    hash.each do |k, v|
      node_with(builder, k, v)
    end

    builder.target!
  end

  def self.array_to_xml(array, key, attributes = {}, options = {})
    raise unless array.kind_of? Array

    builder = options[:builder] || Builder::XmlMarkup.new(indent: 2)
    array.each do |elt|
      node_with(builder, key, elt, attributes)
    end

    builder.target!
  end

  private

  def self.node_with(builder, k, v, attributes = {})
    case v
    when Hash
      if v.keys.include? :content!
        attributes = v.inject({}) do |attrs, (kk, vv)|
          next attrs unless kk.to_s =~ /^@/
          attrs[kk.to_s[1..-1]] = vv
          attrs
        end
        node_with(builder, k, v[:content!], attributes)
      else
        builder.tag!(k, attributes) { hash_to_xml(v, :builder => builder) }
      end
    when Array
      array_to_xml(v, k, attributes, :builder => builder)
    else
      builder.tag!(k, v, attributes)
    end
  end
end

class TestConverter < Minitest::Test
  def setup
    @profile = {:name => "Larry", :twitter => "@larrylv", :github => "@larrylv"}
  end

  def test_string_values
    expected_value = <<-XML.strip_heredoc
      <?xml version=\"1.0\" encoding=\"UTF-8\"?>
      <name>Larry</name>
      <twitter>@larrylv</twitter>
      <github>@larrylv</github>
    XML
    assert_equal expected_value, Converter.hash_to_xml(@profile)
  end

  def test_hash_values
    @profile[:blog] = {
      :tech     => "blog.larrylv.com",
      :journey  => "journey.larrylv.com",
      :homepage => "larrylv.com"
    }
    expected_value = <<-XML.strip_heredoc
      <?xml version=\"1.0\" encoding=\"UTF-8\"?>
      <name>Larry</name>
      <twitter>@larrylv</twitter>
      <github>@larrylv</github>
      <blog>
        <tech>blog.larrylv.com</tech>
        <journey>journey.larrylv.com</journey>
        <homepage>larrylv.com</homepage>
      </blog>
    XML
    assert_equal expected_value, Converter.hash_to_xml(@profile)
  end

  def test_array_values
    @profile[:blogs] = {:blog => %w(blog.larrylv.com journey.larrylv.com larrylv.com)}

    expected_value = <<-XML.strip_heredoc
      <?xml version=\"1.0\" encoding=\"UTF-8\"?>
      <name>Larry</name>
      <twitter>@larrylv</twitter>
      <github>@larrylv</github>
      <blogs>
        <blog>blog.larrylv.com</blog>
        <blog>journey.larrylv.com</blog>
        <blog>larrylv.com</blog>
      </blogs>
    XML
    assert_equal expected_value, Converter.hash_to_xml(@profile)
  end

  def test_attributes
    @profile[:blog] = {
      :content! => {
        :tech     => "blog.larrylv.com",
        :journey  => "journey.larrylv.com",
        :homepage => "larrylv.com"
      },
      :@count => 3,
      :@readers => "few"
    }
    expected_value = <<-XML.strip_heredoc
      <?xml version=\"1.0\" encoding=\"UTF-8\"?>
      <name>Larry</name>
      <twitter>@larrylv</twitter>
      <github>@larrylv</github>
      <blog count="3" readers="few">
        <tech>blog.larrylv.com</tech>
        <journey>journey.larrylv.com</journey>
        <homepage>larrylv.com</homepage>
      </blog>
    XML
    assert_equal expected_value, Converter.hash_to_xml(@profile)
  end

  def test_array_with_attributes
    @profile[:blogs] = {
      :blog => {
        :content! => %w(blog.larrylv.com journey.larrylv.com larrylv.com),
        :@count => 3,
        :@readers => "few"
      }
    }

    expected_value = <<-XML.strip_heredoc
      <?xml version=\"1.0\" encoding=\"UTF-8\"?>
      <name>Larry</name>
      <twitter>@larrylv</twitter>
      <github>@larrylv</github>
      <blogs>
        <blog count="3" readers="few">blog.larrylv.com</blog>
        <blog count="3" readers="few">journey.larrylv.com</blog>
        <blog count="3" readers="few">larrylv.com</blog>
      </blogs>
    XML
    assert_equal expected_value, Converter.hash_to_xml(@profile)
  end
end

This is pretty much the final solution for converting a Hash to XML.

  • Gyoku supports adding attributes to XML node, but the result doesn’t have indentatation which is very unreadable. I will contribute a PR for this.

  • And Hash#to_xml provided by ActiveSupport doesn’t support adding custom attributes, since it will need Hash to be constructed as a different/ugly structure.

The codes are put in Gist #a67e1.

Thanks for reading.

Comments