add Entity and PropertiesDSL
This commit is contained in:
parent
ec5667193b
commit
e955ef8966
9 changed files with 473 additions and 2 deletions
5
Gemfile
5
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
146
lib/diaspora_federation/entity.rb
Normal file
146
lib/diaspora_federation/entity.rb
Normal 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
|
||||
87
lib/diaspora_federation/properties_dsl.rb
Normal file
87
lib/diaspora_federation/properties_dsl.rb
Normal 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
22
spec/entities.rb
Normal 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
|
||||
96
spec/lib/diaspora_federation/entity_spec.rb
Normal file
96
spec/lib/diaspora_federation/entity_spec.rb
Normal 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
|
||||
110
spec/lib/diaspora_federation/properties_dsl_spec.rb
Normal file
110
spec/lib/diaspora_federation/properties_dsl_spec.rb
Normal 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
|
||||
|
|
@ -22,6 +22,9 @@ require "rspec/rails"
|
|||
# load factory girl factories
|
||||
require "factories"
|
||||
|
||||
# load test entities
|
||||
require "entities"
|
||||
|
||||
# some helper methods
|
||||
|
||||
def alice
|
||||
|
|
|
|||
Loading…
Reference in a new issue