You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@deltacloud.apache.org by lu...@redhat.com on 2011/11/09 22:18:23 UTC

[PATCH 1/2] CIMI: model layer

From: David Lutterkort <lu...@redhat.com>

A way to describe the schema of CIMI objects so that we can serialize to
and from XML

Signed-off-by: David Lutterkort <lu...@redhat.com>
---
 server/Rakefile                          |    5 +
 server/lib/cimi/model.rb                 |   23 +++
 server/lib/cimi/model/base.rb            |  169 ++++++++++++++++++++
 server/lib/cimi/model/schema.rb          |  257 ++++++++++++++++++++++++++++++
 server/lib/deltacloud/core_ext.rb        |    1 +
 server/lib/deltacloud/core_ext/array.rb  |   25 +++
 server/lib/deltacloud/core_ext/hash.rb   |    7 +
 server/lib/deltacloud/core_ext/string.rb |    3 +
 server/spec/cimi/model/schema_spec.rb    |  245 ++++++++++++++++++++++++++++
 server/spec/spec_helper.rb               |   27 +++
 10 files changed, 762 insertions(+), 0 deletions(-)
 create mode 100644 server/lib/cimi/model.rb
 create mode 100644 server/lib/cimi/model/base.rb
 create mode 100644 server/lib/cimi/model/schema.rb
 create mode 100644 server/lib/deltacloud/core_ext/array.rb
 create mode 100644 server/spec/cimi/model/schema_spec.rb
 create mode 100644 server/spec/spec_helper.rb

diff --git a/server/Rakefile b/server/Rakefile
index f07a6ce..273469c 100644
--- a/server/Rakefile
+++ b/server/Rakefile
@@ -20,6 +20,7 @@
 require 'rake'
 require 'rake/testtask'
 require 'rubygems/package_task'
+require 'spec/rake/spectask'
 
 begin
   require 'ci/reporter/rake/test_unit'
@@ -89,6 +90,10 @@ task :cucumber do
   end
 end
 
+Spec::Rake::SpecTask.new('spec') do |t|
+  t.spec_files = FileList['spec/**/*_spec.rb']
+end
+
 begin
   require 'yard'
   YARD::Rake::YardocTask.new do |t|
diff --git a/server/lib/cimi/model.rb b/server/lib/cimi/model.rb
new file mode 100644
index 0000000..c1d7f02
--- /dev/null
+++ b/server/lib/cimi/model.rb
@@ -0,0 +1,23 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.  The
+# ASF licenses this file to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance with the
+# License.  You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+
+module CIMI
+  module Model; end
+end
+
+require 'cimi/model/schema'
+require 'cimi/model/base'
+require 'cimi/model/machine_template'
diff --git a/server/lib/cimi/model/base.rb b/server/lib/cimi/model/base.rb
new file mode 100644
index 0000000..0ef2b27
--- /dev/null
+++ b/server/lib/cimi/model/base.rb
@@ -0,0 +1,169 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.  The
+# ASF licenses this file to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance with the
+# License.  You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+require 'xmlsimple'
+require 'json'
+
+# The base class for any CIMI object that we either read from a request or
+# write as a response. This class handles serializing/deserializing XML and
+# JSON into a common form.
+#
+# == Defining the schema
+#
+# The conversion of XML and JSON into internal objects is based on a schema
+# that is defined through a DSL:
+#
+#   class Machine < CIMI::Model::Base
+#     text :status
+#     href :meter
+#     array :volumes do
+#       scalar :href, :attachment_point, :protocol
+#     end
+#   end
+#
+# The DSL automatically takes care of converting identifiers from their
+# underscored form to the camel-cased form used by CIMI. The above class
+# can be used in the following way:
+#
+#   machine = Machine.from_xml(some_xml)
+#   if machine.status == "UP"
+#     ...
+#   end
+#   sda = machine.volumes.find { |v| v.attachment_point == "/dev/sda" }
+#   handle_meter(machine.meter.href)
+#
+# The keywords for the DSL are
+#   [scalar(names, ...)]
+#     Define a scalar attribute; in JSON, this is represented as a string
+#     property. In XML, this can be represented in a number of ways,
+#     depending on whether the option :text is set:
+#       * :text not set: attribute on the enclosing element
+#       * :text == :direct: the text content of the enclosing element
+#       * :text == :nested: the text content of an element +<name>...</name>+
+#   [text(names)]
+#     A shorthand for +scalar(names, :text => :nested)+, i.e., for
+#     attributes that in XML are represented by their own tags
+#   [href(name)]
+#     A shorthand for +struct name { scalar :href }+; in JSON, this is
+#     represented as +{ name: { "href": string } }+, and in XML as +<name
+#     href="..."/>+
+#   [struct(name, opts, &block)]
+#     A structured subobject; the block defines the schema of the
+#     subobject. The +:content+ option can be used to specify the attribute
+#     that should receive the content of hte corresponding XML element
+#   [array(name, opts, &block)]
+#     An array of structured subobjects; the block defines the schema of
+#     the subobjects.
+class CIMI::Model::Base
+
+  #
+  # We keep the values of the attributes in a hash
+  #
+  attr_reader :attribute_values
+
+  # Keep the list of all attributes in an array +attributes+; for each
+  # attribute, we also define a getter and a setter to access/change the
+  # value for that attribute
+  class << self
+    def schema
+      @schema ||= CIMI::Model::Schema.new
+    end
+
+    def schema=(s)
+      @schema = s
+    end
+
+    def inherited(child)
+      child.schema = self.schema.dup
+    end
+
+    def add_attributes!(names, attr_klass, &block)
+      schema.add_attributes!(names, attr_klass, &block)
+      names.each do |name|
+        define_method(name) { @attribute_values[name] }
+        define_method(:"#{name}=") { |newval| @attribute_values[name] = newval }
+      end
+    end
+  end
+
+  extend CIMI::Model::Schema::DSL
+
+  def [](a)
+    @attribute_values[a]
+  end
+
+  def []=(a, v)
+    @attribute_values[a] = v
+  end
+
+  #
+  # Factory methods
+  #
+  def initialize(values = {})
+    @attribute_values = values
+  end
+
+  # Construct a new object from the XML representation +xml+
+  def self.from_xml(text)
+    xml = XmlSimple.xml_in(text, :force_content => true)
+    model = self.new
+    @schema.from_xml(xml, model)
+    model
+  end
+
+  # Construct a new object
+  def self.from_json(text)
+    json = JSON::parse(text)
+    model = self.new
+    @schema.from_json(json, model)
+    model
+  end
+
+  #
+  # Serialize
+  #
+
+  def self.xml_tag_name
+    self.name.split("::").last
+  end
+
+  def self.to_json(model)
+    @schema.to_json(model)
+  end
+
+  def self.to_xml(model)
+    xml = @schema.to_xml(model)
+    xml["xmlns"] = "http://www.dmtf.org/cimi"
+    XmlSimple.xml_out(xml, :root_name => xml_tag_name)
+  end
+
+  def to_json
+    self.class.to_json(self)
+  end
+
+  def to_xml
+    self.class.to_xml(self)
+  end
+
+  #
+  # Common attributes for all resources
+  #
+  text :uri, :name, :description, :created
+
+  # FIXME: this doesn't match with JSON
+  array :properties, :content => :value do
+    scalar :key
+  end
+end
diff --git a/server/lib/cimi/model/schema.rb b/server/lib/cimi/model/schema.rb
new file mode 100644
index 0000000..e470121
--- /dev/null
+++ b/server/lib/cimi/model/schema.rb
@@ -0,0 +1,257 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.  The
+# ASF licenses this file to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance with the
+# License.  You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+
+# The smarts of converting from XML and JSON into internal objects
+class CIMI::Model::Schema
+
+  #
+  # Attributes describe how we extract values from XML/JSON
+  #
+  class Attribute
+    attr_reader :name, :xml_name, :json_name
+
+    def initialize(name, opts = {})
+      @name = name
+      @xml_name = (opts[:xml_name] || name).to_s.camelize(true)
+      @json_name = (opts[:json_name] || name).to_s.camelize(true)
+    end
+
+    def from_xml(xml, model)
+      model[@name] = xml[@xml_name].first if xml.has_key?(@xml_name)
+    end
+
+    def from_json(json, model)
+      model[@name] = json[@json_name]
+    end
+
+    def to_xml(model, xml)
+      xml[@xml_name] = [model[@name]] if model[@name]
+    end
+
+    def to_json(model, json)
+      json[@json_name] = model[@name] if model[@name]
+    end
+  end
+
+  class Scalar < Attribute
+    def initialize(name, opts)
+      @text = opts[:text]
+      if ! [nil, :nested, :direct].include?(@text)
+        raise "text option for scalar must be :nested or :direct"
+      end
+      super(name, opts)
+    end
+
+    def text?; @text; end
+
+    def nested_text?; @text == :nested; end
+
+    def from_xml(xml, model)
+      if @text == :nested
+        model[@name] = xml[@xml_name].first["content"] if xml[@xml_name]
+      elsif @text == :direct
+        model[@name] = xml["content"]
+      else
+        model[@name] = xml[@xml_name]
+      end
+    end
+
+    def to_xml(model, xml)
+      return unless model[@name]
+      if @text == :nested
+        xml[@xml_name] = [{ "content" => model[@name] }]
+      elsif @text == :direct
+        xml["content"] = model[@name]
+      else
+        xml[@xml_name] = model[@name]
+      end
+    end
+  end
+
+  class Struct < Attribute
+    def initialize(name, opts, &block)
+      content = opts[:content]
+      super(name)
+      @schema = CIMI::Model::Schema.new
+      @schema.instance_eval(&block) if block_given?
+      @schema.scalar(content, :text => :direct) if content
+    end
+
+    def from_xml(xml, model)
+      xml = xml.has_key?(xml_name) ? xml[xml_name].first : {}
+      model[name] = convert_from_xml(xml)
+    end
+
+    def from_json(json, model)
+      json = json.has_key?(json_name) ? json[json_name] : {}
+      model[name] = convert_from_json(json)
+    end
+
+    def to_xml(model, xml)
+      conv = convert_to_xml(model[name])
+      xml[xml_name] = [conv] unless conv.empty?
+    end
+
+    def to_json(model, json)
+      conv = convert_to_json(model[name])
+      json[json_name] = conv unless conv.empty?
+    end
+
+    def convert_from_xml(xml)
+      sub = struct.new
+      @schema.from_xml(xml, sub)
+      sub
+    end
+
+    def convert_from_json(json)
+      sub = struct.new
+      @schema.from_json(json, sub)
+      sub
+    end
+
+    def convert_to_xml(model)
+      xml = {}
+      @schema.to_xml(model, xml)
+      xml
+    end
+
+    def convert_to_json(model)
+      json = {}
+      @schema.to_json(model, json)
+      json
+    end
+
+    private
+    def struct
+      cname = "CIMI_#{json_name.capitalize}"
+      if ::Struct.const_defined?(cname)
+        ::Struct.const_get(cname)
+      else
+        ::Struct.new("CIMI_#{json_name.capitalize}",
+                     *@schema.attribute_names)
+      end
+    end
+  end
+
+  class Array < Attribute
+    # For an array :things, we collect all <thing/> elements (XmlSimple
+    # actually does the collecting)
+    def initialize(name, opts = {}, &block)
+      opts[:xml_name] = name.to_s.singularize unless opts[:xml_name]
+      super(name, opts)
+      @struct = Struct.new(name, opts, &block)
+    end
+
+    def from_xml(xml, model)
+      model[name] = (xml[xml_name] || []).map do |elt|
+        @struct.convert_from_xml(elt)
+      end
+    end
+
+    def from_json(json, model)
+      model[name] = (json[json_name] || []).map do |elt|
+        @struct.convert_from_json(elt)
+      end
+    end
+
+    def to_xml(model, xml)
+      ary = model[name].map { |elt| @struct.convert_to_xml(elt) }
+      xml[xml_name] = ary unless ary.empty?
+    end
+
+    def to_json(model, json)
+      ary = model[name].map { |elt| @struct.convert_to_json(elt) }
+      json[json_name] = ary unless ary.empty?
+    end
+  end
+
+  #
+  # The actual Schema class
+  #
+  def initialize
+    @attributes = []
+  end
+
+  def from_xml(xml, model = {})
+    @attributes.freeze
+    @attributes.each { |attr| attr.from_xml(xml, model) }
+    model
+  end
+
+  def from_json(json, model = {})
+    @attributes.freeze
+    @attributes.each { |attr| attr.from_json(json, model) }
+    model
+  end
+
+  def to_xml(model, xml = {})
+    @attributes.freeze
+    @attributes.each { |attr| attr.to_xml(model, xml) }
+    xml
+  end
+
+  def to_json(model, json = {})
+    @attributes.freeze
+    @attributes.each { |attr| attr.to_json(model, json) }
+    json
+  end
+
+  def attribute_names
+    @attributes.map { |a| a.name }
+  end
+
+  #
+  # The DSL
+  #
+  # Requires that the class into which this is included has a
+  # +add_attributes!+ method
+  module DSL
+    def href(*args)
+      args.each do |arg|
+        struct(arg) { scalar :href }
+      end
+    end
+
+    def text(*args)
+      args.expand_opts!(:text => :nested)
+      scalar(*args)
+    end
+
+    def scalar(*args)
+      add_attributes!(args, Scalar)
+    end
+
+    def array(name, opts={}, &block)
+      add_attributes!([name, opts], Array, &block)
+    end
+
+    def struct(name, opts={}, &block)
+      add_attributes!([name, opts], Struct, &block)
+    end
+  end
+
+  include DSL
+
+  def add_attributes!(args, attr_klass, &block)
+    if @attributes.frozen?
+      raise "The schema has already been used to convert objects"
+    end
+    opts = args.extract_opts!
+    args.each do |arg|
+      @attributes << attr_klass.new(arg, opts, &block)
+    end
+  end
+end
diff --git a/server/lib/deltacloud/core_ext.rb b/server/lib/deltacloud/core_ext.rb
index 43b3b25..042bffc 100644
--- a/server/lib/deltacloud/core_ext.rb
+++ b/server/lib/deltacloud/core_ext.rb
@@ -17,3 +17,4 @@
 require 'deltacloud/core_ext/string'
 require 'deltacloud/core_ext/integer'
 require 'deltacloud/core_ext/hash'
+require 'deltacloud/core_ext/array'
diff --git a/server/lib/deltacloud/core_ext/array.rb b/server/lib/deltacloud/core_ext/array.rb
new file mode 100644
index 0000000..620b1f4
--- /dev/null
+++ b/server/lib/deltacloud/core_ext/array.rb
@@ -0,0 +1,25 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.  The
+# ASF licenses this file to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance with the
+# License.  You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+class Array
+  def expand_opts!(more_opts)
+    self << {} unless last.is_a?(Hash)
+    last.update(more_opts)
+  end
+
+  def extract_opts!
+    last.is_a?(Hash) ? pop : {}
+  end
+end
diff --git a/server/lib/deltacloud/core_ext/hash.rb b/server/lib/deltacloud/core_ext/hash.rb
index 9cdba50..fb12045 100644
--- a/server/lib/deltacloud/core_ext/hash.rb
+++ b/server/lib/deltacloud/core_ext/hash.rb
@@ -26,4 +26,11 @@ class Hash
     #remove the original keys
     self.delete_if{|k,v| remove.include?(k)}
   end
+
+  # Method copied from https://github.com/rails/rails/blob/77efc20a54708ba37ba679ffe90021bf8a8d3a8a/activesupport/lib/active_support/core_ext/hash/keys.rb#L23
+  def symbolize_keys
+    keys.each { |key| self[(key.to_sym rescue key) || key] = delete(key) }
+    self
+  end
+
 end
diff --git a/server/lib/deltacloud/core_ext/string.rb b/server/lib/deltacloud/core_ext/string.rb
index 70d0df6..c5d9bf3 100644
--- a/server/lib/deltacloud/core_ext/string.rb
+++ b/server/lib/deltacloud/core_ext/string.rb
@@ -59,4 +59,7 @@ class String
     self[0, 1].downcase + self[1..-1]
   end
 
+  def capitalize
+    self[0, 1].upcase + self[1..-1]
+  end
 end
diff --git a/server/spec/cimi/model/schema_spec.rb b/server/spec/cimi/model/schema_spec.rb
new file mode 100644
index 0000000..0b262f4
--- /dev/null
+++ b/server/spec/cimi/model/schema_spec.rb
@@ -0,0 +1,245 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.  The
+# ASF licenses this file to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance with the
+# License.  You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+
+require 'spec_helper'
+
+require 'cimi/model'
+
+describe "Schema" do
+  before(:each) do
+    @schema = CIMI::Model::Schema.new
+  end
+
+  it "does not allow adding attributes after being used for conversion" do
+    @schema.scalar(:before)
+    @schema.from_json({})
+    lambda { @schema.scalar(:after) }.should raise_error
+  end
+
+  describe "scalars" do
+    before(:each) do
+      @schema.scalar(:attr)
+      @schema.text(:camel_hump)
+
+      @schema.attribute_names.should == [:attr, :camel_hump]
+    end
+
+    let :sample_xml do
+      parse_xml("<camelHump>bumpy</camelHump>", :keep_root => true)
+    end
+
+    it "should camel case attribute names for JSON" do
+      obj = @schema.from_json("camelHump" => "bumpy")
+      obj.should_not be_nil
+      obj[:camel_hump].should == "bumpy"
+
+      json = @schema.to_json(obj)
+      json.should == { "camelHump" => "bumpy" }
+    end
+
+    it "should camel case attribute names for XML" do
+      obj = @schema.from_xml(sample_xml)
+
+      obj.should_not be_nil
+      obj[:camel_hump].should == "bumpy"
+
+      xml = @schema.to_xml(obj)
+
+      xml.should == { "camelHump" => [{ "content" => "bumpy" }] }
+    end
+
+    it "should allow aliasing the XML and JSON name" do
+      @schema.scalar :aliased, :xml_name => :xml, :json_name => :json
+      obj = @schema.from_xml({"aliased" => "no", "xml" => "yes"}, {})
+      obj[:aliased].should == "yes"
+
+      obj = @schema.from_json({"aliased" => "no", "json" => "yes"}, {})
+      obj[:aliased].should == "yes"
+    end
+  end
+
+  describe "hrefs" do
+    before(:each) do
+      @schema.href(:meter)
+    end
+
+    it "should extract the href attribute from XML" do
+      xml = parse_xml("<meter href='http://example.org/'/>")
+
+      obj = @schema.from_xml(xml)
+      check obj
+      @schema.to_xml(obj).should == xml
+    end
+
+    it "should extract the href attribute from JSON" do
+      json = { "meter" =>  { "href" => "http://example.org/" } }
+
+      obj = @schema.from_json(json)
+      check obj
+      @schema.to_json(obj).should == json
+    end
+
+    def check(obj)
+      obj.should_not be_nil
+      obj[:meter].href.should == 'http://example.org/'
+    end
+  end
+
+  describe "structs" do
+    before(:each) do
+      @schema.struct(:struct, :content => :scalar) do
+        scalar   :href
+      end
+      @schema.attribute_names.should == [:struct]
+    end
+
+    let(:sample_json) do
+      { "struct" => { "scalar" => "v1", "href" => "http://example.org/" } }
+    end
+
+    let (:sample_xml) do
+      parse_xml("<struct href='http://example.org/'>v1</struct>")
+    end
+
+    let (:sample_xml_no_href) do
+      parse_xml("<struct>v1</struct>")
+    end
+
+    describe "JSON conversion" do
+      it "should convert empty hash" do
+        model = @schema.from_json({ })
+        check_empty_struct model
+        @schema.to_json(model).should == {}
+      end
+
+      it "should convert empty body" do
+        model = @schema.from_json({ "struct" => { } })
+        check_empty_struct model
+        @schema.to_json(model).should == {}
+      end
+
+      it "should convert values" do
+        model = @schema.from_json(sample_json)
+        check_struct model
+        @schema.to_json(model).should == sample_json
+      end
+    end
+
+    describe "XML conversion" do
+      it "should convert empty hash" do
+        model = @schema.from_xml({ })
+        check_empty_struct model
+        @schema.to_xml(model).should == {}
+      end
+
+      it "should convert empty body" do
+        model = @schema.from_json({ "struct" => { } })
+        check_empty_struct model
+        @schema.to_xml(model).should == {}
+      end
+
+      it "should convert values" do
+        model = @schema.from_xml(sample_xml)
+        check_struct model
+        @schema.to_xml(model).should == sample_xml
+      end
+
+      it "should handle missing attributes" do
+        model = @schema.from_xml(sample_xml_no_href)
+        check_struct model, :nil_href => true
+        @schema.to_xml(model).should == sample_xml_no_href
+      end
+    end
+
+    def check_struct(obj, opts = {})
+      obj.should_not be_nil
+      obj[:struct].should_not be_nil
+      obj[:struct].scalar.should == "v1"
+      if opts[:nil_href]
+        obj[:struct].href.should be_nil
+      else
+        obj[:struct].href.should == "http://example.org/"
+      end
+    end
+
+    def check_empty_struct(obj)
+      obj.should_not be_nil
+      obj[:struct].should_not be_nil
+      obj[:struct].scalar.should be_nil
+      obj[:struct].href.should be_nil
+    end
+  end
+
+  describe "arrays" do
+    before(:each) do
+      @schema.array(:structs, :content => :scalar) do
+        scalar :href
+      end
+    end
+
+    let(:sample_json) do
+      { "structs" => [{ "scalar" => "v1", "href" => "http://example.org/1" },
+                      { "scalar" => "v2", "href" => "http://example.org/2" }] }
+    end
+
+    let (:sample_xml) do
+      parse_xml("<wrapper>
+  <struct href='http://example.org/1'>v1</struct>
+  <struct href='http://example.org/2'>v2</struct>
+</wrapper>", :keep_root => false)
+    end
+
+    it "should convert missing array from JSON" do
+      obj = @schema.from_json({})
+
+      obj.should_not be_nil
+      obj[:structs].should == []
+      @schema.to_json(obj).should == {}
+    end
+
+    it "should convert empty array from JSON" do
+      obj = @schema.from_json("structs" => [])
+
+      obj.should_not be_nil
+      obj[:structs].should == []
+      @schema.to_json(obj).should == {}
+    end
+
+    it "should convert arrays from JSON" do
+      obj = @schema.from_json(sample_json)
+
+      check_structs(obj)
+      @schema.to_json(obj).should == sample_json
+    end
+
+    it "should convert arrays from XML" do
+      obj = @schema.from_xml(sample_xml)
+
+      check_structs(obj)
+      @schema.to_xml(obj).should == sample_xml
+    end
+
+    def check_structs(obj)
+      obj.should_not be_nil
+      obj[:structs].size.should == 2
+      obj[:structs][0].scalar.should == "v1"
+      obj[:structs][0].href.should == "http://example.org/1"
+      obj[:structs][1].scalar.should == "v2"
+      obj[:structs][1].href.should == "http://example.org/2"
+    end
+  end
+
+end
diff --git a/server/spec/spec_helper.rb b/server/spec/spec_helper.rb
new file mode 100644
index 0000000..1e0dd87
--- /dev/null
+++ b/server/spec/spec_helper.rb
@@ -0,0 +1,27 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.  The
+# ASF licenses this file to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance with the
+# License.  You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+
+require 'rubygems'
+require 'pp'
+
+require 'deltacloud/core_ext'
+require 'xmlsimple'
+
+def parse_xml(xml, opts = {})
+  opts[:force_content] = true
+  opts[:keep_root] = true unless opts.has_key?(:keep_root)
+  XmlSimple.xml_in(xml, opts)
+end
-- 
1.7.6.4