lib/snaky_hash/snake.rb



# This is a module-class hybrid.
#
# A flexible key conversion system that supports both String and Symbol keys,
# with optional serialization capabilities.
#
# @example Basic usage with string keys
#   class MyHash < Hashie::Mash
#     include SnakyHash::Snake.new(key_type: :string)
#   end
#
# @example Usage with symbol keys and serialization
#   class MySerializableHash < Hashie::Mash
#     include SnakyHash::Snake.new(key_type: :symbol, serializer: true)
#   end
#
# Hashie's standard SymbolizeKeys is similar to the functionality we want.
# ... but not quite.  We need to support both String (for oauth2) and Symbol keys (for oauth).
# see: Hashie::Extensions::Mash::SymbolizeKeys
#
module SnakyHash
  # Creates a module that provides key conversion functionality when included
  #
  # @note Unlike Hashie::Mash, this implementation allows for both String and Symbol key types
  class Snake < Module
    # Initialize a new Snake module
    #
    # @param key_type [Symbol] the type to convert keys to (:string or :symbol)
    # @param serializer [Boolean] whether to include serialization capabilities
    # @raise [ArgumentError] if key_type is not :string or :symbol
    def initialize(key_type: :string, serializer: false)
      super()
      @key_type = key_type
      @serializer = serializer
    end

    # Includes appropriate conversion methods into the base class
    #
    # @param base [Class] the class including this module
    # @return [void]
    def included(base)
      conversions_module = SnakyModulizer.to_mod(@key_type)
      base.include(conversions_module)
      if @serializer
        base.extend(SnakyHash::Serializer)
      end
    end

    # Internal module factory for creating key conversion functionality
    module SnakyModulizer
      class << self
        # Creates a new module with key conversion methods based on the specified key type
        #
        # @param key_type [Symbol] the type to convert keys to (:string or :symbol)
        # @return [Module] a new module with conversion methods
        # @raise [ArgumentError] if key_type is not supported
        def to_mod(key_type)
          Module.new do
            case key_type
            when :string then
              # Converts a key to a string if it is symbolizable, after underscoring
              #
              # @note checks for to_sym instead of to_s, because nearly everything responds_to?(:to_s)
              #       so respond_to?(:to_s) isn't very useful as a test, and would result in symbolizing integers
              #       amd it also provides parity between the :symbol behavior, and the :string behavior,
              #       regarding which keys get converted for a given version of Ruby.
              #
              # @param key [Object] the key to convert
              # @return [String, Object] the converted key or original if not convertible
              define_method(:convert_key) { |key| key.respond_to?(:to_sym) ? underscore_string(key.to_s) : key }
            when :symbol then
              # Converts a key to a symbol if possible, after underscoring
              #
              # @param key [Object] the key to convert
              # @return [Symbol, Object] the converted key or original if not convertible
              define_method(:convert_key) { |key| key.respond_to?(:to_sym) ? underscore_string(key.to_s).to_sym : key }
            else
              raise ArgumentError, "SnakyHash: Unhandled key_type: #{key_type}"
            end

            # Converts hash values to the appropriate type when assigning
            #
            # @param val [Object] the value to convert
            # @param duping [Boolean] whether the value is being duplicated
            # @return [Object] the converted value
            define_method :convert_value do |val, duping = false| #:nodoc:
              case val
              when self.class
                val.dup
              when ::Hash
                val = val.dup if duping
                self.class.new(val)
              when ::Array
                val.collect { |e| convert_value(e) }
              else
                val
              end
            end

            # Converts a string to underscore case
            #
            # @param str [String, #to_s] the string to convert
            # @return [String] the underscored string
            # @example
            #   underscore_string("CamelCase")  #=> "camel_case"
            #   underscore_string("API::V1")    #=> "api/v1"
            # @note This is the same as ActiveSupport's String#underscore
            define_method :underscore_string do |str|
              str.to_s.strip.
                tr(" ", "_").
                gsub("::", "/").
                gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').
                gsub(/([a-z\d])([A-Z])/, '\1_\2').
                tr("-", "_").
                squeeze("_").
                downcase
            end
          end
        end
      end
    end
  end
end