# frozen_string_literal: true class Serega module SeregaPlugins # # Plugin `:metadata` # # Depends on: `:root` plugin, that must be loaded first # # Adds ability to describe metadata that must be added to serialized response # # Added class-level method `:meta_attribute`, to define metadata, it accepts: # # - `*path` [Array of Symbols] - nested hash keys. # - `**options` [Hash] # # - `:const` - describes metadata value (if it is constant) # - `:value` - describes metadata value as any `#callable` instance # - `:hide_nil` - does not show metadata key if value is nil, `false` by default # - `:hide_empty`, does not show metadata key if value is nil or empty, `false` by default # # - `&block` [Proc] - describes value for current meta attribute # # @example # class AppSerializer < Serega # plugin :root # plugin :metadata # # meta_attribute(:version, const: '1.2.3') # meta_attribute(:ab_tests, :names, value: ABTests.new.method(:names)) # meta_attribute(:meta, :paging, hide_nil: true) do |records, ctx| # next unless records.respond_to?(:total_count) # # { page: records.page, per_page: records.per_page, total_count: records.total_count } # end # end # # AppSerializer.to_h(nil) # => {:data=>nil, :version=>"1.2.3", :ab_tests=>{:names=> ... }} # module Metadata # @return [Symbol] Plugin name def self.plugin_name :metadata end # Checks requirements and loads additional plugins # # @param serializer_class [Class] Current serializer class # @param _opts [Hash] Plugin options # # @return [void] # def self.before_load_plugin(serializer_class, **_opts) unless serializer_class.plugin_used?(:root) raise SeregaError, "Plugin #{plugin_name.inspect} must be loaded after the :root plugin. Please load the :root plugin first" end end # # Applies plugin code to specific serializer # # @param serializer_class [Class] Current serializer class # @param _opts [Hash] Plugin options # # @return [void] # def self.load_plugin(serializer_class, **_opts) serializer_class.extend(ClassMethods) serializer_class.include(InstanceMethods) serializer_class::SeregaConfig.include(ConfigInstanceMethods) require_relative "meta_attribute" require_relative "validations/check_block" require_relative "validations/check_opt_const" require_relative "validations/check_opt_hide_nil" require_relative "validations/check_opt_hide_empty" require_relative "validations/check_opt_value" require_relative "validations/check_opts" require_relative "validations/check_path" meta_attribute_class = Class.new(MetaAttribute) meta_attribute_class.serializer_class = serializer_class serializer_class.const_set(:MetaAttribute, meta_attribute_class) end # # Adds config options and runs other callbacks after plugin was loaded # # @param serializer_class [Class] Current serializer class # @param _opts [Hash] Plugin options # # @return [void] # def self.after_load_plugin(serializer_class, **_opts) serializer_class.config.opts[:metadata] = {attribute_keys: %i[const hide_nil hide_empty value]} end # # Config for `metadata` plugin # class MetadataConfig # @return [Hash] metadata options attr_reader :opts # # Initializes context_metadata config object # # @param opts [Hash] options # # @return [Serega::SeregaPlugins::Metadata::MetadataConfig] # def initialize(opts) @opts = opts end # # Returns allowed metadata attribute keys # def attribute_keys opts.fetch(:attribute_keys) end end # # Config class additional/patched instance methods # # @see Serega::SeregaConfig # module ConfigInstanceMethods # @return [Serega::SeregaPlugins::Metadata::MetadataConfig] metadata config def metadata @metadata ||= MetadataConfig.new(opts.fetch(:metadata)) end end # # Serega class additional/patched class methods # # @see Serega::SeregaConfig # module ClassMethods # # List of added metadata attributes # # @return [Hash Serega::SeregaPlugins::Metadata::MetaAttribute>] Added metadata attributes # def meta_attributes @meta_attributes ||= {} end # # Define metadata attribute # # @param path [String, Symbol] Metadata attribute path keys # @param opts [Hash] Metadata attribute options # @param block [Proc] Block to fetch metadata attribute value # # @return [Serega::SeregaPlugins::Metadata::MetaAttribute] Added metadata attribute # def meta_attribute(*path, **opts, &block) attribute = self::MetaAttribute.new(path: path, opts: opts, block: block) meta_attributes[attribute.name] = attribute end private def inherited(subclass) super meta_attribute_class = Class.new(self::MetaAttribute) meta_attribute_class.serializer_class = subclass subclass.const_set(:MetaAttribute, meta_attribute_class) # Assign same metadata attributes meta_attributes.each_value do |attr| subclass.meta_attribute(*attr.path, **attr.opts, &attr.block) end end end # # Serega additional/patched instance methods # # @see Serega # module InstanceMethods private def serialize(object, opts) result = super return result unless result.is_a?(Hash) # return earlier if not a hash, so no root was added root = build_root(object, opts) return result unless root # return earlier when no root add_metadata(object, opts[:context], result) result end def add_metadata(object, context, hash) self.class.meta_attributes.each_value do |meta_attribute| metadata = meta_attribute_value(object, context, meta_attribute) next unless metadata deep_merge_metadata(hash, metadata) rescue => error raise error.exception(<<~MESSAGE.strip) #{error.message} (when serializing meta_attribute #{meta_attribute.path.inspect} in #{self.class}) MESSAGE end end def meta_attribute_value(object, context, meta_attribute) value = meta_attribute.value(object, context) return if meta_attribute.hide?(value) # Example: # [:foo, :bar].reverse_each.inject(:bazz) { |val, key| { key => val } } # => { foo: { bar: :bazz } } meta_attribute.path.reverse_each.inject(value) { |val, key| {key => val} } end def deep_merge_metadata(hash, metadata) hash.merge!(metadata) do |_key, this_val, other_val| if this_val.is_a?(Hash) && other_val.is_a?(Hash) deep_merge_metadata(this_val, other_val) else other_val end end end end end register_plugin(Metadata.plugin_name, Metadata) end end