elasticsearch_search_api-1.0.x-dev/src/Controller/SearchController.php
src/Controller/SearchController.php
<?php
namespace Drupal\elasticsearch_search_api\Controller;
use Drupal\elasticsearch_search_api\Search\ElasticSearchParamsBuilder;
use Drupal\elasticsearch_search_api\Search\ElasticSearchResultParser;
use Drupal\elasticsearch_search_api\Search\FacetedKeywordSearchAction;
use Drupal\elasticsearch_search_api\Search\FacetedSearchActionInterface;
use Drupal\elasticsearch_search_api\Search\SearchActionFactory;
use Drupal\elasticsearch_search_api\Search\SearchRepository;
use Drupal\elasticsearch_search_api\Search\SearchResult;
use Drupal\elasticsearch_search_api\Search\Suggest\SuggesterInterface;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\AppendCommand;
use Drupal\Core\Ajax\PrependCommand;
use Drupal\Core\Ajax\RemoveCommand;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Routing\CurrentRouteMatch;
use Drupal\Core\Url;
use Elasticsearch\Common\Exceptions\ElasticsearchException;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\ParameterBag;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* Controller to handle the search.
*/
class SearchController extends ControllerBase {
/**
* The renderer.
*
* @var \Drupal\Core\Render\\RendererInterface
*/
protected $renderer;
/**
* Route match.
*
* @var \Drupal\Core\Routing\RouteMatch
*/
protected $routeMatch;
/**
* The Elasticsearch client.
*
* @var \nodespark\DESConnector\ClientInterface
*/
protected $client;
/**
* The search action factory.
*
* @var \Drupal\elasticsearch_search_api\Search\SearchActionFactory
*/
protected $searchActionFactory;
/**
* The search parameters builder.
*
* @var \Drupal\elasticsearch_search_api\Search\ElasticSearchParamsBuilder
*/
protected $searchParamsBuilder;
/**
* The result parser.
*
* @var \Drupal\elasticsearch_search_api\Search\ElasticSearchResultParser
*/
protected $resultParser;
/**
* The suggester.
*
* @var \Drupal\elasticsearch_search_api\Search\Suggest\SuggesterInterface
*/
protected $suggester;
/**
* Facets.
*
* @var array
*/
protected $facets;
/**
* Breadcrumb manager.
*
* @var \Drupal\Core\Breadcrumb\BreadcrumbManager
*/
protected $breadCrumbManager;
/**
* SearchRepository.
*
* @var \Drupal\elasticsearch_search_api\Search\SearchRepository
*/
protected $searchRepository;
/**
* FacetedSearchActiveFiltersBuilder service.
*
* @var \Drupal\elasticsearch_search_api\Search\FacetedSearchActiveFiltersBuilder
*/
protected $activeFiltersBuilder;
/**
* The current path.
*
* @var \Drupal\Core\Path\CurrentPathStack
*/
protected $currentPath;
/**
* NodeViewBuilder.
*
* @var \Drupal\node\NodeViewBuilder
*/
protected $nodeViewBuilder;
/**
* SearchController constructor.
*/
public function __construct(
RendererInterface $renderer,
CurrentRouteMatch $routeMatch,
SearchActionFactory $searchActionFactory,
ElasticSearchParamsBuilder $searchParamsBuilder,
ElasticSearchResultParser $resultParser,
SuggesterInterface $suggester,
SearchRepository $searchRepository,
EntityTypeManagerInterface $entityTypeManager
) {
$this->renderer = $renderer;
$this->routeMatch = $routeMatch;
$this->searchActionFactory = $searchActionFactory;
$this->searchParamsBuilder = $searchParamsBuilder;
$this->resultParser = $resultParser;
$this->suggester = $suggester;
$this->searchRepository = $searchRepository;
$this->nodeViewBuilder = $entityTypeManager->getViewBuilder('node');
$this->facets = $this->getFacets();
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('renderer'),
$container->get('current_route_match'),
$container->get('elasticsearch_search_api.search_action_factory'),
$container->get('elasticsearch_search_api.elasticsearch_params_builder'),
$container->get('elasticsearch_search_api.elasticsearch_result_parser'),
$container->get('elasticsearch_search_api.suggest.title_suggester'),
$container->get('elasticsearch_search_api.search_repository'),
$container->get('entity_type.manager')
);
}
/**
* Search for content.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The HTTP request.
*
* @return array
* A render array.
*
* @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
* When the HTTP request does not seem to be correct.
*/
public function search(Request $request) {
$query = $request->query;
try {
$searchAction = $this->searchActionFactory->searchActionFromQuery($query, $this->facets, $request->isXmlHttpRequest());
}
catch (\Exception $e) {
throw new AccessDeniedHttpException();
}
$drupalSettings = [
'elasticsearch_search_api' => [
'ajaxify' => [
'filter_url' => $this->getFilterUrl()->toString(),
'facets' => $this->facets,
],
'retainFilter' => $this->shouldRetainFilter(),
],
];
$render_base = [
'#theme' => 'elasticsearch_search_api_search',
'#header' => $this->getSearchHeader(),
'#facets' => [],
'#results' => [],
'#result_count' => NULL,
'#did_you_mean' => NULL,
'#cache' => [
'tags' => [
'esa.search',
],
],
'#attached' => [
'library' => [
'elasticsearch_search_api/ajaxify',
'elasticsearch_search_api/loading-overlay',
'elasticsearch_search_api/styles',
],
'drupalSettings' => $drupalSettings,
],
];
try {
$result = $this->parsedResult($searchAction);
}
catch (ElasticsearchException $e) {
$render_base['#results'] = $this->getErrorMessage();
return $render_base;
}
$hits = $this->renderHits($searchAction, $result, $query);
// Requested another page in the result set.
if ($request->isXmlHttpRequest() && !((bool) $request->get('ajax_form'))) {
return $hits + ['#type' => 'container'];
}
$render = [
'#header' => $this->getSearchHeader(),
'#facets' => $this->renderFacets($searchAction, $result),
'#results' => $hits,
'#result_count' => $this->getResultCount($result),
'#did_you_mean' => $this->getSuggestions($query),
];
return array_merge($render_base, $render);
}
/**
* Autocomplete callback for search suggestions.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The HTTP request.
*
* @return \Symfony\Component\HttpFoundation\Response
* A HTTP response.
*
* @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
* When the HTTP request does not seem to be correct.
*/
public function handleAutocomplete(Request $request) {
$searchQuery = $request->get('q');
$results = [];
$params = [
'body' => [
'_source' => 'title',
'suggest' => [
'search-suggest' => [
'prefix' => $searchQuery,
'completion' => [
'field' => 'search_suggest',
'size' => 10,
],
],
],
],
];
$response = $this->searchRepository->query($params);
$data = [];
foreach ($response->getRawResponse()['suggest']['search-suggest'][0]['options'] as $suggestion) {
$value = $suggestion['_source']['title'][0];
$data[$value] = $value;
}
foreach ($data as $value => $label) {
$results[] = [
'value' => $value,
'label' => $this->highlight($searchQuery, $label),
];
}
$build = [
'#theme' => 'elasticsearch_search_api_autocomplete',
'#results' => $results,
];
if ($request->get('t')) {
$build['#layout_wide'] = FALSE;
}
return new Response($this->renderer->render($build));
}
/**
* Highlight the search term in the target string.
*
* @param string $term
* The term that needs to be replaced.
* @param string $target
* The target.
*
* @return string
* The replaced search term.
*/
protected function highlight(string $term, string $target) {
return preg_replace('/(' . preg_quote($term) . ')/i', "<strong>$1</strong>", $target);
}
/**
* Ajax callback to update search results, facets and active filters.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The HTTP request.
*
* @return \Drupal\Core\Ajax\AjaxResponse
* The ajax response, containing commands to update elements on the page.
*
* @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
* When the HTTP request does not seem to be correct.
*/
public function filter(Request $request) {
$query = $request->request;
try {
$searchAction = $this->searchActionFactory->searchActionFromQuery($query, $this->facets, TRUE);
}
catch (\Exception $e) {
throw new AccessDeniedHttpException();
}
$response = new AjaxResponse();
try {
$searchResult = $this->parsedResult($searchAction);
}
catch (ElasticsearchException $e) {
$error = $this->getErrorMessage();
// Replace search hits with error.
$response->addCommand(new RemoveCommand('.esa-results-wrapper .results-wrapper > *'));
$response->addCommand(new AppendCommand('.esa-results-wrapper .results-wrapper', $this->renderer->render($error)));
return $response;
}
$facets = $this->renderFacets($searchAction, $searchResult);
$hits = $this->renderHits($searchAction, $searchResult, $query);
$facets = [
'#type' => 'container',
'#attributes' => ['class' => ['facets']],
'facets' => $facets,
'#attached' => [
'drupalSettings' => [
'elasticsearch_search_api' => [
'ajaxify' => [
'facets' => $this->facets,
],
],
],
],
];
// Replace suggestions.
$response->addCommand(new RemoveCommand('.esa-results-wrapper .did-you-mean'));
$suggestions = $this->getSuggestions($query);
if (!empty($suggestions)) {
$response->addCommand(new PrependCommand('.esa-results-wrapper .suggestion-wrapper', $this->renderer->render($suggestions)));
}
// Replace results count.
$response->addCommand(new RemoveCommand('.esa-results-wrapper .result-count'));
$result_count = $this->getResultCount($searchResult);
if (!empty($result_count)) {
$response->addCommand(new PrependCommand('.esa-results-wrapper .results-count-wrapper', $this->renderer->render($result_count)));
}
// Replace facets.
$response->addCommand(new ReplaceCommand('.facets', $this->renderer->render($facets)));
// Replace search hits.
$response->addCommand(new RemoveCommand('.esa-results-wrapper .results-wrapper > *'));
$response->addCommand(new AppendCommand('.esa-results-wrapper .results-wrapper', $this->renderer->render($hits)));
return $response;
}
/**
* Create a render array from facets.
*
* @param \Drupal\elasticsearch_search_api\Search\FacetedSearchActionInterface $searchAction
* The current search action.
* @param \Drupal\elasticsearch_search_api\Search\SearchResult $result
* SearchResult object parsed from the SearchAction.
*
* @return array
* Render array of facets.
*/
protected function renderFacets(FacetedSearchActionInterface $searchAction, SearchResult $result) {
// Facets as lists of checkboxes.
$facets = array_map(
function ($facet) use ($searchAction, $result) {
// This \Drupal call is hard to avoid as facets are added
// semi-dynamically.
// @codingStandardsIgnoreLine
$renderedFacet = \Drupal::service('elasticsearch_search_api.facet_control.' . $facet)->build($facet, $searchAction, $result);
if (!empty($renderedFacet)) {
return $renderedFacet;
}
return FALSE;
},
$searchAction->getAvailableFacets()
);
return array_filter($facets);
}
/**
* Render search results.
*
* @param \Drupal\elasticsearch_search_api\Search\FacetedSearchActionInterface $searchAction
* The current search action.
* @param \Drupal\elasticsearch_search_api\Search\SearchResult $result
* SearchResult.
* @param \Symfony\Component\HttpFoundation\ParameterBag $query
* Query parameters.
* @param string $view_mode
* The view mode to render search results in. Defaults to 'search_index'.
*
* @return array
* Render array of search results.
*/
protected function renderHits(FacetedSearchActionInterface $searchAction, SearchResult $result, ParameterBag $query, $view_mode = 'search_index') {
$hits = $this->searchRepository->getItemValueFromHits($result->getHits());
$hits = $this->nodeViewBuilder->viewMultiple($hits, $view_mode);
$start = $searchAction->getFrom() + 1;
$end = $searchAction->getSize() + $searchAction->getFrom();
$total = $result->getTotal();
if ($end > $total) {
$end = $total;
}
if ($total > $searchAction->getSize()) {
$hits['summary'] = [
'#type' => 'markup',
'#prefix' => '<div class="pager-summary">',
'#markup' => $this->t('<strong>@start - @end</strong> of @total results', [
'@start' => $start,
'@end' => $end,
'@total' => $total,
]),
'#suffix' => '</div>',
];
$hits['more'] = $this->renderPager($query, $total, $searchAction->getSize());
}
return $hits;
}
/**
* Get a render array representing the pager.
*
* @param \Symfony\Component\HttpFoundation\ParameterBag $query
* Parameter bag.
* @param int $total
* Total amount of results.
* @param int $size
* Size of the result set.
*
* @return array
* Pager render array.
*/
protected function renderPager(ParameterBag $query, int $total, int $size) {
// Tell drupal about a pager being rendered. This
// is necessary to make pager links work correctly.
\Drupal::service('pager.manager')->createPager($total, $size);
return [
'#theme' => 'esa_pager',
'#tags' => [
1 => 'Previous',
3 => 'Next',
],
'#element' => 0,
'#parameters' => $query->all(),
'#total_items' => $total,
'#items_per_page' => $size,
'#route_name' => $this->getSearchUrl()->getRouteName(),
'#route_params' => $this->getSearchUrl()->getRouteParameters(),
];
}
/**
* Parses a SearchAction into a SearchResult object.
*
* @param \Drupal\elasticsearch_search_api\Search\FacetedKeywordSearchAction $searchAction
* The current search action.
*
* @return \Drupal\elasticsearch_search_api\Search\SearchResult
* SearchResult object parsed from the SearchAction
*/
protected function parsedResult(FacetedKeywordSearchAction $searchAction) {
$params = $this->searchParamsBuilder->build($searchAction);
$response = $this->searchRepository->query($params);
return $this->resultParser->parse($searchAction, $response->getRawResponse());
}
/**
* Get a list of suggestions.
*
* @param \Symfony\Component\HttpFoundation\ParameterBag $query
* Query.
*
* @return array
* List of suggestions
*/
protected function getSuggestions(ParameterBag $query) {
$did_you_mean = [];
$keyword = $query->get('keyword');
if (isset($keyword)) {
if (empty(trim($keyword))) {
return [];
}
try {
$suggestions = $this->suggester->suggest($keyword);
} catch (ElasticsearchException $e) {
return [];
}
if (empty($suggestions)) {
return [];
}
foreach ($suggestions as $suggestion) {
$search_url = $this->getSearchUrl();
$did_you_mean[] = [
'#type' => 'link',
'#url' => $search_url->setOption('query', ['keyword' => $suggestion]),
'#title' => $suggestion,
];
}
}
return [
'#theme' => 'elasticsearch_search_api_suggestions',
'#did_you_mean_label' => $this->t('Did you mean'),
'#suggestions' => $did_you_mean,
];
}
/**
* Get the total count of a search result.
*
* @return array
* Theming array of total result count.
*/
public function getResultCount(SearchResult $searchResult) {
return [
'#theme' => 'elasticsearch_search_api_result_count',
'#result_count' => $searchResult->getTotal(),
];
}
/**
* Get a message to show to the client in case of an exception.
*
* @return array
* Render array to print the error message.
*/
protected function getErrorMessage() {
return [
'#theme' => 'elasticsearch_search_api_error',
'#error_message' => $this->t("Search results could not be retrieved."),
];
}
/**
* Get a header to show above the search.
*
* @return array|null
* Render array to print the header, or NULL if no header.
*/
protected function getSearchHeader() {
return NULL;
}
/**
* Define whether the facets should reset after searching for a new keyword.
*
* @return bool
* FALSE if the filter should be retained, TRUE if not.
*/
protected function shouldRetainFilter() {
return FALSE;
}
/**
* Get facet machine names.
*
* This function must be used to assign facets
* in all classes that inherit this base class.
*/
protected function getFacets() {
return [];
}
/**
* Get the search url.
*
* This function must be used to assign route info in all classes that inherit
* this base class, since the route name / params
* will differ for each search instance.
*
* @return \Drupal\Core\Url
* The url object.
*/
protected function getSearchUrl(): Url {
return Url::fromRoute('elasticsearch_search_api.search');
}
/**
* Get the ajax filter search url.
*
* This function must be used to assign route info in all classes
* that inherit this base class, since the route name / params
* will differ for each search instance.
*
* @return \Drupal\Core\Url
* The url object.
*/
protected function getFilterUrl(): Url {
return Url::fromRoute('elasticsearch_search_api.filter');
}
}
