Sometimes messages contain characters that are invalid for XML, but they are filteres out before creating the XML, otherwise the property would be empty in the XML. But for relayables the value is also used for creating the signatures, so we need to filter the invalid characters earlier, before calculating the signature.
354 lines
12 KiB
Ruby
354 lines
12 KiB
Ruby
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
|
|
# {Salmon::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 }
|
|
# property :another_prop, xml_name: :another_name
|
|
# 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
|
|
include Logging
|
|
|
|
# Invalid XML characters
|
|
# @see https://www.w3.org/TR/REC-xml/#charsets "Extensible Markup Language (XML) 1.0"
|
|
INVALID_XML_REGEX = /[^\x09\x0A\x0D\x20-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/
|
|
|
|
# Regex to validate and find entity names
|
|
ENTITY_NAME_REGEX = "[a-z]*(?:_[a-z]*)*".freeze
|
|
|
|
# Initializes the Entity with the given attribute hash and freezes the created
|
|
# instance it returns.
|
|
#
|
|
# After creation, the entity is validated against a Validator, if one is defined.
|
|
# The Validator needs to be in the {DiasporaFederation::Validators} namespace and
|
|
# named like "<EntityName>Validator". Only valid entities can be created.
|
|
#
|
|
# @see DiasporaFederation::Validators
|
|
#
|
|
# @note Attributes not defined as part of the class definition ({PropertiesDSL#property},
|
|
# {PropertiesDSL#entity}) get discarded silently.
|
|
#
|
|
# @param [Hash] data entity data
|
|
# @return [Entity] new instance
|
|
def initialize(data)
|
|
logger.debug "create entity #{self.class} with data: #{data}"
|
|
raise ArgumentError, "expected a Hash" unless data.is_a?(Hash)
|
|
|
|
entity_data = self.class.resolv_aliases(data)
|
|
validate_missing_props(entity_data)
|
|
|
|
self.class.default_values.merge(entity_data).each do |name, value|
|
|
instance_variable_set("@#{name}", instantiate_nested(name, nilify(value))) if setable?(name, value)
|
|
end
|
|
|
|
freeze
|
|
validate
|
|
end
|
|
|
|
# Returns a Hash representing this Entity (attributes => values).
|
|
# Nested entities are also converted to a Hash.
|
|
# @return [Hash] entity data (mostly equal to the hash used for initialization).
|
|
def to_h
|
|
enriched_properties.map {|key, value|
|
|
type = self.class.class_props[key]
|
|
|
|
if type.instance_of?(Symbol) || value.nil?
|
|
[key, value]
|
|
elsif type.instance_of?(Class)
|
|
[key, value.to_h]
|
|
elsif type.instance_of?(Array)
|
|
[key, value.map(&:to_h)]
|
|
end
|
|
}.to_h
|
|
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
|
|
#
|
|
# @return [Nokogiri::XML::Element] root element containing properties as child elements
|
|
def to_xml
|
|
doc = Nokogiri::XML::DocumentFragment.new(Nokogiri::XML::Document.new)
|
|
Nokogiri::XML::Element.new(self.class.entity_name, doc).tap do |root_element|
|
|
xml_elements.each do |name, value|
|
|
add_property_to_xml(doc, root_element, name, value)
|
|
end
|
|
end
|
|
end
|
|
|
|
# Construct a new instance of the given Entity and populate the properties
|
|
# with the attributes found in the XML.
|
|
# Works recursively on nested Entities and Arrays thereof.
|
|
#
|
|
# @param [Nokogiri::XML::Element] root_node xml nodes
|
|
# @return [Entity] instance
|
|
def self.from_xml(root_node)
|
|
from_hash(*xml_parser_class.new(self).parse(root_node))
|
|
end
|
|
|
|
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
|
|
#
|
|
# @see .entity_class
|
|
#
|
|
# @return [String] entity name
|
|
def self.entity_name
|
|
class_name.tap do |word|
|
|
word.gsub!(/(.)([A-Z])/, '\1_\2')
|
|
word.downcase!
|
|
end
|
|
end
|
|
|
|
# Transform the given String from the lowercase underscored version to a
|
|
# camelized variant and returns the Class constant.
|
|
#
|
|
# @see .entity_name
|
|
#
|
|
# @param [String] entity_name "snake_case" class name
|
|
# @return [Class] entity class
|
|
def self.entity_class(entity_name)
|
|
raise InvalidEntityName, "'#{entity_name}' is invalid" unless entity_name =~ /\A#{ENTITY_NAME_REGEX}\z/
|
|
class_name = entity_name.sub(/\A[a-z]/, &:upcase)
|
|
class_name.gsub!(/_([a-z])/) { Regexp.last_match[1].upcase }
|
|
|
|
raise UnknownEntity, "'#{class_name}' not found" unless Entities.const_defined?(class_name)
|
|
|
|
Entities.const_get(class_name)
|
|
end
|
|
|
|
# @return [String] class name as string
|
|
def self.class_name
|
|
name.rpartition("::").last
|
|
end
|
|
|
|
# @return [String] string representation of this object
|
|
def to_s
|
|
"#{self.class.class_name}#{":#{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:<br>
|
|
# 1) Properties of the hash are representation of the entity's class properties<br>
|
|
# 2) Keys of the hash must be of Symbol type<br>
|
|
# 3) Possible values of the hash properties depend on the types of the entity's class properties<br>
|
|
# 4) Basic properties, such as booleans, strings, integers and timestamps are represented by values of respective
|
|
# formats<br>
|
|
# 5) Nested hashes and arrays of hashes are allowed to represent nested entities. Nested hashes follow the same
|
|
# format as the parent hash.<br>
|
|
# 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)
|
|
missing_props = self.class.missing_props(entity_data)
|
|
return if missing_props.empty?
|
|
|
|
obj_str = "#{self.class.class_name}#{":#{entity_data[:guid]}" if entity_data.has_key?(:guid)}" \
|
|
"#{" from #{entity_data[:author]}" if entity_data.has_key?(:author)}"
|
|
raise ValidationError, "#{obj_str}: Missing required properties: #{missing_props.join(', ')}"
|
|
end
|
|
|
|
def setable?(name, val)
|
|
type = self.class.class_props[name]
|
|
return false if type.nil? # property undefined
|
|
|
|
setable_property?(type, val) || setable_nested?(type, val) || setable_multi?(type, val)
|
|
end
|
|
|
|
def setable_property?(type, val)
|
|
setable_string?(type, val) || type == :timestamp && val.is_a?(Time)
|
|
end
|
|
|
|
def setable_string?(type, val)
|
|
%i[string integer boolean].include?(type) && val.respond_to?(:to_s)
|
|
end
|
|
|
|
def setable_nested?(type, val)
|
|
type.instance_of?(Class) && type.ancestors.include?(Entity) && (val.is_a?(Entity) || val.is_a?(Hash))
|
|
end
|
|
|
|
def setable_multi?(type, val)
|
|
type.instance_of?(Array) && val.instance_of?(Array) &&
|
|
(val.all? {|v| v.instance_of?(type.first) } || val.all? {|v| v.instance_of?(Hash) })
|
|
end
|
|
|
|
def nilify(value)
|
|
return nil if value.respond_to?(:empty?) && value.empty? && !value.instance_of?(Array)
|
|
value
|
|
end
|
|
|
|
def instantiate_nested(name, value)
|
|
if value.instance_of?(Array)
|
|
return value unless value.first.instance_of?(Hash)
|
|
value.map {|hash| self.class.class_props[name].first.new(hash) }
|
|
elsif value.instance_of?(Hash)
|
|
self.class.class_props[name].new(value)
|
|
else
|
|
value
|
|
end
|
|
end
|
|
|
|
def validate
|
|
validator_name = "#{self.class.name.split('::').last}Validator"
|
|
return unless Validators.const_defined? validator_name
|
|
|
|
validator_class = Validators.const_get validator_name
|
|
validator = validator_class.new self
|
|
raise ValidationError, error_message(validator) unless validator.valid?
|
|
end
|
|
|
|
def error_message(validator)
|
|
errors = validator.errors.map do |prop, rule|
|
|
"property: #{prop}, value: #{public_send(prop).inspect}, rule: #{rule[:rule]}, with params: #{rule[:params]}"
|
|
end
|
|
"Failed validation for #{self}#{" from #{author}" if respond_to?(:author)} for properties: #{errors.join(' | ')}"
|
|
end
|
|
|
|
# @return [Hash] hash with all properties
|
|
def properties
|
|
self.class.class_props.keys.each_with_object({}) do |prop, hash|
|
|
hash[prop] = public_send(prop)
|
|
end
|
|
end
|
|
|
|
def normalized_properties
|
|
properties.map {|name, value| [name, normalize_property(name, value)] }.to_h
|
|
end
|
|
|
|
def normalize_property(name, value)
|
|
return nil if optional_nil_value?(name, value)
|
|
|
|
case self.class.class_props[name]
|
|
when :string
|
|
value.to_s.gsub(INVALID_XML_REGEX, "\uFFFD")
|
|
when :timestamp
|
|
value.nil? ? "" : value.utc.iso8601
|
|
else
|
|
value
|
|
end
|
|
end
|
|
|
|
# default: nothing to enrich
|
|
def enriched_properties
|
|
normalized_properties
|
|
end
|
|
|
|
# default: no special order
|
|
def xml_elements
|
|
enriched_properties
|
|
end
|
|
|
|
def add_property_to_xml(doc, root_element, 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|
|
|
child = item.to_xml
|
|
root_element << child if child
|
|
end
|
|
end
|
|
end
|
|
|
|
# Create simple node, fill it with text and append to root
|
|
def simple_node(doc, name, value)
|
|
Nokogiri::XML::Element.new(name.to_s, doc).tap do |node|
|
|
node.content = value unless value.empty?
|
|
end
|
|
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]
|
|
next if optional_nil_value?(key, value)
|
|
|
|
if !value.nil? && type.instance_of?(Class)
|
|
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
|
|
}.compact.to_h
|
|
end
|
|
|
|
def optional_nil_value?(name, value)
|
|
value.nil? && self.class.optional_props.include?(name)
|
|
end
|
|
|
|
# Raised, if entity is not valid
|
|
class ValidationError < RuntimeError
|
|
end
|
|
|
|
# Raised, if the entity name in the XML is invalid
|
|
class InvalidEntityName < RuntimeError
|
|
end
|
|
|
|
# Raised, if the entity contained within the XML cannot be mapped to a
|
|
# defined {Entity} subclass.
|
|
class UnknownEntity < RuntimeError
|
|
end
|
|
end
|
|
end
|