MediaWiki REL1_41
SearchHandler.php
Go to the documentation of this file.
1<?php
2
4
5use InvalidArgumentException;
21use SearchEngine;
24use SearchResult;
29
33class 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}
getAuthority()
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
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.
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.