lib/alba/resource.rb in alba-1.6.0 vs lib/alba/resource.rb in alba-2.0.0
- old
+ new
@@ -1,15 +1,18 @@
require_relative 'association'
+require_relative 'conditional_attribute'
require_relative 'typed_attribute'
+require_relative 'nested_attribute'
require_relative 'deprecation'
+require_relative 'layout'
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_type: :none, _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, _collection_key: nil}.freeze # rubocop:disable Layout/LineLength
private_constant :DSLS
WITHIN_DEFAULT = Object.new.freeze
private_constant :WITHIN_DEFAULT
@@ -33,226 +36,239 @@
# @param object [Object] the object to be serialized
# @param params [Hash] user-given Hash for arbitrary data
# @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
+ @params = params
@within = within
@method_existence = {} # Cache for `respond_to?` result
- DSLS.each_key { |name| instance_variable_set("@#{name}", self.class.public_send(name)) }
+ DSLS.each_key { |name| instance_variable_set("@#{name}", self.class.__send__(name)) }
end
# Serialize object into JSON string
#
- # @param key [Symbol, nil, true] DEPRECATED, use root_key instead
# @param root_key [Symbol, nil, true]
# @param meta [Hash] metadata for this seialization
# @return [String] serialized JSON string
- def serialize(key: nil, root_key: nil, meta: {})
- Alba::Deprecation.warn '`key` option to `serialize` method is deprecated, use `root_key` instead.' if key
- key = key.nil? && root_key.nil? ? fetch_key : root_key || key
- hash = if key && key != ''
- h = {key.to_s => serializable_hash}
- hash_with_metadata(h, meta)
- else
- serializable_hash
- end
- serialize_with(hash)
+ def serialize(root_key: nil, meta: {})
+ serialize_with(as_json(root_key: root_key, meta: meta))
end
- alias to_json serialize
+ if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('3.0')
+ # For Rails compatibility
+ # The first options is a dummy parameter but required
+ # You can pass empty Hash if you don't want to pass any arguments
+ #
+ # @see #serialize
+ # @see https://github.com/rails/rails/blob/7-0-stable/actionpack/lib/action_controller/metal/renderers.rb#L156
+ def to_json(options, root_key: nil, meta: {})
+ _to_json(root_key, meta, options)
+ end
+ else
+ # For Rails compatibility
+ # The first options is a dummy parameter
+ #
+ # @see #serialize
+ # @see https://github.com/rails/rails/blob/7-0-stable/actionpack/lib/action_controller/metal/renderers.rb#L156
+ def to_json(options = {}, root_key: nil, meta: {})
+ _to_json(root_key, meta, options)
+ end
+ end
+
+ # Returns a Hash correspondng {Resource#serialize}
+ #
+ # @param root_key [Symbol, nil, true]
+ # @param meta [Hash] metadata for this seialization
+ # @param symbolize_root_key [Boolean] determines if root key should be symbolized
+ # @return [Hash]
+ def as_json(root_key: nil, meta: {})
+ key = root_key.nil? ? fetch_key : root_key.to_s
+ if key && !key.empty?
+ h = {key => serializable_hash}
+ hash_with_metadata(h, meta)
+ else
+ serializable_hash
+ end
+ end
+
# A Hash for serialization
#
# @return [Hash]
def serializable_hash
- collection? ? @object.map(&converter) : converter.call(@object)
+ collection? ? serializable_hash_for_collection : converter.call(@object)
end
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 _to_json(root_key, meta, options)
+ options.reject! { |k, _| %i[layout prefixes template status].include?(k) } # Rails specific guard
+ # TODO: use `filter_map` after dropping support of Ruby 2.6
+ names = options.map { |k, v| k unless v.nil? }
+ names.compact!
+ unless names.empty?
+ names.sort!
+ names.map! { |s| "\"#{s}\"" }
+ message = "You passed #{names.join(', ')} options but ignored. Please refer to the document: https://github.com/okuramasafumi/alba/blob/main/docs/rails.md"
+ Kernel.warn(message)
+ end
+ serialize(root_key: root_key, meta: meta)
+ end
+
def serialize_with(hash)
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)
-
- else # inline
- serialize_within_inline_layout
- end
+ @_layout.serialize(resource: self, serialized_json: serialized_json, binding: binding)
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)
return hash if meta.empty? && @_meta.nil?
metadata = @_meta ? instance_eval(&@_meta).merge(meta) : meta
hash[:meta] = metadata
hash
end
+ def serializable_hash_for_collection
+ if @_collection_key
+ @object.to_h { |item| [item.public_send(@_collection_key).to_s, converter.call(item)] }
+ else
+ @object.each_with_object([], &collection_converter)
+ end
+ end
+
+ # @return [String]
def fetch_key
- collection? ? _key_for_collection : _key
+ k = collection? ? _key_for_collection : _key
+ transforming_root_key? ? transform_key(k) : k
end
def _key_for_collection
- return @_key_for_collection.to_s unless @_key_for_collection == true && Alba.inferring
-
- key = resource_name.pluralize
- transforming_root_key? ? transform_key(key) : key
+ if Alba.inferring
+ @_key_for_collection == true ? resource_name(pluralized: true) : @_key_for_collection.to_s
+ else
+ @_key_for_collection == true ? raise_root_key_inference_error : @_key_for_collection.to_s
+ end
end
# @return [String]
def _key
- return @_key.to_s unless @_key == true && Alba.inferring
+ if Alba.inferring
+ @_key == true ? resource_name(pluralized: false) : @_key.to_s
+ else
+ @_key == true ? raise_root_key_inference_error : @_key.to_s
+ end
+ end
- transforming_root_key? ? transform_key(resource_name) : resource_name
+ def resource_name(pluralized: false)
+ class_name = self.class.name
+ inflector = Alba.inflector
+ name = inflector.demodulize(class_name).delete_suffix('Resource')
+ underscore_name = inflector.underscore(name)
+ pluralized ? inflector.pluralize(underscore_name) : underscore_name
end
- def resource_name
- @resource_name ||= self.class.name.demodulize.delete_suffix('Resource').underscore
+ def raise_root_key_inference_error
+ raise Alba::Error, 'You must call Alba.enable_inference! to set root_key to true for inferring root key.'
end
def transforming_root_key?
- @_transforming_root_key.nil? ? Alba.transforming_root_key : @_transforming_root_key
+ @_transforming_root_key
end
- # rubocop:disable Metrics/MethodLength
def converter
lambda do |object|
- 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.compact!
- arrays.to_h
+ attributes_to_hash(object, {})
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
- fetched_attribute = fetch_attribute(object, key, attribute)
- [key, fetched_attribute]
+ def collection_converter
+ lambda do |object, a|
+ a << {}
+ h = a.last
+ attributes_to_hash(object, h)
+ a
end
end
- def conditional_attribute(object, key, attribute)
- condition = attribute.last
- if condition.is_a?(Proc)
- conditional_attribute_with_proc(object, key, attribute.first, condition)
- else
- conditional_attribute_with_symbol(object, key, attribute.first, condition)
+ def attributes_to_hash(object, hash)
+ attributes.each do |key, attribute|
+ set_key_and_attribute_body_from(object, key, attribute, hash)
+ rescue ::Alba::Error, FrozenError, TypeError
+ raise
+ rescue StandardError => e
+ handle_error(e, object, key, attribute, hash)
end
+ hash
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)
-
- 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)
-
- [key, fetched_attribute]
+ # This is default behavior for getting attributes for serialization
+ # Override this method to filter certain attributes
+ def attributes
+ @_attributes
end
- def conditional_attribute_with_symbol(object, key, attribute, condition)
- return unless __send__(condition)
-
- [key, fetch_attribute(object, key, attribute)]
+ def set_key_and_attribute_body_from(object, key, attribute, hash)
+ key = transform_key(key)
+ value = fetch_attribute(object, key, attribute)
+ hash[key] = value unless value == ConditionalAttribute::CONDITION_UNMET
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]
+ def handle_error(error, object, key, attribute, hash)
+ on_error = @_on_error || :raise
+ case on_error # rubocop:disable Style/MissingElse
+ when :raise, nil then raise(error)
+ when :nullify then hash[key] = nil
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}"
+ when Proc
+ key, value = on_error.call(error, object, key, attribute, self.class)
+ hash[key] = value
end
end
- # rubocop:disable Metrics/MethodLength
# @return [Symbol]
- def transform_key(key)
- return key if @_transform_type == :none
-
+ def transform_key(key) # rubocop:disable Metrics/CyclomaticComplexity
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
+ return key if @_transform_type == :none || key.empty? # We can skip transformation
+
+ inflector = Alba.inflector
+ raise Alba::Error, 'Inflector is nil. You can set inflector with `Alba.enable_inference!(with: :active_support)` for example.' unless inflector
+
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
end
- # rubocop:enable Metrics/MethodLength
- def fetch_attribute(object, key, attribute)
+ def fetch_attribute(object, key, attribute) # rubocop:disable Metrics/CyclomaticComplexity
value = case attribute
when Symbol then fetch_attribute_from_object_and_resource(object, attribute)
when Proc then instance_exec(object, &attribute)
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)
+ when TypedAttribute, NestedAttribute then attribute.value(object)
+ when ConditionalAttribute then attribute.with_passing_condition(resource: self, object: object) { |attr| fetch_attribute(object, key, attr) }
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)
+ has_method ? object.__send__(attribute) : __send__(attribute, object)
end
def nil_handler
- @nil_handler ||= (@_on_nil || Alba._on_nil)
+ @_on_nil
end
def yield_if_within(association_name)
within = check_within(association_name)
yield(within) if within
@@ -302,22 +318,22 @@
assign_attributes_with_types(attrs_with_types, if_value)
end
def assign_attributes(attrs, if_value)
attrs.each do |attr_name|
- attr = if_value ? [attr_name.to_sym, if_value] : attr_name.to_sym
+ attr = if_value ? ConditionalAttribute.new(body: attr_name.to_sym, condition: if_value) : attr_name.to_sym
@_attributes[attr_name.to_sym] = attr
end
end
private :assign_attributes
def assign_attributes_with_types(attrs_with_types, if_value)
attrs_with_types.each do |attr_name, type_and_converter|
attr_name = attr_name.to_sym
type, type_converter = type_and_converter
typed_attr = TypedAttribute.new(name: attr_name, type: type, converter: type_converter)
- attr = if_value ? [typed_attr, if_value] : typed_attr
+ attr = if_value ? ConditionalAttribute.new(body: typed_attr, condition: if_value) : typed_attr
@_attributes[attr_name] = attr
end
end
private :assign_attributes_with_types
@@ -330,42 +346,63 @@
# @raise [ArgumentError] if block is absent
# @return [void]
def attribute(name, **options, &block)
raise ArgumentError, 'No block given in attribute method' unless block
- @_attributes[name.to_sym] = options[:if] ? [block, options[:if]] : block
+ @_attributes[name.to_sym] = options[:if] ? ConditionalAttribute.new(body: block, condition: options[:if]) : block
end
# 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 resource [Class<Alba::Resource>, String, Proc, nil] representing resource for this association
# @param key [String, Symbol, nil] used as key when given
+ # @param params [Hash] params override for the association
# @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::Association#initialize
- def association(name, condition = nil, resource: nil, key: nil, **options, &block)
- nesting = self.name&.rpartition('::')&.first
- 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
+ def association(name, condition = nil, resource: nil, key: nil, params: {}, **options, &block)
+ key_transformation = @_key_transformation_cascade ? @_transform_type : :none
+ assoc = Association.new(
+ name: name, condition: condition, resource: resource, params: params, nesting: nesting, key_transformation: key_transformation,
+&block
+ )
+ @_attributes[key&.to_sym || name.to_sym] = options[:if] ? ConditionalAttribute.new(body: assoc, condition: options[:if]) : assoc
end
alias one association
alias many association
alias has_one association
alias has_many association
- # Set key
+ def nesting
+ if name.nil?
+ nil
+ else
+ name.rpartition('::').first.tap { |n| n.empty? ? nil : n }
+ end
+ end
+ private :nesting
+
+ # Set a nested attribute with the given block
#
- # @param key [String, Symbol]
- # @deprecated Use {#root_key} instead
- def key(key)
- Alba::Deprecation.warn '[DEPRECATION] `key` is deprecated, use `root_key` instead.'
- @_key = key.respond_to?(:to_sym) ? key.to_sym : key
+ # @param name [String, Symbol] key name
+ # @param options [Hash<Symbol, Proc>]
+ # @option options [Proc] if a condition to decide if this attribute should be serialized
+ # @param block [Block] the block called during serialization
+ # @raise [ArgumentError] if block is absent
+ # @return [void]
+ def nested_attribute(name, **options, &block)
+ raise ArgumentError, 'No block given in attribute method' unless block
+
+ key_transformation = @_key_transformation_cascade ? @_transform_type : :none
+ attribute = NestedAttribute.new(key_transformation: key_transformation, &block)
+ @_attributes[name.to_sym] = options[:if] ? ConditionalAttribute.new(body: attribute, condition: options[:if]) : attribute
end
+ alias nested nested_attribute
# Set root key
#
# @param key [String, Symbol]
# @param key_for_collection [String, Symbol]
@@ -373,17 +410,17 @@
def root_key(key, key_for_collection = nil)
@_key = key.to_sym
@_key_for_collection = key_for_collection&.to_sym
end
- # Set key to true
+ # Set root key for collection
#
- # @deprecated Use {#root_key!} instead
- def key!
- Alba::Deprecation.warn '[DEPRECATION] `key!` is deprecated, use `root_key!` instead.'
+ # @param key [String, Symbol]
+ # @raise [NoMethodError] when key doesn't respond to `to_sym` method
+ def root_key_for_collection(key)
@_key = true
- @_key_for_collection = true
+ @_key_for_collection = key.to_sym
end
# Set root key to true
def root_key!
@_key = true
@@ -398,69 +435,60 @@
# 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 = validated_file_layout(file) || validated_inline_layout(inline)
+ @_layout = Layout.new(file: file, inline: 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] 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
+ # @param root [Boolean] decides if root key also should be transformed
+ # @param cascade [Boolean] decides if key transformation cascades into inline association
+ # Default is true but can be set false for old (v1) behavior
# @raise [Alba::Error] when type is not supported
- def transform_keys(type, root: nil)
+ def transform_keys(type, root: true, cascade: true)
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
+ @_key_transformation_cascade = cascade
end
+ # Sets key for collection serialization
+ #
+ # @param key [String, Symbol]
+ def collection_key(key)
+ @_collection_key = key.to_sym
+ end
+
# Set error handler
# If this is set it's used as a error handler overriding global one
#
# @param handler [Symbol] `:raise`, `:ignore` or `:nullify`
# @param block [Block]
def on_error(handler = nil, &block)
raise ArgumentError, 'You cannot specify error handler with both Symbol and block' if handler && block
raise ArgumentError, 'You must specify error handler with either Symbol or block' unless handler || block
- @_on_error = handler || block
+ @_on_error = block || validated_error_handler(handler)
end
+
+ def validated_error_handler(handler)
+ unless %i[raise ignore nullify].include?(handler)
+ # For backward compatibility
+ # TODO: Change this to ArgumentError
+ raise Alba::Error, "Unknown error handler: #{handler}. It must be one of `:raise`, `:ignore` or `:nullify`."
+ end
+
+ handler
+ end
+ private :validated_error_handler
# Set nil handler
#
# @param block [Block]
def on_nil(&block)