Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.84% covered (success)
98.84%
171 / 173
88.24% covered (warning)
88.24%
15 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchHandler
98.84% covered (success)
98.84%
171 / 173
88.24% covered (warning)
88.24%
15 / 17
59
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 postInitSetup
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 createSearchEngine
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 needsWriteAccess
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSearchResultsOrThrow
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 doSearch
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 buildPageObjects
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
10
 buildSinglePage
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
9
 buildResultFromPageInfos
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
10
 serializeThumbnail
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 getSpecialPageDescription
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 buildDescriptionsFromPageIdentities
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 buildThumbnailsFromPageIdentities
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
8
 getParamSettings
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 getResponseBodySchemaFileName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getResponseBodyExampleFileName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Rest\Handler;
4
5use InvalidArgumentException;
6use MediaWiki\Config\Config;
7use MediaWiki\MainConfigNames;
8use MediaWiki\Page\CacheKeyHelper;
9use MediaWiki\Page\PageIdentity;
10use MediaWiki\Page\PageStore;
11use MediaWiki\Page\RedirectLookup;
12use MediaWiki\Permissions\PermissionManager;
13use MediaWiki\Rest\Handler;
14use MediaWiki\Rest\Handler\Helper\RestStatusTrait;
15use MediaWiki\Rest\LocalizedHttpException;
16use MediaWiki\Rest\Response;
17use MediaWiki\Rest\ResponseHeaders;
18use MediaWiki\Search\Entity\SearchResultThumbnail;
19use MediaWiki\Search\ISearchResultSet;
20use MediaWiki\Search\SearchEngine;
21use MediaWiki\Search\SearchEngineConfig;
22use MediaWiki\Search\SearchEngineFactory;
23use MediaWiki\Search\SearchResult;
24use MediaWiki\Search\SearchResultThumbnailProvider;
25use MediaWiki\Search\SearchSuggestion;
26use MediaWiki\SpecialPage\SpecialPageFactory;
27use MediaWiki\Title\TitleFormatter;
28use StatusValue;
29use Wikimedia\Message\MessageValue;
30use Wikimedia\ParamValidator\ParamValidator;
31use Wikimedia\ParamValidator\TypeDef\IntegerDef;
32
33/**
34 * Handler class for Core REST API endpoint that handles basic search
35 */
36class SearchHandler extends Handler {
37    use RestStatusTrait;
38
39    private SearchEngineFactory $searchEngineFactory;
40    private SearchEngineConfig $searchEngineConfig;
41    private SearchResultThumbnailProvider $searchResultThumbnailProvider;
42    private PermissionManager $permissionManager;
43    private RedirectLookup $redirectLookup;
44    private PageStore $pageStore;
45    private TitleFormatter $titleFormatter;
46    private SpecialPageFactory $specialPageFactory;
47
48    /**
49     * Search page body and titles.
50     */
51    public const FULLTEXT_MODE = 'fulltext';
52
53    /**
54     * Search title completion matches.
55     */
56    public const COMPLETION_MODE = 'completion';
57
58    /**
59     * Supported modes
60     */
61    private const SUPPORTED_MODES = [ self::FULLTEXT_MODE, self::COMPLETION_MODE ];
62
63    /**
64     * @var string
65     */
66    private $mode = null;
67
68    /** Limit results to 50 pages by default */
69    private const LIMIT = 50;
70
71    /** Hard limit results to 100 pages */
72    private const MAX_LIMIT = 100;
73
74    /** Default to first page */
75    private const OFFSET = 0;
76
77    /**
78     * Expiry time for use as max-age value in the cache-control header
79     * of completion search responses.
80     * @see $wgSearchSuggestCacheExpiry
81     * @var int|null
82     */
83    private $completionCacheExpiry;
84
85    public function __construct(
86        Config $config,
87        SearchEngineFactory $searchEngineFactory,
88        SearchEngineConfig $searchEngineConfig,
89        SearchResultThumbnailProvider $searchResultThumbnailProvider,
90        PermissionManager $permissionManager,
91        RedirectLookup $redirectLookup,
92        PageStore $pageStore,
93        TitleFormatter $titleFormatter,
94        SpecialPageFactory $specialPageFactory,
95    ) {
96        $this->searchEngineFactory = $searchEngineFactory;
97        $this->searchEngineConfig = $searchEngineConfig;
98        $this->searchResultThumbnailProvider = $searchResultThumbnailProvider;
99        $this->permissionManager = $permissionManager;
100        $this->redirectLookup = $redirectLookup;
101        $this->pageStore = $pageStore;
102        $this->titleFormatter = $titleFormatter;
103
104        // @todo Avoid injecting the entire config, see T246377
105        $this->completionCacheExpiry = $config->get( MainConfigNames::SearchSuggestCacheExpiry );
106        $this->specialPageFactory = $specialPageFactory;
107    }
108
109    protected function postInitSetup() {
110        $this->mode = $this->getConfig()['mode'] ?? self::FULLTEXT_MODE;
111
112        if ( !in_array( $this->mode, self::SUPPORTED_MODES ) ) {
113            throw new InvalidArgumentException(
114                "Unsupported search mode `{$this->mode}` configured. Supported modes: " .
115                implode( ', ', self::SUPPORTED_MODES )
116            );
117        }
118    }
119
120    /**
121     * @return SearchEngine
122     */
123    private function createSearchEngine() {
124        $limit = $this->getValidatedParams()['limit'];
125
126        $searchEngine = $this->searchEngineFactory->create();
127        $searchEngine->setNamespaces( $this->searchEngineConfig->defaultNamespaces() );
128        $searchEngine->setLimitOffset( $limit, self::OFFSET );
129        return $searchEngine;
130    }
131
132    /** @inheritDoc */
133    public function needsWriteAccess() {
134        return false;
135    }
136
137    /**
138     * Get SearchResults when results are either SearchResultSet or Status objects
139     * @param ISearchResultSet|StatusValue|null $results
140     * @return SearchResult[]
141     * @throws LocalizedHttpException
142     */
143    private function getSearchResultsOrThrow( $results ) {
144        if ( $results ) {
145            if ( $results instanceof StatusValue ) {
146                $status = $results;
147                if ( !$status->isOK() ) {
148                    if ( $status->getMessages( 'error' ) ) { // Only throw for errors, suppress warnings (for now)
149                        $this->throwExceptionForStatus( $status, 'rest-search-error', 500 );
150                    }
151                }
152                $statusValue = $status->getValue();
153                if ( $statusValue instanceof ISearchResultSet ) {
154                    return $statusValue->extractResults();
155                }
156            } else {
157                return $results->extractResults();
158            }
159        }
160        return [];
161    }
162
163    /**
164     * Execute search and return info about pages for further processing.
165     *
166     * @param SearchEngine $searchEngine
167     * @return array[]
168     * @throws LocalizedHttpException
169     */
170    private function doSearch( $searchEngine ) {
171        $query = $this->getValidatedParams()['q'];
172
173        if ( $this->mode == self::COMPLETION_MODE ) {
174            $completionSearch = $searchEngine->completionSearchWithVariants( $query );
175            return $this->buildPageObjects( $completionSearch->getSuggestions() );
176        } else {
177            $titleSearch = $searchEngine->searchTitle( $query );
178            $textSearch = $searchEngine->searchText( $query );
179
180            $titleSearchResults = $this->getSearchResultsOrThrow( $titleSearch );
181            $textSearchResults = $this->getSearchResultsOrThrow( $textSearch );
182
183            $mergedResults = array_merge( $titleSearchResults, $textSearchResults );
184            return $this->buildPageObjects( $mergedResults );
185        }
186    }
187
188    /**
189     * Build an array of pageInfo objects.
190     * @param SearchSuggestion[]|SearchResult[] $searchResponse
191     *
192     * @phpcs:ignore Generic.Files.LineLength
193     * @phan-return array{int:array{pageIdentity:PageIdentity,suggestion:?SearchSuggestion,result:?SearchResult,redirect:?PageIdentity}} $pageInfos
194     * @return array Associative array mapping pageID to pageInfo objects:
195     *   - pageIdentity: PageIdentity of page to return as the match
196     *   - suggestion: SearchSuggestion or null if $searchResponse is SearchResults[]
197     *   - result: SearchResult or null if $searchResponse is SearchSuggestions[]
198     *   - redirect: PageIdentity or null if the SearchResult|SearchSuggestion was not a redirect
199     */
200    private function buildPageObjects( array $searchResponse ): array {
201        $pageInfos = [];
202        foreach ( $searchResponse as $response ) {
203            $isSearchResult = $response instanceof SearchResult;
204            if ( $isSearchResult ) {
205                if ( $response->isBrokenTitle() || $response->isMissingRevision() ) {
206                    continue;
207                }
208                $title = $response->getTitle();
209            } else {
210                $title = $response->getSuggestedTitle();
211            }
212            $pageObj = $this->buildSinglePage( $title, $response );
213            if ( $pageObj ) {
214                $pageNsAndID = CacheKeyHelper::getKeyForPage( $pageObj['pageIdentity'] );
215                // This handles the edge case where we have both the redirect source and redirect target page come back
216                // in our search results. In such event, we prefer (and thus replace) with  the redirect target page.
217                if ( isset( $pageInfos[$pageNsAndID] ) ) {
218                    if ( $pageInfos[$pageNsAndID]['redirect'] !== null ) {
219                        $pageInfos[$pageNsAndID]['result'] = $isSearchResult ? $response : null;
220                        $pageInfos[$pageNsAndID]['suggestion'] = $isSearchResult ? null : $response;
221                    }
222                    continue;
223                }
224                $pageInfos[$pageNsAndID] = $pageObj;
225            }
226        }
227        return $pageInfos;
228    }
229
230    /**
231     * Build one pageInfo object from either a SearchResult or SearchSuggestion.
232     * @param PageIdentity $title
233     * @param SearchResult|SearchSuggestion $result
234     *
235     * @phpcs:ignore Generic.Files.LineLength
236     * @phan-return (false|array{pageIdentity:PageIdentity,suggestion:?SearchSuggestion,result:?SearchResult,redirect:?PageIdentity,anchor:?string,description:?string}) $pageInfos
237     * @return bool|array Objects representing a given page:
238     *   - pageIdentity: PageIdentity of page to return as the match
239     *   - suggestion: SearchSuggestion or null if $searchResponse is SearchResults
240     *   - result: SearchResult or null if $searchResponse is SearchSuggestions
241     *   - redirect: PageIdentity|null depending on if the SearchResult|SearchSuggestion was a redirect
242     *      - anchor: string|null if the SearchResult|SearchSuggestion was a redirect, this is the page anchor (if any)
243     */
244    private function buildSinglePage( $title, $result ) {
245        $redirectTarget = $title->canExist() ? $this->redirectLookup->getRedirectTarget( $title ) : null;
246        // Our page has a redirect that is not in a virtual namespace and is not an interwiki link.
247        // See T301346, T303352
248        if ( $redirectTarget && $redirectTarget->getNamespace() > -1 && !$redirectTarget->isExternal() ) {
249            $redirectSource = $title;
250            $anchor = $redirectTarget->getFragment();
251            $title = $this->pageStore->getPageForLink( $redirectTarget );
252        } else {
253            $redirectSource = null;
254            $anchor = null;
255        }
256        if ( !$title || !$this->getAuthority()->probablyCan( 'read', $title ) ) {
257            return false;
258        }
259        return [
260            'pageIdentity' => $title,
261            'suggestion' => $result instanceof SearchSuggestion ? $result : null,
262            'result' => $result instanceof SearchResult ? $result : null,
263            'redirect' => $redirectSource,
264            'anchor' => $anchor,
265            'description' => $this->getSpecialPageDescription( $title ),
266        ];
267    }
268
269    /**
270     * Turn array of page info into serializable array with common information about the page
271     * @param array $pageInfos Page Info objects
272     * @param array $thumbsAndDesc Associative array mapping pageId to array of description and thumbnail
273     * @phpcs:ignore Generic.Files.LineLength
274     * @phan-param array<int,array{pageIdentity:PageIdentity,suggestion:SearchSuggestion,result:SearchResult,redirect:?PageIdentity,anchor:?string,description:?string}> $pageInfos
275     * @phan-param array<int,array{description:array,thumbnail:array}> $thumbsAndDesc
276     *
277     * @phpcs:ignore Generic.Files.LineLength
278     * @phan-return array<int,array{id:int,key:string,title:string,excerpt:?string,matched_title:?string,anchor:?string, description:?array, thumbnail:?array}> $pages
279     * @return array[] of [ id, key, title, excerpt, matched_title, anchor ]
280     */
281    private function buildResultFromPageInfos( array $pageInfos, array $thumbsAndDesc ): array {
282        $pages = [];
283        foreach ( $pageInfos as $pageInfo ) {
284            [
285                'pageIdentity' => $page,
286                'suggestion' => $sugg,
287                'result' => $result,
288                'redirect' => $redirect,
289                'anchor' => $anchor,
290                'description' => $description,
291            ] = $pageInfo;
292            $excerpt = $sugg ? $sugg->getText() : $result->getTextSnippet();
293            $id = ( $page instanceof PageIdentity && $page->canExist() ) ? $page->getId() : 0;
294            $pages[] = [
295                'id' => $id,
296                'key' => $this->titleFormatter->getPrefixedDBkey( $page ),
297                'title' => $this->titleFormatter->getPrefixedText( $page ),
298                'excerpt' => $excerpt ?: null,
299                'matched_title' => $redirect ? $this->titleFormatter->getPrefixedText( $redirect ) : null,
300                'anchor' => $anchor ?: null,
301                'description' => $id > 0 ? $thumbsAndDesc[$id]['description'] : $description,
302                'thumbnail' => $id > 0 ? $thumbsAndDesc[$id]['thumbnail'] : null,
303            ];
304        }
305        return $pages;
306    }
307
308    /**
309     * Converts SearchResultThumbnail object into serializable array
310     *
311     * @param SearchResultThumbnail|null $thumbnail
312     *
313     * @return array|null
314     */
315    private function serializeThumbnail( ?SearchResultThumbnail $thumbnail ): ?array {
316        if ( $thumbnail == null ) {
317            return null;
318        }
319
320        return [
321            'mimetype' => $thumbnail->getMimeType(),
322            'width' => $thumbnail->getWidth(),
323            'height' => $thumbnail->getHeight(),
324            'duration' => $thumbnail->getDuration(),
325            'url' => $thumbnail->getUrl(),
326        ];
327    }
328
329    /**
330     * Return the page description if this PageIdentity refers to a SpecialPage.
331     * @param PageIdentity $title
332     * @return string|null the special page description, null if unknown or not a special page.
333     */
334    private function getSpecialPageDescription( PageIdentity $title ): ?string {
335        if ( $title->getNamespace() === NS_SPECIAL ) {
336            return $this->specialPageFactory
337                ->getPage( $title->getDBkey() )
338                ?->getDescription()
339                ?->plain();
340        }
341        return null;
342    }
343
344    /**
345     * Turn page info into serializable array with description field for the page.
346     *
347     * The information about description should be provided by extension by implementing
348     * 'SearchResultProvideDescription' hook. Description is set to null if no extensions
349     * implement the hook.
350     * @param PageIdentity[] $pageIdentities
351     *
352     * @return array
353     */
354    private function buildDescriptionsFromPageIdentities( array $pageIdentities ) {
355        $descriptions = array_fill_keys( array_keys( $pageIdentities ), null );
356
357        $this->getHookRunner()->onSearchResultProvideDescription( $pageIdentities, $descriptions );
358
359        return array_map( static function ( $description ) {
360            return [ 'description' => $description ];
361        }, $descriptions );
362    }
363
364    /**
365     * Turn page info into serializable array with thumbnail information for the page.
366     *
367     * The information about thumbnail should be provided by extension by implementing
368     * 'SearchResultProvideThumbnail' hook. Thumbnail is set to null if no extensions implement
369     * the hook.
370     *
371     * @param PageIdentity[] $pageIdentities
372     *
373     * @return array
374     */
375    private function buildThumbnailsFromPageIdentities( array $pageIdentities ) {
376        $thumbnails = $this->searchResultThumbnailProvider->getThumbnails( $pageIdentities );
377        $thumbnails += array_fill_keys( array_keys( $pageIdentities ), null );
378
379        return array_map( function ( $thumbnail ) {
380            return [ 'thumbnail' => $this->serializeThumbnail( $thumbnail ) ];
381        }, $thumbnails );
382    }
383
384    /**
385     * @return Response
386     * @throws LocalizedHttpException
387     */
388    public function execute() {
389        $searchEngine = $this->createSearchEngine();
390        $pageInfos = $this->doSearch( $searchEngine );
391
392        // We can only pass validated "real" PageIdentities to our hook handlers below
393        $pageIdentities = array_reduce(
394            array_values( $pageInfos ),
395            static function ( $realPages, $item ) {
396                $page = $item['pageIdentity'];
397                if ( $page instanceof PageIdentity && $page->exists() ) {
398                    $realPages[$item['pageIdentity']->getId()] = $item['pageIdentity'];
399                }
400                return $realPages;
401            }, []
402        );
403
404        $descriptions = $this->buildDescriptionsFromPageIdentities( $pageIdentities );
405        $thumbs = $this->buildThumbnailsFromPageIdentities( $pageIdentities );
406
407        $thumbsAndDescriptions = [];
408        foreach ( $descriptions as $pageId => $description ) {
409            $thumbsAndDescriptions[$pageId] = $description + $thumbs[$pageId];
410        }
411
412        $result = $this->buildResultFromPageInfos( $pageInfos, $thumbsAndDescriptions );
413
414        $response = $this->getResponseFactory()->createJson( [ 'pages' => $result ] );
415
416        if ( $this->mode === self::COMPLETION_MODE && $this->completionCacheExpiry ) {
417            // Type-ahead completion matches should be cached by the client and
418            // in the CDN, especially for short prefixes.
419            // See also $wgSearchSuggestCacheExpiry and ApiOpenSearch
420            if ( $this->permissionManager->isEveryoneAllowed( 'read' ) ) {
421                $cacheControl = 'public, max-age=' . $this->completionCacheExpiry;
422            } else {
423                $cacheControl = 'no-store, max-age=0';
424            }
425            $response->setHeader( ResponseHeaders::CACHE_CONTROL, $cacheControl );
426        }
427        $searchId = $searchEngine->getFeatureData( SearchEngine::SEARCH_ID );
428        if ( $searchId ) {
429            // if the search backend provides a search id propagate it via headers.
430            $response->setHeader( 'X-Search-ID', $searchId );
431        }
432
433        return $response;
434    }
435
436    /** @inheritDoc */
437    public function getParamSettings() {
438        return [
439            'q' => [
440                self::PARAM_SOURCE => 'query',
441                ParamValidator::PARAM_TYPE => 'string',
442                ParamValidator::PARAM_REQUIRED => true,
443                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-search-q' ),
444                Handler::PARAM_EXAMPLE => 'jupiter',
445            ],
446            'limit' => [
447                self::PARAM_SOURCE => 'query',
448                ParamValidator::PARAM_TYPE => 'integer',
449                ParamValidator::PARAM_REQUIRED => false,
450                ParamValidator::PARAM_DEFAULT => self::LIMIT,
451                IntegerDef::PARAM_MIN => 1,
452                IntegerDef::PARAM_MAX => self::MAX_LIMIT,
453                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-search-limit' ),
454                Handler::PARAM_EXAMPLE => 20,
455            ],
456        ];
457    }
458
459    public function getResponseBodySchemaFileName( string $method ): ?string {
460        return __DIR__ . '/Schema/SearchResults.json';
461    }
462
463    public function getResponseBodyExampleFileName( string $method ): ?string {
464        return __DIR__ . '/Example/SearchResults.json';
465    }
466}