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 if ( $status->getMessages( 'error' ) ) { // Only throw for errors, suppress warnings (for now)
142 $this->throwExceptionForStatus( $status, 'rest-search-error', 500 );
143 }
144 }
145 $statusValue = $status->getValue();
146 if ( $statusValue instanceof ISearchResultSet ) {
147 return $statusValue->extractResults();
148 }
149 } else {
150 return $results->extractResults();
151 }
152 }
153 return [];
154 }
155
163 private function doSearch( $searchEngine ) {
164 $query = $this->getValidatedParams()['q'];
165
166 if ( $this->mode == self::COMPLETION_MODE ) {
167 $completionSearch = $searchEngine->completionSearchWithVariants( $query );
168 return $this->buildPageObjects( $completionSearch->getSuggestions() );
169 } else {
170 $titleSearch = $searchEngine->searchTitle( $query );
171 $textSearch = $searchEngine->searchText( $query );
172
173 $titleSearchResults = $this->getSearchResultsOrThrow( $titleSearch );
174 $textSearchResults = $this->getSearchResultsOrThrow( $textSearch );
175
176 $mergedResults = array_merge( $titleSearchResults, $textSearchResults );
177 return $this->buildPageObjects( $mergedResults );
178 }
179 }
180
193 private function buildPageObjects( array $searchResponse ): array {
194 $pageInfos = [];
195 foreach ( $searchResponse as $response ) {
196 $isSearchResult = $response instanceof SearchResult;
197 if ( $isSearchResult ) {
198 if ( $response->isBrokenTitle() || $response->isMissingRevision() ) {
199 continue;
200 }
201 $title = $response->getTitle();
202 } else {
203 $title = $response->getSuggestedTitle();
204 }
205 $pageObj = $this->buildSinglePage( $title, $response );
206 if ( $pageObj ) {
207 $pageNsAndID = CacheKeyHelper::getKeyForPage( $pageObj['pageIdentity'] );
208 // This handles the edge case where we have both the redirect source and redirect target page come back
209 // in our search results. In such event, we prefer (and thus replace) with the redirect target page.
210 if ( isset( $pageInfos[$pageNsAndID] ) ) {
211 if ( $pageInfos[$pageNsAndID]['redirect'] !== null ) {
212 $pageInfos[$pageNsAndID]['result'] = $isSearchResult ? $response : null;
213 $pageInfos[$pageNsAndID]['suggestion'] = $isSearchResult ? null : $response;
214 }
215 continue;
216 }
217 $pageInfos[$pageNsAndID] = $pageObj;
218 }
219 }
220 return $pageInfos;
221 }
222
236 private function buildSinglePage( $title, $result ) {
237 $redirectTarget = $title->canExist() ? $this->redirectLookup->getRedirectTarget( $title ) : null;
238 // Our page has a redirect that is not in a virtual namespace and is not an interwiki link.
239 // See T301346, T303352
240 if ( $redirectTarget && $redirectTarget->getNamespace() > -1 && !$redirectTarget->isExternal() ) {
241 $redirectSource = $title;
242 $title = $this->pageStore->getPageForLink( $redirectTarget );
243 } else {
244 $redirectSource = null;
245 }
246 if ( !$title || !$this->getAuthority()->probablyCan( 'read', $title ) ) {
247 return false;
248 }
249 return [
250 'pageIdentity' => $title,
251 'suggestion' => $result instanceof SearchSuggestion ? $result : null,
252 'result' => $result instanceof SearchResult ? $result : null,
253 'redirect' => $redirectSource
254 ];
255 }
256
269 private function buildResultFromPageInfos( array $pageInfos, array $thumbsAndDesc ): array {
270 $pages = [];
271 foreach ( $pageInfos as $pageInfo ) {
272 [
273 'pageIdentity' => $page,
274 'suggestion' => $sugg,
275 'result' => $result,
276 'redirect' => $redirect
277 ] = $pageInfo;
278 $excerpt = $sugg ? $sugg->getText() : $result->getTextSnippet();
279 $id = ( $page instanceof PageIdentity && $page->canExist() ) ? $page->getId() : 0;
280 $pages[] = [
281 'id' => $id,
282 'key' => $this->titleFormatter->getPrefixedDBkey( $page ),
283 'title' => $this->titleFormatter->getPrefixedText( $page ),
284 'excerpt' => $excerpt ?: null,
285 'matched_title' => $redirect ? $this->titleFormatter->getPrefixedText( $redirect ) : null,
286 'description' => $id > 0 ? $thumbsAndDesc[$id]['description'] : null,
287 'thumbnail' => $id > 0 ? $thumbsAndDesc[$id]['thumbnail'] : null,
288 ];
289 }
290 return $pages;
291 }
292
300 private function serializeThumbnail( ?SearchResultThumbnail $thumbnail ): ?array {
301 if ( $thumbnail == null ) {
302 return null;
303 }
304
305 return [
306 'mimetype' => $thumbnail->getMimeType(),
307 'width' => $thumbnail->getWidth(),
308 'height' => $thumbnail->getHeight(),
309 'duration' => $thumbnail->getDuration(),
310 'url' => $thumbnail->getUrl(),
311 ];
312 }
313
324 private function buildDescriptionsFromPageIdentities( array $pageIdentities ) {
325 $descriptions = array_fill_keys( array_keys( $pageIdentities ), null );
326
327 $this->getHookRunner()->onSearchResultProvideDescription( $pageIdentities, $descriptions );
328
329 return array_map( static function ( $description ) {
330 return [ 'description' => $description ];
331 }, $descriptions );
332 }
333
345 private function buildThumbnailsFromPageIdentities( array $pageIdentities ) {
346 $thumbnails = $this->searchResultThumbnailProvider->getThumbnails( $pageIdentities );
347 $thumbnails += array_fill_keys( array_keys( $pageIdentities ), null );
348
349 return array_map( function ( $thumbnail ) {
350 return [ 'thumbnail' => $this->serializeThumbnail( $thumbnail ) ];
351 }, $thumbnails );
352 }
353
358 public function execute() {
359 $searchEngine = $this->createSearchEngine();
360 $pageInfos = $this->doSearch( $searchEngine );
361
362 // We can only pass validated "real" PageIdentities to our hook handlers below
363 $pageIdentities = array_reduce(
364 array_values( $pageInfos ),
365 static function ( $realPages, $item ) {
366 $page = $item['pageIdentity'];
367 if ( $page instanceof PageIdentity && $page->exists() ) {
368 $realPages[$item['pageIdentity']->getId()] = $item['pageIdentity'];
369 }
370 return $realPages;
371 }, []
372 );
373
374 $descriptions = $this->buildDescriptionsFromPageIdentities( $pageIdentities );
375 $thumbs = $this->buildThumbnailsFromPageIdentities( $pageIdentities );
376
377 $thumbsAndDescriptions = [];
378 foreach ( $descriptions as $pageId => $description ) {
379 $thumbsAndDescriptions[$pageId] = $description + $thumbs[$pageId];
380 }
381
382 $result = $this->buildResultFromPageInfos( $pageInfos, $thumbsAndDescriptions );
383
384 $response = $this->getResponseFactory()->createJson( [ 'pages' => $result ] );
385
386 if ( $this->mode === self::COMPLETION_MODE && $this->completionCacheExpiry ) {
387 // Type-ahead completion matches should be cached by the client and
388 // in the CDN, especially for short prefixes.
389 // See also $wgSearchSuggestCacheExpiry and ApiOpenSearch
390 if ( $this->permissionManager->isEveryoneAllowed( 'read' ) ) {
391 $response->setHeader( 'Cache-Control', 'public, max-age=' . $this->completionCacheExpiry );
392 } else {
393 $response->setHeader( 'Cache-Control', 'no-store, max-age=0' );
394 }
395 }
396
397 return $response;
398 }
399
400 public function getParamSettings() {
401 return [
402 'q' => [
403 self::PARAM_SOURCE => 'query',
404 ParamValidator::PARAM_TYPE => 'string',
405 ParamValidator::PARAM_REQUIRED => true,
406 ],
407 'limit' => [
408 self::PARAM_SOURCE => 'query',
409 ParamValidator::PARAM_TYPE => 'integer',
410 ParamValidator::PARAM_REQUIRED => false,
411 ParamValidator::PARAM_DEFAULT => self::LIMIT,
412 IntegerDef::PARAM_MIN => 1,
413 IntegerDef::PARAM_MAX => self::MAX_LIMIT,
414 ],
415 ];
416 }
417}
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 to the wiki.
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:25
getConfig()
Get the configuration array for the current route.
Definition Handler.php:354
getValidatedParams()
Fetch the validated parameters.
Definition Handler.php:887
getAuthority()
Get the current acting authority.
Definition Handler.php:343
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.