lib/mindee/http/endpoint.rb



# frozen_string_literal: true

require 'json'
require 'net/http'
require_relative 'error'
require_relative '../version'
require_relative 'response_validation'

module Mindee
  module HTTP
    # API key's default environment key name.
    API_KEY_ENV_NAME = 'MINDEE_API_KEY'
    # API key's default value.
    API_KEY_DEFAULT = nil

    # Base URL default environment key name.
    BASE_URL_ENV_NAME = 'MINDEE_BASE_URL'
    # Base URL's default value.
    BASE_URL_DEFAULT = 'https://api.mindee.net/v1'

    # HTTP request timeout default environment key name.
    REQUEST_TIMEOUT_ENV_NAME = 'MINDEE_REQUEST_TIMEOUT'
    # HTTP request timeout default value.
    TIMEOUT_DEFAULT = 120

    # Default value for the user agent.
    USER_AGENT = "mindee-api-ruby@v#{Mindee::VERSION} ruby-v#{RUBY_VERSION} #{Mindee::PLATFORM}"

    # Generic API endpoint for a product.
    class Endpoint
      # @return [String]
      attr_reader :api_key
      # @return [Integer]
      attr_reader :request_timeout
      # @return [String]
      attr_reader :url_root

      def initialize(owner, url_name, version, api_key: '')
        @owner = owner
        @url_name = url_name
        @version = version
        @request_timeout = ENV.fetch(REQUEST_TIMEOUT_ENV_NAME, TIMEOUT_DEFAULT).to_i
        @api_key = api_key.nil? || api_key.empty? ? ENV.fetch(API_KEY_ENV_NAME, API_KEY_DEFAULT) : api_key
        base_url = ENV.fetch(BASE_URL_ENV_NAME, BASE_URL_DEFAULT)
        @url_root = "#{base_url.chomp('/')}/products/#{@owner}/#{@url_name}/v#{@version}"
      end

      # Call the prediction API.
      # @param input_source [Mindee::Input::Source::LocalInputSource, Mindee::Input::Source::UrlInputSource]
      # @param all_words [Boolean] Whether the full word extraction needs to be performed
      # @param full_text [Boolean] Whether to include the full OCR text response in compatible APIs
      # @param close_file [Boolean] Whether the file will be closed after reading
      # @param cropper [Boolean] Whether a cropping operation will be applied
      # @return [Array]
      def predict(input_source, all_words, full_text, close_file, cropper)
        check_api_key
        response = predict_req_post(
          input_source,
          all_words: all_words,
          full_text: full_text,
          close_file: close_file,
          cropper: cropper
        )
        hashed_response = JSON.parse(response.body, object_class: Hash)
        return [hashed_response, response.body] if ResponseValidation.valid_sync_response?(response)

        ResponseValidation.clean_request!(response)
        error = Error.handle_error(@url_name, response)
        raise error
      end

      # Call the prediction API.
      # @param input_source [Mindee::Input::Source::LocalInputSource, Mindee::Input::Source::UrlInputSource]
      # @param all_words [Boolean] Whether the full word extraction needs to be performed
      # @param full_text [Boolean] Whether to include the full OCR text response in compatible APIs.
      # @param close_file [Boolean] Whether the file will be closed after reading
      # @param cropper [Boolean] Whether a cropping operation will be applied
      # @return [Array]
      def predict_async(input_source, all_words, full_text, close_file, cropper)
        check_api_key
        response = document_queue_req_get(input_source, all_words, full_text, close_file, cropper)
        hashed_response = JSON.parse(response.body, object_class: Hash)
        return [hashed_response, response.body] if ResponseValidation.valid_async_response?(response)

        ResponseValidation.clean_request!(response)
        error = Error.handle_error(@url_name, response)
        raise error
      end

      # Calls the parsed async doc.
      # @param job_id [String]
      # @return [Array]
      def parse_async(job_id)
        check_api_key
        response = document_queue_req(job_id)
        hashed_response = JSON.parse(response.body, object_class: Hash)
        return [hashed_response, response.body] if ResponseValidation.valid_async_response?(response)

        ResponseValidation.clean_request!(response)
        error = Error.handle_error(@url_name, response)
        raise error
      end

      private

      # @param input_source [Mindee::Input::Source::LocalInputSource, Mindee::Input::Source::UrlInputSource]
      # @param all_words [Boolean] Whether the full word extraction needs to be performed
      # @param full_text [Boolean] Whether to include the full OCR text response in compatible APIs.
      # @param close_file [Boolean] Whether the file will be closed after reading
      # @param cropper [Boolean] Whether a cropping operation will be applied
      # @return [Net::HTTPResponse, nil]
      def predict_req_post(input_source, all_words: false, full_text: false, close_file: true, cropper: false)
        uri = URI("#{@url_root}/predict")

        params = {}
        params[:cropper] = 'true' if cropper
        params[:full_text_ocr] = 'true' if full_text
        uri.query = URI.encode_www_form(params)

        headers = {
          'Authorization' => "Token #{@api_key}",
          'User-Agent' => USER_AGENT,
        }
        req = Net::HTTP::Post.new(uri, headers)
        form_data = if input_source.is_a?(Mindee::Input::Source::UrlInputSource)
                      [['document', input_source.url]]
                    else
                      [input_source.read_document(close: close_file)]
                    end
        form_data.push ['include_mvision', 'true'] if all_words

        req.set_form(form_data, 'multipart/form-data')
        response = nil
        Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: @request_timeout) do |http|
          response = http.request(req)
        end
        response
      end

      # @param input_source [Mindee::Input::Source::LocalInputSource, Mindee::Input::Source::UrlInputSource]
      # @param all_words [Boolean] Whether the full word extraction needs to be performed
      # @param full_text [Boolean] Whether to include the full OCR text response in compatible APIs.
      # @param close_file [Boolean] Whether the file will be closed after reading
      # @param cropper [Boolean] Whether a cropping operation will be applied
      # @return [Net::HTTPResponse, nil]
      def document_queue_req_get(input_source, all_words, full_text, close_file, cropper)
        uri = URI("#{@url_root}/predict_async")

        params = {}
        params[:cropper] = 'true' if cropper
        params[:full_text_ocr] = 'true' if full_text
        uri.query = URI.encode_www_form(params)

        headers = {
          'Authorization' => "Token #{@api_key}",
          'User-Agent' => USER_AGENT,
        }
        req = Net::HTTP::Post.new(uri, headers)
        form_data = if input_source.is_a?(Mindee::Input::Source::UrlInputSource)
                      [['document', input_source.url]]
                    else
                      [input_source.read_document(close: close_file)]
                    end
        form_data.push ['include_mvision', 'true'] if all_words

        req.set_form(form_data, 'multipart/form-data')

        response = nil
        Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: @request_timeout) do |http|
          response = http.request(req)
        end
        response
      end

      # @param job_id [String]
      # @return [Net::HTTPResponse, nil]
      def document_queue_req(job_id)
        uri = URI("#{@url_root}/documents/queue/#{job_id}")

        headers = {
          'Authorization' => "Token #{@api_key}",
          'User-Agent' => USER_AGENT,
        }

        req = Net::HTTP::Get.new(uri, headers)

        response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: @request_timeout) do |http|
          http.request(req)
        end

        if response.code.to_i > 299 && response.code.to_i < 400
          req = Net::HTTP::Get.new(response['location'], headers)
          Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: @request_timeout) do |http|
            response = http.request(req)
          end
        end
        response
      end

      # Checks API key
      def check_api_key
        return unless @api_key.nil? || @api_key.empty?

        raise "Missing API key for product \"'#{@url_name}' v#{@version}\" (belonging to \"#{@owner}\"), " \
              "check your Client Configuration.\n" \
              'You can set this using the ' \
              "'#{HTTP::API_KEY_ENV_NAME}' environment variable."
      end
    end
  end
end