sharetribe/sharetribe

View on GitHub
app/services/listing_index_service/search/zappy_adapter.rb

Summary

Maintainability
B
4 hrs
Test Coverage
module ListingIndexService::Search

  class ZappyAdapter < SearchEngineAdapter

    INCLUDE_MAP = {
      listing_images: :listing_images,
      author: :author,
      num_of_reviews: {author: :received_testimonials},
      location: :location
    }

    API_KEY = APP_CONFIG.external_search_apikey
    SEARCH_URL = APP_CONFIG.external_search_url

    def initialize(raise_errors:)
      logger = ::Logger.new(STDOUT)
      logger.level = ::Logger::INFO # log only on INFO level so that secrets are not logged
      @conn = Faraday.new(url: SEARCH_URL) do |c|
         c.request  :url_encoded             # form-encode POST params
         c.response :logger, logger, headers: false # log requests to STDOUT
         c.response :json, :content_type => /\bjson$/
         c.adapter  Faraday.default_adapter  # make requests with Net::HTTP
         c.use Faraday::Response::RaiseError if raise_errors
      end
    end

    def search(community_id:, search:, includes: nil)
      included_models = includes.map { |m| INCLUDE_MAP[m] }

      if DatabaseSearchHelper.needs_db_query?(search) && DatabaseSearchHelper.needs_search?(search)
        return Result::Error.new(ArgumentError.new("Both DB query and search engine would be needed to fulfill the search"))
      end

      if DatabaseSearchHelper.needs_search?(search)
        begin
          res = @conn.get do |req|
            req.url("/api/v1/marketplace/#{community_id}/listings", format_params(search))
            req.headers['Authorization'] = "apikey key=#{API_KEY}"
          end
          Result::Success.new(parse_response(res.body, includes))
        rescue StandardError => e
          Result::Error.new(e)
        end
      else
        DatabaseSearchHelper.fetch_from_db(community_id: community_id,
                                           search: search,
                                           included_models: included_models,
                                           includes: includes)
      end
    end

    private

    def format_params(original)
      location_params =
        if(original[:latitude].present? && original[:longitude].present?)
          { :'search[lat]' => original[:latitude],
            :'search[lng]' => original[:longitude],
            :'search[distance_unit]' => original[:distance_unit],
            :'search[scale]' => original[:scale],
            :'search[offset]' => original[:offset],
            :'filter[distance_max]' => original[:distance_max]
          }
        else
          {}
        end

      custom_fields = Maybe(original[:fields]).map { |fields|
        fields.select { |f| [:numeric_range, :selection_group].include?(f[:type]) }
        fields.map { |f|
          if f[:type]  == :numeric_range
            [:"custom[#{f[:id]}]", "double:#{f[:value].first}:#{f[:value].last}"]
          else
            [:"custom[#{f[:id]}]", "opt:#{f[:operator]}:#{f[:value].join(",")}"]
          end
        }.to_h
      }.or_else({})

      {
       :'search[keywords]' => original[:keywords],
       :'page[number]' => original[:page],
       :'page[size]' => original[:per_page],
       :'filter[price_min]' => Maybe(original[:price_cents]).map{ |p| p.min }.or_else(nil),
       :'filter[price_max]' => Maybe(original[:price_cents]).map{ |p| p.max }.or_else(nil),
       :'filter[omit_closed]' => !original[:include_closed],
       :'filter[listing_shape_ids]' => Maybe(original[:listing_shape_ids]).join(",").or_else(nil),
       :'filter[category_ids]' => Maybe(original[:categories]).join(",").or_else(nil),
       :'search[locale]' => original[:locale],
       :sort => original[:sort]
      }.merge(location_params).merge(custom_fields).compact
    end

    def listings_from_ids(id_obs, includes)
      # TODO: use pluck instead of instantiating the ActiveRecord objects completely, for better performance
      # http://collectiveidea.com/blog/archives/2015/03/05/optimizing-rails-for-memory-usage-part-3-pluck-and-database-laziness/

      l_ids = id_obs.map { |r| r['id'] }
      data_by_id =  Hash[id_obs.map { |m| [m['id'].to_i, m] }]

      Maybe(l_ids).map { |ids|
        Listing
          .where(id: ids)
          .order(Arel.sql("field(listings.id, #{ids.join ','})"))
          .map { |l|
            distance_hash = parse_distance(data_by_id[l.id])
            ListingIndexService::Search::Converters.listing_hash(l, includes, distance_hash)
          }
      }.or_else([])
    end

    def parse_response(res, includes)
      listings = listings_from_ids(res["data"], includes)

      {count: res["meta"]["total"],
       listings: listings}
    end

    def parse_distance(data)
      Maybe(data['meta'])
        .map{ |m|
          distance = m['distance']
          distance_unit = m['distance-unit']
          if(distance.present? && distance_unit.present?)
            { distance: distance, distance_unit: distance_unit }
          else
            {}
          end
        }.or_else({})
    end
  end
end