lib/shopify_api/context.rb



# typed: strict
# frozen_string_literal: true

module ShopifyAPI
  class Context
    extend T::Sig

    @api_key = T.let("", String)
    @api_secret_key = T.let("", String)
    @api_version = T.let(LATEST_SUPPORTED_ADMIN_VERSION, String)
    @scope = T.let(Auth::AuthScopes.new, Auth::AuthScopes)
    @is_private = T.let(false, T::Boolean)
    @private_shop = T.let(nil, T.nilable(String))
    @is_embedded = T.let(true, T::Boolean)
    # Logger can either be a Logger or an ActiveSupport::BroadcastLogger, which is new in Rails 7.1.0. To avoid adding a
    # dependency Active Support >= 7.1.0, we go with T.untyped
    @logger = T.let(::Logger.new($stdout), T.untyped)
    @log_level = T.let(:info, Symbol)
    @notified_missing_resources_folder = T.let({}, T::Hash[String, T::Boolean])
    @active_session = T.let(Concurrent::ThreadLocalVar.new { nil }, T.nilable(Concurrent::ThreadLocalVar))
    @user_agent_prefix = T.let(nil, T.nilable(String))
    @old_api_secret_key = T.let(nil, T.nilable(String))

    @rest_resource_loader = T.let(nil, T.nilable(Zeitwerk::Loader))

    class << self
      extend T::Sig

      sig do
        params(
          api_key: String,
          api_secret_key: String,
          api_version: String,
          scope: T.any(T::Array[String], String),
          is_private: T::Boolean,
          is_embedded: T::Boolean,
          log_level: T.any(String, Symbol),
          logger: T.untyped,
          host_name: T.nilable(String),
          host: T.nilable(String),
          private_shop: T.nilable(String),
          user_agent_prefix: T.nilable(String),
          old_api_secret_key: T.nilable(String),
        ).void
      end
      def setup(
        api_key:,
        api_secret_key:,
        api_version:,
        scope:,
        is_private:,
        is_embedded:,
        log_level: :info,
        logger: ::Logger.new($stdout),
        host_name: nil,
        host: ENV["HOST"] || "https://#{host_name}",
        private_shop: nil,
        user_agent_prefix: nil,
        old_api_secret_key: nil
      )
        unless ShopifyAPI::AdminVersions::SUPPORTED_ADMIN_VERSIONS.include?(api_version)
          raise Errors::UnsupportedVersionError,
            "Invalid version #{api_version}, supported versions: #{ShopifyAPI::AdminVersions::SUPPORTED_ADMIN_VERSIONS}"
        end

        @api_key = api_key
        @api_secret_key = api_secret_key
        @api_version = api_version
        @host = T.let(host, T.nilable(String))
        @is_private = is_private
        @scope = Auth::AuthScopes.new(scope)
        @is_embedded = is_embedded
        @logger = logger
        @private_shop = private_shop
        @user_agent_prefix = user_agent_prefix
        @old_api_secret_key = old_api_secret_key
        @log_level = if valid_log_level?(log_level)
          log_level.to_sym
        else
          :info
        end

        load_rest_resources(api_version: api_version)
      end

      sig { params(api_version: String).void }
      def load_rest_resources(api_version:)
        # Unload any previous instances - mostly useful for tests where we need to reset the version
        @rest_resource_loader&.setup
        @rest_resource_loader&.unload

        # No resources for the unstable version
        return if api_version == "unstable"

        version_folder_name = api_version.gsub("-", "_")
        path = "#{__dir__}/rest/resources/#{version_folder_name}"

        unless Dir.exist?(path)
          unless @notified_missing_resources_folder.key?(api_version)
            @logger.warn("Cannot autoload REST resources for API version '#{version_folder_name}', folder is missing")
            @notified_missing_resources_folder[api_version] = true
          end

          return
        end

        @rest_resource_loader = T.let(Zeitwerk::Loader.new, T.nilable(Zeitwerk::Loader))
        T.must(@rest_resource_loader).enable_reloading
        T.must(@rest_resource_loader).ignore("#{__dir__}/rest/resources")
        T.must(@rest_resource_loader).setup
        T.must(@rest_resource_loader).push_dir(path, namespace: ShopifyAPI)
        T.must(@rest_resource_loader).reload
      end

      sig { returns(String) }
      attr_reader :api_key, :api_secret_key, :api_version

      sig { returns(Auth::AuthScopes) }
      attr_reader :scope

      sig { returns(T.untyped) }
      attr_reader :logger

      sig { returns(Symbol) }
      attr_reader :log_level

      sig { returns(T::Boolean) }
      def private?
        @is_private
      end

      sig { returns(T.nilable(String)) }
      attr_reader :private_shop, :user_agent_prefix, :old_api_secret_key, :host

      sig { returns(T::Boolean) }
      def embedded?
        @is_embedded
      end

      sig { returns(T::Boolean) }
      def setup?
        [api_key, api_secret_key, T.must(host)].none?(&:empty?)
      end

      sig { returns(T.nilable(Auth::Session)) }
      def active_session
        @active_session&.value
      end

      sig { params(session: T.nilable(Auth::Session)).void }
      def activate_session(session)
        T.must(@active_session).value = session
      end

      sig { void }
      def deactivate_session
        T.must(@active_session).value = nil
      end

      sig { returns(String) }
      def host_scheme
        T.must(URI.parse(T.must(host)).scheme)
      end

      sig { returns(String) }
      def host_name
        T.must(URI(T.must(host)).host)
      end

      private

      sig { params(log_level: T.any(Symbol, String)).returns(T::Boolean) }
      def valid_log_level?(log_level)
        return true if ::ShopifyAPI::Logger.levels.include?(log_level.to_sym)

        ShopifyAPI::Logger.warn("#{log_level} is not a valid log_level. "\
          "Valid options are #{::ShopifyAPI::Logger.levels.join(", ")}")

        false
      end
    end
  end
end