From c58d076c96c4232af0e1d2bdd0d8d50d66675f62 Mon Sep 17 00:00:00 2001 From: cmrd Senya Date: Thu, 23 Mar 2017 13:48:15 +0200 Subject: [PATCH] Entity JSON serialization/deserialization feature --- Gemfile | 2 + Gemfile.lock | 12 +- diaspora_federation-json_schema.gemspec | 19 + lib/diaspora_federation.rb | 1 + lib/diaspora_federation/entities/message.rb | 23 +- .../entities/participation.rb | 26 +- .../entities/related_entity.rb | 4 + lib/diaspora_federation/entities/relayable.rb | 82 ++-- .../entities/relayable_retraction.rb | 12 +- lib/diaspora_federation/entities/request.rb | 5 +- lib/diaspora_federation/entities/reshare.rb | 7 +- .../entities/retraction.rb | 9 +- .../entities/signed_retraction.rb | 12 +- lib/diaspora_federation/entity.rb | 147 +++---- lib/diaspora_federation/parsers.rb | 13 + .../parsers/base_parser.rb | 61 +++ .../parsers/json_parser.rb | 60 +++ .../parsers/relayable_json_parser.rb | 25 ++ .../parsers/relayable_xml_parser.rb | 22 ++ lib/diaspora_federation/parsers/xml_parser.rb | 84 ++++ .../schemas/federation_entities.json | 373 ++++++++++++++++++ spec/entities.rb | 24 ++ spec/integration/comment_integration_spec.rb | 4 +- .../entities/comment_spec.rb | 23 ++ .../diaspora_federation/entities/like_spec.rb | 24 ++ .../entities/location_spec.rb | 13 + .../entities/message_spec.rb | 2 +- .../entities/participation_spec.rb | 22 ++ .../entities/photo_spec.rb | 20 + .../entities/poll_answer_spec.rb | 12 + .../entities/poll_participation_spec.rb | 22 ++ .../diaspora_federation/entities/poll_spec.rb | 15 + .../entities/profile_spec.rb | 23 ++ .../entities/relayable_spec.rb | 353 ++++++++++++----- .../entities/reshare_spec.rb | 17 + .../entities/status_message_spec.rb | 58 +++ spec/lib/diaspora_federation/entity_spec.rb | 341 +++++++++------- .../parsers/base_parser_spec.rb | 11 + .../parsers/json_parser_spec.rb | 95 +++++ .../parsers/relayable_json_parser_spec.rb | 31 ++ .../parsers/relayable_xml_parser_spec.rb | 24 ++ .../parsers/xml_parser_spec.rb | 169 ++++++++ spec/spec_helper.rb | 4 + spec/support/shared_entity_specs.rb | 85 +++- spec/support/shared_parser_specs.rb | 7 + 45 files changed, 1977 insertions(+), 421 deletions(-) create mode 100644 diaspora_federation-json_schema.gemspec create mode 100644 lib/diaspora_federation/parsers.rb create mode 100644 lib/diaspora_federation/parsers/base_parser.rb create mode 100644 lib/diaspora_federation/parsers/json_parser.rb create mode 100644 lib/diaspora_federation/parsers/relayable_json_parser.rb create mode 100644 lib/diaspora_federation/parsers/relayable_xml_parser.rb create mode 100644 lib/diaspora_federation/parsers/xml_parser.rb create mode 100644 lib/diaspora_federation/schemas/federation_entities.json create mode 100644 spec/lib/diaspora_federation/parsers/base_parser_spec.rb create mode 100644 spec/lib/diaspora_federation/parsers/json_parser_spec.rb create mode 100644 spec/lib/diaspora_federation/parsers/relayable_json_parser_spec.rb create mode 100644 spec/lib/diaspora_federation/parsers/relayable_xml_parser_spec.rb create mode 100644 spec/lib/diaspora_federation/parsers/xml_parser_spec.rb create mode 100644 spec/support/shared_parser_specs.rb diff --git a/Gemfile b/Gemfile index e084db3..d4587d2 100644 --- a/Gemfile +++ b/Gemfile @@ -51,7 +51,9 @@ group :test do # test helpers gem "factory_girl_rails", "~> 4.7" gem "fixture_builder", "0.5.0" + gem "json-schema-rspec", "0.0.4" gem "rspec-collection_matchers", "~> 1.1.2" + gem "rspec-json_expectations", "~> 2.1" gem "webmock", "~> 2.0" end diff --git a/Gemfile.lock b/Gemfile.lock index 3808d87..ecb667f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,6 +7,7 @@ PATH nokogiri (~> 1.6, >= 1.6.8) typhoeus (~> 1.0) valid (~> 1.0) + diaspora_federation-json_schema (0.1.8) diaspora_federation-rails (0.1.8) diaspora_federation (= 0.1.8) rails (>= 4.2, < 6) @@ -117,6 +118,11 @@ GEM multi_xml (>= 0.5.2) i18n (0.8.1) json (1.8.6) + json-schema (2.7.0) + addressable (>= 2.4) + json-schema-rspec (0.0.4) + json-schema (~> 2.5) + rspec listen (3.1.5) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) @@ -217,6 +223,7 @@ GEM rspec-expectations (3.5.0) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.5.0) + rspec-json_expectations (2.1.0) rspec-mocks (3.5.0) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.5.0) @@ -294,6 +301,7 @@ PLATFORMS DEPENDENCIES codeclimate-test-reporter (~> 1.0.0) diaspora_federation! + diaspora_federation-json_schema! diaspora_federation-rails! diaspora_federation-test! factory_girl_rails (~> 4.7) @@ -301,6 +309,7 @@ DEPENDENCIES fuubar (= 2.2.0) guard-rspec guard-rubocop + json-schema-rspec (= 0.0.4) logging-rails (= 0.5.0) nyan-cat-formatter pronto (= 0.8.1) @@ -309,6 +318,7 @@ DEPENDENCIES pry-byebug rspec-collection_matchers (~> 1.1.2) rspec-core (~> 3.5.1) + rspec-json_expectations (~> 2.1) rspec-rails (~> 3.5.1) rubocop (= 0.47.1) simplecov (= 0.13.0) @@ -321,4 +331,4 @@ DEPENDENCIES yard BUNDLED WITH - 1.14.5 + 1.14.6 diff --git a/diaspora_federation-json_schema.gemspec b/diaspora_federation-json_schema.gemspec new file mode 100644 index 0000000..f5a982b --- /dev/null +++ b/diaspora_federation-json_schema.gemspec @@ -0,0 +1,19 @@ +$LOAD_PATH.push File.expand_path("../lib", __FILE__) + +# Maintain your gem's version: +require "diaspora_federation/version" + +# Describe your gem and declare its dependencies: +Gem::Specification.new do |s| + s.name = "diaspora_federation-json_schema" + s.version = DiasporaFederation::VERSION + s.authors = ["Benjamin Neff", "cmrd Senya"] + s.email = ["benjamin@coding4.coffee", "senya@riseup.net"] + s.homepage = "https://github.com/diaspora/diaspora_federation" + s.summary = "diaspora* federation json schemas" + s.description = "This gem provides JSON schemas (currently one schema) for "\ + "validating JSON serialized federation objects." + s.license = "AGPL-3.0" + + s.files = Dir["lib/diaspora_federation/schemas/*.json"] +end diff --git a/lib/diaspora_federation.rb b/lib/diaspora_federation.rb index e62363a..dd632aa 100644 --- a/lib/diaspora_federation.rb +++ b/lib/diaspora_federation.rb @@ -10,6 +10,7 @@ require "diaspora_federation/validators" require "diaspora_federation/http_client" require "diaspora_federation/entities" +require "diaspora_federation/parsers" require "diaspora_federation/discovery" require "diaspora_federation/salmon" diff --git a/lib/diaspora_federation/entities/message.rb b/lib/diaspora_federation/entities/message.rb index d9a983b..00cccea 100644 --- a/lib/diaspora_federation/entities/message.rb +++ b/lib/diaspora_federation/entities/message.rb @@ -38,6 +38,20 @@ module DiasporaFederation super.tap {|hash| hash[:created_at] = created_at.utc.iso8601 } end + # @deprecated remove after {Message} doesn't include {Relayable} anymore + private_class_method def self.xml_parser_class + DiasporaFederation::Parsers::XmlParser + end + + # Default implementation, don't verify signatures for a {Message}. + # @see Entity.from_hash + # @deprecated remove after {Message} doesn't include {Relayable} anymore + # @param [Hash] properties hash + # @return [Entity] instance + def self.from_hash(hash) + new({parent_guid: nil, parent: nil}.merge(hash)) + end + private # @deprecated remove after {Message} doesn't include {Relayable} anymore @@ -62,15 +76,6 @@ module DiasporaFederation super end end - - # Default implementation, don't verify signatures for a {Message}. - # @see Entity.populate_entity - # @deprecated remove after {Message} doesn't include {Relayable} anymore - # @param [Nokogiri::XML::Element] root_node xml nodes - # @return [Entity] instance - private_class_method def self.populate_entity(root_node) - new({parent_guid: nil, parent: nil}.merge(entity_data(root_node))) - end end end end diff --git a/lib/diaspora_federation/entities/participation.rb b/lib/diaspora_federation/entities/participation.rb index 075e05d..d42fd53 100644 --- a/lib/diaspora_federation/entities/participation.rb +++ b/lib/diaspora_federation/entities/participation.rb @@ -22,6 +22,14 @@ module DiasporaFederation sender == author end + # hackaround hacky from_hash override + # @deprecated remove after {Participation} doesn't include {Relayable} anymore + def enriched_properties + super.tap {|hash| + hash.delete(:parent) if hash[:parent].nil? + } + end + # Validates that the parent exists and the parent author is local def validate_parent parent = DiasporaFederation.callbacks.trigger(:fetch_related_entity, parent_type, parent_guid) @@ -29,11 +37,21 @@ module DiasporaFederation end # Don't verify signatures for a {Participation}. Validate that the parent is local. - # @see Entity.populate_entity - # @param [Nokogiri::XML::Element] root_node xml nodes + # @see Entity.from_hash + # @param [Hash] hash entity initialization hash # @return [Entity] instance - private_class_method def self.populate_entity(root_node) - new(entity_data(root_node).merge(parent: nil)).tap(&:validate_parent) + def self.from_hash(hash) + new(hash.merge(parent: nil)).tap(&:validate_parent) + end + + # @deprecated remove after {Participation} doesn't include {Relayable} anymore + private_class_method def self.xml_parser_class + DiasporaFederation::Parsers::XmlParser + end + + # @deprecated remove after {Participation} doesn't include {Relayable} anymore + private_class_method def self.json_parser_class + DiasporaFederation::Parsers::JsonParser end # Raised, if the parent is not owned by the receiving pod. diff --git a/lib/diaspora_federation/entities/related_entity.rb b/lib/diaspora_federation/entities/related_entity.rb index 850babe..b98bf14 100644 --- a/lib/diaspora_federation/entities/related_entity.rb +++ b/lib/diaspora_federation/entities/related_entity.rb @@ -28,6 +28,10 @@ module DiasporaFederation def to_xml nil end + + def to_json + nil + end end end end diff --git a/lib/diaspora_federation/entities/relayable.rb b/lib/diaspora_federation/entities/relayable.rb index 7a06ddf..4ed432f 100644 --- a/lib/diaspora_federation/entities/relayable.rb +++ b/lib/diaspora_federation/entities/relayable.rb @@ -14,9 +14,9 @@ module DiasporaFederation # @return [Array] order from xml attr_reader :xml_order - # Additional properties from parsed xml - # @return [Hash] additional xml elements - attr_reader :additional_xml_elements + # Additional properties from parsed input object + # @return [Hash] additional elements + attr_reader :additional_data # On inclusion of this module the required properties for a relayable are added to the object that includes it. # @@ -62,18 +62,18 @@ module DiasporaFederation entity :parent, Entities::RelatedEntity end - klass.extend ParseXML + klass.extend Parsing end # Initializes a new relayable Entity with order and additional xml elements # # @param [Hash] data entity data # @param [Array] xml_order order from xml - # @param [Hash] additional_xml_elements additional xml elements + # @param [Hash] additional_data additional xml elements # @see DiasporaFederation::Entity#initialize - def initialize(data, xml_order=nil, additional_xml_elements={}) + def initialize(data, xml_order=nil, additional_data={}) @xml_order = xml_order.reject {|name| name =~ /signature/ } if xml_order - @additional_xml_elements = additional_xml_elements + @additional_data = additional_data super(data) end @@ -96,6 +96,15 @@ module DiasporaFederation "#{super}#{":#{parent_type}" if respond_to?(:parent_type)}:#{parent_guid}" end + def to_json + super.merge!(property_order: signature_order).tap {|json_hash| + missing_properties = json_hash[:property_order] - json_hash[:entity_data].keys + missing_properties.each {|property| + json_hash[:entity_data][property] = nil + } + } + end + private # Check that signature is a correct signature @@ -151,7 +160,7 @@ module DiasporaFederation # # @return [Hash] properties with updated signatures def enriched_properties - super.merge(additional_xml_elements).tap do |hash| + super.merge(additional_data).tap do |hash| hash[:author_signature] = author_signature || sign_with_author hash[:parent_author_signature] = parent_author_signature || sign_with_parent_author_if_available.to_s end @@ -179,41 +188,32 @@ module DiasporaFederation # @return [String] signature data string def signature_data - data = normalized_properties.merge(additional_xml_elements) + data = normalized_properties.merge(additional_data) signature_order.map {|name| data[name] }.join(";") end - # Override class methods from {Entity} to parse the xml - module ParseXML - private + # Override class methods from {Entity} to parse serialized data + module Parsing + # Does the same job as Entity.from_hash except of the following differences: + # 1) unknown properties from the properties_hash are stored to additional_data of the relayable instance + # 2) parent entity fetch is attempted + # 3) signatures verification is performed; property_order is used as the order in which properties are composed + # to compute signatures + # 4) unknown properties' keys must be of String type + # + # @see Entity.from_hash + def from_hash(properties_hash, property_order) + # Use all known properties to build the Entity (entity_data). All additional elements + # are respected and attached to a hash as string (additional_data). This is needed + # to support receiving objects from the future versions of diaspora*, where new elements may have been added. + additional_data = properties_hash.reject {|key, _| class_props.has_key?(key) } - # @param [Nokogiri::XML::Element] root_node xml nodes - # @return [Entity] instance - def 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 also remembers - # the order of the xml-nodes (xml_order). This is needed to support receiving objects from - # the future versions of diaspora*, where new elements may have been added. - entity_data = {} - additional_xml_elements = {} - - xml_order = root_node.element_children.map do |child| - xml_name = child.name - property = find_property_for_xml_name(xml_name) - - if property - entity_data[property] = parse_element_from_node(xml_name, class_props[property], root_node) - property - else - additional_xml_elements[xml_name] = child.text - xml_name - end - end - - fetch_parent(entity_data) - new(entity_data, xml_order, additional_xml_elements).tap(&:verify_signatures) + fetch_parent(properties_hash) + new(properties_hash, property_order, additional_data).tap(&:verify_signatures) end + private + def fetch_parent(data) type = data.fetch(:parent_type) { break self::PARENT_TYPE if const_defined?(:PARENT_TYPE) @@ -232,6 +232,14 @@ module DiasporaFederation Federation::Fetcher.fetch_public(data[:author], type, guid) data[:parent] = DiasporaFederation.callbacks.trigger(:fetch_related_entity, type, guid) end + + def xml_parser_class + DiasporaFederation::Parsers::RelayableXmlParser + end + + def json_parser_class + DiasporaFederation::Parsers::RelayableJsonParser + end end # Raised, if creating the author_signature fails, because the private key was not found diff --git a/lib/diaspora_federation/entities/relayable_retraction.rb b/lib/diaspora_federation/entities/relayable_retraction.rb index 00f0768..85570ca 100644 --- a/lib/diaspora_federation/entities/relayable_retraction.rb +++ b/lib/diaspora_federation/entities/relayable_retraction.rb @@ -69,16 +69,14 @@ module DiasporaFederation "RelayableRetraction:#{target_type}:#{target_guid}" end - private - - # @param [Nokogiri::XML::Element] root_node xml nodes # @return [Retraction] instance - private_class_method def self.populate_entity(root_node) - entity_data = entity_data(root_node) - entity_data[:target] = Retraction.send(:fetch_target, entity_data[:target_type], entity_data[:target_guid]) - new(entity_data).to_retraction + def self.from_hash(hash) + hash[:target] = Retraction.send(:fetch_target, hash[:target_type], hash[:target_guid]) + new(hash).to_retraction end + private + # It updates also the signatures with the keys of the author and the parent # if the signatures are not there yet and if the keys are available. # diff --git a/lib/diaspora_federation/entities/request.rb b/lib/diaspora_federation/entities/request.rb index 7290974..b8b44c4 100644 --- a/lib/diaspora_federation/entities/request.rb +++ b/lib/diaspora_federation/entities/request.rb @@ -29,10 +29,9 @@ module DiasporaFederation "Request:#{author}:#{recipient}" end - # @param [Nokogiri::XML::Element] root_node xml nodes # @return [Retraction] instance - private_class_method def self.populate_entity(root_node) - super(root_node).to_contact + def self.from_hash(hash) + super.to_contact end end end diff --git a/lib/diaspora_federation/entities/reshare.rb b/lib/diaspora_federation/entities/reshare.rb index cd831d7..308e53b 100644 --- a/lib/diaspora_federation/entities/reshare.rb +++ b/lib/diaspora_federation/entities/reshare.rb @@ -35,11 +35,10 @@ module DiasporaFederation end # Fetch root post after parse - # @see Entity.populate_entity - # @param [Nokogiri::XML::Element] root_node xml nodes + # @see Entity.from_hash # @return [Entity] instance - private_class_method def self.populate_entity(root_node) - super(root_node).tap(&:fetch_root) + def self.from_hash(hash) + super.tap(&:fetch_root) end end end diff --git a/lib/diaspora_federation/entities/retraction.rb b/lib/diaspora_federation/entities/retraction.rb index cf1df09..67799b8 100644 --- a/lib/diaspora_federation/entities/retraction.rb +++ b/lib/diaspora_federation/entities/retraction.rb @@ -39,12 +39,11 @@ module DiasporaFederation "Retraction:#{target_type}:#{target_guid}" end - # @param [Nokogiri::XML::Element] root_node xml nodes + # @see Entity.from_hash # @return [Retraction] instance - private_class_method def self.populate_entity(root_node) - entity_data = entity_data(root_node) - entity_data[:target] = fetch_target(entity_data[:target_type], entity_data[:target_guid]) - new(entity_data) + def self.from_hash(hash) + hash[:target] = fetch_target(hash[:target_type], hash[:target_guid]) + new(hash) end private_class_method def self.fetch_target(target_type, target_guid) diff --git a/lib/diaspora_federation/entities/signed_retraction.rb b/lib/diaspora_federation/entities/signed_retraction.rb index ed88dbd..389543e 100644 --- a/lib/diaspora_federation/entities/signed_retraction.rb +++ b/lib/diaspora_federation/entities/signed_retraction.rb @@ -54,16 +54,14 @@ module DiasporaFederation "SignedRetraction:#{target_type}:#{target_guid}" end - private - - # @param [Nokogiri::XML::Element] root_node xml nodes # @return [Retraction] instance - private_class_method def self.populate_entity(root_node) - entity_data = entity_data(root_node) - entity_data[:target] = Retraction.send(:fetch_target, entity_data[:target_type], entity_data[:target_guid]) - new(entity_data).to_retraction + def self.from_hash(hash) + hash[:target] = Retraction.send(:fetch_target, hash[:target_type], hash[:target_guid]) + new(hash).to_retraction end + private + # It updates also the signatures with the keys of the author and the parent # if the signatures are not there yet and if the keys are available. # diff --git a/lib/diaspora_federation/entity.rb b/lib/diaspora_federation/entity.rb index d19a242..6d738d2 100644 --- a/lib/diaspora_federation/entity.rb +++ b/lib/diaspora_federation/entity.rb @@ -109,10 +109,21 @@ module DiasporaFederation # @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 + from_hash(*xml_parser_class.new(self).parse(root_node)) + end - populate_entity(root_node) + private_class_method def self.xml_parser_class + DiasporaFederation::Parsers::XmlParser + end + + # Creates an instance of self by parsing a hash in the format of JSON serialized object (which usually means + # data from a parsed JSON input). + def self.from_json(json_hash) + from_hash(*json_parser_class.new(self).parse(json_hash)) + end + + private_class_method def self.json_parser_class + DiasporaFederation::Parsers::JsonParser end # Makes an underscored, lowercase form of the class name @@ -149,6 +160,33 @@ module DiasporaFederation "#{self.class.name.rpartition('::').last}#{":#{guid}" if respond_to?(:guid)}" end + # Renders entity to a hash representation of the entity JSON format + # @return [Hash] Returns a hash that is equal by structure to the entity in JSON format + def to_json + { + entity_type: self.class.entity_name, + entity_data: json_data + } + end + + # Creates an instance of self, filling it with data from a provided hash of properties. + # + # The hash format is described as following:
+ # 1) Properties of the hash are representation of the entity's class properties
+ # 2) Keys of the hash must be of Symbol type
+ # 3) Possible values of the hash properties depend on the types of the entity's class properties
+ # 4) Basic properties, such as booleans, strings, integers and timestamps are represented by values of respective + # formats
+ # 5) Nested hashes and arrays of hashes are allowed to represent nested entities. Nested hashes follow the same + # format as the parent hash.
+ # 6) Besides, the nested entities can be passed in the hash as already instantiated objects of the respective type. + # + # @param [Hash] properties_hash A hash of the expected format + # @return [Entity] an instance + def self.from_hash(properties_hash) + new(properties_hash) + end + private def validate_missing_props(entity_data) @@ -225,7 +263,7 @@ module DiasporaFederation def normalize_property(name, value) case self.class.class_props[name] - when :string, :integer, :boolean + when :string value.to_s when :timestamp value.nil? ? "" : value.utc.iso8601 @@ -245,8 +283,8 @@ module DiasporaFederation end def add_property_to_xml(doc, root_element, name, value) - if value.is_a? String - root_element << simple_node(doc, name, value) + if [String, TrueClass, FalseClass, Integer].any? {|c| value.is_a? c } + root_element << simple_node(doc, name, value.to_s) else # call #to_xml for each item and append to root [*value].compact.each do |item| @@ -264,96 +302,29 @@ module DiasporaFederation end end - # @param [Nokogiri::XML::Element] root_node xml nodes - # @return [Entity] instance - private_class_method def self.populate_entity(root_node) - new(entity_data(root_node)) - end + # Generates a hash with entity properties which is put to the "entity_data" + # field of a JSON serialized object. + # @return [Hash] object properties in JSON format + def json_data + enriched_properties.map {|key, value| + type = self.class.class_props[key] - # @param [Nokogiri::XML::Element] root_node xml nodes - # @return [Hash] entity data - private_class_method def self.entity_data(root_node) - class_props.map {|name, type| - value = parse_element_from_node(name, type, root_node) - [name, value] unless value.nil? - }.compact.to_h - end - - # @param [String] name property name to parse - # @param [Class, Symbol] type target type to parse - # @param [Nokogiri::XML::Element] root_node XML node to parse - # @return [Object] parsed data - private_class_method def self.parse_element_from_node(name, type, root_node) - if type.instance_of?(Symbol) - parse_string_from_node(name, type, root_node) - elsif type.instance_of?(Array) - parse_array_from_node(type.first, root_node) - elsif type.ancestors.include?(Entity) - parse_entity_from_node(type, root_node) - end - end - - # Create simple entry in data hash - # - # @param [String] name xml tag to parse - # @param [Class, Symbol] type target type to parse - # @param [Nokogiri::XML::Element] root_node XML root_node to parse - # @return [String] data - private_class_method def self.parse_string_from_node(name, type, root_node) - node = root_node.xpath(name.to_s) - node = root_node.xpath(xml_names[name].to_s) if node.empty? - parse_string(type, node.first.text) if node.any? - end - - # @param [Symbol] type target type to parse - # @param [String] text data as string - # @return [String, Boolean, Integer, Time] data - private_class_method def self.parse_string(type, text) - case type - when :timestamp - begin - Time.parse(text).utc - rescue - nil + if !value.nil? && type.instance_of?(Class) && value.respond_to?(:to_json) + entity_data = value.to_json + [key, entity_data] unless entity_data.nil? + elsif type.instance_of?(Array) + entity_data = value.nil? ? nil : value.map(&:to_json) + [key, entity_data] unless entity_data.nil? + else + [key, value] end - when :integer - text.to_i if text =~ /\A\d+\z/ - when :boolean - return true if text =~ /\A(true|t|yes|y|1)\z/i - false if text =~ /\A(false|f|no|n|0)\z/i - else - text - end - end - - # 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 - private_class_method def self.parse_entity_from_node(type, root_node) - node = root_node.xpath(type.entity_name) - type.from_xml(node.first) if node.any? && node.first.children.any? - end - - # 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 - private_class_method def self.parse_array_from_node(type, root_node) - node = root_node.xpath(type.entity_name) - node.select {|child| child.children.any? }.map {|child| type.from_xml(child) } unless node.empty? + }.compact.to_h end # 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 diff --git a/lib/diaspora_federation/parsers.rb b/lib/diaspora_federation/parsers.rb new file mode 100644 index 0000000..a08ba5c --- /dev/null +++ b/lib/diaspora_federation/parsers.rb @@ -0,0 +1,13 @@ +module DiasporaFederation + # This namespace contains parsers which are used to deserialize federation entities + # objects from supported formats (XML, JSON) to objects of DiasporaFederation::Entity + # classes + module Parsers + end +end + +require "diaspora_federation/parsers/base_parser" +require "diaspora_federation/parsers/json_parser" +require "diaspora_federation/parsers/xml_parser" +require "diaspora_federation/parsers/relayable_json_parser" +require "diaspora_federation/parsers/relayable_xml_parser" diff --git a/lib/diaspora_federation/parsers/base_parser.rb b/lib/diaspora_federation/parsers/base_parser.rb new file mode 100644 index 0000000..b277654 --- /dev/null +++ b/lib/diaspora_federation/parsers/base_parser.rb @@ -0,0 +1,61 @@ +module DiasporaFederation + module Parsers + # +BaseParser+ is an abstract class which is used for defining parsers for different + # deserialization methods. + class BaseParser + # @param [Class] entity_type type of DiasporaFederation::Entity that we want to parse with that parser instance + def initialize(entity_type) + @entity_type = entity_type + end + + # This method is used to parse input with a serialized object data. It returns + # a comprehensive data which must be enough to construct a DiasporaFederation::Entity instance. + # + # Since parser method output is normally passed to a .from_hash method of an entity + # as arguments using * operator, the parse method must return an array of a size matching the number + # of arguments of .from_hash method of the entity type we link with + # @abstract + def parse(*) + raise NotImplementedError.new("you must override this method when creating your own parser") + end + + private + + # @param [Symbol] type target type to parse + # @param [String] text data as string + # @return [String, Boolean, Integer, Time] data + def parse_string(type, text) + case type + when :timestamp + begin + Time.parse(text).utc + rescue + nil + end + when :integer + text.to_i if text =~ /\A\d+\z/ + when :boolean + return true if text =~ /\A(true|t|yes|y|1)\z/i + false if text =~ /\A(false|f|no|n|0)\z/i + else + text + end + end + + def assert_parsability_of(entity_class) + return if entity_class == entity_type.entity_name + raise InvalidRootNode, "'#{entity_class}' can't be parsed by #{entity_type.name}" + end + + attr_reader :entity_type + + def class_properties + entity_type.class_props + end + + # Raised, if the root node doesn't match the class name + class InvalidRootNode < RuntimeError + end + end + end +end diff --git a/lib/diaspora_federation/parsers/json_parser.rb b/lib/diaspora_federation/parsers/json_parser.rb new file mode 100644 index 0000000..0c408cd --- /dev/null +++ b/lib/diaspora_federation/parsers/json_parser.rb @@ -0,0 +1,60 @@ +module DiasporaFederation + module Parsers + # This is a parser of JSON serialized object. JSON object format is defined by + # JSON schema which is available at https://diaspora.github.io/diaspora_federation/schemas/federation_entities.json. + # TODO: We must publish the schema at a real URL + class JsonParser < BaseParser + # @see BaseParser#parse + # @param [Hash] json_hash A hash acquired by running JSON.parse with JSON serialized entity + # @return [Array[1]] comprehensive data for an entity instantiation + def parse(json_hash) + from_json_sanity_validation(json_hash) + parse_entity_data(json_hash["entity_data"]) + end + + private + + def parse_entity_data(entity_data) + hash = entity_data.map {|key, value| + property = entity_type.find_property_for_xml_name(key) + if property + type = entity_type.class_props[property] + [property, parse_element_from_value(type, entity_data[key])] + else + [key, value] + end + }.to_h + + [hash] + end + + def parse_element_from_value(type, value) + return if value.nil? + if %i(integer boolean timestamp).include?(type) && !value.is_a?(String) + value + elsif type.instance_of?(Symbol) + parse_string(type, value) + elsif type.instance_of?(Array) + raise DeserializationError, "Expected array for #{type}" unless value.respond_to?(:map) + value.map {|element| + type.first.from_json(element) + } + elsif type.ancestors.include?(Entity) + type.from_json(value) + end + end + + def from_json_sanity_validation(json_hash) + missing = %w(entity_type entity_data).map {|prop| + prop if json_hash[prop].nil? + }.compact.join(", ") + raise DeserializationError, "Required properties are missing in JSON object: #{missing}" unless missing.empty? + assert_parsability_of(json_hash["entity_type"]) + end + + # Raised when the format of the input JSON data doesn't match the parser's expectations + class DeserializationError < RuntimeError + end + end + end +end diff --git a/lib/diaspora_federation/parsers/relayable_json_parser.rb b/lib/diaspora_federation/parsers/relayable_json_parser.rb new file mode 100644 index 0000000..192a572 --- /dev/null +++ b/lib/diaspora_federation/parsers/relayable_json_parser.rb @@ -0,0 +1,25 @@ +module DiasporaFederation + module Parsers + # This is a parser of JSON serialized object, that is normally used for parsing data of relayables. + # Assumed format differs from the usual entity by additional "property_order" property which is used to + # compute signatures deterministically. + # Input JSON for this parser is expected to match "/definitions/relayable" subschema of the JSON schema at + # https://diaspora.github.io/diaspora_federation/schemas/federation_entities.json. + class RelayableJsonParser < JsonParser + # @see JsonParser#parse + # @see BaseParser#parse + # @return [Array[2]] comprehensive data for an entity instantiation + def parse(json_hash) + super.push(json_hash["property_order"]) + end + + private + + def from_json_sanity_validation(json_hash) + super + return unless json_hash["property_order"].nil? + raise DeserializationError, "Required property is missing in JSON object: property_order" + end + end + end +end diff --git a/lib/diaspora_federation/parsers/relayable_xml_parser.rb b/lib/diaspora_federation/parsers/relayable_xml_parser.rb new file mode 100644 index 0000000..272a4ca --- /dev/null +++ b/lib/diaspora_federation/parsers/relayable_xml_parser.rb @@ -0,0 +1,22 @@ +module DiasporaFederation + module Parsers + # This is a parser of XML serialized object that is normally used for parsing data of relayables. + # Explanations about the XML data format can be found + # {https://diaspora.github.io/diaspora_federation/federation/xml_serialization.html here}. + # Specific features of relayables are described + # {https://diaspora.github.io/diaspora_federation/federation/relayable.html here}. + # + # @see https://diaspora.github.io/diaspora_federation/federation/xml_serialization.html XML Serialization + # documentation + # @see https://diaspora.github.io/diaspora_federation/federation/relayable.html Relayable documentation + class RelayableXmlParser < XmlParser + # @see XmlParser#parse + # @see BaseParser#parse + # @return [Array[2]] comprehensive data for an entity instantiation + def parse(*args) + hash = super[0] + [hash, hash.keys] + end + end + end +end diff --git a/lib/diaspora_federation/parsers/xml_parser.rb b/lib/diaspora_federation/parsers/xml_parser.rb new file mode 100644 index 0000000..2e04418 --- /dev/null +++ b/lib/diaspora_federation/parsers/xml_parser.rb @@ -0,0 +1,84 @@ +module DiasporaFederation + module Parsers + # This is a parser of XML serialized object. + # Explanations about the XML data format can be found + # {https://diaspora.github.io/diaspora_federation/federation/xml_serialization.html here}. + # @see https://diaspora.github.io/diaspora_federation/federation/xml_serialization.html XML Serialization + # documentation + class XmlParser < BaseParser + # @see BaseParser#parse + # @param [Nokogiri::XML::Element] root_node root XML node of the XML representation of the entity + # @return [Array[1]] comprehensive data for an entity instantiation + def parse(root_node) + from_xml_sanity_validation(root_node) + + hash = root_node.element_children.map {|child| + xml_name = child.name + property = entity_type.find_property_for_xml_name(xml_name) + if property + type = class_properties[property] + value = parse_element_from_node(xml_name, type, root_node) + [property, value] + else + [xml_name, child.text] + end + }.to_h + + [hash] + end + + private + + # @param [String] name property name to parse + # @param [Class, Symbol] type target type to parse + # @param [Nokogiri::XML::Element] root_node XML node to parse + # @return [Object] parsed data + def parse_element_from_node(name, type, root_node) + if type.instance_of?(Symbol) + parse_string_from_node(name, type, root_node) + elsif type.instance_of?(Array) + parse_array_from_node(type.first, root_node) + elsif type.ancestors.include?(Entity) + parse_entity_from_node(type, root_node) + end + end + + # Create simple entry in data hash + # + # @param [String] name xml tag to parse + # @param [Class, Symbol] type target type to parse + # @param [Nokogiri::XML::Element] root_node XML root_node to parse + # @return [String] data + def parse_string_from_node(name, type, root_node) + node = root_node.xpath(name.to_s) + node = root_node.xpath(xml_names[name].to_s) if node.empty? + parse_string(type, node.first.text) if node.any? + end + + # 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 parse_entity_from_node(type, root_node) + node = root_node.xpath(type.entity_name) + type.from_xml(node.first) if node.any? && node.first.children.any? + end + + # 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 parse_array_from_node(type, root_node) + node = root_node.xpath(type.entity_name) + node.select {|child| child.children.any? }.map {|child| type.from_xml(child) } unless node.empty? + end + + def from_xml_sanity_validation(root_node) + raise ArgumentError, "only Nokogiri::XML::Element allowed" unless root_node.instance_of?(Nokogiri::XML::Element) + assert_parsability_of(root_node.name) + end + end + end +end diff --git a/lib/diaspora_federation/schemas/federation_entities.json b/lib/diaspora_federation/schemas/federation_entities.json new file mode 100644 index 0000000..3df4433 --- /dev/null +++ b/lib/diaspora_federation/schemas/federation_entities.json @@ -0,0 +1,373 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "https://diaspora.github.io/diaspora_federation/schemas/federation_entities-0.2.0-dev.json", + "oneOf": [ + {"$ref": "#/definitions/comment"}, + {"$ref": "#/definitions/like"}, + {"$ref": "#/definitions/participation"}, + {"$ref": "#/definitions/poll_participation"}, + {"$ref": "#/definitions/status_message"}, + {"$ref": "#/definitions/reshare"}, + {"$ref": "#/definitions/profile"}, + {"$ref": "#/definitions/location"}, + {"$ref": "#/definitions/photo"}, + {"$ref": "#/definitions/poll"}, + {"$ref": "#/definitions/poll_answer"} + ], + + "definitions": { + "signature": { + "type": "string", + "minLength": 30 + }, + + "guid": { + "type": "string", + "minLength": 16, + "maxLength": 255 + }, + + "relayable": { + "type": "object", + "description": "please don't use this object unless you're defining a new child relayable schema", + "properties": { + "entity_data": { + "type": "object", + "properties": { + "author": { "type": "string" }, + "guid": { "$ref": "#/definitions/guid" }, + "parent_guid": { "$ref": "#/definitions/guid" }, + "author_signature": { "$ref": "#/definitions/signature" }, + "parent_author_signature": { "$ref": "#/definitions/signature" } + }, + "required": [ + "author", "guid", "parent_guid" + ] + }, + "property_order": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": [ + "entity_data", "entity_type", "property_order" + ] + }, + + "comment": { + "allOf": [ + {"$ref": "#/definitions/relayable"}, + { + "type": "object", + "properties": { + "entity_data": { + "type": "object", + "properties": { + "text": { "type": "string" }, + "created_at": { "type": "string" } + }, + "required": ["text"] + }, + "entity_type": { + "type": "string", + "pattern": "^comment$" + } + } + } + ] + }, + + "like": { + "allOf": [ + {"$ref": "#/definitions/relayable"}, + { + "type": "object", + "properties": { + "entity_type": { + "type": "string", + "pattern": "^like$" + }, + "entity_data": { + "type": "object", + "properties": { + "positive": { "type": "boolean" }, + "parent_type": { "enum": ["Post", "Comment"] } + }, + "required": ["positive"] + } + } + } + ] + }, + + "participation": { + "allOf": [ + {"$ref": "#/definitions/relayable"}, + { + "type": "object", + "properties": { + "entity_type": { + "type": "string", + "pattern": "^participation$" + }, + "entity_data": { + "type": "object", + "properties": { + "parent_type": {"enum": ["Post"]} + } + } + } + } + ] + }, + + "poll_participation": { + "allOf": [ + {"$ref": "#/definitions/relayable"}, + { + "type": "object", + "properties": { + "entity_type": { + "type": "string", + "pattern": "^poll_participation$" + }, + "entity_data": { + "type": "object", + "properties": { + "poll_answer_guid": { "$ref": "#/definitions/guid" } + }, + "required": ["poll_answer_guid"] + } + } + } + ] + }, + + "post": { + "type": "object", + "description": "please don't use this object unless you're defining a new child post schema", + "properties": { + "entity_data": { + "type": "object", + "properties": { + "guid": { "$ref": "#/definitions/guid" }, + "public": { "type": "boolean" }, + "created_at": { "type": "string" }, + "provider_display_name" : { "type": "string" } + }, + "required": [ + "guid", "public", "created_at" + ] + }, + "required": [ + "entity_type", "entity_data" + ] + } + }, + + "status_message": { + "allOf": [ + {"$ref": "#/definitions/post"}, + { + "type": "object", + "properties": { + "entity_type": { + "type": "string", + "pattern": "^status_message$" + }, + + "entity_data": { + "type": "object", + "properties": { + "text": { "type": "string" }, + + "location": { + "oneOf": [ + { "$ref": "#/definitions/location" }, + { "type": "null" } + ] + }, + + "poll": { + "oneOf": [ + { "$ref": "#/definitions/poll" }, + { "type": "null" } + ] + }, + + "photos": { + "type": ["array", "null"], + "items": { "$ref": "#/definitions/photo" } + } + }, + + "required": ["text"] + } + } + } + ] + }, + + "reshare": { + "allOf": [ + {"$ref": "#/definitions/post"}, + { + "type": "object", + "properties": { + "entity_type": { + "type": "string", + "pattern": "^reshare$" + }, + + "entity_data": { + "type": "object", + "properties": { + "root_author": {"type": "string"}, + "root_guid": {"$ref": "#/definitions/guid"} + }, + + "required": ["root_author", "root_guid"] + } + } + } + ] + }, + + "profile": { + "type": "object", + "properties": { + "entity_type": { + "type": "string", + "pattern": "^profile$" + }, + "entity_data": { + "type": "object", + "properties": { + "first_name": { "type": ["string", "null"] }, + "last_name": { "type": ["string", "null"] }, + "gender": { "type": ["string", "null"] }, + "bio": { "type": ["string", "null"] }, + "birthday": { "type": ["string", "null"] }, + "location": { "type": ["string", "null"] }, + "image_url": { "type": ["string", "null"] }, + "author": { "type": "string" } + }, + "required": [ + "author" + ] + } + }, + "required" :["entity_data", "entity_type"] + }, + + "photo": { + "type": "object", + "properties": { + "entity_type": { + "type": "string", + "pattern": "^photo$" + }, + "entity_data": { + "type": "object", + "properties": { + "author": { "type": "string" }, + "guid": {"$ref": "#/definitions/guid"}, + "public": {"type": "boolean"}, + "created_at": {"type": "string"}, + "remote_photo_path": {"type": "string"}, + "remote_photo_name": {"type": "string"}, + "text": {"type": ["null", "string"]}, + "status_message_guid": {"$ref": "#/definitions/guid"}, + "width": {"type": "number"}, + "height": {"type": "number"} + }, + "required": [ + "author", "guid", "public", "created_at", "remote_photo_path", "remote_photo_name", "width", "height" + ] + } + } + }, + + "poll_answer": { + "type": "object", + "properties": { + "entity_type": { + "type": "string", + "pattern": "^poll_answer$" + }, + "entity_data": { + "type": "object", + "properties": { + "guid": { "$ref": "#/definitions/guid" }, + "answer": { "type": "string" } + }, + "required": [ + "answer", + "guid" + ] + } + } + }, + + "poll": { + "type": "object", + "properties": { + "entity_type": { + "type": "string", + "pattern": "^poll$" + }, + "entity_data": { + "type": "object", + "properties": { + "guid": { + "$ref": "#/definitions/guid" + }, + "question": { + "type": "string" + }, + "poll_answers": { + "type": "array", + "items": { + "$ref": "#/definitions/poll_answer" + } + } + }, + "required": [ + "guid", + "question", + "poll_answers" + ] + } + } + }, + + "location": { + "type": "object", + "properties": { + "entity_type": { + "type": "string", + "pattern": "^location$" + }, + "entity_data": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "lat": { + "type": "string" + }, + "lng": { + "type": "string" + } + }, + "required": [ + "address", + "lat", + "lng" + ] + } + } + } + } +} diff --git a/spec/entities.rb b/spec/entities.rb index b42c531..e452fa9 100644 --- a/spec/entities.rb +++ b/spec/entities.rb @@ -46,6 +46,30 @@ module DiasporaFederation class TestEntityWithTimestamp < DiasporaFederation::Entity property :test, :timestamp end + + class TestComplexEntity < DiasporaFederation::Entity + property :test1, :string + property :test2, :boolean + property :test3, :string + property :test4, :integer + property :test5, :timestamp + entity :test6, TestEntity + entity :multi, [OtherEntity] + end + + class SomeRelayable < DiasporaFederation::Entity + LEGACY_SIGNATURE_ORDER = %i(guid author property parent_guid).freeze + + PARENT_TYPE = "Parent".freeze + + include Entities::Relayable + + property :property, :string + + def parent_type + PARENT_TYPE + end + end end module Validators diff --git a/spec/integration/comment_integration_spec.rb b/spec/integration/comment_integration_spec.rb index 3d63665..2d26f2e 100644 --- a/spec/integration/comment_integration_spec.rb +++ b/spec/integration/comment_integration_spec.rb @@ -248,7 +248,7 @@ XML expect(entity.author).to eq(author) expect(entity.text).to eq(text) - expect(entity.additional_xml_elements["new_data"]).to eq(new_data) + expect(entity.additional_data["new_data"]).to eq(new_data) end it "parses new xml with additional data" do @@ -257,7 +257,7 @@ XML expect(entity.author).to eq(author) expect(entity.text).to eq(text) - expect(entity.additional_xml_elements["new_data"]).to eq(new_data) + expect(entity.additional_data["new_data"]).to eq(new_data) end end end diff --git a/spec/lib/diaspora_federation/entities/comment_spec.rb b/spec/lib/diaspora_federation/entities/comment_spec.rb index f454535..c59cfc9 100644 --- a/spec/lib/diaspora_federation/entities/comment_spec.rb +++ b/spec/lib/diaspora_federation/entities/comment_spec.rb @@ -24,12 +24,35 @@ module DiasporaFederation XML + let(:json) { <<-JSON } +{ + "entity_type": "comment", + "entity_data": { + "author": "#{data[:author]}", + "guid": "#{data[:guid]}", + "parent_guid": "#{parent.guid}", + "author_signature": "#{data[:author_signature]}", + "parent_author_signature": "#{data[:parent_author_signature]}", + "text": "#{data[:text]}", + "created_at": "#{data[:created_at].iso8601}" + }, + "property_order": [ + "guid", + "parent_guid", + "text", + "author" + ] +} +JSON + let(:string) { "Comment:#{data[:guid]}:#{parent.guid}" } it_behaves_like "an Entity subclass" it_behaves_like "an XML Entity", [:created_at] + it_behaves_like "a JSON Entity" + it_behaves_like "a relayable Entity" describe "#created_at" do diff --git a/spec/lib/diaspora_federation/entities/like_spec.rb b/spec/lib/diaspora_federation/entities/like_spec.rb index 8959091..800f0bc 100644 --- a/spec/lib/diaspora_federation/entities/like_spec.rb +++ b/spec/lib/diaspora_federation/entities/like_spec.rb @@ -24,12 +24,36 @@ module DiasporaFederation XML + let(:json) { <<-JSON } +{ + "entity_type": "like", + "entity_data": { + "author": "#{data[:author]}", + "guid": "#{data[:guid]}", + "parent_guid": "#{parent.guid}", + "author_signature": "#{data[:author_signature]}", + "parent_author_signature": "#{data[:parent_author_signature]}", + "positive": #{data[:positive]}, + "parent_type": "#{parent.entity_type}" + }, + "property_order": [ + "positive", + "guid", + "parent_type", + "parent_guid", + "author" + ] +} +JSON + let(:string) { "Like:#{data[:guid]}:Post:#{parent.guid}" } it_behaves_like "an Entity subclass" it_behaves_like "an XML Entity" + it_behaves_like "a JSON Entity" + it_behaves_like "a relayable Entity" context "invalid XML" do diff --git a/spec/lib/diaspora_federation/entities/location_spec.rb b/spec/lib/diaspora_federation/entities/location_spec.rb index a3477cd..053d3e6 100644 --- a/spec/lib/diaspora_federation/entities/location_spec.rb +++ b/spec/lib/diaspora_federation/entities/location_spec.rb @@ -10,10 +10,23 @@ module DiasporaFederation XML + let(:json) { <<-JSON } +{ + "entity_type": "location", + "entity_data": { + "address": "#{data[:address]}", + "lat": "#{data[:lat]}", + "lng": "#{data[:lng]}" + } +} +JSON + let(:string) { "Location" } it_behaves_like "an Entity subclass" it_behaves_like "an XML Entity" + + it_behaves_like "a JSON Entity" end end diff --git a/spec/lib/diaspora_federation/entities/message_spec.rb b/spec/lib/diaspora_federation/entities/message_spec.rb index 50ed5c8..a2df46d 100644 --- a/spec/lib/diaspora_federation/entities/message_spec.rb +++ b/spec/lib/diaspora_federation/entities/message_spec.rb @@ -76,7 +76,7 @@ XML end end - describe ".populate_entity" do + describe ".from_xml" do it "adds a nil parent" do xml = Entities::Message.new(data).to_xml parsed = Entities::Message.from_xml(xml) diff --git a/spec/lib/diaspora_federation/entities/participation_spec.rb b/spec/lib/diaspora_federation/entities/participation_spec.rb index 504183c..2ceb84e 100644 --- a/spec/lib/diaspora_federation/entities/participation_spec.rb +++ b/spec/lib/diaspora_federation/entities/participation_spec.rb @@ -23,12 +23,34 @@ module DiasporaFederation XML + let(:json) { <<-JSON } +{ + "entity_type": "participation", + "entity_data": { + "author": "#{data[:author]}", + "guid": "#{data[:guid]}", + "parent_guid": "#{parent.guid}", + "author_signature": "#{data[:author_signature]}", + "parent_author_signature": "#{data[:parent_author_signature]}", + "parent_type": "#{parent.entity_type}" + }, + "property_order": [ + "guid", + "parent_type", + "parent_guid", + "author" + ] +} +JSON + let(:string) { "Participation:#{data[:guid]}:Post:#{parent.guid}" } it_behaves_like "an Entity subclass" it_behaves_like "an XML Entity", [:parent] + it_behaves_like "a JSON Entity" + it_behaves_like "a relayable Entity" describe "#sender_valid?" do diff --git a/spec/lib/diaspora_federation/entities/photo_spec.rb b/spec/lib/diaspora_federation/entities/photo_spec.rb index a9db12e..0f8d71b 100644 --- a/spec/lib/diaspora_federation/entities/photo_spec.rb +++ b/spec/lib/diaspora_federation/entities/photo_spec.rb @@ -17,12 +17,32 @@ module DiasporaFederation XML + let(:json) { <<-JSON } +{ + "entity_type": "photo", + "entity_data": { + "guid": "#{data[:guid]}", + "author": "#{data[:author]}", + "public": #{data[:public]}, + "created_at": "#{data[:created_at].utc.iso8601}", + "remote_photo_path": "#{data[:remote_photo_path]}", + "remote_photo_name": "#{data[:remote_photo_name]}", + "text": "#{data[:text]}", + "status_message_guid": "#{data[:status_message_guid]}", + "height": #{data[:height]}, + "width": #{data[:width]} + } +} +JSON + let(:string) { "Photo:#{data[:guid]}" } it_behaves_like "an Entity subclass" it_behaves_like "an XML Entity" + it_behaves_like "a JSON Entity" + context "default values" do it "uses default values" do minimal_xml = <<-XML diff --git a/spec/lib/diaspora_federation/entities/poll_answer_spec.rb b/spec/lib/diaspora_federation/entities/poll_answer_spec.rb index a9941f5..fd88740 100644 --- a/spec/lib/diaspora_federation/entities/poll_answer_spec.rb +++ b/spec/lib/diaspora_federation/entities/poll_answer_spec.rb @@ -9,10 +9,22 @@ module DiasporaFederation XML + let(:json) { <<-JSON } +{ + "entity_type": "poll_answer", + "entity_data": { + "guid": "#{data[:guid]}", + "answer": "#{data[:answer]}" + } +} +JSON + let(:string) { "PollAnswer:#{data[:guid]}" } it_behaves_like "an Entity subclass" it_behaves_like "an XML Entity" + + it_behaves_like "a JSON Entity" end end diff --git a/spec/lib/diaspora_federation/entities/poll_participation_spec.rb b/spec/lib/diaspora_federation/entities/poll_participation_spec.rb index 8ee541c..8d64df5 100644 --- a/spec/lib/diaspora_federation/entities/poll_participation_spec.rb +++ b/spec/lib/diaspora_federation/entities/poll_participation_spec.rb @@ -22,12 +22,34 @@ module DiasporaFederation XML + let(:json) { <<-JSON } +{ + "entity_type": "poll_participation", + "entity_data": { + "author": "#{data[:author]}", + "guid": "#{data[:guid]}", + "parent_guid": "#{parent.guid}", + "author_signature": "#{data[:author_signature]}", + "parent_author_signature": "#{data[:parent_author_signature]}", + "poll_answer_guid": "#{data[:poll_answer_guid]}" + }, + "property_order": [ + "guid", + "parent_guid", + "author", + "poll_answer_guid" + ] +} +JSON + let(:string) { "PollParticipation:#{data[:guid]}:#{parent.guid}" } it_behaves_like "an Entity subclass" it_behaves_like "an XML Entity" + it_behaves_like "a JSON Entity" + it_behaves_like "a relayable Entity" end end diff --git a/spec/lib/diaspora_federation/entities/poll_spec.rb b/spec/lib/diaspora_federation/entities/poll_spec.rb index ac7fb62..7422d39 100644 --- a/spec/lib/diaspora_federation/entities/poll_spec.rb +++ b/spec/lib/diaspora_federation/entities/poll_spec.rb @@ -10,10 +10,25 @@ module DiasporaFederation XML + let(:json) { <<-JSON } +{ + "entity_type": "poll", + "entity_data": { + "guid": "#{data[:guid]}", + "question": "#{data[:question]}", + "poll_answers": [ +#{data[:poll_answers].map {|a| JSON.pretty_generate(a.to_json).indent(6) }.join(",\n")} + ] + } +} +JSON + let(:string) { "Poll:#{data[:guid]}" } it_behaves_like "an Entity subclass" it_behaves_like "an XML Entity" + + it_behaves_like "a JSON Entity" end end diff --git a/spec/lib/diaspora_federation/entities/profile_spec.rb b/spec/lib/diaspora_federation/entities/profile_spec.rb index 775b636..6aca890 100644 --- a/spec/lib/diaspora_federation/entities/profile_spec.rb +++ b/spec/lib/diaspora_federation/entities/profile_spec.rb @@ -20,12 +20,35 @@ module DiasporaFederation XML + let(:json) { <<-JSON } +{ + "entity_type": "profile", + "entity_data": { + "author": "#{data[:author]}", + "first_name": "#{data[:first_name]}", + "last_name": "", + "image_url": "#{data[:image_url]}", + "image_url_medium": "#{data[:image_url]}", + "image_url_small": "#{data[:image_url]}", + "birthday": "#{data[:birthday]}", + "gender": "#{data[:gender]}", + "bio": "#{data[:bio]}", + "location": "#{data[:location]}", + "searchable": #{data[:searchable]}, + "nsfw": #{data[:nsfw]}, + "tag_string": "#{data[:tag_string]}" + } +} +JSON + let(:string) { "Profile:#{data[:author]}" } it_behaves_like "an Entity subclass" it_behaves_like "an XML Entity" + it_behaves_like "a JSON Entity" + context "default values" do it "uses default values" do minimal_xml = <<-XML diff --git a/spec/lib/diaspora_federation/entities/relayable_spec.rb b/spec/lib/diaspora_federation/entities/relayable_spec.rb index 5512f1b..9161fc7 100644 --- a/spec/lib/diaspora_federation/entities/relayable_spec.rb +++ b/spec/lib/diaspora_federation/entities/relayable_spec.rb @@ -11,23 +11,10 @@ module DiasporaFederation let(:local_parent) { FactoryGirl.build(:related_entity, author: bob.diaspora_id) } let(:remote_parent) { FactoryGirl.build(:related_entity, author: bob.diaspora_id, local: false) } let(:hash) { {guid: guid, author: author, parent_guid: parent_guid, parent: local_parent, property: property} } + let(:hash_with_fake_signatures) { hash.merge!(author_signature: "aa", parent_author_signature: "bb") } let(:legacy_signature_data) { "#{guid};#{author};#{property};#{parent_guid}" } - class SomeRelayable < Entity - LEGACY_SIGNATURE_ORDER = %i(guid author property parent_guid).freeze - - PARENT_TYPE = "Parent".freeze - - include Entities::Relayable - - property :property, :string - - def parent_type - PARENT_TYPE - end - end - def sign_with_key(privkey, signature_data) Base64.strict_encode64(privkey.sign(OpenSSL::Digest::SHA256.new, signature_data)) end @@ -36,6 +23,15 @@ module DiasporaFederation pubkey.verify(OpenSSL::Digest::SHA256.new, Base64.decode64(signature), signed_string) end + describe "#initialize" do + it "filters signatures from order" do + xml_order = [:author, :guid, :parent_guid, :property, "new_property", :author_signature] + + expect(Entities::SomeRelayable.new(hash, xml_order).xml_order) + .to eq([:author, :guid, :parent_guid, :property, "new_property"]) + end + end + describe "#verify_signatures" do it "doesn't raise anything if correct signatures with legacy-string were passed" do hash[:author_signature] = sign_with_key(author_pkey, legacy_signature_data) @@ -45,14 +41,14 @@ module DiasporaFederation expect_callback(:fetch_public_key, author).and_return(author_pkey.public_key) expect_callback(:fetch_public_key, remote_parent.author).and_return(parent_pkey.public_key) - expect { SomeRelayable.new(hash).verify_signatures }.not_to raise_error + expect { Entities::SomeRelayable.new(hash).verify_signatures }.not_to raise_error end it "raises when no public key for author was fetched" do expect_callback(:fetch_public_key, anything).and_return(nil) expect { - SomeRelayable.new(hash).verify_signatures + Entities::SomeRelayable.new(hash).verify_signatures }.to raise_error Entities::Relayable::PublicKeyNotFound end @@ -62,7 +58,7 @@ module DiasporaFederation expect_callback(:fetch_public_key, author).and_return(author_pkey.public_key) expect { - SomeRelayable.new(hash).verify_signatures + Entities::SomeRelayable.new(hash).verify_signatures }.to raise_error Entities::Relayable::SignatureVerificationFailed end @@ -74,7 +70,7 @@ module DiasporaFederation expect_callback(:fetch_public_key, remote_parent.author).and_return(nil) expect { - SomeRelayable.new(hash).verify_signatures + Entities::SomeRelayable.new(hash).verify_signatures }.to raise_error Entities::Relayable::PublicKeyNotFound end @@ -87,7 +83,7 @@ module DiasporaFederation expect_callback(:fetch_public_key, remote_parent.author).and_return(parent_pkey.public_key) expect { - SomeRelayable.new(hash).verify_signatures + Entities::SomeRelayable.new(hash).verify_signatures }.to raise_error Entities::Relayable::SignatureVerificationFailed end @@ -98,7 +94,7 @@ module DiasporaFederation expect_callback(:fetch_public_key, author).and_return(author_pkey.public_key) - expect { SomeRelayable.new(hash).verify_signatures }.not_to raise_error + expect { Entities::SomeRelayable.new(hash).verify_signatures }.not_to raise_error end context "new signatures" do @@ -113,7 +109,7 @@ module DiasporaFederation expect_callback(:fetch_public_key, author).and_return(author_pkey.public_key) expect_callback(:fetch_public_key, remote_parent.author).and_return(parent_pkey.public_key) - expect { SomeRelayable.new(hash, xml_order).verify_signatures }.not_to raise_error + expect { Entities::SomeRelayable.new(hash, xml_order).verify_signatures }.not_to raise_error end it "doesn't raise anything if correct signatures with new property were passed" do @@ -128,7 +124,7 @@ module DiasporaFederation expect_callback(:fetch_public_key, remote_parent.author).and_return(parent_pkey.public_key) expect { - SomeRelayable.new(hash, xml_order, "new_property" => new_property).verify_signatures + Entities::SomeRelayable.new(hash, xml_order, "new_property" => new_property).verify_signatures }.not_to raise_error end @@ -139,7 +135,7 @@ module DiasporaFederation xml_order = [:author, :guid, :parent_guid, :property, "new_property"] expect { - SomeRelayable.new(hash, xml_order, "new_property" => new_property).verify_signatures + Entities::SomeRelayable.new(hash, xml_order, "new_property" => new_property).verify_signatures }.to raise_error Entities::Relayable::SignatureVerificationFailed end end @@ -159,42 +155,32 @@ module DiasporaFederation XML it "adds new unknown xml elements to the xml again" do - hash.merge!(author_signature: "aa", parent_author_signature: "bb") xml_order = [:author, :guid, :parent_guid, :property, "new_property"] - xml = SomeRelayable.new(hash, xml_order, "new_property" => new_property).to_xml + xml = Entities::SomeRelayable.new(hash_with_fake_signatures, xml_order, "new_property" => new_property).to_xml expect(xml.to_s.strip).to eq(expected_xml.strip) end it "converts strings in xml_order to symbol if needed" do - hash.merge!(author_signature: "aa", parent_author_signature: "bb") xml_order = %w(author guid parent_guid property new_property) - xml = SomeRelayable.new(hash, xml_order, "new_property" => new_property).to_xml + xml = Entities::SomeRelayable.new(hash_with_fake_signatures, xml_order, "new_property" => new_property).to_xml expect(xml.to_s.strip).to eq(expected_xml.strip) end it "adds missing properties from xml_order to xml" do - hash.merge!(author_signature: "aa", parent_author_signature: "bb") xml_order = [:author, :guid, :parent_guid, :property, "new_property"] - xml = SomeRelayable.new(hash, xml_order).to_xml + xml = Entities::SomeRelayable.new(hash_with_fake_signatures, xml_order).to_xml expect(xml.at_xpath("new_property").text).to be_empty end - it "filters signatures from order" do - xml_order = [:author, :guid, :parent_guid, :property, "new_property", :author_signature] - - expect(SomeRelayable.new(hash, xml_order).xml_order) - .to eq([:author, :guid, :parent_guid, :property, "new_property"]) - end - it "computes correct signatures for the entity" do expect_callback(:fetch_private_key, author).and_return(author_pkey) expect_callback(:fetch_private_key, local_parent.author).and_return(parent_pkey) - xml = SomeRelayable.new(hash).to_xml + xml = Entities::SomeRelayable.new(hash).to_xml author_signature = xml.at_xpath("author_signature").text parent_author_signature = xml.at_xpath("parent_author_signature").text @@ -210,7 +196,7 @@ XML xml_order = [:author, :guid, :parent_guid, "new_property", :property] signature_data_with_new_property = "#{author};#{guid};#{parent_guid};#{new_property};#{property}" - xml = SomeRelayable.new(hash, xml_order, "new_property" => new_property).to_xml + xml = Entities::SomeRelayable.new(hash, xml_order, "new_property" => new_property).to_xml author_signature = xml.at_xpath("author_signature").text parent_author_signature = xml.at_xpath("parent_author_signature").text @@ -220,9 +206,7 @@ XML end it "doesn't change signatures if they are already set" do - hash.merge!(author_signature: "aa", parent_author_signature: "bb") - - xml = SomeRelayable.new(hash).to_xml + xml = Entities::SomeRelayable.new(hash_with_fake_signatures).to_xml expect(xml.at_xpath("author_signature").text).to eq("aa") expect(xml.at_xpath("parent_author_signature").text).to eq("bb") @@ -232,7 +216,7 @@ XML expect_callback(:fetch_private_key, author).and_return(nil) expect { - SomeRelayable.new(hash).to_xml + Entities::SomeRelayable.new(hash).to_xml }.to raise_error Entities::Relayable::AuthorPrivateKeyNotFound end @@ -240,19 +224,16 @@ XML expect_callback(:fetch_private_key, author).and_return(author_pkey) expect_callback(:fetch_private_key, local_parent.author).and_return(nil) - xml = SomeRelayable.new(hash).to_xml + xml = Entities::SomeRelayable.new(hash).to_xml expect(xml.at_xpath("parent_author_signature").text).to eq("") end end describe ".from_xml" do - before do - expect_callback(:fetch_related_entity, "Parent", parent_guid).and_return(remote_parent) - end - context "parsing" do before do + expect_callback(:fetch_related_entity, "Parent", parent_guid).and_return(remote_parent) expect_callback(:fetch_public_key, author).and_return(author_pkey.public_key) expect_callback(:fetch_public_key, remote_parent.author).and_return(parent_pkey.public_key) end @@ -271,109 +252,283 @@ XML XML it "doesn't drop unknown properties" do - entity = SomeRelayable.from_xml(Nokogiri::XML::Document.parse(new_xml).root) + entity = Entities::SomeRelayable.from_xml(Nokogiri::XML::Document.parse(new_xml).root) - expect(entity).to be_an_instance_of SomeRelayable + expect(entity).to be_an_instance_of Entities::SomeRelayable expect(entity.property).to eq(property) - expect(entity.additional_xml_elements).to eq( + expect(entity.additional_data).to eq( "new_property" => new_property ) end it "hand over the order in the xml to the instance without signatures" do - entity = SomeRelayable.from_xml(Nokogiri::XML::Document.parse(new_xml).root) + entity = Entities::SomeRelayable.from_xml(Nokogiri::XML::Document.parse(new_xml).root) expect(entity.xml_order).to eq([:author, :guid, :parent_guid, "new_property", :property]) end - it "creates Entity with empty 'additional_xml_elements' if the xml has only known properties" do + it "creates Entity with empty 'additional_data' if the xml has only known properties" do hash[:author_signature] = sign_with_key(author_pkey, legacy_signature_data) hash[:parent_author_signature] = sign_with_key(parent_pkey, legacy_signature_data) - xml = SomeRelayable.new(hash).to_xml + xml = Entities::SomeRelayable.new(hash).to_xml - entity = SomeRelayable.from_xml(xml) + entity = Entities::SomeRelayable.from_xml(xml) - expect(entity).to be_an_instance_of SomeRelayable + expect(entity).to be_an_instance_of Entities::SomeRelayable expect(entity.property).to eq(property) - expect(entity.additional_xml_elements).to be_empty + expect(entity.additional_data).to be_empty end end - context "relayable signature verification feature support" do - it "calls signatures verification on relayable unpack" do - hash.merge!(author_signature: "aa", parent_author_signature: "bb") - - xml = SomeRelayable.new(hash).to_xml - - expect_callback(:fetch_public_key, author).and_return(author_pkey.public_key) - - expect { - SomeRelayable.from_xml(xml) - }.to raise_error DiasporaFederation::Entities::Relayable::SignatureVerificationFailed - end - end - end - - context "parse invalid XML" do - it "raises a ValidationError if the parent_guid is missing" do - broken_xml = <<-XML + context "parse invalid XML" do + it "raises a ValidationError if the parent_guid is missing" do + broken_xml = <<-XML XML - expect { - SomeRelayable.from_xml(Nokogiri::XML::Document.parse(broken_xml).root) - }.to raise_error Entity::ValidationError, "invalid DiasporaFederation::SomeRelayable! missing 'parent_guid'." + expect { + Entities::SomeRelayable.from_xml(Nokogiri::XML::Document.parse(broken_xml).root) + }.to raise_error Entity::ValidationError, + "invalid DiasporaFederation::Entities::SomeRelayable! missing 'parent_guid'." + end end end - context "fetch parent" do - before do - expect_callback(:fetch_public_key, author).and_return(author_pkey.public_key) - expect_callback(:fetch_public_key, remote_parent.author).and_return(parent_pkey.public_key) + describe "#to_json" do + let(:entity_class) { Entities::SomeRelayable } + + it "contains the property order within the property_order property" do + property_order = %i(author guid parent_guid property) + json = entity_class.new(hash_with_fake_signatures, property_order).to_json.to_json + + expect(json).to include_json(property_order: property_order.map(&:to_s)) + end + + it "uses legacy order for filling property_order when no xml_order supplied" do + entity = entity_class.new(hash_with_fake_signatures) + expect( + entity.to_json.to_json + ).to include_json(property_order: entity_class::LEGACY_SIGNATURE_ORDER.map(&:to_s)) + end + + it "adds new unknown elements to the json again" do + property_order = [:author, :guid, :parent_guid, :property, "new_property"] + json = Entities::SomeRelayable.new(hash_with_fake_signatures, property_order, "new_property" => new_property) + .to_json.to_json + + expect(json).to include_json( + entity_data: {new_property: new_property}, + property_order: {4 => "new_property"} + ) + end + + it "adds missing properties from property order to json" do + property_order = [:author, :guid, :parent_guid, :property, "new_property"] + json = Entities::SomeRelayable.new(hash_with_fake_signatures, property_order).to_json.to_json + + expect(json).to include_json( + entity_data: {new_property: nil}, + property_order: {4 => "new_property"} + ) + end + + it "computes correct signatures for the entity with new unknown elements" do expect_callback(:fetch_private_key, author).and_return(author_pkey) - expect_callback(:fetch_private_key, remote_parent.author).and_return(parent_pkey) + expect_callback(:fetch_private_key, local_parent.author).and_return(parent_pkey) + + property_order = [:author, :guid, :parent_guid, "new_property", :property] + signature_data_with_new_property = "#{author};#{guid};#{parent_guid};#{new_property};#{property}" + + json_hash = Entities::SomeRelayable.new(hash, property_order, "new_property" => new_property).to_json + author_signature = json_hash[:entity_data][:author_signature] + parent_author_signature = json_hash[:entity_data][:parent_author_signature] + + expect(verify_signature(author_pkey, author_signature, signature_data_with_new_property)).to be_truthy + expect(verify_signature(parent_pkey, parent_author_signature, signature_data_with_new_property)).to be_truthy end - let(:xml) { SomeRelayable.new(hash).to_xml } - - it "fetches the parent from the backend" do - expect_callback(:fetch_related_entity, "Parent", parent_guid).and_return(remote_parent) - expect(Federation::Fetcher).not_to receive(:fetch_public) - - entity = SomeRelayable.from_xml(xml) - - expect(entity.parent).to eq(remote_parent) + it "doesn't change signatures if they are already set" do + json = Entities::SomeRelayable.new(hash_with_fake_signatures).to_json.to_json + expect(json).to include_json(entity_data: {author_signature: "aa"}) + expect(json).to include_json(entity_data: {parent_author_signature: "bb"}) end - it "fetches the parent from remote if not found on backend" do - expect_callback(:fetch_related_entity, "Parent", parent_guid).and_return(nil, remote_parent) - expect(Federation::Fetcher).to receive(:fetch_public).with(author, "Parent", parent_guid) + it "raises when author_signature not set and key isn't supplied" do + expect_callback(:fetch_private_key, author).and_return(nil) - entity = SomeRelayable.from_xml(xml) + expect { + Entities::SomeRelayable.new(hash).to_json + }.to raise_error Entities::Relayable::AuthorPrivateKeyNotFound + end - expect(entity.parent).to eq(remote_parent) + it "doesn't set parent_author_signature if key isn't supplied" do + expect_callback(:fetch_private_key, author).and_return(author_pkey) + expect_callback(:fetch_private_key, local_parent.author).and_return(nil) + + json = Entities::SomeRelayable.new(hash).to_json.to_json + expect(json).to include_json(entity_data: {parent_author_signature: ""}) + end + end + + describe ".from_hash" do + let(:entity_class) { Entities::SomeRelayable } + + context "parsing" do + before do + expect_callback(:fetch_related_entity, "Parent", parent_guid).and_return(remote_parent) + expect_callback(:fetch_public_key, author).and_return(author_pkey.public_key) + expect_callback(:fetch_public_key, remote_parent.author).and_return(parent_pkey.public_key) + end + + context "when properties are sorted and there is an unknown property" do + let(:new_signature_data) { "#{author};#{guid};#{parent_guid};#{new_property};#{property}" } + let(:author_signature) { sign_with_key(author_pkey, new_signature_data) } + let(:parent_author_signature) { sign_with_key(parent_pkey, new_signature_data) } + let(:entity_data) { + { + :guid => guid, + :author => author, + :property => property, + :parent_guid => parent_guid, + "new_property" => new_property, + :author_signature => author_signature, + :parent_author_signature => parent_author_signature + } + } + let(:property_order) { %w(author guid parent_guid new_property property) } + + it "parses entity properties from the input data" do + entity = Entities::SomeRelayable.from_hash(entity_data, property_order) + expect(entity).to be_an_instance_of Entities::SomeRelayable + expect(entity.author).to eq(author) + expect(entity.guid).to eq(guid) + expect(entity.parent_guid).to eq(parent_guid) + expect(entity.property).to eq(property) + expect(entity.author_signature).to eq(author_signature) + expect(entity.parent_author_signature).to eq(parent_author_signature) + end + + it "makes unknown properties available via #additional_data" do + entity = Entities::SomeRelayable.from_hash(entity_data, property_order) + expect(entity.additional_data).to eq("new_property" => new_property) + end + + it "hands over the order in the data to the instance without signatures" do + entity = Entities::SomeRelayable.from_hash(entity_data, property_order) + expect(entity.xml_order).to eq(%w(author guid parent_guid new_property property)) + end + + it "calls a constructor of the entity of the appropriate type" do + expect(Entities::SomeRelayable).to receive(:new).with( + { + author: author, + guid: guid, + parent_guid: parent_guid, + property: property, + author_signature: author_signature, + parent_author_signature: parent_author_signature, + parent: remote_parent + }.merge("new_property" => new_property), + %w(author guid parent_guid new_property property), + "new_property" => new_property + ).and_call_original + Entities::SomeRelayable.from_hash(entity_data, property_order) + end + end + + it "creates Entity with empty 'additional_data' if it has only known properties" do + property_order = %w(guid author property parent_guid) + + entity_data = { + guid: guid, + author: author, + property: property, + parent_guid: parent_guid, + author_signature: sign_with_key(author_pkey, legacy_signature_data), + parent_author_signature: sign_with_key(parent_pkey, legacy_signature_data) + } + + entity = Entities::SomeRelayable.from_hash(entity_data, property_order) + + expect(entity).to be_an_instance_of Entities::SomeRelayable + expect(entity.additional_data).to be_empty + end + end + + context "relayable signature verification feature support" do + it "calls signatures verification on relayable unpack" do + property_order = %w(guid author property parent_guid) + entity_data = { + guid: guid, + author: author, + property: property, + parent_guid: parent_guid, + author_signature: "aa", + parent_author_signature: "bb" + } + + expect_callback(:fetch_related_entity, "Parent", parent_guid).and_return(remote_parent) + expect_callback(:fetch_public_key, author).and_return(author_pkey.public_key) + expect { + Entities::SomeRelayable.from_hash(entity_data, property_order) + }.to raise_error DiasporaFederation::Entities::Relayable::SignatureVerificationFailed + end + end + + context "fetch parent" do + before do + expect_callback(:fetch_public_key, author).and_return(author_pkey.public_key) + expect_callback(:fetch_public_key, remote_parent.author).and_return(parent_pkey.public_key) + expect_callback(:fetch_private_key, author).and_return(author_pkey) + expect_callback(:fetch_private_key, remote_parent.author).and_return(parent_pkey) + end + + let(:entity) { Entities::SomeRelayable.new(hash) } + let(:data) { + entity.to_h.tap {|hash| + hash.delete(:parent) + } + } + + it "fetches the parent from the backend" do + expect_callback(:fetch_related_entity, "Parent", parent_guid).and_return(remote_parent) + expect(Federation::Fetcher).not_to receive(:fetch_public) + + new_entity = Entities::SomeRelayable.from_hash(data, entity.send(:signature_order)) + + expect(new_entity.parent).to eq(remote_parent) + end + + it "fetches the parent from remote if not found on backend" do + expect_callback(:fetch_related_entity, "Parent", parent_guid).and_return(nil, remote_parent) + expect(Federation::Fetcher).to receive(:fetch_public).with(author, "Parent", parent_guid) + + new_entity = Entities::SomeRelayable.from_hash(data, entity.send(:signature_order)) + + expect(new_entity.parent).to eq(remote_parent) + end end end describe "#sender_valid?" do it "allows author" do - entity = SomeRelayable.new(hash) + entity = Entities::SomeRelayable.new(hash) expect(entity.sender_valid?(author)).to be_truthy end it "allows parent author" do - entity = SomeRelayable.new(hash) + entity = Entities::SomeRelayable.new(hash) expect(entity.sender_valid?(local_parent.author)).to be_truthy end it "does not allow any random author" do - entity = SomeRelayable.new(hash) + entity = Entities::SomeRelayable.new(hash) invalid_author = FactoryGirl.generate(:diaspora_id) expect(entity.sender_valid?(invalid_author)).to be_falsey diff --git a/spec/lib/diaspora_federation/entities/reshare_spec.rb b/spec/lib/diaspora_federation/entities/reshare_spec.rb index d1efba8..f0c9d96 100644 --- a/spec/lib/diaspora_federation/entities/reshare_spec.rb +++ b/spec/lib/diaspora_federation/entities/reshare_spec.rb @@ -15,12 +15,29 @@ module DiasporaFederation XML + let(:json) { <<-JSON } +{ + "entity_type": "reshare", + "entity_data": { + "author": "#{data[:author]}", + "guid": "#{data[:guid]}", + "created_at": "#{data[:created_at].utc.iso8601}", + "provider_display_name": "#{data[:provider_display_name]}", + "root_author": "#{data[:root_author]}", + "root_guid": "#{data[:root_guid]}", + "public": #{data[:public]} + } +} +JSON + let(:string) { "Reshare:#{data[:guid]}:#{data[:root_guid]}" } it_behaves_like "an Entity subclass" it_behaves_like "an XML Entity" + it_behaves_like "a JSON Entity" + context "default values" do it "uses default values" do minimal_xml = <<-XML diff --git a/spec/lib/diaspora_federation/entities/status_message_spec.rb b/spec/lib/diaspora_federation/entities/status_message_spec.rb index 1f0ca4a..1df135d 100644 --- a/spec/lib/diaspora_federation/entities/status_message_spec.rb +++ b/spec/lib/diaspora_federation/entities/status_message_spec.rb @@ -54,12 +54,70 @@ module DiasporaFederation XML + let(:json) { <<-JSON } +{ + "entity_type": "status_message", + "entity_data": { + "author": "#{data[:author]}", + "guid": "#{data[:guid]}", + "created_at": "#{data[:created_at].utc.iso8601}", + "provider_display_name": "#{data[:provider_display_name]}", + "text": "#{data[:text]}", + "photos": [ + { + "entity_type": "photo", + "entity_data": { + "guid": "#{photo1.guid}", + "author": "#{photo1.author}", + "public": #{photo1.public}, + "created_at": "#{photo1.created_at.utc.iso8601}", + "remote_photo_path": "#{photo1.remote_photo_path}", + "remote_photo_name": "#{photo1.remote_photo_name}", + "text": "#{photo1.text}", + "status_message_guid": "#{photo1.status_message_guid}", + "height": #{photo1.height}, + "width": #{photo1.width} + } + }, + { + "entity_type": "photo", + "entity_data": { + "guid": "#{photo2.guid}", + "author": "#{photo2.author}", + "public": #{photo2.public}, + "created_at": "#{photo2.created_at.utc.iso8601}", + "remote_photo_path": "#{photo2.remote_photo_path}", + "remote_photo_name": "#{photo2.remote_photo_name}", + "text": "#{photo2.text}", + "status_message_guid": "#{photo2.status_message_guid}", + "height": #{photo2.height}, + "width": #{photo2.width} + } + } + ], + "location": { + "entity_type": "location", + "entity_data": { + "address": "#{location.address}", + "lat": "#{location.lat}", + "lng": "#{location.lng}" + } + }, + "poll": null, + "event": null, + "public": #{data[:public]} + } +} +JSON + let(:string) { "StatusMessage:#{data[:guid]}" } it_behaves_like "an Entity subclass" it_behaves_like "an XML Entity" + it_behaves_like "a JSON Entity" + context "default values" do it "uses default values" do minimal_xml = <<-XML diff --git a/spec/lib/diaspora_federation/entity_spec.rb b/spec/lib/diaspora_federation/entity_spec.rb index 88a9cd9..63d76bf 100644 --- a/spec/lib/diaspora_federation/entity_spec.rb +++ b/spec/lib/diaspora_federation/entity_spec.rb @@ -68,7 +68,11 @@ module DiasporaFederation describe "#to_h" do it "returns a hash of the internal data" do entity = Entities::TestDefaultEntity.new(data) - expect(entity.to_h).to eq(data.transform_values(&:to_s)) + expect(entity.to_h).to eq( + data.map {|key, value| + [key, entity.class.class_props[key] == :string ? value.to_s : value] + }.to_h + ) end end @@ -104,34 +108,6 @@ module DiasporaFederation 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) } @@ -145,126 +121,213 @@ XML 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 "parses the string to the correct type" do - xml = <<-XML.strip + it "calls .from_hash with the hash representation of provided XML" do + expect(Entities::TestDefaultEntity).to receive(:from_hash).with( + test1: "asdf", + test2: "qwer", + test3: true + ) + Entities::TestDefaultEntity.from_xml(Nokogiri::XML::Document.parse(<<-XML).root) asdf qwer true XML - - entity = Entities::TestDefaultEntity.from_xml(Nokogiri::XML::Document.parse(xml).root) - - expect(entity).to be_an_instance_of Entities::TestDefaultEntity - expect(entity.test1).to eq("asdf") - expect(entity.test2).to eq("qwer") - expect(entity.test3).to eq(true) - end - - it "parses boolean fields with false value" do - xml = <<-XML.strip - - false - -XML - - entity = Entities::TestEntityWithBoolean.from_xml(Nokogiri::XML::Document.parse(xml).root) - expect(entity).to be_an_instance_of Entities::TestEntityWithBoolean - expect(entity.test).to eq(false) - end - - it "parses boolean fields with a randomly matching pattern as erroneous" do - %w(ttFFFtt yesFFDSFSDy noDFDSFFDFn fXf LLyes).each do |weird_value| - xml = <<-XML.strip - - #{weird_value} - -XML - - expect { - Entities::TestEntityWithBoolean.from_xml(Nokogiri::XML::Document.parse(xml).root) - }.to raise_error Entity::ValidationError, "missing required properties: test" - end - end - - it "parses integer fields with a randomly matching pattern as erroneous" do - %w(1,2,3 foobar two).each do |weird_value| - xml = <<-XML.strip - - #{weird_value} - -XML - - expect { - Entities::TestEntityWithInteger.from_xml(Nokogiri::XML::Document.parse(xml).root) - }.to raise_error Entity::ValidationError, "missing required properties: test" - end - end - - it "parses timestamp fields with a randomly matching pattern as erroneous" do - %w(foobar yesterday now 1.2.foo).each do |weird_value| - xml = <<-XML.strip - - #{weird_value} - -XML - - expect { - Entities::TestEntityWithTimestamp.from_xml(Nokogiri::XML::Document.parse(xml).root) - }.to raise_error Entity::ValidationError, "missing required properties: test" - end - 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 "forms .from_hash arguments basing on parse return array" do + arguments = [{arg1: "value"}] + expect_any_instance_of(DiasporaFederation::Parsers::XmlParser).to receive(:parse).and_return(arguments) + expect(Entities::TestDefaultEntity).to receive(:from_hash).with(*arguments) + Entities::TestDefaultEntity.from_xml(Nokogiri::XML::Document.parse("").root) + end - 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") + it "passes input parameter directly to .parse method of the parser" do + root = Nokogiri::XML::Document.parse("").root + expect_any_instance_of(DiasporaFederation::Parsers::XmlParser).to receive(:parse).with(root) + .and_return([{test1: "2", test2: "1"}]) + Entities::TestDefaultEntity.from_xml(root) + end + end + + describe "#to_json" do + let(:basic_props) { + { + test1: "123", + test2: false, + test3: "456", + test4: 789, + test5: Time.current.utc + } + } + + let(:hash) { + basic_props.merge( + test6: { + test: "000" + }, + multi: [ + {asdf: "01"}, + {asdf: "02"} + ] + ) + } + let(:entity_class) { Entities::TestComplexEntity } + + it "generates expected JSON data" do + json_output = entity_class.new(hash).to_json.to_json + basic_props[:test5] = basic_props[:test5].iso8601 + expect(json_output).to include_json( + entity_type: "test_complex_entity", + entity_data: basic_props.merge( + test6: { + entity_type: "test_entity", + entity_data: { + test: "000" + } + }, + multi: [ + { + entity_type: "other_entity", + entity_data: { + asdf: "01" + } + }, + { + entity_type: "other_entity", + entity_data: { + asdf: "02" + } + } + ] + ) + ) + end + end + + describe ".from_json" do + it "parses entity properties from the input JSON data" do + now = Time.now.change(usec: 0).utc + entity_data = <<-JSON +{ + "entity_type": "test_complex_entity", + "entity_data": { + "test1": "abc", + "test2": false, + "test3": "def", + "test4": 123, + "test5": "#{now.iso8601}", + "test6": { + "entity_type": "test_entity", + "entity_data": { + "test": "nested" + } + }, + "multi": [ + { + "entity_type": "other_entity", + "entity_data": { + "asdf": "01" + } + }, + { + "entity_type": "other_entity", + "entity_data": { + "asdf": "02" + } + } + ] + } +} +JSON + + entity = Entities::TestComplexEntity.from_json(JSON.parse(entity_data)) + expect(entity).to be_an_instance_of(Entities::TestComplexEntity) + expect(entity.test1).to eq("abc") + expect(entity.test2).to eq(false) + expect(entity.test3).to eq("def") + expect(entity.test4).to eq(123) + expect(entity.test5).to eq(now) + expect(entity.test6.test).to eq("nested") + expect(entity.multi[0].asdf).to eq("01") + expect(entity.multi[1].asdf).to eq("02") + end + + it "calls .from_hash with the entity_data of json hash" do + json = { + "entity_type" => "test_entity", + "entity_data" => { + "test" => "value" + } + } + expect(Entities::TestEntity).to receive(:json_parser_class).and_call_original + expect_any_instance_of(Parsers::JsonParser).to receive(:parse).with(json).and_call_original + expect(Entities::TestEntity).to receive(:from_hash).with(test: "value") + Entities::TestEntity.from_json(json) + end + + it "forms .from_hash arguments basing on parse return array" do + class EntityWithFromHashMethod < Entity + def self.from_hash(_arg1, _arg2, _arg3); end end + + expect(EntityWithFromHashMethod).to receive(:json_parser_class).and_call_original + expect_any_instance_of(Parsers::JsonParser).to receive(:parse).with("{}").and_return(%i(arg1 arg2 arg3)) + expect(EntityWithFromHashMethod).to receive(:from_hash).with(:arg1, :arg2, :arg3) + EntityWithFromHashMethod.from_json("{}") + end + end + + describe ".from_hash" do + it "parses entity properties from the input data" do + now = Time.now.change(usec: 0).utc + entity_data = { + test1: "abc", + test2: false, + test3: "def", + test4: 123, + test5: now, + test6: { + test: "nested" + }, + multi: [ + {asdf: "01"}, + {asdf: "02"} + ] + } + + entity = Entities::TestComplexEntity.from_hash(entity_data) + expect(entity).to be_an_instance_of(Entities::TestComplexEntity) + expect(entity.test1).to eq("abc") + expect(entity.test2).to eq(false) + expect(entity.test3).to eq("def") + expect(entity.test4).to eq(123) + expect(entity.test5).to eq(now) + expect(entity.test6.test).to eq("nested") + expect(entity.multi[0].asdf).to eq("01") + expect(entity.multi[1].asdf).to eq("02") + end + + it "calls a constructor of the entity of the appropriate type" do + entity_data = {test1: "abc", test2: "123"} + expect(Entities::TestDefaultEntity).to receive(:new).with(test1: "abc", test2: "123") + Entities::TestDefaultEntity.from_hash(entity_data) + end + + it "supports instantiation of nested entities using objects of the respective type" do + entity1 = Entities::TestEntity.new(test: "hello") + entity2 = Entities::OtherEntity.new(asdf: "01") + entity3 = Entities::OtherEntity.new(asdf: "02") + entity_data = { + asdf: "value", + test: entity1, + multi: [entity2, entity3] + } + entity = Entities::TestNestedEntity.from_hash(entity_data) + expect(entity.test).to eq(entity1) + expect(entity.multi[0]).to eq(entity2) + expect(entity.multi[1]).to eq(entity3) end end diff --git a/spec/lib/diaspora_federation/parsers/base_parser_spec.rb b/spec/lib/diaspora_federation/parsers/base_parser_spec.rb new file mode 100644 index 0000000..1fdf683 --- /dev/null +++ b/spec/lib/diaspora_federation/parsers/base_parser_spec.rb @@ -0,0 +1,11 @@ +module DiasporaFederation + describe Parsers::BaseParser do + describe ".parse" do + it "raises NotImplementedError error" do + expect { + Parsers::BaseParser.new(Entity).parse + }.to raise_error(NotImplementedError, "you must override this method when creating your own parser") + end + end + end +end diff --git a/spec/lib/diaspora_federation/parsers/json_parser_spec.rb b/spec/lib/diaspora_federation/parsers/json_parser_spec.rb new file mode 100644 index 0000000..13c173c --- /dev/null +++ b/spec/lib/diaspora_federation/parsers/json_parser_spec.rb @@ -0,0 +1,95 @@ +module DiasporaFederation + describe Parsers::JsonParser do + describe ".parse" do + let(:entity_class) { Entities::TestComplexEntity } + let(:json_parser) { Parsers::JsonParser.new(entity_class) } + + it "raises error when the entity class doesn't match the entity_type property" do + expect { + json_parser.parse(JSON.parse(<<-JSON +{ + "entity_type": "unknown_entity", + "entity_data": {} +} +JSON + )) + }.to raise_error DiasporaFederation::Parsers::BaseParser::InvalidRootNode, + "'unknown_entity' can't be parsed by #{entity_class}" + end + + include_examples ".parse parse error", + "Required properties are missing in JSON object: entity_type", + '{"entity_data": {}}' + + include_examples ".parse parse error", + "Required properties are missing in JSON object: entity_data", + '{"entity_type": "test_complex_entity"}' + + it "returns a hash for the correct JSON input" do + now = Time.now.change(usec: 0).utc + json = <<-JSON +{ + "entity_type": "test_complex_entity", + "entity_data": { + "test1": "abc", + "test2": false, + "test3": "def", + "test4": 123, + "test5": "#{now.iso8601}", + "test6": { + "entity_type": "test_entity", + "entity_data": { + "test": "nested" + } + }, + "multi": [ + { + "entity_type": "other_entity", + "entity_data": { + "asdf": "01" + } + }, + { + "entity_type": "other_entity", + "entity_data": { + "asdf": "02" + } + } + ] + } +} +JSON + hash = json_parser.parse(JSON.parse(json)).first + expect(hash).to be_a(Hash) + expect(hash[:test1]).to eq("abc") + expect(hash[:test2]).to eq(false) + expect(hash[:test3]).to eq("def") + expect(hash[:test4]).to eq(123) + expect(hash[:test5]).to eq(now) + expect(hash[:test6]).to be_a(Entities::TestEntity) + expect(hash[:test6].test).to eq("nested") + expect(hash[:multi]).to be_an(Array) + expect(hash[:multi][0]).to be_an(Entities::OtherEntity) + expect(hash[:multi][0].asdf).to eq("01") + expect(hash[:multi][1].asdf).to eq("02") + end + + it "doesn't drop extra properties" do + json = <<-JSON.strip +{ + "entity_type": "test_default_entity", + "entity_data": { + "test1": "abc", + "test2": false, + "test3": "def", + "test_new": "new_value" + } +} +JSON + + parsed = Parsers::JsonParser.new(Entities::TestDefaultEntity).parse(JSON.parse(json)) + expect(parsed[0]["test_new"]).to eq("new_value") + end + end + end +end diff --git a/spec/lib/diaspora_federation/parsers/relayable_json_parser_spec.rb b/spec/lib/diaspora_federation/parsers/relayable_json_parser_spec.rb new file mode 100644 index 0000000..2c75d5e --- /dev/null +++ b/spec/lib/diaspora_federation/parsers/relayable_json_parser_spec.rb @@ -0,0 +1,31 @@ +module DiasporaFederation + describe Parsers::RelayableJsonParser do + describe ".parse" do + let(:entity_class) { Entities::SomeRelayable } + let(:json_parser) { Parsers::RelayableJsonParser.new(entity_class) } + include_examples ".parse parse error", + "Required property is missing in JSON object: property_order", + '{"entity_type": "some_relayable", "entity_data": {}}' + + it "returns property order as a second argument" do + json = JSON.parse <<-JSON +{ + "entity_type": "some_relayable", + "property_order": ["property", "guid", "author"], + "entity_data": { + "author": "id@example.tld", + "guid": "im a guid", + "property": "value" + } +} +JSON + parsed_data = json_parser.parse(json) + expect(parsed_data[0]).to be_a(Hash) + expect(parsed_data[0][:guid]).to eq("im a guid") + expect(parsed_data[0][:property]).to eq("value") + expect(parsed_data[0][:author]).to eq("id@example.tld") + expect(parsed_data[1]).to eq(%w(property guid author)) + end + end + end +end diff --git a/spec/lib/diaspora_federation/parsers/relayable_xml_parser_spec.rb b/spec/lib/diaspora_federation/parsers/relayable_xml_parser_spec.rb new file mode 100644 index 0000000..e40b979 --- /dev/null +++ b/spec/lib/diaspora_federation/parsers/relayable_xml_parser_spec.rb @@ -0,0 +1,24 @@ +module DiasporaFederation + describe Parsers::RelayableXmlParser do + describe ".parse" do + let(:entity_class) { Entities::SomeRelayable } + let(:xml_parser) { Parsers::RelayableXmlParser.new(entity_class) } + it "passes order of the XML elements as a second argument in the returned list" do + xml_object = Nokogiri::XML::Document.parse(<<-XML).root + + im a guid + value + id@example.tld + +XML + + parsed_data = xml_parser.parse(xml_object) + expect(parsed_data[0]).to be_a(Hash) + expect(parsed_data[0][:guid]).to eq("im a guid") + expect(parsed_data[0][:property]).to eq("value") + expect(parsed_data[0][:author]).to eq("id@example.tld") + expect(parsed_data[1]).to eq(%i(guid property author)) + end + end + end +end diff --git a/spec/lib/diaspora_federation/parsers/xml_parser_spec.rb b/spec/lib/diaspora_federation/parsers/xml_parser_spec.rb new file mode 100644 index 0000000..010b35c --- /dev/null +++ b/spec/lib/diaspora_federation/parsers/xml_parser_spec.rb @@ -0,0 +1,169 @@ +module DiasporaFederation + describe Parsers::XmlParser do + describe ".parse" do + let(:entity_class) { Entities::TestComplexEntity } + let(:xml_parser) { Parsers::XmlParser.new(entity_class) } + + it "expects an Nokogiri::XML::Element as param" do + expect { + Entities::TestEntity.from_xml(Entities::TestEntity.new(test: "asdf").to_xml) + }.not_to raise_error + end + + it "raises an error when the entity class doesn't match the root node" do + xml = <<-XML + + asdf + +XML + + expect { + xml_parser.parse(Nokogiri::XML::Document.parse(xml).root) + }.to raise_error Parsers::BaseParser::InvalidRootNode, + "'unknown_entity' can't be parsed by DiasporaFederation::Entities::TestComplexEntity" + end + + it "raises an error when the param is not an Nokogiri::XML::Element" do + ["asdf", 1234, true, :test].each do |val| + expect { + xml_parser.parse(val) + }.to raise_error ArgumentError, "only Nokogiri::XML::Element allowed" + end + end + + it "uses xml_name for parsing" do + xml = <<-XML.strip + + asdf + qwer + +XML + + parsed = Parsers::XmlParser.new(Entities::TestEntityWithXmlName).parse(Nokogiri::XML::Document.parse(xml).root) + + expect(parsed[0][:test]).to eq("asdf") + expect(parsed[0][:qwer]).to eq("qwer") + end + + it "allows name for parsing even when property has a xml_name" do + xml = <<-XML.strip + + asdf + qwer + +XML + + parsed = Parsers::XmlParser.new(Entities::TestEntityWithXmlName).parse(Nokogiri::XML::Document.parse(xml).root) + + expect(parsed[0][:test]).to eq("asdf") + expect(parsed[0][:qwer]).to eq("qwer") + end + + it "parses the string to the correct type" do + xml = <<-XML.strip + + asdf + qwer + true + +XML + + parsed = Parsers::XmlParser.new(Entities::TestDefaultEntity).parse(Nokogiri::XML::Document.parse(xml).root) + + expect(parsed[0][:test1]).to eq("asdf") + expect(parsed[0][:test2]).to eq("qwer") + expect(parsed[0][:test3]).to eq(true) + end + + it "parses boolean fields with false value" do + xml = <<-XML.strip + + false + +XML + + parsed = Parsers::XmlParser.new(Entities::TestEntityWithBoolean).parse(Nokogiri::XML::Document.parse(xml).root) + expect(parsed[0][:test]).to eq(false) + end + + it "parses boolean fields with a randomly matching pattern as nil" do + %w(ttFFFtt yesFFDSFSDy noDFDSFFDFn fXf LLyes).each do |weird_value| + xml = <<-XML.strip + + #{weird_value} + +XML + + parsed = Parsers::XmlParser.new(Entities::TestEntityWithBoolean).parse( + Nokogiri::XML::Document.parse(xml).root + ) + expect(parsed[0][:test]).to be_nil + end + end + + it "parses integer fields with a randomly matching pattern as nil" do + %w(1,2,3 foobar two).each do |weird_value| + xml = <<-XML.strip + + #{weird_value} + +XML + + parsed = Parsers::XmlParser.new(Entities::TestEntityWithInteger).parse( + Nokogiri::XML::Document.parse(xml).root + ) + expect(parsed[0][:test]).to be_nil + end + end + + it "parses timestamp fields with a randomly matching pattern as nil" do + %w(foobar yesterday now 1.2.foo).each do |weird_value| + xml = <<-XML.strip + + #{weird_value} + +XML + + parsed = Parsers::XmlParser.new(Entities::TestEntityWithTimestamp).parse( + Nokogiri::XML::Document.parse(xml).root + ) + expect(parsed[0][:test]).to be_nil + 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 + parsed = Parsers::XmlParser.new(Entities::TestNestedEntity).parse(nested_payload) + + expect(parsed[0][:test].to_h).to eq(child_entity1.to_h) + expect(parsed[0][:multi]).to have(2).items + expect(parsed[0][:multi].first.to_h).to eq(child_entity2.to_h) + expect(parsed[0][:asdf]).to eq("QWERT") + end + end + + it "doesn't drop extra properties" do + xml = <<-XML.strip + + asdf + qwer + true + new_value + +XML + + parsed = Parsers::XmlParser.new(Entities::TestDefaultEntity).parse(Nokogiri::XML::Document.parse(xml).root) + expect(parsed[0]["test_new"]).to eq("new_value") + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index fdb096a..9f07f48 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -17,6 +17,7 @@ require File.join(File.dirname(__FILE__), "..", "test", "dummy", "config", "envi require "rspec/rails" require "webmock/rspec" +require "rspec/json_expectations" # load factory girl factories require "factories" @@ -52,6 +53,9 @@ support_files.each {|f| require f } require fixture_builder_file RSpec.configure do |config| + config.include JSON::SchemaMatchers + config.json_schemas[:entity_schema] = "lib/diaspora_federation/schemas/federation_entities.json" + config.example_status_persistence_file_path = "spec/rspec-persistance.txt" config.infer_spec_type_from_file_location! diff --git a/spec/support/shared_entity_specs.rb b/spec/support/shared_entity_specs.rb index 6dd85c4..9f5c9f5 100644 --- a/spec/support/shared_entity_specs.rb +++ b/spec/support/shared_entity_specs.rb @@ -1,3 +1,17 @@ +def entity_hash_from(hash) + hash.transform_values {|value| + if [String, TrueClass, FalseClass, Integer, NilClass].any? {|c| value.is_a? c } + value + elsif value.is_a? Time + value.iso8601 + elsif value.instance_of?(Array) + value.map(&:to_h) + else + value.to_h + end + } +end + shared_examples "an Entity subclass" do it "should be an Entity" do expect(described_class).to be < DiasporaFederation::Entity @@ -26,21 +40,7 @@ shared_examples "an Entity subclass" do describe "#to_h" do it "should return a hash with nested data" do - expected_data = data.transform_values {|value| - if [String, TrueClass, FalseClass, Integer].any? {|c| value.is_a? c } - value.to_s - elsif value.nil? - nil - elsif value.is_a? Time - value.iso8601 - elsif value.instance_of?(Array) - value.map(&:to_h) - else - value.to_h - end - } - - expect(instance.to_h).to eq(expected_data) + expect(instance.to_h).to eq(entity_hash_from(data)) end end @@ -133,3 +133,58 @@ shared_examples "a retraction" do end end end + +shared_examples "a JSON Entity" do + describe "#to_json" do + it "#to_json output matches JSON schema" do + json = described_class.new(data).to_json + expect(json.to_json).to match_json_schema(:entity_schema) + end + + let(:to_json_output) { described_class.new(data).to_json.to_json } + + it "contains described_class property matching the entity class (underscored)" do + expect(to_json_output).to include_json(entity_type: described_class.entity_name) + end + + it "contains JSON properties for each of the entity properties with the entity_data property" do + entity_data = entity_hash_from(data) + entity_data.delete(:parent) + nested_elements = entity_data.select {|_key, value| value.is_a?(Array) || value.is_a?(Hash) } + entity_data.reject! {|_key, value| value.is_a?(Array) || value.is_a?(Hash) } + + expect(to_json_output).to include_json(entity_data: entity_data) + nested_elements.each {|key, value| + type = described_class.class_props[key] + if value.is_a?(Array) + data = value.map {|element| + { + entity_type: type.first.entity_name, + entity_data: element + } + } + expect(to_json_output).to include_json(entity_data: {key => data}) + else + expect(to_json_output).to include_json( + entity_data: { + key => { + entity_type: type.entity_name, + entity_data: value + } + } + ) + end + } + end + + it "produces correct JSON" do + entity_json = JSON.pretty_generate(described_class.new(data).to_json) + expect(entity_json).to eq(json.strip) + end + end + + it ".from_json(entity_json).to_json should match entity.to_json" do + entity_json = described_class.new(data).to_json.to_json + expect(described_class.from_json(JSON.parse(entity_json)).to_json.to_json).to eq(entity_json) + end +end diff --git a/spec/support/shared_parser_specs.rb b/spec/support/shared_parser_specs.rb new file mode 100644 index 0000000..f5cb26c --- /dev/null +++ b/spec/support/shared_parser_specs.rb @@ -0,0 +1,7 @@ +shared_examples ".parse parse error" do |reason, json| + it "raises error when #{reason}" do + expect { + json_parser.parse(JSON.parse(json)) + }.to raise_error DiasporaFederation::Parsers::JsonParser::DeserializationError, reason + end +end