MediaWiki  master
SearchHandler.php
Go to the documentation of this file.
1 <?php
2 
3 namespace MediaWiki\Rest\Handler;
4 
5 use Config;
6 use InvalidArgumentException;
19 use SearchEngine;
22 use SearchResult;
24 use Status;
25 use TitleFormatter;
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  'size' => $thumbnail->getSize(),
334  'width' => $thumbnail->getWidth(),
335  'height' => $thumbnail->getHeight(),
336  'duration' => $thumbnail->getDuration(),
337  'url' => $thumbnail->getUrl(),
338  ];
339  }
340 
351  private function buildDescriptionsFromPageIdentities( array $pageIdentities ) {
352  $descriptions = array_fill_keys( array_keys( $pageIdentities ), null );
353 
354  $this->getHookRunner()->onSearchResultProvideDescription( $pageIdentities, $descriptions );
355 
356  return array_map( static function ( $description ) {
357  return [ 'description' => $description ];
358  }, $descriptions );
359  }
360 
372  private function buildThumbnailsFromPageIdentities( array $pageIdentities ) {
373  $thumbnails = $this->searchResultThumbnailProvider->getThumbnails( $pageIdentities );
374  $thumbnails += array_fill_keys( array_keys( $pageIdentities ), null );
375 
376  return array_map( function ( $thumbnail ) {
377  return [ 'thumbnail' => $this->serializeThumbnail( $thumbnail ) ];
378  }, $thumbnails );
379  }
380 
385  public function execute() {
386  $searchEngine = $this->createSearchEngine();
387  $pageInfos = $this->doSearch( $searchEngine );
388 
389  // We can only pass validated "real" PageIdentities to our hook handlers below
390  $pageIdentities = array_reduce(
391  array_values( $pageInfos ),
392  static function ( $realPages, $item ) {
393  $page = $item['pageIdentity'];
394  if ( $page instanceof PageIdentity && $page->exists() ) {
395  $realPages[$item['pageIdentity']->getId()] = $item['pageIdentity'];
396  }
397  return $realPages;
398  }, []
399  );
400 
401  $descriptions = $this->buildDescriptionsFromPageIdentities( $pageIdentities );
402  $thumbs = $this->buildThumbnailsFromPageIdentities( $pageIdentities );
403 
404  $thumbsAndDescriptions = [];
405  foreach ( $descriptions as $pageId => $description ) {
406  $thumbsAndDescriptions[$pageId] = $description + $thumbs[$pageId];
407  }
408 
409  $result = $this->buildResultFromPageInfos( $pageInfos, $thumbsAndDescriptions );
410 
411  $response = $this->getResponseFactory()->createJson( [ 'pages' => $result ] );
412 
413  if ( $this->mode === self::COMPLETION_MODE && $this->completionCacheExpiry ) {
414  // Type-ahead completion matches should be cached by the client and
415  // in the CDN, especially for short prefixes.
416  // See also $wgSearchSuggestCacheExpiry and ApiOpenSearch
417  if ( $this->permissionManager->isEveryoneAllowed( 'read' ) ) {
418  $response->setHeader( 'Cache-Control', 'public, max-age=' . $this->completionCacheExpiry );
419  } else {
420  $response->setHeader( 'Cache-Control', 'no-store, max-age=0' );
421  }
422  }
423 
424  return $response;
425  }
426 
427  public function getParamSettings() {
428  return [
429  'q' => [
430  self::PARAM_SOURCE => 'query',
431  ParamValidator::PARAM_TYPE => 'string',
432  ParamValidator::PARAM_REQUIRED => true,
433  ],
434  'limit' => [
435  self::PARAM_SOURCE => 'query',
436  ParamValidator::PARAM_TYPE => 'integer',
437  ParamValidator::PARAM_REQUIRED => false,
438  ParamValidator::PARAM_DEFAULT => self::LIMIT,
439  IntegerDef::PARAM_MIN => 1,
440  IntegerDef::PARAM_MAX => self::MAX_LIMIT,
441  ],
442  ];
443  }
444 }
if(!defined('MW_SETUP_CALLBACK'))
The persistent session ID (if any) loaded at startup.
Definition: WebStart.php:82
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:168
getValidatedParams()
Fetch the validated parameters.
Definition: Handler.php:358
getAuthority()
Get the current acting authority.
Definition: Handler.php:157
Class that stores information about thumbnail, e.
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.
Search suggestion.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition: Status.php:45
Value object representing a message for i18n.
Service for formatting and validating API parameters.
Type definition for integer types.
Definition: IntegerDef.php:23
Interface for configuration instances.
Definition: Config.php:30
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".
A set of SearchEngine results.
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.