lib/alba/resource.rb in alba-1.5.0 vs lib/alba/resource.rb in alba-1.6.0
- old
+ new
@@ -1,17 +1,15 @@
-require_relative 'one'
-require_relative 'many'
-require_relative 'key_transform_factory'
+require_relative 'association'
require_relative 'typed_attribute'
require_relative 'deprecation'
module Alba
# This module represents what should be serialized
module Resource
# @!parse include InstanceMethods
# @!parse extend ClassMethods
- DSLS = {_attributes: {}, _key: nil, _key_for_collection: nil, _meta: nil, _transform_key_function: nil, _transforming_root_key: false, _on_error: nil, _on_nil: nil, _layout: nil}.freeze # rubocop:disable Layout/LineLength
+ DSLS = {_attributes: {}, _key: nil, _key_for_collection: nil, _meta: nil, _transform_type: :none, _transforming_root_key: false, _on_error: nil, _on_nil: nil, _layout: nil}.freeze # rubocop:disable Layout/LineLength
private_constant :DSLS
WITHIN_DEFAULT = Object.new.freeze
private_constant :WITHIN_DEFAULT
@@ -37,10 +35,11 @@
# @param within [Object, nil, false, true] determines what associations to be serialized. If not set, it serializes all associations.
def initialize(object, params: {}, within: WITHIN_DEFAULT)
@object = object
@params = params.freeze
@within = within
+ @method_existence = {} # Cache for `respond_to?` result
DSLS.each_key { |name| instance_variable_set("@#{name}", self.class.public_send(name)) }
end
# Serialize object into JSON string
#
@@ -65,37 +64,54 @@
#
# @return [Hash]
def serializable_hash
collection? ? @object.map(&converter) : converter.call(@object)
end
- alias to_hash serializable_hash
+ alias to_h serializable_hash
+ # @deprecated Use {#serializable_hash} instead
+ def to_hash
+ warn '[DEPRECATION] `to_hash` is deprecated, use `serializable_hash` instead.'
+ serializable_hash
+ end
+
private
attr_reader :serialized_json # Mainly for layout
def encode(hash)
Alba.encoder.call(hash)
end
def serialize_with(hash)
- @serialized_json = encode(hash)
- case @_layout
- when String # file
+ serialized_json = encode(hash)
+ return serialized_json unless @_layout
+
+ @serialized_json = serialized_json
+ if @_layout.is_a?(String) # file
ERB.new(File.read(@_layout)).result(binding)
- when Proc # inline
- inline = instance_eval(&@_layout)
- inline.is_a?(Hash) ? encode(inline) : inline
- else # no layout
- @serialized_json
+
+ else # inline
+ serialize_within_inline_layout
end
end
+ def serialize_within_inline_layout
+ inline = instance_eval(&@_layout)
+ case inline
+ when Hash then encode(inline)
+ when String then inline
+ else
+ raise Alba::Error, 'Inline layout must be a Proc returning a Hash or a String'
+ end
+ end
+
def hash_with_metadata(hash, meta)
- base = @_meta ? instance_eval(&@_meta) : {}
- metadata = base.merge(meta)
- hash[:meta] = metadata unless metadata.empty?
+ return hash if meta.empty? && @_meta.nil?
+
+ metadata = @_meta ? instance_eval(&@_meta).merge(meta) : meta
+ hash[:meta] = metadata
hash
end
def fetch_key
collection? ? _key_for_collection : _key
@@ -114,30 +130,39 @@
transforming_root_key? ? transform_key(resource_name) : resource_name
end
def resource_name
- self.class.name.demodulize.delete_suffix('Resource').underscore
+ @resource_name ||= self.class.name.demodulize.delete_suffix('Resource').underscore
end
def transforming_root_key?
@_transforming_root_key.nil? ? Alba.transforming_root_key : @_transforming_root_key
end
+ # rubocop:disable Metrics/MethodLength
def converter
lambda do |object|
- arrays = @_attributes.map do |key, attribute|
+ arrays = attributes.map do |key, attribute|
key_and_attribute_body_from(object, key, attribute)
rescue ::Alba::Error, FrozenError, TypeError
raise
rescue StandardError => e
handle_error(e, object, key, attribute)
end
- arrays.reject(&:empty?).to_h
+ arrays.compact!
+ arrays.to_h
end
end
+ # rubocop:enable Metrics/MethodLength
+ # This is default behavior for getting attributes for serialization
+ # Override this method to filter certain attributes
+ def attributes
+ @_attributes
+ end
+
def key_and_attribute_body_from(object, key, attribute)
key = transform_key(key)
if attribute.is_a?(Array) # Conditional
conditional_attribute(object, key, attribute)
else
@@ -156,56 +181,76 @@
end
def conditional_attribute_with_proc(object, key, attribute, condition)
arity = condition.arity
# We can return early to skip fetch_attribute
- return [] if arity <= 1 && !instance_exec(object, &condition)
+ return if arity <= 1 && !instance_exec(object, &condition)
fetched_attribute = fetch_attribute(object, key, attribute)
attr = attribute.is_a?(Alba::Association) ? attribute.object : fetched_attribute
- return [] if arity >= 2 && !instance_exec(object, attr, &condition)
+ return if arity >= 2 && !instance_exec(object, attr, &condition)
[key, fetched_attribute]
end
def conditional_attribute_with_symbol(object, key, attribute, condition)
- return [] unless __send__(condition)
+ return unless __send__(condition)
[key, fetch_attribute(object, key, attribute)]
end
def handle_error(error, object, key, attribute)
on_error = @_on_error || Alba._on_error
case on_error
when :raise, nil then raise
when :nullify then [key, nil]
- when :ignore then []
+ when :ignore then nil
when Proc then on_error.call(error, object, key, attribute, self.class)
else
raise ::Alba::Error, "Unknown on_error: #{on_error.inspect}"
end
end
- # Override this method to supply custom key transform method
+ # rubocop:disable Metrics/MethodLength
+ # @return [Symbol]
def transform_key(key)
- return key if @_transform_key_function.nil?
+ return key if @_transform_type == :none
- @_transform_key_function.call(key.to_s)
+ key = key.to_s
+ # TODO: Using default inflector here is for backward compatibility
+ # From 2.0 it'll raise error when inflector is nil
+ inflector = Alba.inflector || begin
+ require_relative 'default_inflector'
+ Alba::DefaultInflector
+ end
+ case @_transform_type # rubocop:disable Style/MissingElse
+ when :camel then inflector.camelize(key)
+ when :lower_camel then inflector.camelize_lower(key)
+ when :dash then inflector.dasherize(key)
+ when :snake then inflector.underscore(key)
+ end.to_sym
end
+ # rubocop:enable Metrics/MethodLength
def fetch_attribute(object, key, attribute)
value = case attribute
- when Symbol then object.public_send attribute
+ when Symbol then fetch_attribute_from_object_and_resource(object, attribute)
when Proc then instance_exec(object, &attribute)
- when Alba::One, Alba::Many then yield_if_within(attribute.name.to_sym) { |within| attribute.to_hash(object, params: params, within: within) }
+ when Alba::Association then yield_if_within(attribute.name.to_sym) { |within| attribute.to_h(object, params: params, within: within) }
when TypedAttribute then attribute.value(object)
else
raise ::Alba::Error, "Unsupported type of attribute: #{attribute.class}"
end
value.nil? && nil_handler ? instance_exec(object, key, attribute, &nil_handler) : value
end
+ def fetch_attribute_from_object_and_resource(object, attribute)
+ has_method = @method_existence[attribute]
+ has_method = @method_existence[attribute] = object.respond_to?(attribute) if has_method.nil?
+ has_method ? object.public_send(attribute) : __send__(attribute, object)
+ end
+
def nil_handler
@nil_handler ||= (@_on_nil || Alba._on_nil)
end
def yield_if_within(association_name)
@@ -223,12 +268,14 @@
else
raise Alba::Error, "Unknown type for within option: #{@within.class}"
end
end
+ # Detect if object is a collection or not.
+ # When object is a Struct, it's Enumerable but not a collection
def collection?
- @object.is_a?(Enumerable)
+ @object.is_a?(Enumerable) && !@object.is_a?(Struct)
end
end
# Class methods
module ClassMethods
@@ -239,11 +286,10 @@
super
DSLS.each_key { |name| subclass.instance_variable_set("@#{name}", instance_variable_get("@#{name}").clone) }
end
# Defining methods for DSLs and disable parameter number check since for users' benefits increasing params is fine
- # rubocop:disable Metrics/ParameterLists
# Set multiple attributes at once
#
# @param attrs [Array<String, Symbol>]
# @param if [Proc] condition to decide if it should serialize these attributes
@@ -287,46 +333,31 @@
raise ArgumentError, 'No block given in attribute method' unless block
@_attributes[name.to_sym] = options[:if] ? [block, options[:if]] : block
end
- # Set One association
+ # Set association
#
# @param name [String, Symbol] name of the association, used as key when `key` param doesn't exist
# @param condition [Proc, nil] a Proc to modify the association
# @param resource [Class<Alba::Resource>, String, nil] representing resource for this association
# @param key [String, Symbol, nil] used as key when given
# @param options [Hash<Symbol, Proc>]
# @option options [Proc] if a condition to decide if this association should be serialized
# @param block [Block]
# @return [void]
- # @see Alba::One#initialize
- def one(name, condition = nil, resource: nil, key: nil, **options, &block)
+ # @see Alba::Association#initialize
+ def association(name, condition = nil, resource: nil, key: nil, **options, &block)
nesting = self.name&.rpartition('::')&.first
- one = One.new(name: name, condition: condition, resource: resource, nesting: nesting, &block)
- @_attributes[key&.to_sym || name.to_sym] = options[:if] ? [one, options[:if]] : one
+ assoc = Association.new(name: name, condition: condition, resource: resource, nesting: nesting, &block)
+ @_attributes[key&.to_sym || name.to_sym] = options[:if] ? [assoc, options[:if]] : assoc
end
- alias has_one one
+ alias one association
+ alias many association
+ alias has_one association
+ alias has_many association
- # Set Many association
- #
- # @param name [String, Symbol] name of the association, used as key when `key` param doesn't exist
- # @param condition [Proc, nil] a Proc to filter the collection
- # @param resource [Class<Alba::Resource>, String, nil] representing resource for this association
- # @param key [String, Symbol, nil] used as key when given
- # @param options [Hash<Symbol, Proc>]
- # @option options [Proc] if a condition to decide if this association should be serialized
- # @param block [Block]
- # @return [void]
- # @see Alba::Many#initialize
- def many(name, condition = nil, resource: nil, key: nil, **options, &block)
- nesting = self.name&.rpartition('::')&.first
- many = Many.new(name: name, condition: condition, resource: resource, nesting: nesting, &block)
- @_attributes[key&.to_sym || name.to_sym] = options[:if] ? [many, options[:if]] : many
- end
- alias has_many many
-
# Set key
#
# @param key [String, Symbol]
# @deprecated Use {#root_key} instead
def key(key)
@@ -367,29 +398,56 @@
# Set layout
#
# @params file [String] name of the layout file
# @params inline [Proc] a proc returning JSON string or a Hash representing JSON
def layout(file: nil, inline: nil)
- @_layout = file || inline
+ @_layout = validated_file_layout(file) || validated_inline_layout(inline)
end
+ def validated_file_layout(filename)
+ case filename
+ when String, nil then filename
+ else
+ raise ArgumentError, 'File layout must be a String representing filename'
+ end
+ end
+ private :validated_file_layout
+
+ def validated_inline_layout(inline_layout)
+ case inline_layout
+ when Proc, nil then inline_layout
+ else
+ raise ArgumentError, 'Inline layout must be a Proc returning a Hash or a String'
+ end
+ end
+ private :validated_inline_layout
+
# Delete attributes
# Use this DSL in child class to ignore certain attributes
#
# @param attributes [Array<String, Symbol>]
def ignoring(*attributes)
+ Alba::Deprecation.warn '`ignoring` is deprecated now. Instead please use `attributes` instance method to filter out attributes.'
attributes.each do |attr_name|
@_attributes.delete(attr_name.to_sym)
end
end
# Transform keys as specified type
#
- # @param type [String, Symbol]
- # @param root [Boolean] decides if root key also should be transformed
+ # @param type [String, Symbol] one of `snake`, `:camel`, `:lower_camel`, `:dash` and `none`
+ # @param root [Boolean, nil] decides if root key also should be transformed
+ # When it's `nil`, Alba's default setting will be applied
+ # @raise [Alba::Error] when type is not supported
def transform_keys(type, root: nil)
- @_transform_key_function = KeyTransformFactory.create(type.to_sym)
+ type = type.to_sym
+ unless %i[none snake camel lower_camel dash].include?(type)
+ # This should be `ArgumentError` but for backward compatibility it raises `Alba::Error`
+ raise ::Alba::Error, "Unknown transform type: #{type}. Supported type are :camel, :lower_camel and :dash."
+ end
+
+ @_transform_type = type
@_transforming_root_key = root
end
# Set error handler
# If this is set it's used as a error handler overriding global one
@@ -407,10 +465,8 @@
#
# @param block [Block]
def on_nil(&block)
@_on_nil = block
end
-
- # rubocop:enable Metrics/ParameterLists
end
end
end