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