From eb707c2592f262387cd18fa597b0eb25a3307b42 Mon Sep 17 00:00:00 2001 From: Benjamin Neff Date: Thu, 24 Sep 2015 23:25:24 +0200 Subject: [PATCH] add xml_payload (+tests) from raven24's gem and do some basic refactorings for rubocop --- lib/diaspora_federation.rb | 1 + lib/diaspora_federation/entity.rb | 4 +- lib/diaspora_federation/salmon.rb | 8 ++ lib/diaspora_federation/salmon/xml_payload.rb | 123 ++++++++++++++++++ .../salmon/xml_payload_spec.rb | 113 ++++++++++++++++ 5 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 lib/diaspora_federation/salmon.rb create mode 100644 lib/diaspora_federation/salmon/xml_payload.rb create mode 100644 spec/lib/diaspora_federation/salmon/xml_payload_spec.rb diff --git a/lib/diaspora_federation.rb b/lib/diaspora_federation.rb index 7fc1729..ffd1cbd 100644 --- a/lib/diaspora_federation.rb +++ b/lib/diaspora_federation.rb @@ -10,6 +10,7 @@ require "diaspora_federation/fetcher" require "diaspora_federation/entities" require "diaspora_federation/discovery" +require "diaspora_federation/salmon" # diaspora* federation library module DiasporaFederation diff --git a/lib/diaspora_federation/entity.rb b/lib/diaspora_federation/entity.rb index 7be2d5d..944333a 100644 --- a/lib/diaspora_federation/entity.rb +++ b/lib/diaspora_federation/entity.rb @@ -6,7 +6,7 @@ module DiasporaFederation # # Any entity also provides the means to serialize itself and all nested # entities to XML (for deserialization from XML to +Entity+ instances, see - # {XmlPayload}). + # {Salmon::XmlPayload}). # # @abstract Subclass and specify properties to implement various entities. # @@ -76,7 +76,7 @@ module DiasporaFederation # {http://www.rubydoc.info/gems/nokogiri/Nokogiri/XML/Element Nokogiri::XML::Element}s # # @see Nokogiri::XML::Node.to_xml - # @see XmlPayload.pack + # @see Salmon::XmlPayload.pack # # @return [Nokogiri::XML::Element] root element containing properties as child elements def to_xml diff --git a/lib/diaspora_federation/salmon.rb b/lib/diaspora_federation/salmon.rb new file mode 100644 index 0000000..f7b2c47 --- /dev/null +++ b/lib/diaspora_federation/salmon.rb @@ -0,0 +1,8 @@ +module DiasporaFederation + # This module contains a Diaspora*-specific implementation of parts of the + # {http://www.salmon-protocol.org/ Salmon Protocol}. + module Salmon + end +end + +require "diaspora_federation/salmon/xml_payload" diff --git a/lib/diaspora_federation/salmon/xml_payload.rb b/lib/diaspora_federation/salmon/xml_payload.rb new file mode 100644 index 0000000..25d70bb --- /dev/null +++ b/lib/diaspora_federation/salmon/xml_payload.rb @@ -0,0 +1,123 @@ +module DiasporaFederation + module Salmon + # +XmlPayload+ provides methods to wrap a XML-serialized {Entity} inside a + # common XML structure that will become the payload for federation messages. + # + # The wrapper looks like so: + # + # + # {data} + # + # + # + # (The +post+ element is there for historic reasons...) + class XmlPayload + # Encapsulates an Entity inside the wrapping xml structure + # and returns the XML Object. + # + # @param [Entity] entity subject + # @return [Nokogiri::XML::Element] XML root node + # @raise [ArgumentError] if the argument is not an Entity subclass + def self.pack(entity) + raise ArgumentError, "only instances of DiasporaFederation::Entity allowed" unless entity.is_a?(Entity) + + entity_xml = entity.to_xml + doc = entity_xml.document + wrap = Nokogiri::XML::Element.new("XML", doc) + wrap_post = Nokogiri::XML::Element.new("post", doc) + entity_xml.parent = wrap_post + wrap << wrap_post + + wrap + end + + # Extracts the Entity XML from the wrapping XML structure, parses the entity + # XML and returns a new instance of the Entity that was packed inside the + # given payload. + # + # @param [Nokogiri::XML::Element] xml payload XML root node + # @return [Entity] re-constructed Entity instance + # @raise [ArgumentError] if the argument is not an + # {http://www.rubydoc.info/gems/nokogiri/Nokogiri/XML/Element Nokogiri::XML::Element} + # @raise [InvalidStructure] if the XML doesn't look like the wrapper XML + # @raise [UnknownEntity] if the class for the entity contained inside the + # XML can't be found + def self.unpack(xml) + raise ArgumentError, "only Nokogiri::XML::Element allowed" unless xml.instance_of?(Nokogiri::XML::Element) + raise InvalidStructure unless wrap_valid?(xml) + + data = xml.at_xpath("post/*[1]") + klass_name = entity_class(data.name) + raise UnknownEntity unless Entities.const_defined?(klass_name) + + klass = Entities.const_get(klass_name) + populate_entity(klass, data) + end + + private + + # @param [Nokogiri::XML::Element] element + def self.wrap_valid?(element) + (element.name == "XML" && !element.at_xpath("post").nil? && + !element.at_xpath("post").children.empty?) + end + private_class_method :wrap_valid? + + # Transform the given String from the lowercase underscored version to a + # camelized variant, used later for getting the Class constant. + # + # @note some of this is from Rails "Inflector.camelize" + # + # @param [String] term "snake_case" class name + # @return [String] "CamelCase" class name + def self.entity_class(term) + string = term.to_s + string = string.sub(/^[a-z\d]*/) { $&.capitalize } + string.gsub(%r{(?:_|(\/))([a-z\d]*)}i) { Regexp.last_match[2].capitalize }.gsub("/", "::") + end + private_class_method :entity_class + + # Construct a new instance of the given Entity and populate the properties + # with the attributes found in the XML. + # Works recursively on nested Entities and Arrays thereof. + # + # @param [Class] klass entity class + # @param [Nokogiri::XML::Element] node xml nodes + # @return [Entity] instance + def self.populate_entity(klass, node) + data = {} + klass.class_props.each do |prop_def| + name = prop_def[:name] + type = prop_def[:type] + + if type == String + # create simple entry in data hash + n = node.xpath(name.to_s) + data[name] = n.first.text if n.any? + elsif type.instance_of?(Array) + # collect all nested children of that type and create an array in the data hash + n = node.xpath(type.first.entity_name) + data[name] = n.map {|child| populate_entity(type.first, child) } + elsif type.ancestors.include?(Entity) + # create an entry in the data hash for the nested entity + n = node.xpath(type.entity_name) + data[name] = populate_entity(type, n.first) if n.any? + end + end + + klass.new(data) + end + private_class_method :populate_entity + + # Raised, if the XML structure of the parsed document doesn't resemble the + # expected structure. + class InvalidStructure < RuntimeError + end + + # Raised, if the entity contained within the XML cannot be mapped to a + # defined {Entity} subclass. + class UnknownEntity < RuntimeError + end + end + end +end diff --git a/spec/lib/diaspora_federation/salmon/xml_payload_spec.rb b/spec/lib/diaspora_federation/salmon/xml_payload_spec.rb new file mode 100644 index 0000000..89094b1 --- /dev/null +++ b/spec/lib/diaspora_federation/salmon/xml_payload_spec.rb @@ -0,0 +1,113 @@ +module DiasporaFederation + describe Salmon::XmlPayload do + let(:entity) { Entities::TestEntity.new(test: "asdf") } + let(:payload) { Salmon::XmlPayload.pack(entity) } + + context ".pack" do + it "expects an Entity as param" do + expect { + Salmon::XmlPayload.pack(entity) + }.not_to raise_error + end + + it "raises an error when the param is not an Entity" do + ["asdf", 1234, true, :test, payload].each do |val| + expect { + Salmon::XmlPayload.pack(val) + }.to raise_error ArgumentError + end + end + + context "returned xml" do + subject { Salmon::XmlPayload.pack(entity) } + + it "returns an xml wrapper" do + expect(subject).to be_an_instance_of Nokogiri::XML::Element + expect(subject.name).to eq("XML") + expect(subject.children).to have(1).item + expect(subject.children[0].name).to eq("post") + expect(subject.children[0].children).to have(1).item + end + + it "returns the entity xml inside the wrapper" do + expect(subject.children[0].children[0].name).to eq("test_entity") + expect(subject.children[0].children[0].children).to have(1).item + end + + it "produces the expected XML" do + xml_str = <<-XML.strip + + + + asdf + + + +XML + expect(subject.to_xml).to eq(xml_str) + end + end + end + + context ".unpack" do + context "sanity" do + it "expects an Nokogiri::XML::Element as param" do + expect { + Salmon::XmlPayload.unpack(payload) + }.not_to raise_error + end + + it "raises and error when the param is not an Nokogiri::XML::Element" do + ["asdf", 1234, true, :test, entity].each do |val| + expect { + Salmon::XmlPayload.unpack(val) + }.to raise_error ArgumentError, "only Nokogiri::XML::Element allowed" + end + end + + it "raises an error when the xml is wrong" do + xml = <<-XML + + + +XML + expect { + Salmon::XmlPayload.unpack(Nokogiri::XML::Document.parse(xml).root) + }.to raise_error Salmon::XmlPayload::InvalidStructure + end + end + + context "returned object" do + subject { Salmon::XmlPayload.unpack(payload) } + + it "#to_h should match entity.to_h" do + expect(subject.to_h).to eq(entity.to_h) + end + + it "returns an entity instance of the original class" do + expect(subject).to be_an_instance_of Entities::TestEntity + expect(subject.test).to eq("asdf") + end + end + + context "nested entities" do + let(:child_entity1) { Entities::TestEntity.new(test: "bla") } + let(:child_entity2) { Entities::OtherEntity.new(asdf: "blabla") } + let(:nested_entity) { + Entities::TestNestedEntity.new(asdf: "QWERT", + test: child_entity1, + multi: [child_entity2, child_entity2]) + } + let(:nested_payload) { Salmon::XmlPayload.pack(nested_entity) } + + it "parses the xml with all the nested data" do + entity = Salmon::XmlPayload.unpack(nested_payload) + expect(entity.test.to_h).to eq(child_entity1.to_h) + expect(entity.multi).to have(2).items + expect(entity.multi.first.to_h).to eq(child_entity2.to_h) + expect(entity.asdf).to eq("QWERT") + end + end + end + end +end