SearchController.java

/*
 * Copyright 2018 Global Crop Diversity Trust
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.genesys.server.api.v1;

import static org.elasticsearch.index.query.QueryBuilders.boolQuery;
import static org.elasticsearch.index.query.QueryBuilders.termsQuery;

import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.apache.commons.lang3.StringUtils;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.QueryBuilder;
import org.genesys.blocks.model.EmptyModel;
import org.genesys.blocks.model.JsonViews;
import org.genesys.server.api.ApiBaseController;
import org.genesys.server.exception.InvalidApiUsageException;
import org.genesys.server.model.Partner;
import org.genesys.server.model.dataset.Dataset;
import org.genesys.server.model.filters.DatasetFilter;
import org.genesys.server.model.genesys.AccessionRef;
import org.genesys.server.model.impl.Crop;
import org.genesys.server.model.traits.Descriptor;
import org.genesys.server.service.ElasticsearchService;
import org.genesys.server.service.ElasticsearchService.SearchResults;
import org.genesys.server.exception.SearchException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.fasterxml.jackson.annotation.JsonView;

/**
 * API to search the Catalog.
 *
 * @author Matija Obreza
 */
@RestController("searchApi1")
@RequestMapping(SearchController.CONTROLLER_URL)
public class SearchController extends ApiBaseController {

	/** The Constant CONTROLLER_URL. */
	public static final String CONTROLLER_URL = ApiBaseController.APIv1_BASE + "/search";

	@Autowired(required = false)
	private ElasticsearchService elasticsearch;

	/**
	 * Full-text search across all indexed entities.
	 *
	 * @param text the search text
	 * @return the map
	 */
	@PostMapping(value = "", produces = { MediaType.APPLICATION_JSON_VALUE })
	@JsonView({ JsonViews.Public.class })
	public Map<String, List<? extends EmptyModel>> search(@RequestBody final String text) throws SearchException {
		return elasticsearch.fullTextSearch(text);
	}

	/**
	 * Get suggestions for dataset filters .
	 *
	 * @param filters the filters
	 * @param searchQuery the search query
	 * @return the map
	 */
	@JsonView({ JsonViews.Minimal.class })
	@PostMapping(value = "/dataset/suggest", produces = MediaType.APPLICATION_JSON_VALUE)
	public Map<String, SearchResults<?>> datasets(@RequestBody(required = false) final DatasetFilter filters, @RequestParam(value = "q", required = true) String searchQuery) {
		LOG.trace("Incoming {}", searchQuery);
		searchQuery = sanitizeQuery(searchQuery);
		LOG.info("Suggestions for datasets for: {}", searchQuery);

		if (StringUtils.isBlank(searchQuery)) {
			throw new InvalidApiUsageException("No search query provided");
		}

		QueryBuilder extraFilters = filtersFromDataset(filters);

		Set<Class<? extends EmptyModel>> clazzes = Set.of(Crop.class, Partner.class, Descriptor.class);
		Map<Class<? extends EmptyModel>, List<? extends EmptyModel>> hitsByEntity = elasticsearch.search(extraFilters, searchQuery, clazzes);

		Map<String, SearchResults<?>> suggestions = new HashMap<>();
		suggestions.put("search.group.crop", SearchResults.from("code", Arrays.asList("crop"), hitsByEntity.get(Crop.class)));
		suggestions.put("search.group.partner", SearchResults.from("uuid", Arrays.asList("owner.uuid"), hitsByEntity.get(Partner.class)));
		suggestions.put("search.group.accession", SearchResults.from("doi", Arrays.asList("accessionRef.doi"), hitsByEntity.get(AccessionRef.class)));
		suggestions.put("search.group.descriptor", SearchResults.from("uuid", Arrays.asList("descriptor.uuid"), hitsByEntity.get(Descriptor.class)));

		// Search datasets
		suggestions.put("search.matches", SearchResults.from("uuid", Arrays.asList("uuid"), elasticsearch.search(extraFilters, searchQuery, Dataset.class)));

		return suggestions;
	}

	/// Try to enhance the search by adding known Dataset filters
	private QueryBuilder filtersFromDataset(DatasetFilter filters) {
		if (filters == null) {
			return null;
		}
		BoolQueryBuilder q = boolQuery();
		if (filters.crops != null && !filters.crops.isEmpty()) {
			q.must(termsQuery("crop", filters.crops));
		}
		q.boost(3.0f);

		return q.hasClauses() ? q : null;
	}

	/**
	 * Sanitize incoming search query
	 *
	 * @param searchQuery incoming
	 * @return sanitized search query
	 */
	private String sanitizeQuery(String searchQuery) {
		if (StringUtils.isBlank(searchQuery)) {
			return null;
		}
		return searchQuery.replaceAll("[^\\w\\d\\s]+", "").replaceAll("\\s\\s+", " ").trim();
	}
}