From 2cdaea0d70238ef2e42649deaaf2825fc429ef86 Mon Sep 17 00:00:00 2001 From: Benjamin Neff Date: Sat, 20 Feb 2016 20:23:33 +0100 Subject: [PATCH] move parse code to Entity --- lib/diaspora_federation/entity.rb | 124 +++++++++++++ lib/diaspora_federation/salmon/exceptions.rb | 9 - lib/diaspora_federation/salmon/xml_payload.rb | 105 +---------- spec/lib/diaspora_federation/entity_spec.rb | 170 ++++++++++++++++++ .../salmon/xml_payload_spec.rb | 147 +-------------- 5 files changed, 297 insertions(+), 258 deletions(-) diff --git a/lib/diaspora_federation/entity.rb b/lib/diaspora_federation/entity.rb index bd2943e..85923f8 100644 --- a/lib/diaspora_federation/entity.rb +++ b/lib/diaspora_federation/entity.rb @@ -99,7 +99,23 @@ module DiasporaFederation end end + # 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 [Nokogiri::XML::Element] root_node xml nodes + # @return [Entity] instance + def self.from_xml(root_node) + raise ArgumentError, "only Nokogiri::XML::Element allowed" unless root_node.instance_of?(Nokogiri::XML::Element) + raise InvalidRootNode, "'#{root_node.name}' can't be parsed by #{name}" unless root_node.name == entity_name + + populate_entity(root_node) + end + # Makes an underscored, lowercase form of the class name + # + # @see .entity_class + # # @return [String] entity name def self.entity_name name.rpartition("::").last.tap do |word| @@ -108,6 +124,23 @@ module DiasporaFederation end end + # Transform the given String from the lowercase underscored version to a + # camelized variant and returns the Class constant. + # + # @see .entity_name + # + # @param [String] class_name "snake_case" class name + # @return [Class] entity class + def self.entity_class(class_name) + raise InvalidEntityName, "'#{class_name}' is invalid" unless class_name =~ /^[a-z]*(_[a-z]*)*$/ + class_name.sub!(/^[a-z]/, &:upcase) + class_name.gsub!(/_([a-z])/) { Regexp.last_match[1].upcase } + + raise UnknownEntity, "'#{class_name}' not found" unless Entities.const_defined?(class_name) + + Entities.const_get(class_name) + end + private def setable?(name, val) @@ -173,8 +206,99 @@ module DiasporaFederation end end + # @param [Nokogiri::XML::Element] root_node xml nodes + # @return [Entity] instance + def self.populate_entity(root_node) + # Use all known properties to build the Entity (entity_data). All additional xml elements + # are respected and attached to a hash as string (additional_xml_elements). It is intended + # to build a hash invariable of an Entity definition, in order to support receiving objects + # from the future versions of Diaspora, where new elements may have been added. + entity_data = {} + xml_order = [] + additional_xml_elements = {} + + root_node.element_children.each do |child| + xml_name = child.name + property = find_property_for_xml_name(xml_name) + + if property + entity_data[property] = parse_element_from_node(class_props[property], xml_name, root_node) + xml_order << property + else + additional_xml_elements[xml_name] = child.text + xml_order << xml_name + end + end + + new(entity_data, xml_order, additional_xml_elements).tap do |entity| + entity.verify_signatures if entity.respond_to? :verify_signatures + end + end + private_class_method :populate_entity + + # @param [Class] type target type to parse + # @param [String] xml_name xml tag to parse + # @param [Nokogiri::XML::Element] node XML node to parse + # @return [Object] parsed data + def self.parse_element_from_node(type, xml_name, node) + if type == String + parse_string_from_node(xml_name, node) + elsif type.instance_of?(Array) + parse_array_from_node(type.first, node) + elsif type.ancestors.include?(Entity) + parse_entity_from_node(type, node) + end + end + private_class_method :parse_element_from_node + + # create simple entry in data hash + # + # @param [String] name xml tag to parse + # @param [Nokogiri::XML::Element] root_node XML root_node to parse + # @return [String] data + def self.parse_string_from_node(name, root_node) + node = root_node.xpath(name.to_s) + node.first.text if node.any? + end + private_class_method :parse_string_from_node + + # create an entry in the data hash for the nested entity + # + # @param [Class] type target type to parse + # @param [Nokogiri::XML::Element] root_node XML node to parse + # @return [Entity] parsed child entity + def self.parse_entity_from_node(type, root_node) + node = root_node.xpath(type.entity_name) + type.from_xml(node.first) if node.any? + end + private_class_method :parse_entity_from_node + + # collect all nested children of that type and create an array in the data hash + # + # @param [Class] type target type to parse + # @param [Nokogiri::XML::Element] root_node XML node to parse + # @return [Array] array with parsed child entities + def self.parse_array_from_node(type, root_node) + node = root_node.xpath(type.entity_name) + node.map {|child| type.from_xml(child) } + end + private_class_method :parse_array_from_node + # Raised, if entity is not valid class ValidationError < RuntimeError end + + # Raised, if the root node doesn't match the class name + class InvalidRootNode < RuntimeError + end + + # Raised, if the entity name in the XML is invalid + class InvalidEntityName < RuntimeError + end + + # Raised, if the entity contained within the XML cannot be mapped to a + # defined {Entity} subclass. + class UnknownEntity < RuntimeError + end end end diff --git a/lib/diaspora_federation/salmon/exceptions.rb b/lib/diaspora_federation/salmon/exceptions.rb index accc08b..4de9a45 100644 --- a/lib/diaspora_federation/salmon/exceptions.rb +++ b/lib/diaspora_federation/salmon/exceptions.rb @@ -40,14 +40,5 @@ module DiasporaFederation # Raised, if the parsed Magic Envelope specifies an unhandled encoding. class InvalidEncoding < RuntimeError end - - # Raised, if the entity name in the XML is invalid - class InvalidEntityName < RuntimeError - end - - # Raised, if the entity contained within the XML cannot be mapped to a - # defined {Entity} subclass. - class UnknownEntity < RuntimeError - end end end diff --git a/lib/diaspora_federation/salmon/xml_payload.rb b/lib/diaspora_federation/salmon/xml_payload.rb index e800a68..c9b8fee 100644 --- a/lib/diaspora_federation/salmon/xml_payload.rb +++ b/lib/diaspora_federation/salmon/xml_payload.rb @@ -11,6 +11,7 @@ module DiasporaFederation # # # (The +post+ element is there for historic reasons...) + # @deprecated module XmlPayload # Encapsulates an Entity inside the wrapping xml structure # and returns the XML Object. @@ -18,7 +19,6 @@ module DiasporaFederation # @param [Entity] entity subject # @return [Nokogiri::XML::Element] XML root node # @raise [ArgumentError] if the argument is not an Entity subclass - # @deprecated def self.pack(entity) raise ArgumentError, "only instances of DiasporaFederation::Entity allowed" unless entity.is_a?(Entity) @@ -46,117 +46,16 @@ module DiasporaFederation raise ArgumentError, "only Nokogiri::XML::Element allowed" unless xml.instance_of?(Nokogiri::XML::Element) data = xml_wrapped?(xml) ? xml.at_xpath("post/*[1]") : xml - klass_name = entity_class_name(data.name) - raise Salmon::UnknownEntity, "'#{klass_name}' not found" unless Entities.const_defined?(klass_name) - klass = Entities.const_get(klass_name) - populate_entity(klass, data) + Entity.entity_class(data.name).from_xml(data) end # @param [Nokogiri::XML::Element] element - # @deprecated def self.xml_wrapped?(element) (element.name == "XML" && !element.at_xpath("post").nil? && !element.at_xpath("post").children.empty?) end private_class_method :xml_wrapped? - - # Transform the given String from the lowercase underscored version to a - # camelized variant, used later for getting the Class constant. - # - # @param [String] term "snake_case" class name - # @return [String] "CamelCase" class name - def self.entity_class_name(term) - term.to_s.tap do |string| - raise Salmon::InvalidEntityName, "'#{string}' is invalid" unless string =~ /^[a-z]*(_[a-z]*)*$/ - string.sub!(/^[a-z]/, &:upcase) - string.gsub!(/_([a-z])/) { Regexp.last_match[1].upcase } - end - end - private_class_method :entity_class_name - - # 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] root_node xml nodes - # @return [Entity] instance - def self.populate_entity(klass, root_node) - # Use all known properties to build the Entity (entity_data). All additional xml elements - # are respected and attached to a hash as string (additional_xml_elements). It is intended - # to build a hash invariable of an Entity definition, in order to support receiving objects - # from the future versions of Diaspora, where new elements may have been added. - entity_data = {} - xml_order = [] - additional_xml_elements = {} - - root_node.element_children.each do |child| - xml_name = child.name - property = klass.find_property_for_xml_name(xml_name) - - if property - entity_data[property] = parse_element_from_node(klass.class_props[property], xml_name, root_node) - xml_order << property - else - additional_xml_elements[xml_name] = child.text - xml_order << xml_name - end - end - - klass.new(entity_data, xml_order, additional_xml_elements).tap do |entity| - entity.verify_signatures if entity.respond_to? :verify_signatures - end - end - private_class_method :populate_entity - - # @param [Class] type target type to parse - # @param [String] xml_name xml tag to parse - # @param [Nokogiri::XML::Element] node XML node to parse - # @return [Object] parsed data - def self.parse_element_from_node(type, xml_name, node) - if type == String - parse_string_from_node(xml_name, node) - elsif type.instance_of?(Array) - parse_array_from_node(type, node) - elsif type.ancestors.include?(Entity) - parse_entity_from_node(type, node) - end - end - private_class_method :parse_element_from_node - - # create simple entry in data hash - # - # @param [String] name xml tag to parse - # @param [Nokogiri::XML::Element] root_node XML root_node to parse - # @return [String] data - def self.parse_string_from_node(name, root_node) - node = root_node.xpath(name.to_s) - node.first.text if node.any? - end - private_class_method :parse_string_from_node - - # create an entry in the data hash for the nested entity - # - # @param [Class] type target type to parse - # @param [Nokogiri::XML::Element] root_node XML node to parse - # @return [Entity] parsed child entity - def self.parse_entity_from_node(type, root_node) - node = root_node.xpath(type.entity_name) - populate_entity(type, node.first) if node.any? - end - private_class_method :parse_entity_from_node - - # collect all nested children of that type and create an array in the data hash - # - # @param [Array] type target type to parse - # @param [Nokogiri::XML::Element] root_node XML node to parse - # @return [Array] array with parsed child entities - def self.parse_array_from_node(type, root_node) - node = root_node.xpath(type.first.entity_name) - node.map {|child| populate_entity(type.first, child) } - end - private_class_method :parse_array_from_node end end end diff --git a/spec/lib/diaspora_federation/entity_spec.rb b/spec/lib/diaspora_federation/entity_spec.rb index 8bbd0b1..cb71554 100644 --- a/spec/lib/diaspora_federation/entity_spec.rb +++ b/spec/lib/diaspora_federation/entity_spec.rb @@ -93,6 +93,142 @@ module DiasporaFederation end end + describe ".from_xml" do + let(:entity) { Entities::TestEntity.new(test: "asdf") } + let(:entity_xml) { entity.to_xml } + + context "sanity" do + it "expects an Nokogiri::XML::Element as param" do + expect { + Entities::TestEntity.from_xml(entity_xml) + }.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 { + Entity.from_xml(val) + }.to raise_error ArgumentError, "only Nokogiri::XML::Element allowed" + end + end + + it "raises an error when the entity class doesn't match the root node" do + xml = <<-XML + + asdf + +XML + + expect { + Entity.from_xml(Nokogiri::XML::Document.parse(xml).root) + }.to raise_error Entity::InvalidRootNode, "'unknown_entity' can't be parsed by DiasporaFederation::Entity" + end + end + + context "returned object" do + subject { Entities::TestEntity.from_xml(entity_xml) } + + 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 "parsing" do + it "uses xml_name for parsing" do + xml = <<-XML.strip + + asdf + qwer + +XML + + entity = Entities::TestEntityWithXmlName.from_xml(Nokogiri::XML::Document.parse(xml).root) + + expect(entity).to be_an_instance_of Entities::TestEntityWithXmlName + expect(entity.test).to eq("asdf") + expect(entity.qwer).to eq("qwer") + end + + it "allows name for parsing even when property has a xml_name" do + xml = <<-XML.strip + + asdf + qwer + +XML + + entity = Entities::TestEntityWithXmlName.from_xml(Nokogiri::XML::Document.parse(xml).root) + + expect(entity).to be_an_instance_of Entities::TestEntityWithXmlName + expect(entity.test).to eq("asdf") + expect(entity.qwer).to eq("qwer") + end + + it "doesn't drop unknown properties" do + xml = <<-XML + + some value + asdf + another value + +XML + + entity = Entities::TestEntity.from_xml(Nokogiri::XML::Document.parse(xml).root) + + expect(entity).to be_an_instance_of Entities::TestEntity + expect(entity.test).to eq("asdf") + expect(entity.additional_xml_elements).to eq( + "a_prop_from_newer_diaspora_version" => "some value", + "some_random_property" => "another value" + ) + end + + it "creates Entity with nil 'additional_xml_elements' if the xml has only known properties" do + entity = Entities::TestEntity.from_xml(entity_xml) + + expect(entity).to be_an_instance_of Entities::TestEntity + expect(entity.test).to eq("asdf") + expect(entity.additional_xml_elements).to be_empty + end + end + + context "relayable signature verification feature support" do + it "calls signatures verification on relayable unpack" do + entity = FactoryGirl.build(:comment_entity, author: alice.diaspora_id) + entity_xml = entity.to_xml + entity_xml.at_xpath("author_signature").content = nil + + expect { + Entities::Comment.from_xml(entity_xml) + }.to raise_error DiasporaFederation::Entities::Relayable::SignatureVerificationFailed + 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) { nested_entity.to_xml } + + it "parses the xml with all the nested data" do + entity = Entities::TestNestedEntity.from_xml(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 + describe ".entity_name" do it "strips the module and returns the name underscored" do expect(Entities::TestDefaultEntity.entity_name).to eq("test_default_entity") @@ -105,6 +241,40 @@ module DiasporaFederation end end + describe ".entity_class" do + it "should parse a single word" do + expect(Entity.entity_class("entity")).to eq(Entities::Entity) + end + + it "should parse with underscore" do + expect(Entity.entity_class("test_entity")).to eq(Entities::TestEntity) + end + + it "raises an error when the entity name contains special characters" do + expect { + Entity.entity_class("te.st-enti/ty") + }.to raise_error Entity::InvalidEntityName, "'te.st-enti/ty' is invalid" + end + + it "raises an error when the entity name contains upper case letters" do + expect { + Entity.entity_class("TestEntity") + }.to raise_error Entity::InvalidEntityName, "'TestEntity' is invalid" + end + + it "raises an error when the entity name contains numbers" do + expect { + Entity.entity_class("te5t_ent1ty_w1th_number5") + }.to raise_error Entity::InvalidEntityName, "'te5t_ent1ty_w1th_number5' is invalid" + end + + it "raises an error when the entity is unknown" do + expect { + Entity.entity_class("unknown_entity") + }.to raise_error Entity::UnknownEntity, "'UnknownEntity' not found" + end + end + context "nested entities" do let(:nested_data) { { diff --git a/spec/lib/diaspora_federation/salmon/xml_payload_spec.rb b/spec/lib/diaspora_federation/salmon/xml_payload_spec.rb index d32a640..5fff9f6 100644 --- a/spec/lib/diaspora_federation/salmon/xml_payload_spec.rb +++ b/spec/lib/diaspora_federation/salmon/xml_payload_spec.rb @@ -66,25 +66,10 @@ XML }.to raise_error ArgumentError, "only Nokogiri::XML::Element allowed" end end - - it "raises an error when the entity is unknown" do - xml = <<-XML - - - - asdf - - - -XML - expect { - Salmon::XmlPayload.unpack(Nokogiri::XML::Document.parse(xml).root) - }.to raise_error Salmon::UnknownEntity, "'UnknownEntity' not found" - end end context "returned object" do - subject { Salmon::XmlPayload.unpack(payload) } + subject { Salmon::XmlPayload.unpack(Nokogiri::XML::Document.parse(entity_xml).root) } it "#to_h should match entity.to_h" do expect(subject.to_h).to eq(entity.to_h) @@ -94,149 +79,19 @@ XML expect(subject).to be_an_instance_of Entities::TestEntity expect(subject.test).to eq("asdf") end - end - context "unwrapped xml" do it "allows unwrapped entities" do xml = <<-XML asdf - XML - - entity = Salmon::XmlPayload.unpack(Nokogiri::XML::Document.parse(xml).root) - - expect(entity).to be_an_instance_of Entities::TestEntity - expect(entity.test).to eq("asdf") - end - end - - context "parsing" do - it "uses xml_name for parsing" do - xml = <<-XML.strip - - - - asdf - qwer - - - -XML - entity = Salmon::XmlPayload.unpack(Nokogiri::XML::Document.parse(xml).root) - - expect(entity).to be_an_instance_of Entities::TestEntityWithXmlName - expect(entity.test).to eq("asdf") - expect(entity.qwer).to eq("qwer") - end - - it "allows name for parsing even when property has a xml_name" do - xml = <<-XML.strip - - - - asdf - qwer - - - -XML - entity = Salmon::XmlPayload.unpack(Nokogiri::XML::Document.parse(xml).root) - - expect(entity).to be_an_instance_of Entities::TestEntityWithXmlName - expect(entity.test).to eq("asdf") - expect(entity.qwer).to eq("qwer") - end - - it "doesn't drop unknown properties" do - xml = <<-XML - - - - some value - asdf - another value - - - XML entity = Salmon::XmlPayload.unpack(Nokogiri::XML::Document.parse(xml).root) expect(entity).to be_an_instance_of Entities::TestEntity expect(entity.test).to eq("asdf") - expect(entity.additional_xml_elements).to eq( - "a_prop_from_newer_diaspora_version" => "some value", - "some_random_property" => "another value" - ) end - - it "creates Entity with nil 'additional_xml_elements' if the xml has only known properties" do - entity = Salmon::XmlPayload.unpack(Nokogiri::XML::Document.parse(entity_xml).root) - - expect(entity).to be_an_instance_of Entities::TestEntity - expect(entity.test).to eq("asdf") - expect(entity.additional_xml_elements).to be_empty - end - end - - context "relayable signature verification feature support" do - it "calls signatures verification on relayable unpack" do - entity = FactoryGirl.build(:comment_entity, author: alice.diaspora_id) - payload = Salmon::XmlPayload.pack(entity) - payload.at_xpath("post/*[1]/author_signature").content = nil - - expect { - Salmon::XmlPayload.unpack(payload) - }.to raise_error DiasporaFederation::Entities::Relayable::SignatureVerificationFailed - 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 - - describe ".entity_class_name" do - it "should parse a single word" do - expect(Salmon::XmlPayload.send(:entity_class_name, "entity")).to eq("Entity") - end - - it "should parse with underscore" do - expect(Salmon::XmlPayload.send(:entity_class_name, "test_entity")).to eq("TestEntity") - end - - it "raises an error when the entity name contains special characters" do - expect { - Salmon::XmlPayload.send(:entity_class_name, "te.st-enti/ty") - }.to raise_error Salmon::InvalidEntityName, "'te.st-enti/ty' is invalid" - end - - it "raises an error when the entity name contains upper case letters" do - expect { - Salmon::XmlPayload.send(:entity_class_name, "TestEntity") - }.to raise_error Salmon::InvalidEntityName, "'TestEntity' is invalid" - end - - it "raises an error when the entity name contains numbers" do - expect { - Salmon::XmlPayload.send(:entity_class_name, "te5t_ent1ty_w1th_number5") - }.to raise_error Salmon::InvalidEntityName, "'te5t_ent1ty_w1th_number5' is invalid" end end end