aglushkov/serega

View on GitHub
lib/serega.rb

Summary

Maintainability
A
1 hr
Test Coverage
A
100%
# frozen_string_literal: true

require_relative "serega/version"

# Parent class for your serializers
class Serega
  # Frozen hash
  # @return [Hash] frozen hash
  FROZEN_EMPTY_HASH = {}.freeze

  # Frozen array
  # @return [Array] frozen array
  FROZEN_EMPTY_ARRAY = [].freeze
end

require_relative "serega/errors"
require_relative "serega/helpers/serializer_class_helper"
require_relative "serega/utils/enum_deep_dup"
require_relative "serega/utils/enum_deep_freeze"
require_relative "serega/utils/params_count"
require_relative "serega/utils/symbol_name"
require_relative "serega/utils/to_hash"
require_relative "serega/json/adapter"

require_relative "serega/attribute"
require_relative "serega/attribute_normalizer"
require_relative "serega/validations/utils/check_allowed_keys"
require_relative "serega/validations/utils/check_extra_keyword_arg"
require_relative "serega/validations/utils/check_opt_is_bool"
require_relative "serega/validations/utils/check_opt_is_hash"
require_relative "serega/validations/utils/check_opt_is_string_or_symbol"
require_relative "serega/validations/attribute/check_block"
require_relative "serega/validations/attribute/check_name"
require_relative "serega/validations/attribute/check_opt_const"
require_relative "serega/validations/attribute/check_opt_hide"
require_relative "serega/validations/attribute/check_opt_delegate"
require_relative "serega/validations/attribute/check_opt_many"
require_relative "serega/validations/attribute/check_opt_method"
require_relative "serega/validations/attribute/check_opt_serializer"
require_relative "serega/validations/attribute/check_opt_value"
require_relative "serega/validations/initiate/check_modifiers"
require_relative "serega/validations/check_attribute_params"
require_relative "serega/validations/check_initiate_params"
require_relative "serega/validations/check_serialize_params"

require_relative "serega/config"
require_relative "serega/object_serializer"
require_relative "serega/plan_point"
require_relative "serega/plan"
require_relative "serega/plugins"

class Serega
  @config = SeregaConfig.new

  # Validates `Serializer.attribute` params
  check_attribute_params_class = Class.new(SeregaValidations::CheckAttributeParams)
  check_attribute_params_class.serializer_class = self
  const_set(:CheckAttributeParams, check_attribute_params_class)

  # Validates `Serializer#new` params
  check_initiate_params_class = Class.new(SeregaValidations::CheckInitiateParams)
  check_initiate_params_class.serializer_class = self
  const_set(:CheckInitiateParams, check_initiate_params_class)

  # Validates `serializer#call(obj, PARAMS)` params
  check_serialize_params_class = Class.new(SeregaValidations::CheckSerializeParams)
  check_serialize_params_class.serializer_class = self
  const_set(:CheckSerializeParams, check_serialize_params_class)

  #
  # Serializers class methods
  #
  module ClassMethods
    # Returns current config
    # @return [SeregaConfig] current serializer config
    attr_reader :config
    #
    # Enables plugin for current serializer
    #
    # @param name [Symbol, Class<Module>] Plugin name or plugin module itself
    # @param opts [Hash>] Plugin options
    #
    # @return [class<Module>] Loaded plugin module
    #
    def plugin(name, **opts)
      raise SeregaError, "This plugin is already loaded" if plugin_used?(name)

      plugin = SeregaPlugins.find_plugin(name)

      # We split loading of plugin to three parts - before_load, load, after_load:
      #
      # - **before_load_plugin** usually used to check requirements and to load additional plugins
      # - **load_plugin** usually used to include plugin modules
      # - **after_load_plugin** usually used to add config options
      plugin.before_load_plugin(self, **opts) if plugin.respond_to?(:before_load_plugin)
      plugin.load_plugin(self, **opts) if plugin.respond_to?(:load_plugin)
      plugin.after_load_plugin(self, **opts) if plugin.respond_to?(:after_load_plugin)

      # Store attached plugins, so we can check it is loaded later
      config.plugins << (plugin.respond_to?(:plugin_name) ? plugin.plugin_name : plugin)

      plugin
    end

    #
    # Checks plugin is used
    #
    # @param name [Symbol, Class<Module>] Plugin name or plugin module itself
    #
    # @return [Boolean] Is plugin used
    #
    def plugin_used?(name)
      plugin_name =
        case name
        when Module then name.respond_to?(:plugin_name) ? name.plugin_name : name
        else name
        end

      config.plugins.include?(plugin_name)
    end

    #
    # Lists attributes
    #
    # @return [Hash] attributes list
    #
    def attributes
      @attributes ||= {}
    end

    #
    # Adds attribute
    #
    # Patched in:
    # - plugin :presenter (additionally adds method in Presenter class)
    #
    # @param name [Symbol] Attribute name. Attribute value will be found by executing `object.<name>`
    # @param opts [Hash] Options to serialize attribute
    # @param block [Proc] Custom block to find attribute value. Accepts object and context.
    #
    # @return [Serega::SeregaAttribute] Added attribute
    #
    def attribute(name, **opts, &block)
      attribute = self::SeregaAttribute.new(name: name, opts: opts, block: block)
      attributes[attribute.name] = attribute
    end

    #
    # Serializes provided object to Hash
    #
    # @param object [Object] Serialized object
    # @param opts [Hash, nil] Serializer modifiers and other instantiating options
    # @option opts [Array, Hash, String, Symbol] :only The only attributes to serialize
    # @option opts [Array, Hash, String, Symbol] :except Attributes to hide
    # @option opts [Array, Hash, String, Symbol] :with Attributes (usually hidden) to serialize additionally
    # @option opts [Boolean] :validate Validates provided modifiers (Default is true)
    # @option opts [Hash] :context Serialization context
    # @option opts [Boolean] :many Set true if provided multiple objects (Default `object.is_a?(Enumerable)`)
    #
    # @return [Hash] Serialization result
    #
    def call(object, opts = nil)
      opts ||= FROZEN_EMPTY_HASH
      initiate_keys = config.initiate_keys

      if opts.empty?
        modifiers_opts = FROZEN_EMPTY_HASH
        serialize_opts = nil
      else
        serialize_opts = opts.except(*initiate_keys)
        modifiers_opts = opts.slice(*initiate_keys)
      end

      new(modifiers_opts).to_h(object, serialize_opts)
    end

    #
    # Serializes provided object to Hash
    #
    # @param object [Object] Serialized object
    # @param opts [Hash, nil] Serializer modifiers and other instantiating options
    # @option opts [Array, Hash, String, Symbol] :only The only attributes to serialize
    # @option opts [Array, Hash, String, Symbol] :except Attributes to hide
    # @option opts [Array, Hash, String, Symbol] :with Attributes (usually hidden) to serialize additionally
    # @option opts [Boolean] :validate Validates provided modifiers (Default is true)
    # @option opts [Hash] :context Serialization context
    # @option opts [Boolean] :many Set true if provided multiple objects (Default `object.is_a?(Enumerable)`)
    #
    # @return [Hash] Serialization result
    #
    def to_h(object, opts = nil)
      call(object, opts)
    end

    #
    # Serializes provided object to JSON string
    #
    # @param object [Object] Serialized object
    # @param opts [Hash, nil] Serializer modifiers and other instantiating options
    # @option opts [Array, Hash, String, Symbol] :only The only attributes to serialize
    # @option opts [Array, Hash, String, Symbol] :except Attributes to hide
    # @option opts [Array, Hash, String, Symbol] :with Attributes (usually hidden) to serialize additionally
    # @option opts [Boolean] :validate Validates provided modifiers (Default is true)
    # @option opts [Hash] :context Serialization context
    # @option opts [Boolean] :many Set true if provided multiple objects (Default `object.is_a?(Enumerable)`)
    #
    # @return [String] Serialization result
    #
    def to_json(object, opts = nil)
      config.to_json.call(to_h(object, opts))
    end

    #
    # Serializes provided object as JSON
    #
    # @param object [Object] Serialized object
    # @param opts [Hash, nil] Serializer modifiers and other instantiating options
    # @option opts [Array, Hash, String, Symbol] :only The only attributes to serialize
    # @option opts [Array, Hash, String, Symbol] :except Attributes to hide
    # @option opts [Array, Hash, String, Symbol] :with Attributes (usually hidden) to serialize additionally
    # @option opts [Boolean] :validate Validates provided modifiers (Default is true)
    # @option opts [Hash] :context Serialization context
    # @option opts [Boolean] :many Set true if provided multiple objects (Default `object.is_a?(Enumerable)`)
    #
    # @return [Hash] Serialization result
    #
    def as_json(object, opts = nil)
      config.from_json.call(to_json(object, opts))
    end

    private

    # Patched in:
    # - plugin :batch (defines SeregaBatchLoaders, SeregaBatchLoader)
    # - plugin :metadata (defines MetaAttribute and copies meta_attributes to subclasses)
    # - plugin :presenter (defines Presenter)
    def inherited(subclass)
      config_class = Class.new(self::SeregaConfig)
      config_class.serializer_class = subclass
      subclass.const_set(:SeregaConfig, config_class)
      subclass.instance_variable_set(:@config, subclass::SeregaConfig.new(config.opts))

      attribute_class = Class.new(self::SeregaAttribute)
      attribute_class.serializer_class = subclass
      subclass.const_set(:SeregaAttribute, attribute_class)

      attribute_normalizer_class = Class.new(self::SeregaAttributeNormalizer)
      attribute_normalizer_class.serializer_class = subclass
      subclass.const_set(:SeregaAttributeNormalizer, attribute_normalizer_class)

      plan_class = Class.new(self::SeregaPlan)
      plan_class.serializer_class = subclass
      subclass.const_set(:SeregaPlan, plan_class)

      plan_point_class = Class.new(self::SeregaPlanPoint)
      plan_point_class.serializer_class = subclass
      subclass.const_set(:SeregaPlanPoint, plan_point_class)

      object_serializer_class = Class.new(self::SeregaObjectSerializer)
      object_serializer_class.serializer_class = subclass
      subclass.const_set(:SeregaObjectSerializer, object_serializer_class)

      check_attribute_params_class = Class.new(self::CheckAttributeParams)
      check_attribute_params_class.serializer_class = subclass
      subclass.const_set(:CheckAttributeParams, check_attribute_params_class)

      check_initiate_params_class = Class.new(self::CheckInitiateParams)
      check_initiate_params_class.serializer_class = subclass
      subclass.const_set(:CheckInitiateParams, check_initiate_params_class)

      check_serialize_params_class = Class.new(self::CheckSerializeParams)
      check_serialize_params_class.serializer_class = subclass
      subclass.const_set(:CheckSerializeParams, check_serialize_params_class)

      # Assign same attributes
      attributes.each_value do |attr|
        params = attr.initials
        subclass.attribute(params[:name], **params[:opts], &params[:block])
      end

      super
    end
  end

  #
  # Serializers instance methods
  #
  module InstanceMethods
    #
    # Instantiates new Serega class
    #
    # @param opts [Hash, nil] Serializer modifiers and other instantiating options
    # @option opts [Array, Hash, String, Symbol] :only The only attributes to serialize
    # @option opts [Array, Hash, String, Symbol] :except Attributes to hide
    # @option opts [Array, Hash, String, Symbol] :with Attributes (usually hidden) to serialize additionally
    # @option opts [Boolean] :validate Validates provided modifiers (Default is true)
    #
    def initialize(opts = nil)
      @opts = (opts.nil? || opts.empty?) ? FROZEN_EMPTY_HASH : parse_modifiers(opts)
      self.class::CheckInitiateParams.new(@opts).validate if opts&.fetch(:check_initiate_params) { config.check_initiate_params }

      @plan = self.class::SeregaPlan.call(@opts)
    end

    #
    # Plan for serialization.
    # This plan can be traversed to find serialized attributes and nested attributes.
    #
    # @return [Serega::SeregaPlan] Serialization plan
    attr_reader :plan

    #
    # Serializes provided object to Hash
    #
    # @param object [Object] Serialized object
    # @param opts [Hash, nil] Serializer modifiers and other instantiating options
    # @option opts [Hash] :context Serialization context
    # @option opts [Boolean] :many Set true if provided multiple objects (Default `object.is_a?(Enumerable)`)
    #
    # @return [Hash] Serialization result
    #
    def call(object, opts = nil)
      self.class::CheckSerializeParams.new(opts).validate if opts&.any?
      opts ||= {}
      opts[:context] ||= {}

      serialize(object, opts)
    end

    # @see #call
    def to_h(object, opts = nil)
      call(object, opts)
    end

    #
    # Serializes provided object to JSON string
    #
    # @param object [Object] Serialized object
    # @param opts [Hash, nil] Serializer modifiers and other instantiating options
    # @option opts [Hash] :context Serialization context
    # @option opts [Boolean] :many Set true if provided multiple objects (Default `object.is_a?(Enumerable)`)
    #
    # @return [Hash] Serialization result
    #
    def to_json(object, opts = nil)
      hash = to_h(object, opts)
      config.to_json.call(hash)
    end

    #
    # Serializes provided object as JSON
    #
    # @param object [Object] Serialized object
    # @param opts [Hash, nil] Serializer modifiers and other instantiating options
    # @option opts [Hash] :context Serialization context
    # @option opts [Boolean] :many Set true if provided multiple objects (Default `object.is_a?(Enumerable)`)
    #
    # @return [Hash] Serialization result
    #
    def as_json(object, opts = nil)
      json = to_json(object, opts)
      config.from_json.call(json)
    end

    private

    attr_reader :opts

    def config
      self.class.config
    end

    def parse_modifiers(opts)
      result = {}

      opts.each do |key, value|
        value = parse_modifier(value) if (key == :only) || (key == :except) || (key == :with)
        result[key] = value
      end

      result
    end

    # Patched in:
    # - plugin :string_modifiers (parses string modifiers differently)
    def parse_modifier(value)
      SeregaUtils::ToHash.call(value)
    end

    # Patched in:
    # - plugin :activerecord_preloads (loads defined :preloads to object)
    # - plugin :batch (runs serialization of collected batches)
    # - plugin :root (wraps result `{ root => result }`)
    # - plugin :context_metadata (adds context metadata to final result)
    # - plugin :metadata (adds metadata to final result)
    def serialize(object, opts)
      self.class::SeregaObjectSerializer
        .new(**opts, plan: plan)
        .serialize(object)
    end
  end

  extend ClassMethods
  include InstanceMethods
end