diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index f32af449c..2bd6d508f 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -17,6 +17,16 @@ class SearchController < ApplicationController per_page ].freeze + FEED_PARAMS = %i[ + page + per_page + published_at + search_fields + sort_by + tag_names + user_id + ].freeze + def tags tag_docs = Search::Tag.search_documents("name:#{params[:name]}* AND supported:true") @@ -47,6 +57,12 @@ class SearchController < ApplicationController render json: { result: user_docs } end + def feed_content + feed_docs = Search::FeedContent.search_documents(params: feed_params.to_h) + + render json: { result: feed_docs } + end + private def chat_channel_params @@ -70,6 +86,10 @@ class SearchController < ApplicationController params.permit(USER_PARAMS) end + def feed_params + params.permit(FEED_PARAMS) + end + def format_integer_params params[:page] = params[:page].to_i if params[:page].present? params[:per_page] = params[:per_page].to_i if params[:per_page].present? diff --git a/app/services/search/feed_content.rb b/app/services/search/feed_content.rb index 1324ac421..c02677df6 100644 --- a/app/services/search/feed_content.rb +++ b/app/services/search/feed_content.rb @@ -3,8 +3,21 @@ module Search INDEX_NAME = "feed_content_#{Rails.env}".freeze INDEX_ALIAS = "feed_content_#{Rails.env}_alias".freeze MAPPINGS = JSON.parse(File.read("config/elasticsearch/mappings/feed_content.json"), symbolize_names: true).freeze + DEFAULT_PAGE = 0 + DEFAULT_PER_PAGE = 60 class << self + def search_documents(params:) + set_query_size(params) + query_hash = Search::QueryBuilders::FeedContent.new(params).as_hash + + results = search(body: query_hash) + hits = results.dig("hits", "hits").map do |feed_doc| + feed_doc.dig("_source") + end + paginate_hits(hits, params) + end + private def index_settings diff --git a/app/services/search/query_builders/feed_content.rb b/app/services/search/query_builders/feed_content.rb new file mode 100644 index 000000000..a0d77ca10 --- /dev/null +++ b/app/services/search/query_builders/feed_content.rb @@ -0,0 +1,78 @@ +module Search + module QueryBuilders + class FeedContent < QueryBase + QUERY_KEYS = %i[ + search_fields + ].freeze + + TERM_KEYS = { + tag_names: "tags.name", + approved: "approved", + user_id: "user.id", + class_name: "class_name" + }.freeze + + RANGE_KEYS = %i[ + published_at + ].freeze + + DEFAULT_PARAMS = { + sort_by: "hotness_score", + sort_direction: "desc", + size: 0 + }.freeze + + attr_accessor :params, :body + + def initialize(params) + @params = params.deep_symbolize_keys + build_body + end + + private + + def build_queries + @body[:query] = { bool: {} } + @body[:query][:bool][:filter] = filter_conditions if filter_keys_present? + @body[:query][:bool][:must] = query_conditions if query_keys_present? + end + + def filter_conditions + filter_conditions = [] + + filter_conditions.concat(term_keys) if terms_keys_present? + filter_conditions.concat(range_keys) if range_keys_present? + + filter_conditions + end + + def filter_keys_present? + range_keys_present? || terms_keys_present? + end + + def terms_keys_present? + TERM_KEYS.detect { |key, _| @params[key].present? } + end + + def range_keys_present? + RANGE_KEYS.detect { |key| @params[key].present? } + end + + def term_keys + TERM_KEYS.map do |term_key, search_key| + next unless @params.key? term_key + + { term: { search_key => @params[term_key] } } + end.compact + end + + def range_keys + RANGE_KEYS.map do |range_key| + next unless @params.key? range_key + + { range: { range_key => @params[range_key] } } + end.compact + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 5c74d6abe..024e0a283 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -225,6 +225,7 @@ Rails.application.routes.draw do get "/search/chat_channels" => "search#chat_channels" get "/search/classified_listings" => "search#classified_listings" get "/search/users" => "search#users" + get "/search/feed_content" => "search#feed_content" get "/chat_channel_memberships/find_by_chat_channel_id" => "chat_channel_memberships#find_by_chat_channel_id" get "/listings/dashboard" => "classified_listings#dashboard" get "/listings/:category" => "classified_listings#index" diff --git a/spec/requests/search_spec.rb b/spec/requests/search_spec.rb index f9ba00e84..29836329e 100644 --- a/spec/requests/search_spec.rb +++ b/spec/requests/search_spec.rb @@ -55,4 +55,32 @@ RSpec.describe "Search", type: :request, proper_status: true do expect(response.parsed_body).to eq("result" => mock_documents) end end + + describe "GET /search/users" do + let(:authorized_user) { create(:user) } + let(:mock_documents) { [{ "username" => "firstlast" }] } + + it "returns json" do + sign_in authorized_user + allow(Search::User).to receive(:search_documents).and_return( + mock_documents, + ) + get "/search/users" + expect(response.parsed_body).to eq("result" => mock_documents) + end + end + + describe "GET /search/feed_content" do + let(:authorized_user) { create(:user) } + let(:mock_documents) { [{ "title" => "article1" }] } + + it "returns json" do + sign_in authorized_user + allow(Search::FeedContent).to receive(:search_documents).and_return( + mock_documents, + ) + get "/search/feed_content" + expect(response.parsed_body).to eq("result" => mock_documents) + end + end end diff --git a/spec/services/search/feed_content_spec.rb b/spec/services/search/feed_content_spec.rb index 8cdadcb30..3635f145d 100644 --- a/spec/services/search/feed_content_spec.rb +++ b/spec/services/search/feed_content_spec.rb @@ -6,4 +6,91 @@ RSpec.describe Search::FeedContent, type: :service do expect(described_class::INDEX_ALIAS).not_to be_nil expect(described_class::MAPPINGS).not_to be_nil end + + describe "::search_documents", elasticsearch: true do + let(:article1) { create(:article) } + let(:article2) { create(:article) } + + it "parses feed content document hits from search response" do + mock_search_response = { "hits" => { "hits" => {} } } + allow(described_class).to receive(:search) { mock_search_response } + described_class.search_documents(params: {}) + expect(described_class).to have_received(:search).with(body: a_kind_of(Hash)) + end + + context "with a query" do + it "searches by search_fields" do + allow(article1).to receive(:title).and_return("ruby") + allow(article2).to receive(:body_text).and_return("Ruby Tuesday") + index_documents([article1, article2]) + query_params = { size: 5, search_fields: "ruby" } + + feed_docs = described_class.search_documents(params: query_params) + expect(feed_docs.count).to eq(2) + doc_ids = feed_docs.map { |t| t.dig("id") } + expect(doc_ids).to include(article1.id, article2.id) + end + end + + context "with a filter term" do + it "filters by tag names" do + article1.tags << create(:tag, name: "ruby") + article2.tags << create(:tag, name: "python") + index_documents([article1, article2]) + query_params = { size: 5, tag_names: "ruby" } + + feed_docs = described_class.search_documents(params: query_params) + expect(feed_docs.count).to eq(1) + doc_ids = feed_docs.map { |t| t.dig("id") } + expect(doc_ids).to include(article1.id) + end + + it "filters by user_id" do + index_documents([article1, article2]) + query_params = { size: 5, user_id: article1.user_id } + + feed_docs = described_class.search_documents(params: query_params) + expect(feed_docs.count).to eq(1) + doc_ids = feed_docs.map { |t| t.dig("id") } + expect(doc_ids).to include(article1.id) + end + + it "filters by approved" do + article1.update(approved: false) + article2.update(approved: true) + index_documents([article1, article2]) + query_params = { size: 5, approved: true } + + feed_docs = described_class.search_documents(params: query_params) + expect(feed_docs.count).to eq(1) + doc_ids = feed_docs.map { |t| t.dig("id") } + expect(doc_ids).to include(article2.id) + end + + it "filters by class_name" do + pde = create(:podcast_episode) + index_documents([pde, article1, article2]) + query_params = { size: 5, class_name: "PodcastEpisode" } + + feed_docs = described_class.search_documents(params: query_params) + expect(feed_docs.count).to eq(1) + doc_ids = feed_docs.map { |t| t.dig("id") } + expect(doc_ids).to include(pde.id) + end + end + + context "with range keys" do + it "searches by published_at" do + article1.update(published_at: 1.year.ago) + article2.update(published_at: 1.month.ago) + index_documents([article1, article2]) + query_params = { size: 5, published_at: { gte: 2.months.ago.iso8601 } } + + feed_docs = described_class.search_documents(params: query_params) + expect(feed_docs.count).to eq(1) + doc_ids = feed_docs.map { |t| t.dig("id") } + expect(doc_ids).to include(article2.id) + end + end + end end diff --git a/spec/services/search/query_builders/feed_content_spec.rb b/spec/services/search/query_builders/feed_content_spec.rb new file mode 100644 index 000000000..6c508d0a0 --- /dev/null +++ b/spec/services/search/query_builders/feed_content_spec.rb @@ -0,0 +1,86 @@ +require "rails_helper" + +RSpec.describe Search::QueryBuilders::FeedContent, type: :service do + describe "::intialize" do + it "sets params" do + filter_params = { foo: "bar" } + filter = described_class.new(filter_params) + expect(filter.params).to include(filter_params) + end + + it "builds query body" do + filter = described_class.new({}) + expect(filter.body).not_to be_nil + end + end + + describe "#as_hash" do + it "applies QUERY_KEYS from params" do + params = { search_fields: "test" } + filter = described_class.new(params) + exepcted_query = [{ + "simple_query_string" => { + "query" => "test*", "fields" => [:search_fields], "lenient" => true, "analyze_wildcard" => true + } + }] + expect(filter.as_hash.dig("query", "bool", "must")).to match_array(exepcted_query) + end + + it "applies TERM_KEYS from params" do + params = { approved: true, tag_names: "beginner", user_id: 777, class_name: "Article" } + filter = described_class.new(params) + exepcted_filters = [ + { "term" => { "approved" => true } }, + { "term" => { "tags.name" => "beginner" } }, + { "term" => { "user.id" => 777 } }, + { "term" => { "class_name" => "Article" } }, + ] + expect(filter.as_hash.dig("query", "bool", "filter")).to match_array(exepcted_filters) + end + + it "applies RANGE_KEYS from params" do + Timecop.freeze(Time.current) do + params = { published_at: { lte: Time.current } } + filter = described_class.new(params) + exepcted_filters = [ + { "range" => { "published_at" => { lte: Time.current } } }, + ] + expect(filter.as_hash.dig("query", "bool", "filter")).to match_array(exepcted_filters) + end + end + + it "applies QUERY_KEYS, TERM_KEYS, and RANGE_KEYS from params" do + Timecop.freeze(Time.current) do + params = { search_fields: "ruby", published_at: { lte: Time.current }, tag_names: "cfp" } + filter = described_class.new(params) + exepcted_query = [{ + "simple_query_string" => { "query" => "ruby*", "fields" => [:search_fields], "lenient" => true, "analyze_wildcard" => true } + }] + exepcted_filters = [ + { "range" => { "published_at" => { lte: Time.current } } }, + { "term" => { "tags.name" => "cfp" } }, + ] + expect(filter.as_hash.dig("query", "bool", "must")).to match_array(exepcted_query) + expect(filter.as_hash.dig("query", "bool", "filter")).to match_array(exepcted_filters) + end + end + + it "ignores params we don't support" do + params = { not_supported: "trash", search_fields: "cfp" } + filter = described_class.new(params) + exepcted_query = [{ + "simple_query_string" => { + "query" => "cfp*", "fields" => [:search_fields], "lenient" => true, "analyze_wildcard" => true + } + }] + expect(filter.as_hash.dig("query", "bool", "must")).to match_array(exepcted_query) + end + + it "allows default params to be overriden" do + params = { sort_by: "published_at", sort_direction: "asc", size: 20 } + filter = described_class.new(params).as_hash + expect(filter.dig("sort")).to eq("published_at" => "asc") + expect(filter.dig("size")).to eq(20) + end + end +end diff --git a/spec/services/search/user_spec.rb b/spec/services/search/user_spec.rb index 9334454d7..a95747f00 100644 --- a/spec/services/search/user_spec.rb +++ b/spec/services/search/user_spec.rb @@ -1,13 +1,13 @@ require "rails_helper" -RSpec.describe Search::User, type: :service, elasticsearch: true do +RSpec.describe Search::User, type: :service do it "defines INDEX_NAME, INDEX_ALIAS, and MAPPINGS", :aggregate_failures do expect(described_class::INDEX_NAME).not_to be_nil expect(described_class::INDEX_ALIAS).not_to be_nil expect(described_class::MAPPINGS).not_to be_nil end - describe "::search_documents" do + describe "::search_documents", elasticsearch: true do let(:user1) { create(:user) } let(:user2) { create(:user) }