MediaWiki  master
SearchHandler.php
Go to the documentation of this file.
1 <?php
2 
3 namespace MediaWiki\Rest\Handler;
4 
5 use InvalidArgumentException;
21 use SearchEngine;
24 use SearchResult;
29 
33 class SearchHandler extends Handler {
34 
36  private $searchEngineFactory;
37 
39  private $searchEngineConfig;
40 
42  private $searchResultThumbnailProvider;
43 
45  private $permissionManager;
46 
48  private $redirectLookup;
49 
51  private $pageStore;
52 
54  private $titleFormatter;
55 
59  public const FULLTEXT_MODE = 'fulltext';
60 
64  public const COMPLETION_MODE = 'completion';
65 
69  private const SUPPORTED_MODES = [ self::FULLTEXT_MODE, self::COMPLETION_MODE ];
70 
74  private $mode = null;
75 
77  private const LIMIT = 50;
78 
80  private const MAX_LIMIT = 100;
81 
83  private const OFFSET = 0;
84 
91  private $completionCacheExpiry;
92 
103  public function __construct(
104  Config $config,
105  SearchEngineFactory $searchEngineFactory,
106  SearchEngineConfig $searchEngineConfig,
107  SearchResultThumbnailProvider $searchResultThumbnailProvider,
108  PermissionManager $permissionManager,
109  RedirectLookup $redirectLookup,
110  PageStore $pageStore,
111  TitleFormatter $titleFormatter
112  ) {
113  $this->searchEngineFactory = $searchEngineFactory;
114  $this->searchEngineConfig = $searchEngineConfig;
115  $this->searchResultThumbnailProvider = $searchResultThumbnailProvider;
116  $this->permissionManager = $permissionManager;
117  $this->redirectLookup = $redirectLookup;
118  $this->pageStore = $pageStore;
119  $this->titleFormatter = $titleFormatter;
120 
121  // @todo Avoid injecting the entire config, see T246377
122  $this->completionCacheExpiry = $config->get( MainConfigNames::SearchSuggestCacheExpiry );
123  }
124 
125  protected function postInitSetup() {
126  $this->mode = $this->getConfig()['mode'] ?? self::FULLTEXT_MODE;
127 
128  if ( !in_array( $this->mode, self::SUPPORTED_MODES ) ) {
129  throw new InvalidArgumentException(
130  "Unsupported search mode `{$this->mode}` configured. Supported modes: " .
131  implode( ', ', self::SUPPORTED_MODES )
132  );
133  }
134  }
135 
139  private function createSearchEngine() {
140  $limit = $this->getValidatedParams()['limit'];
141 
142  $searchEngine = $this->searchEngineFactory->create();
143  $searchEngine->setNamespaces( $this->searchEngineConfig->defaultNamespaces() );
144  $searchEngine->setLimitOffset( $limit, self::OFFSET );
145  return $searchEngine;
146  }
147 
148  public function needsWriteAccess() {
149  return false;
150  }
151 
158  private function getSearchResultsOrThrow( $results ) {
159  if ( $results ) {
160  if ( $results instanceof Status ) {
161  $status = $results;
162  if ( !$status->isOK() ) {
163  [ $error ] = $status->splitByErrorType();
164  if ( $error->getErrors() ) { // Only throw for errors, suppress warnings (for now)
165  $errorMessages = $error->getMessage();
166  throw new LocalizedHttpException(
167  new MessageValue( "rest-search-error", [ $errorMessages->getKey() ] )
168  );
169  }
170  }
171  $statusValue = $status->getValue();
172  if ( $statusValue instanceof ISearchResultSet ) {
173  return $statusValue->extractResults();
174  }
175  } else {
176  return $results->extractResults();
177  }
178  }
179  return [];
180  }
181 
189  private function doSearch( $searchEngine ) {
190  $query = $this->getValidatedParams()['q'];
191 
192  if ( $this->mode == self::COMPLETION_MODE ) {
193  $completionSearch = $searchEngine->completionSearchWithVariants( $query );
194  return $this->buildPageObjects( $completionSearch->getSuggestions() );
195  } else {
196  $titleSearch = $searchEngine->searchTitle( $query );
197  $textSearch = $searchEngine->searchText( $query );
198 
199  $titleSearchResults = $this->getSearchResultsOrThrow( $titleSearch );
200  $textSearchResults = $this->getSearchResultsOrThrow( $textSearch );
201 
202  $mergedResults = array_merge( $titleSearchResults, $textSearchResults );
203  return $this->buildPageObjects( $mergedResults );
204  }
205  }
206 
219  private function buildPageObjects( array $searchResponse ): array {
220  $pageInfos = [];
221  foreach ( $searchResponse as $response ) {
222  $isSearchResult = $response instanceof SearchResult;
223  if ( $isSearchResult ) {
224  if ( $response->isBrokenTitle() || $response->isMissingRevision() ) {
225  continue;
226  }
227  $title = $response->getTitle();
228  } else {
229  $title = $response->getSuggestedTitle();
230  }
231  $pageObj = $this->buildSinglePage( $title, $response );
232  if ( $pageObj ) {
233  $pageNsAndID = CacheKeyHelper::getKeyForPage( $pageObj['pageIdentity'] );
234  // This handles the edge case where we have both the redirect source and redirect target page come back
235  // in our search results. In such event, we prefer (and thus replace) with the redirect target page.
236  if ( isset( $pageInfos[$pageNsAndID] ) ) {
237  if ( $pageInfos[$pageNsAndID]['redirect'] !== null ) {
238  $pageInfos[$pageNsAndID]['result'] = $isSearchResult ? $response : null;
239  $pageInfos[$pageNsAndID]['suggestion'] = $isSearchResult ? null : $response;
240  }
241  continue;
242  }
243  $pageInfos[$pageNsAndID] = $pageObj;
244  }
245  }
246  return $pageInfos;
247  }
248 
262  private function buildSinglePage( $title, $result ) {
263  $redirectTarget = $title->canExist() ? $this->redirectLookup->getRedirectTarget( $title ) : null;
264  // Our page has a redirect that is not in a virtual namespace and is not an interwiki link.
265  // See T301346, T303352
266  if ( $redirectTarget && $redirectTarget->getNamespace() > -1 && !$redirectTarget->isExternal() ) {
267  $redirectSource = $title;
268  $title = $this->pageStore->getPageForLink( $redirectTarget );
269  } else {
270  $redirectSource = null;
271  }
272  if ( !$title || !$this->getAuthority()->probablyCan( 'read', $title ) ) {
273  return false;
274  }
275  return [
276  'pageIdentity' => $title,
277  'suggestion' => $result instanceof SearchSuggestion ? $result : null,
278  'result' => $result instanceof SearchResult ? $result : null,
279  'redirect' => $redirectSource
280  ];
281  }
282 
295  private function buildResultFromPageInfos( array $pageInfos, array $thumbsAndDesc ): array {
296  $pages = [];
297  foreach ( $pageInfos as $pageInfo ) {
298  [
299  'pageIdentity' => $page,
300  'suggestion' => $sugg,
301  'result' => $result,
302  'redirect' => $redirect
303  ] = $pageInfo;
304  $excerpt = $sugg ? $sugg->getText() : $result->getTextSnippet();
305  $id = ( $page instanceof PageIdentity && $page->canExist() ) ? $page->getId() : 0;
306  $pages[] = [
307  'id' => $id,
308  'key' => $this->titleFormatter->getPrefixedDBkey( $page ),
309  'title' => $this->titleFormatter->getPrefixedText( $page ),
310  'excerpt' => $excerpt ?: null,
311  'matched_title' => $redirect ? $this->titleFormatter->getPrefixedText( $redirect ) : null,
312  'description' => $id > 0 ? $thumbsAndDesc[$id]['description'] : null,
313  'thumbnail' => $id > 0 ? $thumbsAndDesc[$id]['thumbnail'] : null,
314  ];
315  }
316  return $pages;
317  }
318 
326  private function serializeThumbnail( ?SearchResultThumbnail $thumbnail ): ?array {
327  if ( $thumbnail == null ) {
328  return null;
329  }
330 
331  return [
332  'mimetype' => $thumbnail->getMimeType(),
333  'width' => $thumbnail->getWidth(),
334  'height' => $thumbnail->getHeight(),
335  'duration' => $thumbnail->getDuration(),
336  'url' => $thumbnail->getUrl(),
337  ];
338  }
339 
350  private function buildDescriptionsFromPageIdentities( array $pageIdentities ) {
351  $descriptions = array_fill_keys( array_keys( $pageIdentities ), null );
352 
353  $this->getHookRunner()->onSearchResultProvideDescription( $pageIdentities, $descriptions );
354 
355  return array_map( static function ( $description ) {
356  return [ 'description' => $description ];
357  }, $descriptions );
358  }
359 
371  private function buildThumbnailsFromPageIdentities( array $pageIdentities ) {
372  $thumbnails = $this->searchResultThumbnailProvider->getThumbnails( $pageIdentities );
373  $thumbnails += array_fill_keys( array_keys( $pageIdentities ), null );
374 
375  return array_map( function ( $thumbnail ) {
376  return [ 'thumbnail' => $this->serializeThumbnail( $thumbnail ) ];
377  }, $thumbnails );
378  }
379 
384  public function execute() {
385  $searchEngine = $this->createSearchEngine();
386  $pageInfos = $this->doSearch( $searchEngine );
387 
388  // We can only pass validated "real" PageIdentities to our hook handlers below
389  $pageIdentities = array_reduce(
390  array_values( $pageInfos ),
391  static function ( $realPages, $item ) {
392  $page = $item['pageIdentity'];
393  if ( $page instanceof PageIdentity && $page->exists() ) {
394  $realPages[$item['pageIdentity']->getId()] = $item['pageIdentity'];
395  }
396  return $realPages;
397  }, []
398  );
399 
400  $descriptions = $this->buildDescriptionsFromPageIdentities( $pageIdentities );
401  $thumbs = $this->buildThumbnailsFromPageIdentities( $pageIdentities );
402 
403  $thumbsAndDescriptions = [];
404  foreach ( $descriptions as $pageId => $description ) {
405  $thumbsAndDescriptions[$pageId] = $description + $thumbs[$pageId];
406  }
407 
408  $result = $this->buildResultFromPageInfos( $pageInfos, $thumbsAndDescriptions );
409 
410  $response = $this->getResponseFactory()->createJson( [ 'pages' => $result ] );
411 
412  if ( $this->mode === self::COMPLETION_MODE && $this->completionCacheExpiry ) {
413  // Type-ahead completion matches should be cached by the client and
414  // in the CDN, especially for short prefixes.
415  // See also $wgSearchSuggestCacheExpiry and ApiOpenSearch
416  if ( $this->permissionManager->isEveryoneAllowed( 'read' ) ) {
417  $response->setHeader( 'Cache-Control', 'public, max-age=' . $this->completionCacheExpiry );
418  } else {
419  $response->setHeader( 'Cache-Control', 'no-store, max-age=0' );
420  }
421  }
422 
423  return $response;
424  }
425 
426  public function getParamSettings() {
427  return [
428  'q' => [
429  self::PARAM_SOURCE => 'query',
430  ParamValidator::PARAM_TYPE => 'string',
431  ParamValidator::PARAM_REQUIRED => true,
432  ],
433  'limit' => [
434  self::PARAM_SOURCE => 'query',
435  ParamValidator::PARAM_TYPE => 'integer',
436  ParamValidator::PARAM_REQUIRED => false,
437  ParamValidator::PARAM_DEFAULT => self::LIMIT,
438  IntegerDef::PARAM_MIN => 1,
439  IntegerDef::PARAM_MAX => self::MAX_LIMIT,
440  ],
441  ];
442  }
443 }
if(!defined('MW_SETUP_CALLBACK'))
Definition: WebStart.php:88
Helper class for mapping value objects representing basic entities to cache keys.
A class containing constants representing the names of configuration variables.
const SearchSuggestCacheExpiry
Name constant for the SearchSuggestCacheExpiry setting, for use with Config::get()
A service class for checking permissions To obtain an instance, use MediaWikiServices::getInstance()-...
Handler class for Core REST API endpoint that handles basic search.
needsWriteAccess()
Indicates whether this route requires write access.
const FULLTEXT_MODE
Search page body and titles.
__construct(Config $config, SearchEngineFactory $searchEngineFactory, SearchEngineConfig $searchEngineConfig, SearchResultThumbnailProvider $searchResultThumbnailProvider, PermissionManager $permissionManager, RedirectLookup $redirectLookup, PageStore $pageStore, TitleFormatter $titleFormatter)
const COMPLETION_MODE
Search title completion matches.
postInitSetup()
The handler can override this to do any necessary setup after init() is called to inject the dependen...
getParamSettings()
Fetch ParamValidator settings for parameters.
Base class for REST route handlers.
Definition: Handler.php:20
getConfig()
Get the configuration array for the current route.
Definition: Handler.php:177
getValidatedParams()
Fetch the validated parameters.
Definition: Handler.php:371
getAuthority()
Get the current acting authority.
Definition: Handler.php:166
Class that stores information about thumbnail, e.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition: Status.php:58
Configuration handling class for SearchEngine.
Factory class for SearchEngine.
Contain a class for special pages.
NOTE: this class is being refactored into an abstract base class.
A search suggestion.
Value object representing a message for i18n.
Service for formatting and validating API parameters.
Type definition for integer types.
Definition: IntegerDef.php:23
A set of SearchEngine results.
Interface for configuration instances.
Definition: Config.php:32
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".
Interface for objects (potentially) representing an editable wiki page.
exists()
Checks if the page currently exists.
Service for resolving a wiki page redirect.
A title formatter service for MediaWiki.
Copyright (C) 2011-2020 Wikimedia Foundation and others.