add Entity and PropertiesDSL

This commit is contained in:
Benjamin Neff 2015-07-06 01:01:02 +02:00
parent ec5667193b
commit e955ef8966
9 changed files with 473 additions and 2 deletions

View file

@ -39,8 +39,9 @@ group :test do
gem "codeclimate-test-reporter", require: false gem "codeclimate-test-reporter", require: false
# test helpers # test helpers
gem "fixture_builder", "~> 0.4.1" gem "fixture_builder", "~> 0.4.1"
gem "factory_girl_rails", "~> 4.5.0" gem "factory_girl_rails", "~> 4.5.0"
gem "rspec-collection_matchers", "~> 1.1.2"
end end
group :development, :test do group :development, :test do

View file

@ -167,6 +167,8 @@ GEM
rspec-core (~> 3.3.0) rspec-core (~> 3.3.0)
rspec-expectations (~> 3.3.0) rspec-expectations (~> 3.3.0)
rspec-mocks (~> 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-core (3.3.1)
rspec-support (~> 3.3.0) rspec-support (~> 3.3.0)
rspec-expectations (3.3.0) rspec-expectations (3.3.0)
@ -238,6 +240,7 @@ DEPENDENCIES
pry pry
pry-byebug pry-byebug
pry-debundle pry-debundle
rspec-collection_matchers (~> 1.1.2)
rspec-rails (~> 3.3.2) rspec-rails (~> 3.3.2)
rubocop (= 0.32.1) rubocop (= 0.32.1)
simplecov (= 0.10.0) simplecov (= 0.10.0)

View file

@ -1,6 +1,9 @@
require "diaspora_federation/logging" require "diaspora_federation/logging"
require "diaspora_federation/callbacks" require "diaspora_federation/callbacks"
require "diaspora_federation/properties_dsl"
require "diaspora_federation/entity"
require "diaspora_federation/web_finger" require "diaspora_federation/web_finger"
# diaspora* federation library # diaspora* federation library

View file

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

View file

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

22
spec/entities.rb Normal file
View file

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

View file

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

View file

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

View file

@ -22,6 +22,9 @@ require "rspec/rails"
# load factory girl factories # load factory girl factories
require "factories" require "factories"
# load test entities
require "entities"
# some helper methods # some helper methods
def alice def alice