lib/shopify_api/rest/base.rb



# typed: strict
# frozen_string_literal: true

require "active_support/inflector"
require "hash_diff"

module ShopifyAPI
  module Rest
    class Base
      extend T::Sig
      extend T::Helpers
      abstract!

      @has_one = T.let({}, T::Hash[Symbol, Class])
      @has_many = T.let({}, T::Hash[Symbol, Class])
      @paths = T.let([], T::Array[T::Hash[Symbol, T.any(T::Array[Symbol], String, Symbol)]])
      @custom_prefix = T.let(nil, T.nilable(String))
      @read_only_attributes = T.let([], T.nilable(T::Array[Symbol]))
      @aliased_properties = T.let({}, T::Hash[String, String])

      sig { returns(T::Hash[Symbol, T.untyped]) }
      attr_accessor :original_state

      sig { returns(T.any(Rest::BaseErrors, T.nilable(T::Hash[T.untyped, T.untyped]))) }
      attr_reader :errors

      sig do
        params(
          session: T.nilable(Auth::Session),
          from_hash: T.nilable(T::Hash[Symbol, T.untyped]),
        ).void
      end
      def initialize(session: nil, from_hash: nil)
        @original_state = T.let({}, T::Hash[Symbol, T.untyped])
        @custom_prefix = T.let(nil, T.nilable(String))
        @forced_nils = T.let({}, T::Hash[String, T::Boolean])
        @aliased_properties = T.let({}, T::Hash[String, String])

        session ||= ShopifyAPI::Context.active_session

        client = ShopifyAPI::Clients::Rest::Admin.new(session: session)

        @session = T.let(T.must(session), Auth::Session)
        @client = T.let(client, Clients::Rest::Admin)
        @errors = T.let(Rest::BaseErrors.new, Rest::BaseErrors)

        from_hash&.each do |key, value|
          set_property(key, value)
        end
      end

      class << self
        extend T::Sig

        sig { returns(T.nilable(String)) }
        attr_reader :custom_prefix

        sig { returns(T::Hash[Symbol, Class]) }
        attr_reader :has_many

        sig { returns(T::Hash[Symbol, Class]) }
        attr_reader :has_one

        sig do
          params(
            session: T.nilable(Auth::Session),
            ids: T::Hash[Symbol, String],
            params: T::Hash[Symbol, T.untyped],
          ).returns(T::Array[Base])
        end
        def base_find(session: nil, ids: {}, params: {})
          session ||= ShopifyAPI::Context.active_session

          client = ShopifyAPI::Clients::Rest::Admin.new(session: session)

          path = T.must(get_path(http_method: :get, operation: :get, ids: ids))
          response = client.get(path: path, query: params.compact)

          instance_variable_get(:"@prev_page_info").value = response.prev_page_info
          instance_variable_get(:"@next_page_info").value = response.next_page_info

          create_instances_from_response(response: response, session: T.must(session))
        end

        sig { returns(String) }
        def class_name
          T.must(name).demodulize.underscore
        end

        sig { returns(String) }
        def primary_key
          "id"
        end

        sig { returns(String) }
        def json_body_name
          class_name.underscore
        end

        sig { returns(T::Array[String]) }
        def json_response_body_names
          [class_name]
        end

        sig { returns(T.nilable(String)) }
        def prev_page_info
          instance_variable_get(:"@prev_page_info").value
        end

        sig { returns(T.nilable(String)) }
        def next_page_info
          instance_variable_get(:"@next_page_info").value
        end

        sig { returns(T::Boolean) }
        def prev_page?
          !instance_variable_get(:"@prev_page_info").value.nil?
        end

        sig { returns(T::Boolean) }
        def next_page?
          !instance_variable_get(:"@next_page_info").value.nil?
        end

        sig { params(attribute: Symbol).returns(T::Boolean) }
        def has_many?(attribute)
          @has_many.include?(attribute)
        end

        sig { params(attribute: Symbol).returns(T::Boolean) }
        def has_one?(attribute)
          @has_one.include?(attribute)
        end

        sig { returns(T.nilable(T::Array[Symbol])) }
        def read_only_attributes
          @read_only_attributes&.map { |a| :"@#{a}" }
        end

        sig do
          params(
            http_method: Symbol,
            operation: Symbol,
            entity: T.nilable(Base),
            ids: T::Hash[Symbol, T.any(Integer, String)],
          ).returns(T.nilable(String))
        end
        def get_path(http_method:, operation:, entity: nil, ids: {})
          match = T.let(nil, T.nilable(String))
          max_ids = T.let(-1, Integer)
          @paths.each do |path|
            next if http_method != path[:http_method] || operation != path[:operation]

            path_ids = T.cast(path[:ids], T::Array[Symbol])

            url_ids = ids.transform_keys(&:to_sym)
            path_ids.each do |id|
              if url_ids[id].nil? && (entity_id = entity&.public_send(id))
                url_ids[id] = entity_id
              end
            end

            url_ids.compact!

            # We haven't found all the required ids or we have a more specific match
            next if !(path_ids - url_ids.keys).empty? || path_ids.length <= max_ids

            max_ids = path_ids.length
            match = T.cast(path[:path], String).gsub(/(<([^>]+)>)/) do
              url_ids[T.unsafe(Regexp.last_match)[2].to_sym]
            end
          end

          custom_prefix ? "#{T.must(custom_prefix).sub(%r{\A/}, "")}/#{match}" : match
        end

        sig do
          params(
            http_method: Symbol,
            operation: T.any(String, Symbol),
            session: T.nilable(Auth::Session),
            ids: T::Hash[Symbol, String],
            params: T::Hash[Symbol, T.untyped],
            body: T.nilable(T::Hash[T.any(Symbol, String), T.untyped]),
            entity: T.untyped,
          ).returns(Clients::HttpResponse)
        end
        def request(http_method:, operation:, session:, ids: {}, params: {}, body: nil, entity: nil)
          client = ShopifyAPI::Clients::Rest::Admin.new(session: session)

          path = get_path(http_method: http_method, operation: operation.to_sym, ids: ids)

          case http_method
          when :get
            client.get(path: T.must(path), query: params.compact)
          when :post
            client.post(path: T.must(path), query: params.compact, body: body || {})
          when :put
            client.put(path: T.must(path), query: params.compact, body: body || {})
          when :delete
            client.delete(path: T.must(path), query: params.compact)
          else
            raise Errors::InvalidHttpRequestError, "Invalid HTTP method: #{http_method}"
          end
        end

        sig { params(response: Clients::HttpResponse, session: Auth::Session).returns(T::Array[Base]) }
        def create_instances_from_response(response:, session:)
          objects = []

          body = T.cast(response.body, T::Hash[String, T.untyped])

          response_names = json_response_body_names

          response_names.each do |response_name|
            if body.key?(response_name.pluralize) || (body.key?(response_name) && body[response_name].is_a?(Array))
              (body[response_name.pluralize] || body[response_name]).each do |entry|
                objects << create_instance(data: entry, session: session)
              end
            elsif body.key?(response_name)
              objects << create_instance(data: body[response_name], session: session)
            end
          end

          objects
        end

        sig do
          params(data: T::Hash[String, T.untyped], session: Auth::Session, instance: T.nilable(Base)).returns(Base)
        end
        def create_instance(data:, session:, instance: nil)
          instance ||= new(session: session)
          instance.original_state = {}

          data.each do |attribute, value|
            attr_sym = attribute.to_sym

            if has_many?(attr_sym) && value
              instance.original_state[attr_sym] = []
              attr_list = []
              value.each do |element|
                child = T.unsafe(@has_many[attr_sym]).create_instance(data: element, session: session)
                attr_list << child
                instance.original_state[attr_sym] << child.to_hash(true)
              end
              instance.public_send("#{attribute}=", attr_list)
            elsif has_one?(attr_sym) && value
              # force a hash if core returns values that instantiate objects like "USD"
              data_hash = value.is_a?(Hash) ? value : { attribute.to_s => value }
              child = T.unsafe(@has_one[attr_sym]).create_instance(data: data_hash, session: session)
              instance.public_send("#{attribute}=", child)
              instance.original_state[attr_sym] = child.to_hash(true)
            else
              instance.public_send("#{attribute}=", value)
              instance.original_state[attr_sym] = value
            end
          end

          instance
        end
      end

      sig { params(meth_id: Symbol, val: T.untyped).returns(T.untyped) }
      def method_missing(meth_id, val = nil)
        match = meth_id.id2name.match(/([^=]+)(=)?/)

        var = T.must(T.must(match)[1])

        if T.must(match)[2]
          set_property(var, val)
          @forced_nils[var] = val.nil?
        else
          get_property(var)
        end
      end

      sig { params(meth_id: Symbol, args: T.untyped).void }
      def respond_to_missing?(meth_id, *args)
        str = meth_id.id2name
        match = str.match(/([^=]+)=/)

        match.nil? ? true : super
      end

      sig { params(saving: T::Boolean).returns(T::Hash[String, T.untyped]) }
      def to_hash(saving = false)
        hash = {}
        instance_variables.each do |var|
          next if [
            :"@original_state",
            :"@session",
            :"@client",
            :"@forced_nils",
            :"@errors",
            :"@aliased_properties",
          ].include?(var)
          next if saving && self.class.read_only_attributes&.include?(var)

          var = var.to_s.delete("@")
          attribute = if @aliased_properties.value?(var)
            T.must(@aliased_properties.key(var))
          else
            var
          end.to_sym

          if self.class.has_many?(attribute)
            attribute_class = self.class.has_many[attribute]
            hash[attribute.to_s] = get_property(attribute).map do |element|
              get_element_hash(element, T.unsafe(attribute_class), saving)
            end.to_a if get_property(attribute)
          elsif self.class.has_one?(attribute)
            element_hash = get_element_hash(
              get_property(attribute),
              T.unsafe(self.class.has_one[attribute]),
              saving,
            )
            hash[attribute.to_s] = element_hash if element_hash || @forced_nils[attribute.to_s]
          elsif !get_property(attribute).nil? || @forced_nils[attribute.to_s]
            hash[attribute.to_s] =
              get_property(attribute)
          end
        end
        hash
      end

      sig { params(params: T::Hash[T.untyped, T.untyped]).void }
      def delete(params: {})
        @client.delete(
          path: T.must(self.class.get_path(http_method: :delete, operation: :delete, entity: self)),
          query: params.compact,
        )
      rescue ShopifyAPI::Errors::HttpResponseError => e
        @errors.errors << e
        raise
      end

      sig { void }
      def save!
        save(update_object: true)
      end

      sig { params(update_object: T::Boolean).void }
      def save(update_object: false)
        method = deduce_write_verb
        response = @client.public_send(
          method,
          body: { self.class.json_body_name => attributes_to_update },
          path: deduce_write_path(method),
        )

        if update_object
          response_name = self.class.json_response_body_names & response.body.keys
          self.class.create_instance(data: response.body[response_name.first], session: @session, instance: self)
        end
      rescue ShopifyAPI::Errors::HttpResponseError => e
        @errors.errors << e
        raise
      end

      private

      sig { returns(T::Hash[String, String]) }
      def attributes_to_update
        original_state_for_update = original_state.reject do |attribute, _|
          self.class.read_only_attributes&.include?("@#{attribute}".to_sym)
        end

        diff = HashDiff::Comparison.new(
          deep_stringify_keys(original_state_for_update),
          deep_stringify_keys(to_hash(true)),
        ).left_diff

        diff.each do |attribute, value|
          if value.is_a?(Hash) && value[0] == HashDiff::NO_VALUE
            diff[attribute] = send(attribute)
          end
        end

        diff
      end

      sig { returns(Symbol) }
      def deduce_write_verb
        send(self.class.primary_key) ? :put : :post
      end

      sig { params(method: Symbol).returns(T.nilable(String)) }
      def deduce_write_path(method)
        path = self.class.get_path(http_method: method, operation: method, entity: self)

        if path.nil?
          method = method == :post ? :put : :post
          path = self.class.get_path(http_method: method, operation: method, entity: self)
        end

        path
      end

      sig { params(hash: T::Hash[T.any(String, Symbol), T.untyped]).returns(T::Hash[String, String]) }
      def deep_stringify_keys(hash)
        hash.each_with_object({}) do |(key, value), result|
          new_key = key.to_s
          new_value = value.is_a?(Hash) ? deep_stringify_keys(value) : value
          result[new_key] = new_value
        end
      end

      sig { params(key: T.any(String, Symbol), val: T.untyped).void }
      def set_property(key, val)
        # Some API fields contain invalid characters, like `?`, which causes issues when setting them as instance
        # variables. To work around that, we're cleaning them up here but keeping track of the properties that were
        # aliased this way. When loading up the property, we can map back from the "invalid" field so that it is
        # transparent to outside callers
        clean_key = key.to_s.gsub(/[\?\s]/, "")
        @aliased_properties[key.to_s] = clean_key if clean_key != key

        instance_variable_set("@#{clean_key}", val)
      end

      sig { params(key: T.any(String, Symbol)).returns(T.untyped) }
      def get_property(key)
        clean_key = @aliased_properties.key?(key.to_s) ? @aliased_properties[key.to_s] : key

        instance_variable_get("@#{clean_key}")
      end

      sig do
        params(
          element: T.nilable(T.any(T::Hash[String, T.untyped], ShopifyAPI::Rest::Base)),
          attribute_class: Class,
          saving: T::Boolean,
        ).returns(T.nilable(T::Hash[String, T.untyped]))
      end
      def get_element_hash(element, attribute_class, saving)
        return nil if element.nil?
        return element.to_hash(saving) unless element.is_a?(Hash)

        T.unsafe(attribute_class).create_instance(session: @session, data: element).to_hash(saving)
      end
    end
  end
end