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;
18 use SearchEngine;
21 use SearchResult;
23 use Status;
24 use TitleFormatter;
28 
32 class SearchHandler extends Handler {
33 
35  private $searchEngineFactory;
36 
38  private $searchEngineConfig;
39 
41  private $permissionManager;
42 
44  private $redirectLookup;
45 
47  private $pageStore;
48 
50  private $titleFormatter;
51 
55  public const FULLTEXT_MODE = 'fulltext';
56 
60  public const COMPLETION_MODE = 'completion';
61 
65  private const SUPPORTED_MODES = [ self::FULLTEXT_MODE, self::COMPLETION_MODE ];
66 
70  private $mode = null;
71 
73  private const LIMIT = 50;
74 
76  private const MAX_LIMIT = 100;
77 
79  private const OFFSET = 0;
80 
87  private $completionCacheExpiry;
88 
98  public function __construct(
99  Config $config,
100  SearchEngineFactory $searchEngineFactory,
101  SearchEngineConfig $searchEngineConfig,
102  PermissionManager $permissionManager,
103  RedirectLookup $redirectLookup,
104  PageStore $pageStore,
105  TitleFormatter $titleFormatter
106  ) {
107  $this->searchEngineFactory = $searchEngineFactory;
108  $this->searchEngineConfig = $searchEngineConfig;
109  $this->permissionManager = $permissionManager;
110  $this->redirectLookup = $redirectLookup;
111  $this->pageStore = $pageStore;
112  $this->titleFormatter = $titleFormatter;
113 
114  // @todo Avoid injecting the entire config, see T246377
115  $this->completionCacheExpiry = $config->get( MainConfigNames::SearchSuggestCacheExpiry );
116  }
117 
118  protected function postInitSetup() {
119  $this->mode = $this->getConfig()['mode'] ?? self::FULLTEXT_MODE;
120 
121  if ( !in_array( $this->mode, self::SUPPORTED_MODES ) ) {
122  throw new InvalidArgumentException(
123  "Unsupported search mode `{$this->mode}` configured. Supported modes: " .
124  implode( ', ', self::SUPPORTED_MODES )
125  );
126  }
127  }
128 
132  private function createSearchEngine() {
133  $limit = $this->getValidatedParams()['limit'];
134 
135  $searchEngine = $this->searchEngineFactory->create();
136  $searchEngine->setNamespaces( $this->searchEngineConfig->defaultNamespaces() );
137  $searchEngine->setLimitOffset( $limit, self::OFFSET );
138  return $searchEngine;
139  }
140 
141  public function needsWriteAccess() {
142  return false;
143  }
144 
151  private function getSearchResultsOrThrow( $results ) {
152  if ( $results ) {
153  if ( $results instanceof Status ) {
154  $status = $results;
155  if ( !$status->isOK() ) {
156  list( $error ) = $status->splitByErrorType();
157  if ( $error->getErrors() ) { // Only throw for errors, suppress warnings (for now)
158  $errorMessages = $error->getMessage();
159  throw new LocalizedHttpException(
160  new MessageValue( "rest-search-error", [ $errorMessages->getKey() ] )
161  );
162  }
163  }
164  $statusValue = $status->getValue();
165  if ( $statusValue instanceof ISearchResultSet ) {
166  return $statusValue->extractResults();
167  }
168  } else {
169  return $results->extractResults();
170  }
171  }
172  return [];
173  }
174 
182  private function doSearch( $searchEngine ) {
183  $query = $this->getValidatedParams()['q'];
184 
185  if ( $this->mode == self::COMPLETION_MODE ) {
186  $completionSearch = $searchEngine->completionSearchWithVariants( $query );
187  return $this->buildPageObjects( $completionSearch->getSuggestions() );
188  } else {
189  $titleSearch = $searchEngine->searchTitle( $query );
190  $textSearch = $searchEngine->searchText( $query );
191 
192  $titleSearchResults = $this->getSearchResultsOrThrow( $titleSearch );
193  $textSearchResults = $this->getSearchResultsOrThrow( $textSearch );
194 
195  $mergedResults = array_merge( $titleSearchResults, $textSearchResults );
196  return $this->buildPageObjects( $mergedResults );
197  }
198  }
199 
212  private function buildPageObjects( array $searchResponse ): array {
213  $pageInfos = [];
214  foreach ( $searchResponse as $response ) {
215  $isSearchResult = $response instanceof SearchResult;
216  if ( $isSearchResult ) {
217  if ( $response->isBrokenTitle() || $response->isMissingRevision() ) {
218  continue;
219  }
220  $title = $response->getTitle();
221  } else {
222  $title = $response->getSuggestedTitle();
223  }
224  $pageObj = $this->buildSinglePage( $title, $response );
225  if ( $pageObj ) {
226  $pageNsAndID = CacheKeyHelper::getKeyForPage( $pageObj['pageIdentity'] );
227  // This handles the edge case where we have both the redirect source and redirect target page come back
228  // in our search results. In such event, we prefer (and thus replace) with the redirect target page.
229  if ( isset( $pageInfos[$pageNsAndID] ) ) {
230  if ( $pageInfos[$pageNsAndID]['redirect'] !== null ) {
231  $pageInfos[$pageNsAndID]['result'] = $isSearchResult ? $response : null;
232  $pageInfos[$pageNsAndID]['suggestion'] = $isSearchResult ? null : $response;
233  }
234  continue;
235  }
236  $pageInfos[$pageNsAndID] = $pageObj;
237  }
238  }
239  return $pageInfos;
240  }
241 
255  private function buildSinglePage( $title, $result ) {
256  $redirectTarget = $title->canExist() ? $this->redirectLookup->getRedirectTarget( $title ) : null;
257  // Our page has a redirect that is not in a virtual namespace and is not an interwiki link.
258  // See T301346, T303352
259  if ( $redirectTarget && $redirectTarget->getNamespace() > -1 && !$redirectTarget->isExternal() ) {
260  $redirectSource = $title;
261  $title = $this->pageStore->getPageForLink( $redirectTarget );
262  } else {
263  $redirectSource = null;
264  }
265  if ( !$title || !$this->getAuthority()->probablyCan( 'read', $title ) ) {
266  return false;
267  }
268  return [
269  'pageIdentity' => $title,
270  'suggestion' => $result instanceof SearchSuggestion ? $result : null,
271  'result' => $result instanceof SearchResult ? $result : null,
272  'redirect' => $redirectSource
273  ];
274  }
275 
288  private function buildResultFromPageInfos( array $pageInfos, array $thumbsAndDesc ): array {
289  $pages = [];
290  foreach ( $pageInfos as $pageInfo ) {
291  [
292  'pageIdentity' => $page,
293  'suggestion' => $sugg,
294  'result' => $result,
295  'redirect' => $redirect
296  ] = $pageInfo;
297  $excerpt = $sugg ? $sugg->getText() : $result->getTextSnippet();
298  $id = ( $page instanceof PageIdentity && $page->canExist() ) ? $page->getId() : 0;
299  $pages[] = [
300  'id' => $id,
301  'key' => $this->titleFormatter->getPrefixedDBkey( $page ),
302  'title' => $this->titleFormatter->getPrefixedText( $page ),
303  'excerpt' => $excerpt ?: null,
304  'matched_title' => $redirect ? $this->titleFormatter->getPrefixedText( $redirect ) : null,
305  'description' => $id > 0 ? $thumbsAndDesc[$id]['description'] : null,
306  'thumbnail' => $id > 0 ? $thumbsAndDesc[$id]['thumbnail'] : null,
307  ];
308  }
309  return $pages;
310  }
311 
319  private function serializeThumbnail( ?SearchResultThumbnail $thumbnail ): ?array {
320  if ( $thumbnail == null ) {
321  return null;
322  }
323 
324  return [
325  'mimetype' => $thumbnail->getMimeType(),
326  'size' => $thumbnail->getSize(),
327  'width' => $thumbnail->getWidth(),
328  'height' => $thumbnail->getHeight(),
329  'duration' => $thumbnail->getDuration(),
330  'url' => $thumbnail->getUrl(),
331  ];
332  }
333 
344  private function buildDescriptionsFromPageIdentities( array $pageIdentities ) {
345  $descriptions = array_fill_keys( array_keys( $pageIdentities ), null );
346 
347  $this->getHookRunner()->onSearchResultProvideDescription( $pageIdentities, $descriptions );
348 
349  return array_map( static function ( $description ) {
350  return [ 'description' => $description ];
351  }, $descriptions );
352  }
353 
365  private function buildThumbnailsFromPageIdentities( array $pageIdentities ) {
366  $thumbnails = array_fill_keys( array_keys( $pageIdentities ), null );
367 
368  $this->getHookRunner()->onSearchResultProvideThumbnail( $pageIdentities, $thumbnails );
369 
370  return array_map( function ( $thumbnail ) {
371  return [ 'thumbnail' => $this->serializeThumbnail( $thumbnail ) ];
372  }, $thumbnails );
373  }
374 
379  public function execute() {
380  $searchEngine = $this->createSearchEngine();
381  $pageInfos = $this->doSearch( $searchEngine );
382 
383  // We can only pass validated "real" PageIdentities to our hook handlers below
384  $pageIdentities = array_reduce(
385  array_values( $pageInfos ),
386  static function ( $realPages, $item ) {
387  $page = $item['pageIdentity'];
388  if ( $page instanceof PageIdentity && $page->exists() ) {
389  $realPages[$item['pageIdentity']->getId()] = $item['pageIdentity'];
390  }
391  return $realPages;
392  }, []
393  );
394 
395  $descriptions = $this->buildDescriptionsFromPageIdentities( $pageIdentities );
396  $thumbs = $this->buildThumbnailsFromPageIdentities( $pageIdentities );
397 
398  $thumbsAndDescriptions = [];
399  foreach ( $descriptions as $pageId => $description ) {
400  $thumbsAndDescriptions[$pageId] = $description + $thumbs[$pageId];
401  }
402 
403  $result = $this->buildResultFromPageInfos( $pageInfos, $thumbsAndDescriptions );
404 
405  $response = $this->getResponseFactory()->createJson( [ 'pages' => $result ] );
406 
407  if ( $this->mode === self::COMPLETION_MODE && $this->completionCacheExpiry ) {
408  // Type-ahead completion matches should be cached by the client and
409  // in the CDN, especially for short prefixes.
410  // See also $wgSearchSuggestCacheExpiry and ApiOpenSearch
411  if ( $this->permissionManager->isEveryoneAllowed( 'read' ) ) {
412  $response->setHeader( 'Cache-Control', 'public, max-age=' . $this->completionCacheExpiry );
413  } else {
414  $response->setHeader( 'Cache-Control', 'no-store, max-age=0' );
415  }
416  }
417 
418  return $response;
419  }
420 
421  public function getParamSettings() {
422  return [
423  'q' => [
424  self::PARAM_SOURCE => 'query',
425  ParamValidator::PARAM_TYPE => 'string',
426  ParamValidator::PARAM_REQUIRED => true,
427  ],
428  'limit' => [
429  self::PARAM_SOURCE => 'query',
430  ParamValidator::PARAM_TYPE => 'integer',
431  ParamValidator::PARAM_REQUIRED => false,
432  ParamValidator::PARAM_DEFAULT => self::LIMIT,
433  IntegerDef::PARAM_MIN => 1,
434  IntegerDef::PARAM_MAX => self::MAX_LIMIT,
435  ],
436  ];
437  }
438 }
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.
__construct(Config $config, SearchEngineFactory $searchEngineFactory, SearchEngineConfig $searchEngineConfig, PermissionManager $permissionManager, RedirectLookup $redirectLookup, PageStore $pageStore, TitleFormatter $titleFormatter)
needsWriteAccess()
Indicates whether this route requires write access.
const FULLTEXT_MODE
Search page body and titles.
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:336
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:44
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.