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