Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
14.65% covered (danger)
14.65%
29 / 198
23.08% covered (danger)
23.08%
3 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiOpenSearch
14.72% covered (danger)
14.72%
29 / 197
23.08% covered (danger)
23.08%
3 / 13
1358.34
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getFormat
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getBaseFormat
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getCustomPrinter
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 execute
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 search
0.00% covered (danger)
0.00%
0 / 64
0.00% covered (danger)
0.00%
0 / 1
156
 populateResult
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
110
 getAllowedParams
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 getSearchProfileParams
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpUrls
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 trimExtract
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 getOpenSearchTemplate
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2/**
3 * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
4 * Copyright © 2008 Brooke Vibber <bvibber@wikimedia.org>
5 * Copyright © 2014 Wikimedia Foundation and contributors
6 *
7 * @license GPL-2.0-or-later
8 * @file
9 */
10
11namespace MediaWiki\Api;
12
13use InvalidArgumentException;
14use MediaWiki\MainConfigNames;
15use MediaWiki\MediaWikiServices;
16use MediaWiki\Page\LinkBatchFactory;
17use MediaWiki\Search\SearchEngine;
18use MediaWiki\Search\SearchEngineConfig;
19use MediaWiki\Search\SearchEngineFactory;
20use MediaWiki\Title\Title;
21use MediaWiki\Utils\UrlUtils;
22use Wikimedia\ParamValidator\ParamValidator;
23
24/**
25 * @ingroup API
26 */
27class ApiOpenSearch extends ApiBase {
28    use SearchApi;
29
30    public function __construct(
31        ApiMain $mainModule,
32        string $moduleName,
33        private readonly LinkBatchFactory $linkBatchFactory,
34        SearchEngineConfig $searchEngineConfig,
35        SearchEngineFactory $searchEngineFactory,
36        private readonly UrlUtils $urlUtils,
37    ) {
38        parent::__construct( $mainModule, $moduleName );
39        // Services needed in SearchApi trait
40        $this->searchEngineConfig = $searchEngineConfig;
41        $this->searchEngineFactory = $searchEngineFactory;
42    }
43
44    private function getFormat(): string {
45        return $this->getParameter( 'format' );
46    }
47
48    private function getBaseFormat(): string {
49        $format = $this->getFormat();
50        return str_ends_with( $format, 'fm' ) ? substr( $format, 0, -2 ) : $format;
51    }
52
53    /** @inheritDoc */
54    public function getCustomPrinter() {
55        switch ( $this->getBaseFormat() ) {
56            case 'json':
57                return new ApiOpenSearchFormatJson(
58                    $this->getMain(), $this->getFormat(), $this->getParameter( 'warningsaserror' )
59                );
60
61            case 'xml':
62                $printer = $this->getMain()->createPrinterByName( $this->getFormat() );
63                '@phan-var ApiFormatXml $printer';
64                /** @var ApiFormatXml $printer */
65                $printer->setRootElement( 'SearchSuggestion' );
66                return $printer;
67
68            default:
69                ApiBase::dieDebug( __METHOD__, "Unsupported format '{$this->getBaseFormat()}'" );
70        }
71    }
72
73    public function execute() {
74        $params = $this->extractRequestParams();
75        $search = $params['search'];
76
77        // Open search results may be stored for a very long time
78        $this->getMain()->setCacheMaxAge(
79            $this->getConfig()->get( MainConfigNames::SearchSuggestCacheExpiry ) );
80        $this->getMain()->setCacheMode( 'public' );
81        $results = $this->search( $search, $params );
82
83        // Allow hooks to populate extracts and images
84        $this->getHookRunner()->onApiOpenSearchSuggest( $results );
85
86        // Trim extracts, if necessary
87        $length = $this->getConfig()->get( MainConfigNames::OpenSearchDescriptionLength );
88        foreach ( $results as &$r ) {
89            if ( is_string( $r['extract'] ) && !$r['extract trimmed'] ) {
90                $r['extract'] = self::trimExtract( $r['extract'], $length );
91            }
92        }
93
94        // Populate result object
95        $this->populateResult( $search, $results );
96    }
97
98    /**
99     * Perform the search
100     * @param string $search the search query
101     * @param array $params api request params
102     * @return array search results. Keys are integers.
103     * @phan-return array<array{title:Title,redirect_from:?Title,extract:false,extract_trimmed:false,image:false,url:string}>
104     *  Note that phan annotations don't support keys containing a space.
105     */
106    private function search( $search, array $params ) {
107        $searchEngine = $this->buildSearchEngine( $params );
108        $titles = $searchEngine->extractTitles( $searchEngine->completionSearchWithVariants( $search ) );
109        $results = [];
110
111        if ( !$titles ) {
112            return $results;
113        }
114
115        // Special pages need unique integer ids in the return list, so we just
116        // assign them negative numbers because those won't clash with the
117        // always positive articleIds that non-special pages get.
118        $nextSpecialPageId = -1;
119
120        if ( $params['redirects'] === null ) {
121            // Backwards compatibility, don't resolve for JSON.
122            $resolveRedir = $this->getBaseFormat() !== 'json';
123        } else {
124            $resolveRedir = $params['redirects'] === 'resolve';
125        }
126
127        if ( $resolveRedir ) {
128            // Query for redirects
129            $redirects = [];
130            $lb = $this->linkBatchFactory->newLinkBatch( $titles );
131            if ( !$lb->isEmpty() ) {
132                $db = $this->getDB();
133                $res = $db->newSelectQueryBuilder()
134                    ->select( [ 'page_namespace', 'page_title', 'rd_namespace', 'rd_title' ] )
135                    ->from( 'page' )
136                    ->join( 'redirect', null, [ 'rd_from = page_id' ] )
137                    ->where( [
138                        'rd_interwiki' => '',
139                        $lb->constructSet( 'page', $db )
140                    ] )
141                    ->caller( __METHOD__ )
142                    ->fetchResultSet();
143                foreach ( $res as $row ) {
144                    $redirects[$row->page_namespace][$row->page_title] =
145                        [ $row->rd_namespace, $row->rd_title ];
146                }
147            }
148
149            // Bypass any redirects
150            $seen = [];
151            foreach ( $titles as $title ) {
152                $ns = $title->getNamespace();
153                $dbkey = $title->getDBkey();
154                $from = null;
155                if ( isset( $redirects[$ns][$dbkey] ) ) {
156                    [ $ns, $dbkey ] = $redirects[$ns][$dbkey];
157                    $from = $title;
158                    $title = Title::makeTitle( $ns, $dbkey );
159                }
160                if ( !isset( $seen[$ns][$dbkey] ) ) {
161                    $seen[$ns][$dbkey] = true;
162                    $resultId = $title->getArticleID();
163                    if ( $resultId === 0 ) {
164                        $resultId = $nextSpecialPageId;
165                        $nextSpecialPageId--;
166                    }
167                    $results[$resultId] = [
168                        'title' => $title,
169                        'redirect from' => $from,
170                        'extract' => false,
171                        'extract trimmed' => false,
172                        'image' => false,
173                        'url' => (string)$this->urlUtils->expand( $title->getFullURL(), PROTO_CURRENT ),
174                    ];
175                }
176            }
177        } else {
178            foreach ( $titles as $title ) {
179                $resultId = $title->getArticleID();
180                if ( $resultId === 0 ) {
181                    $resultId = $nextSpecialPageId;
182                    $nextSpecialPageId--;
183                }
184                $results[$resultId] = [
185                    'title' => $title,
186                    'redirect from' => null,
187                    'extract' => false,
188                    'extract trimmed' => false,
189                    'image' => false,
190                    'url' => (string)$this->urlUtils->expand( $title->getFullURL(), PROTO_CURRENT ),
191                ];
192            }
193        }
194
195        return $results;
196    }
197
198    /**
199     * @param string $search
200     * @param array[] &$results
201     */
202    private function populateResult( $search, &$results ) {
203        $result = $this->getResult();
204
205        switch ( $this->getBaseFormat() ) {
206            case 'json':
207                // http://www.opensearch.org/Specifications/OpenSearch/Extensions/Suggestions/1.1
208                $result->addArrayType( null, 'array' );
209                $result->addValue( null, 0, strval( $search ) );
210                $terms = [];
211                $descriptions = [];
212                $urls = [];
213                foreach ( $results as $r ) {
214                    $terms[] = $r['title']->getPrefixedText();
215                    $descriptions[] = strval( $r['extract'] );
216                    $urls[] = $r['url'];
217                }
218                $result->addValue( null, 1, $terms );
219                $result->addValue( null, 2, $descriptions );
220                $result->addValue( null, 3, $urls );
221                break;
222
223            case 'xml':
224                // https://msdn.microsoft.com/en-us/library/cc891508(v=vs.85).aspx
225                $imageKeys = [
226                    'source' => true,
227                    'alt' => true,
228                    'width' => true,
229                    'height' => true,
230                    'align' => true,
231                ];
232                $items = [];
233                foreach ( $results as $r ) {
234                    $item = [
235                        'Text' => $r['title']->getPrefixedText(),
236                        'Url' => $r['url'],
237                    ];
238                    if ( is_string( $r['extract'] ) && $r['extract'] !== '' ) {
239                        $item['Description'] = $r['extract'];
240                    }
241                    if ( is_array( $r['image'] ) && isset( $r['image']['source'] ) ) {
242                        $item['Image'] = array_intersect_key( $r['image'], $imageKeys );
243                    }
244                    ApiResult::setSubelementsList( $item, array_keys( $item ) );
245                    $items[] = $item;
246                }
247                ApiResult::setIndexedTagName( $items, 'Item' );
248                $result->addValue( null, 'version', '2.0' );
249                $result->addValue( null, 'xmlns', 'http://opensearch.org/searchsuggest2' );
250                $result->addValue( null, 'Query', strval( $search ) );
251                $result->addSubelementsList( null, 'Query' );
252                $result->addValue( null, 'Section', $items );
253                break;
254
255            default:
256                ApiBase::dieDebug( __METHOD__, "Unsupported format '{$this->getBaseFormat()}'" );
257        }
258    }
259
260    /** @inheritDoc */
261    public function getAllowedParams() {
262        $allowedParams = $this->buildCommonApiParams( false ) + [
263            'suggest' => [
264                ParamValidator::PARAM_DEFAULT => false,
265                // Deprecated since 1.35
266                ParamValidator::PARAM_DEPRECATED => true,
267            ],
268            'redirects' => [
269                ParamValidator::PARAM_TYPE => [ 'return', 'resolve' ],
270                ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
271                ApiBase::PARAM_HELP_MSG_APPEND => [ 'apihelp-opensearch-param-redirects-append' ],
272            ],
273            'format' => [
274                ParamValidator::PARAM_DEFAULT => 'json',
275                ParamValidator::PARAM_TYPE => [ 'json', 'jsonfm', 'xml', 'xmlfm' ],
276            ],
277            'warningsaserror' => false,
278        ];
279
280        // Use open search specific default limit
281        $allowedParams['limit'][ParamValidator::PARAM_DEFAULT] = $this->getConfig()->get(
282            MainConfigNames::OpenSearchDefaultLimit
283        );
284
285        return $allowedParams;
286    }
287
288    /** @inheritDoc */
289    public function getSearchProfileParams() {
290        return [
291            'profile' => [
292                'profile-type' => SearchEngine::COMPLETION_PROFILE_TYPE,
293                'help-message' => 'apihelp-query+prefixsearch-param-profile'
294            ],
295        ];
296    }
297
298    /** @inheritDoc */
299    protected function getExamplesMessages() {
300        return [
301            'action=opensearch&search=Te'
302                => 'apihelp-opensearch-example-te',
303        ];
304    }
305
306    /** @inheritDoc */
307    public function getHelpUrls() {
308        return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Opensearch';
309    }
310
311    /**
312     * Trim an extract to a sensible length.
313     *
314     * Adapted from Extension:OpenSearchXml, which adapted it from
315     * Extension:ActiveAbstract.
316     *
317     * @param string $text
318     * @param int $length Target length; actual result will continue to the end of a sentence.
319     * @return string
320     */
321    public static function trimExtract( $text, $length ) {
322        static $regex = null;
323
324        if ( $regex === null ) {
325            $endchars = [
326                '([^\d])\.\s', '\!\s', '\?\s', // regular ASCII
327                '。', // full-width ideographic full-stop
328                '.', '!', '?', // double-width roman forms
329                '。', // half-width ideographic full stop
330            ];
331            $endgroup = implode( '|', $endchars );
332            $end = "(?:$endgroup)";
333            $sentence = ".{{$length},}?$end+";
334            $regex = "/^($sentence)/u";
335        }
336
337        $matches = [];
338        if ( preg_match( $regex, $text, $matches ) ) {
339            return trim( $matches[1] );
340        } else {
341            // Just return the first line
342            return trim( explode( "\n", $text )[0] );
343        }
344    }
345
346    /**
347     * Fetch the template for a type.
348     *
349     * @param string $type MIME type
350     * @return string
351     */
352    public static function getOpenSearchTemplate( $type ) {
353        $services = MediaWikiServices::getInstance();
354        $canonicalServer = $services->getMainConfig()->get( MainConfigNames::CanonicalServer );
355        $searchEngineConfig = $services->getSearchEngineConfig();
356        $ns = implode( '|', $searchEngineConfig->defaultNamespaces() );
357        if ( !$ns ) {
358            $ns = '0';
359        }
360
361        switch ( $type ) {
362            case 'application/x-suggestions+json':
363                return $canonicalServer .
364                    wfScript( 'api' ) . '?action=opensearch&search={searchTerms}&namespace=' . $ns;
365
366            case 'application/x-suggestions+xml':
367                return $canonicalServer .
368                    wfScript( 'api' ) .
369                    '?action=opensearch&format=xml&search={searchTerms}&namespace=' . $ns;
370
371            default:
372                throw new InvalidArgumentException( __METHOD__ . ": Unknown type '$type'" );
373        }
374    }
375}
376
377/** @deprecated class alias since 1.43 */
378class_alias( ApiOpenSearch::class, 'ApiOpenSearch' );