move parse code to Entity

This commit is contained in:
Benjamin Neff 2016-02-20 20:23:33 +01:00
parent 2e3bf2f132
commit 2cdaea0d70
5 changed files with 297 additions and 258 deletions

View file

@ -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<Entity>] 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

View file

@ -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

View file

@ -11,6 +11,7 @@ module DiasporaFederation
# </XML>
#
# (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<Class>] type target type to parse
# @param [Nokogiri::XML::Element] root_node XML node to parse
# @return [Array<Entity>] 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

View file

@ -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
<unknown_entity>
<test>asdf</test>
</unknown_entity>
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
<test_entity_with_xml_name>
<test>asdf</test>
<asdf>qwer</asdf>
</test_entity_with_xml_name>
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
<test_entity_with_xml_name>
<test>asdf</test>
<qwer>qwer</qwer>
</test_entity_with_xml_name>
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
<test_entity>
<a_prop_from_newer_diaspora_version>some value</a_prop_from_newer_diaspora_version>
<test>asdf</test>
<some_random_property>another value</some_random_property>
</test_entity>
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) {
{

View file

@ -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
<XML>
<post>
<unknown_entity>
<test>asdf</test>
</test_entity>
</post>
</XML>
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,9 +79,7 @@ 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
<test_entity>
@ -110,134 +93,6 @@ XML
expect(entity.test).to eq("asdf")
end
end
context "parsing" do
it "uses xml_name for parsing" do
xml = <<-XML.strip
<XML>
<post>
<test_entity_with_xml_name>
<test>asdf</test>
<asdf>qwer</asdf>
</test_entity>
</post>
</XML>
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
<XML>
<post>
<test_entity_with_xml_name>
<test>asdf</test>
<qwer>qwer</qwer>
</test_entity>
</post>
</XML>
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
<XML>
<post>
<test_entity>
<a_prop_from_newer_diaspora_version>some value</a_prop_from_newer_diaspora_version>
<test>asdf</test>
<some_random_property>another value</some_random_property>
</test_entity>
</post>
</XML>
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
end