MediaWiki master
SearchHandler.php
Go to the documentation of this file.
1<?php
2
4
5use InvalidArgumentException;
15use MediaWiki\Rest\Handler\Helper\RestStatusTrait;
21use SearchEngine;
24use SearchResult;
26use StatusValue;
29
33class SearchHandler extends Handler {
34 use RestStatusTrait;
35
36 private SearchEngineFactory $searchEngineFactory;
37 private SearchEngineConfig $searchEngineConfig;
38 private SearchResultThumbnailProvider $searchResultThumbnailProvider;
39 private PermissionManager $permissionManager;
40 private RedirectLookup $redirectLookup;
41 private PageStore $pageStore;
42 private TitleFormatter $titleFormatter;
43
47 public const FULLTEXT_MODE = 'fulltext';
48
52 public const COMPLETION_MODE = 'completion';
53
57 private const SUPPORTED_MODES = [ self::FULLTEXT_MODE, self::COMPLETION_MODE ];
58
62 private $mode = null;
63
65 private const LIMIT = 50;
66
68 private const MAX_LIMIT = 100;
69
71 private const OFFSET = 0;
72
79 private $completionCacheExpiry;
80
81 public function __construct(
82 Config $config,
83 SearchEngineFactory $searchEngineFactory,
84 SearchEngineConfig $searchEngineConfig,
85 SearchResultThumbnailProvider $searchResultThumbnailProvider,
86 PermissionManager $permissionManager,
87 RedirectLookup $redirectLookup,
88 PageStore $pageStore,
89 TitleFormatter $titleFormatter
90 ) {
91 $this->searchEngineFactory = $searchEngineFactory;
92 $this->searchEngineConfig = $searchEngineConfig;
93 $this->searchResultThumbnailProvider = $searchResultThumbnailProvider;
94 $this->permissionManager = $permissionManager;
95 $this->redirectLookup = $redirectLookup;
96 $this->pageStore = $pageStore;
97 $this->titleFormatter = $titleFormatter;
98
99 // @todo Avoid injecting the entire config, see T246377
100 $this->completionCacheExpiry = $config->get( MainConfigNames::SearchSuggestCacheExpiry );
101 }
102
103 protected function postInitSetup() {
104 $this->mode = $this->getConfig()['mode'] ?? self::FULLTEXT_MODE;
105
106 if ( !in_array( $this->mode, self::SUPPORTED_MODES ) ) {
107 throw new InvalidArgumentException(
108 "Unsupported search mode `{$this->mode}` configured. Supported modes: " .
109 implode( ', ', self::SUPPORTED_MODES )
110 );
111 }
112 }
113
117 private function createSearchEngine() {
118 $limit = $this->getValidatedParams()['limit'];
119
120 $searchEngine = $this->searchEngineFactory->create();
121 $searchEngine->setNamespaces( $this->searchEngineConfig->defaultNamespaces() );
122 $searchEngine->setLimitOffset( $limit, self::OFFSET );
123 return $searchEngine;
124 }
125
126 public function needsWriteAccess() {
127 return false;
128 }
129
136 private function getSearchResultsOrThrow( $results ) {
137 if ( $results ) {
138 if ( $results instanceof StatusValue ) {
139 $status = $results;
140 if ( !$status->isOK() ) {
141 [ $error ] = $status->splitByErrorType();
142 if ( $error->getErrors() ) { // Only throw for errors, suppress warnings (for now)
143 $this->throwExceptionForStatus( $status, 'rest-search-error', 500 );
144 }
145 }
146 $statusValue = $status->getValue();
147 if ( $statusValue instanceof ISearchResultSet ) {
148 return $statusValue->extractResults();
149 }
150 } else {
151 return $results->extractResults();
152 }
153 }
154 return [];
155 }
156
164 private function doSearch( $searchEngine ) {
165 $query = $this->getValidatedParams()['q'];
166
167 if ( $this->mode == self::COMPLETION_MODE ) {
168 $completionSearch = $searchEngine->completionSearchWithVariants( $query );
169 return $this->buildPageObjects( $completionSearch->getSuggestions() );
170 } else {
171 $titleSearch = $searchEngine->searchTitle( $query );
172 $textSearch = $searchEngine->searchText( $query );
173
174 $titleSearchResults = $this->getSearchResultsOrThrow( $titleSearch );
175 $textSearchResults = $this->getSearchResultsOrThrow( $textSearch );
176
177 $mergedResults = array_merge( $titleSearchResults, $textSearchResults );
178 return $this->buildPageObjects( $mergedResults );
179 }
180 }
181
194 private function buildPageObjects( array $searchResponse ): array {
195 $pageInfos = [];
196 foreach ( $searchResponse as $response ) {
197 $isSearchResult = $response instanceof SearchResult;
198 if ( $isSearchResult ) {
199 if ( $response->isBrokenTitle() || $response->isMissingRevision() ) {
200 continue;
201 }
202 $title = $response->getTitle();
203 } else {
204 $title = $response->getSuggestedTitle();
205 }
206 $pageObj = $this->buildSinglePage( $title, $response );
207 if ( $pageObj ) {
208 $pageNsAndID = CacheKeyHelper::getKeyForPage( $pageObj['pageIdentity'] );
209 // This handles the edge case where we have both the redirect source and redirect target page come back
210 // in our search results. In such event, we prefer (and thus replace) with the redirect target page.
211 if ( isset( $pageInfos[$pageNsAndID] ) ) {
212 if ( $pageInfos[$pageNsAndID]['redirect'] !== null ) {
213 $pageInfos[$pageNsAndID]['result'] = $isSearchResult ? $response : null;
214 $pageInfos[$pageNsAndID]['suggestion'] = $isSearchResult ? null : $response;
215 }
216 continue;
217 }
218 $pageInfos[$pageNsAndID] = $pageObj;
219 }
220 }
221 return $pageInfos;
222 }
223
237 private function buildSinglePage( $title, $result ) {
238 $redirectTarget = $title->canExist() ? $this->redirectLookup->getRedirectTarget( $title ) : null;
239 // Our page has a redirect that is not in a virtual namespace and is not an interwiki link.
240 // See T301346, T303352
241 if ( $redirectTarget && $redirectTarget->getNamespace() > -1 && !$redirectTarget->isExternal() ) {
242 $redirectSource = $title;
243 $title = $this->pageStore->getPageForLink( $redirectTarget );
244 } else {
245 $redirectSource = null;
246 }
247 if ( !$title || !$this->getAuthority()->probablyCan( 'read', $title ) ) {
248 return false;
249 }
250 return [
251 'pageIdentity' => $title,
252 'suggestion' => $result instanceof SearchSuggestion ? $result : null,
253 'result' => $result instanceof SearchResult ? $result : null,
254 'redirect' => $redirectSource
255 ];
256 }
257
270 private function buildResultFromPageInfos( array $pageInfos, array $thumbsAndDesc ): array {
271 $pages = [];
272 foreach ( $pageInfos as $pageInfo ) {
273 [
274 'pageIdentity' => $page,
275 'suggestion' => $sugg,
276 'result' => $result,
277 'redirect' => $redirect
278 ] = $pageInfo;
279 $excerpt = $sugg ? $sugg->getText() : $result->getTextSnippet();
280 $id = ( $page instanceof PageIdentity && $page->canExist() ) ? $page->getId() : 0;
281 $pages[] = [
282 'id' => $id,
283 'key' => $this->titleFormatter->getPrefixedDBkey( $page ),
284 'title' => $this->titleFormatter->getPrefixedText( $page ),
285 'excerpt' => $excerpt ?: null,
286 'matched_title' => $redirect ? $this->titleFormatter->getPrefixedText( $redirect ) : null,
287 'description' => $id > 0 ? $thumbsAndDesc[$id]['description'] : null,
288 'thumbnail' => $id > 0 ? $thumbsAndDesc[$id]['thumbnail'] : null,
289 ];
290 }
291 return $pages;
292 }
293
301 private function serializeThumbnail( ?SearchResultThumbnail $thumbnail ): ?array {
302 if ( $thumbnail == null ) {
303 return null;
304 }
305
306 return [
307 'mimetype' => $thumbnail->getMimeType(),
308 'width' => $thumbnail->getWidth(),
309 'height' => $thumbnail->getHeight(),
310 'duration' => $thumbnail->getDuration(),
311 'url' => $thumbnail->getUrl(),
312 ];
313 }
314
325 private function buildDescriptionsFromPageIdentities( array $pageIdentities ) {
326 $descriptions = array_fill_keys( array_keys( $pageIdentities ), null );
327
328 $this->getHookRunner()->onSearchResultProvideDescription( $pageIdentities, $descriptions );
329
330 return array_map( static function ( $description ) {
331 return [ 'description' => $description ];
332 }, $descriptions );
333 }
334
346 private function buildThumbnailsFromPageIdentities( array $pageIdentities ) {
347 $thumbnails = $this->searchResultThumbnailProvider->getThumbnails( $pageIdentities );
348 $thumbnails += array_fill_keys( array_keys( $pageIdentities ), null );
349
350 return array_map( function ( $thumbnail ) {
351 return [ 'thumbnail' => $this->serializeThumbnail( $thumbnail ) ];
352 }, $thumbnails );
353 }
354
359 public function execute() {
360 $searchEngine = $this->createSearchEngine();
361 $pageInfos = $this->doSearch( $searchEngine );
362
363 // We can only pass validated "real" PageIdentities to our hook handlers below
364 $pageIdentities = array_reduce(
365 array_values( $pageInfos ),
366 static function ( $realPages, $item ) {
367 $page = $item['pageIdentity'];
368 if ( $page instanceof PageIdentity && $page->exists() ) {
369 $realPages[$item['pageIdentity']->getId()] = $item['pageIdentity'];
370 }
371 return $realPages;
372 }, []
373 );
374
375 $descriptions = $this->buildDescriptionsFromPageIdentities( $pageIdentities );
376 $thumbs = $this->buildThumbnailsFromPageIdentities( $pageIdentities );
377
378 $thumbsAndDescriptions = [];
379 foreach ( $descriptions as $pageId => $description ) {
380 $thumbsAndDescriptions[$pageId] = $description + $thumbs[$pageId];
381 }
382
383 $result = $this->buildResultFromPageInfos( $pageInfos, $thumbsAndDescriptions );
384
385 $response = $this->getResponseFactory()->createJson( [ 'pages' => $result ] );
386
387 if ( $this->mode === self::COMPLETION_MODE && $this->completionCacheExpiry ) {
388 // Type-ahead completion matches should be cached by the client and
389 // in the CDN, especially for short prefixes.
390 // See also $wgSearchSuggestCacheExpiry and ApiOpenSearch
391 if ( $this->permissionManager->isEveryoneAllowed( 'read' ) ) {
392 $response->setHeader( 'Cache-Control', 'public, max-age=' . $this->completionCacheExpiry );
393 } else {
394 $response->setHeader( 'Cache-Control', 'no-store, max-age=0' );
395 }
396 }
397
398 return $response;
399 }
400
401 public function getParamSettings() {
402 return [
403 'q' => [
404 self::PARAM_SOURCE => 'query',
405 ParamValidator::PARAM_TYPE => 'string',
406 ParamValidator::PARAM_REQUIRED => true,
407 ],
408 'limit' => [
409 self::PARAM_SOURCE => 'query',
410 ParamValidator::PARAM_TYPE => 'integer',
411 ParamValidator::PARAM_REQUIRED => false,
412 ParamValidator::PARAM_DEFAULT => self::LIMIT,
413 IntegerDef::PARAM_MIN => 1,
414 IntegerDef::PARAM_MAX => self::MAX_LIMIT,
415 ],
416 ];
417 }
418}
getAuthority()
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:81
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 the init functions are called to inject...
getParamSettings()
Fetch ParamValidator settings for parameters.
Base class for REST route handlers.
Definition Handler.php:24
getConfig()
Get the configuration array for the current route.
Definition Handler.php:345
getValidatedParams()
Fetch the validated parameters.
Definition Handler.php:820
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.
A search suggestion.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Service for formatting and validating API parameters.
Type definition for integer types.
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.