diff --git a/Gemfile b/Gemfile index 90bd930..862f93f 100644 --- a/Gemfile +++ b/Gemfile @@ -39,8 +39,9 @@ group :test do gem "codeclimate-test-reporter", require: false # test helpers - gem "fixture_builder", "~> 0.4.1" - gem "factory_girl_rails", "~> 4.5.0" + gem "fixture_builder", "~> 0.4.1" + gem "factory_girl_rails", "~> 4.5.0" + gem "rspec-collection_matchers", "~> 1.1.2" end group :development, :test do diff --git a/Gemfile.lock b/Gemfile.lock index fc73be7..8edf793 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -167,6 +167,8 @@ GEM rspec-core (~> 3.3.0) rspec-expectations (~> 3.3.0) rspec-mocks (~> 3.3.0) + rspec-collection_matchers (1.1.2) + rspec-expectations (>= 2.99.0.beta1) rspec-core (3.3.1) rspec-support (~> 3.3.0) rspec-expectations (3.3.0) @@ -238,6 +240,7 @@ DEPENDENCIES pry pry-byebug pry-debundle + rspec-collection_matchers (~> 1.1.2) rspec-rails (~> 3.3.2) rubocop (= 0.32.1) simplecov (= 0.10.0) diff --git a/lib/diaspora_federation.rb b/lib/diaspora_federation.rb index 3f1229b..1e4c546 100644 --- a/lib/diaspora_federation.rb +++ b/lib/diaspora_federation.rb @@ -1,6 +1,9 @@ require "diaspora_federation/logging" require "diaspora_federation/callbacks" +require "diaspora_federation/properties_dsl" +require "diaspora_federation/entity" + require "diaspora_federation/web_finger" # diaspora* federation library diff --git a/lib/diaspora_federation/entity.rb b/lib/diaspora_federation/entity.rb new file mode 100644 index 0000000..fa3793f --- /dev/null +++ b/lib/diaspora_federation/entity.rb @@ -0,0 +1,146 @@ +module DiasporaFederation + # +Entity+ is the base class for all other objects used to encapsulate data + # for federation messages in the Diaspora* network. + # Entity fields are specified using a simple {PropertiesDSL DSL} as part of + # the class definition. + # + # Any entity also provides the means to serialize itself and all nested + # entities to XML (for deserialization from XML to +Entity+ instances, see + # {XmlPayload}). + # + # @abstract Subclass and specify properties to implement various entities. + # + # @example Entity subclass definition + # class MyEntity < Entity + # property :prop + # property :optional, default: false + # property :dynamic_default, default: -> { Time.now } + # entity :nested, NestedEntity + # entity :multiple, [OtherEntity] + # end + # + # @example Entity instantiation + # nentity = NestedEntity.new + # oe1 = OtherEntity.new + # oe2 = OtherEntity.new + # + # entity = MyEntity.new(prop: 'some property', + # nested: nentity, + # multiple: [oe1, oe2]) + # + # @note Entity properties can only be set during initialization, after that the + # entity instance becomes frozen and must not be modified anymore. Instances + # are intended to be immutable data containers, only. + class Entity + extend PropertiesDSL + + # Initializes the Entity with the given attribute hash and freezes the created + # instance it returns. + # + # @note Attributes not defined as part of the class definition ({PropertiesDSL#property}, + # {PropertiesDSL#entity}) get discarded silently. + # + # @param [Hash] data + # @return [Entity] new instance + def initialize(data) + raise ArgumentError, "expected a Hash" unless data.is_a?(Hash) + missing_props = self.class.missing_props(data) + unless missing_props.empty? + raise ArgumentError, "missing required properties: #{missing_props.join(', ')}" + end + + self.class.default_values.merge(data).each do |k, v| + instance_variable_set("@#{k}", v) if setable?(k, v) + end + freeze + end + + # Returns a Hash representing this Entity (attributes => values) + # @return [Hash] entity data (mostly equal to the hash used for initialization). + def to_h + self.class.class_prop_names.each_with_object({}) do |prop, hash| + hash[prop] = send(prop) + end + end + + # Returns the XML representation for this entity constructed out of + # {http://www.rubydoc.info/gems/nokogiri/Nokogiri/XML/Element Nokogiri::XML::Element}s + # + # @see Nokogiri::XML::Node.to_xml + # @see XmlPayload.pack + # + # @return [Nokogiri::XML::Element] root element containing properties as child elements + def to_xml + entity_xml + end + + # some of this is from Rails "Inflector.demodulize" and "Inflector.undersore" + def self.entity_name + word = name.dup + i = word.rindex("::") + word = word[(i + 2)..-1] if i + + word.gsub!("::", "/") + word.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2') + word.gsub!(/([a-z\d])([A-Z])/, '\1_\2') + word.tr!("-", "_") + word.downcase! + word + end + + private + + def setable?(name, val) + prop_def = self.class.class_props.find {|p| p[:name] == name } + return false if prop_def.nil? # property undefined + + setable_string?(prop_def, val) || setable_nested?(prop_def, val) || setable_multi?(prop_def, val) + end + + def setable_string?(definition, val) + (definition[:type] == String && val.respond_to?(:to_s)) + end + + def setable_nested?(definition, val) + t = definition[:type] + (t.is_a?(Class) && t.ancestors.include?(Entity) && val.is_a?(Entity)) + end + + def setable_multi?(definition, val) + t = definition[:type] + (t.instance_of?(Array) && + val.instance_of?(Array) && + val.all? {|v| v.instance_of?(t.first) }) + end + + # Serialize the Entity into XML elements + # @return [Nokogiri::XML::Element] root node + def entity_xml + doc = Nokogiri::XML::DocumentFragment.new(Nokogiri::XML::Document.new) + root_element = Nokogiri::XML::Element.new(self.class.entity_name, doc) + + self.class.class_props.each do |prop_def| + name = prop_def[:name] + type = prop_def[:type] + if type == String + root_element << simple_node(doc, name) + else + # call #to_xml for each item and append to root + [*send(name)].compact.each do |item| + root_element << item.to_xml + end + end + end + + root_element + end + + # create simple node, fill it with text and append to root + def simple_node(doc, name) + node = Nokogiri::XML::Element.new(name.to_s, doc) + data = send(name).to_s + node.content = data unless data.empty? + node + end + end +end diff --git a/lib/diaspora_federation/properties_dsl.rb b/lib/diaspora_federation/properties_dsl.rb new file mode 100644 index 0000000..16e84b7 --- /dev/null +++ b/lib/diaspora_federation/properties_dsl.rb @@ -0,0 +1,87 @@ +module DiasporaFederation + # Provides a simple DSL for specifying {Entity} properties during class + # definition. + module PropertiesDSL + # @return [Hash] hash of declared entity properties + def class_props + @class_props ||= [] + end + + # Define a generic (string-type) property + # @param [Symbol] name property name + # @param [Hash] opts further options + # @option opts [Object, #call] :default a default value, making the + # property optional + def property(name, opts={}) + define_property name, String, opts + end + + # Define a property that should contain another Entity or an array of + # other Entities + # @param [Symbol] name property name + # @param [Entity, Array] type Entity subclass or + # Array with exactly one Entity subclass constant inside + # @param [Hash] opts further options + # @option opts [Object, #call] :default a default value, making the + # property optional + def entity(name, type, opts={}) + raise InvalidType unless type_valid?(type) + + define_property name, type, opts + end + + # Return array of missing required property names + def missing_props(args) + class_prop_names - default_props.keys - args.keys + end + + # Return a new hash of default values, with dynamic values + # resolved on each call + def default_values + default_props.each_with_object({}) { |(name, prop), hash| + hash[name] = prop.respond_to?(:call) ? prop.call : prop + } + end + + def nested_class_props + @nested_class_props ||= class_props.select {|p| p[:type] != String } + end + + def class_prop_names + @class_prop_names ||= class_props.map {|p| p[:name] } + end + + private + + def define_property(name, type, opts={}) + raise InvalidName unless name_valid?(name) + + class_props << {name: name, type: type} + default_props[name] = opts[:default] if opts.has_key? :default + + instance_eval { attr_reader name } + end + + def name_valid?(name) + name.instance_of?(Symbol) || name.instance_of?(String) + end + + def type_valid?(type) + [type].flatten.all? { |type| + type.respond_to?(:ancestors) && type.ancestors.include?(Entity) + } + end + + def default_props + @default_props ||= {} + end + + # Raised, if the name is of an unexpected type + class InvalidName < RuntimeError + end + + # Raised, if the type is of an unexpected type + class InvalidType < RuntimeError + end + end +end diff --git a/spec/entities.rb b/spec/entities.rb new file mode 100644 index 0000000..486a8a6 --- /dev/null +++ b/spec/entities.rb @@ -0,0 +1,22 @@ +module Entities + class TestEntity < DiasporaFederation::Entity + property :test + end + + class TestDefaultEntity < DiasporaFederation::Entity + property :test1 + property :test2 + property :test3, default: true + property :test4, default: -> { true } + end + + class OtherEntity < DiasporaFederation::Entity + property :asdf + end + + class TestNestedEntity < DiasporaFederation::Entity + property :asdf + entity :test, TestEntity + entity :multi, [OtherEntity] + end +end diff --git a/spec/lib/diaspora_federation/entity_spec.rb b/spec/lib/diaspora_federation/entity_spec.rb new file mode 100644 index 0000000..10192e5 --- /dev/null +++ b/spec/lib/diaspora_federation/entity_spec.rb @@ -0,0 +1,96 @@ +module DiasporaFederation + describe Entity do + let(:data) { {test1: "asdf", test2: 1234, test3: false, test4: false} } + + it "should extend Entity" do + expect(Entities::TestDefaultEntity).to be < Entity + end + + context "creation" do + it "freezes the instance after initialization" do + entity = Entities::TestDefaultEntity.new(data) + expect(entity).to be_frozen + end + + it "checks for required properties" do + expect { + Entities::TestDefaultEntity.new({}) + }.to raise_error ArgumentError, "missing required properties: test1, test2" + end + + it "sets the defaults" do + entity = Entities::TestDefaultEntity.new(test1: 1, test2: 2) + expect(entity.to_h[:test3]).to be_truthy + end + + it "handles callable defaults" do + entity = Entities::TestDefaultEntity.new(test1: 1, test2: 2) + expect(entity.to_h[:test4]).to be_truthy + end + + it "uses provided values over defaults" do + entity = Entities::TestDefaultEntity.new(data) + expect(entity.to_h[:test3]).to be_falsey + end + end + + 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) + end + end + + describe "#to_xml" do + it "returns an Nokogiri::XML::Element" do + entity = Entities::TestDefaultEntity.new(data) + expect(entity.to_xml).to be_an_instance_of Nokogiri::XML::Element + end + + it "has the root node named after the class (underscored)" do + entity = Entities::TestDefaultEntity.new(data) + expect(entity.to_xml.name).to eq("test_default_entity") + end + + it "contains nodes for each of the properties" do + entity = Entities::TestDefaultEntity.new(data) + entity.to_xml.children.each do |node| + expect(%w(test1 test2 test3 test4)).to include(node.name) + end + end + end + + describe ".entity_name" do + it "strips the module and returns the name underscored" do + expect(Entities::TestDefaultEntity.entity_name).to eq("test_default_entity") + expect(Entities::TestNestedEntity.entity_name).to eq("test_nested_entity") + expect(Entities::OtherEntity.entity_name).to eq("other_entity") + end + end + + context "nested entities" do + let(:nested_data) { + { + asdf: "FDSA", + test: Entities::TestEntity.new(test: "test"), + multi: [Entities::OtherEntity.new(asdf: "asdf"), Entities::OtherEntity.new(asdf: "asdf")] + } + } + + it "gets returned by #to_h" do + entity = Entities::TestNestedEntity.new(nested_data) + expect(entity.to_h).to eq(nested_data) + end + + it "gets xml-ified by #to_xml" do + entity = Entities::TestNestedEntity.new(nested_data) + xml = entity.to_xml + xml.children.each do |node| + expect(%w(asdf test_entity other_entity)).to include(node.name) + end + expect(xml.xpath("test_entity")).to have_exactly(1).items + expect(xml.xpath("other_entity")).to have_exactly(2).items + end + end + end +end diff --git a/spec/lib/diaspora_federation/properties_dsl_spec.rb b/spec/lib/diaspora_federation/properties_dsl_spec.rb new file mode 100644 index 0000000..8a225f2 --- /dev/null +++ b/spec/lib/diaspora_federation/properties_dsl_spec.rb @@ -0,0 +1,110 @@ +module DiasporaFederation + describe PropertiesDSL do + subject(:dsl) { Class.new.extend(PropertiesDSL) } + + context "simple properties" do + it "can name simple properties by symbol" do + dsl.property :test + properties = dsl.class_props + expect(properties).to have(1).item + expect(properties.first[:name]).to eq(:test) + expect(properties.first[:type]).to eq(String) + end + + it "can name simple properties by string" do + dsl.property "test" + properties = dsl.class_props + expect(properties).to have(1).item + expect(properties.first[:name]).to eq("test") + expect(properties.first[:type]).to eq(String) + end + + it "will not accept other types for names" do + [1234, true, {}].each do |val| + expect { + dsl.property val + }.to raise_error PropertiesDSL::InvalidName + end + end + + it "can define multiple properties" do + dsl.property :test + dsl.property :asdf + dsl.property :zzzz + properties = dsl.class_props + expect(properties).to have(3).items + expect(properties.map {|e| e[:name] }).to include(:test, :asdf, :zzzz) + properties.each {|e| expect(e[:type]).to eq(String) } + end + end + + context "nested entities" do + it "gets included in the properties" do + expect(Entities::TestNestedEntity.class_prop_names).to include(:test, :multi) + end + + it "can define nested entities" do + dsl.entity :other, Entities::TestEntity + properties = dsl.class_props + expect(properties).to have(1).item + expect(properties.first[:name]).to eq(:other) + expect(properties.first[:type]).to eq(Entities::TestEntity) + end + + it "can define an array of a nested entity" do + dsl.entity :other, [Entities::TestEntity] + properties = dsl.class_props + expect(properties).to have(1).item + expect(properties.first[:name]).to eq(:other) + expect(properties.first[:type]).to be_an_instance_of(Array) + expect(properties.first[:type].first).to eq(Entities::TestEntity) + end + + it "must be an entity subclass" do + [1234, true, {}].each do |val| + expect { + dsl.entity :fail, val + }.to raise_error PropertiesDSL::InvalidType + end + end + + it "must be an entity subclass for array" do + [1234, true, {}].each do |val| + expect { + dsl.entity :fail, [val] + }.to raise_error PropertiesDSL::InvalidType + end + end + end + + describe ".default_values" do + it "can accept default values" do + dsl.property :test, default: :foobar + defaults = dsl.default_values + expect(defaults[:test]).to eq(:foobar) + end + + it "can accept default blocks" do + dsl.property :test, default: -> { "default" } + defaults = dsl.default_values + expect(defaults[:test]).to eq("default") + end + end + + describe ".nested_class_props" do + it "returns the definition of nested class properties in an array" do + n_props = Entities::TestNestedEntity.nested_class_props + expect(n_props).to be_an_instance_of(Array) + expect(n_props.map {|p| p[:name] }).to include(:test, :multi) + expect(n_props.map {|p| p[:type] }).to include(Entities::TestEntity, [Entities::OtherEntity]) + end + end + + describe ".class_prop_names" do + it "returns the names of all class props in an array" do + expect(Entities::TestDefaultEntity.class_prop_names).to be_an_instance_of(Array) + expect(Entities::TestDefaultEntity.class_prop_names).to include(:test1, :test2, :test3, :test4) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 2e97a0a..825cb21 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -22,6 +22,9 @@ require "rspec/rails" # load factory girl factories require "factories" +# load test entities +require "entities" + # some helper methods def alice