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