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)