Completed
Branch master (02e057)
by
unknown
27:42
created

ApiOpenSearch::getSearchProfileParams()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 4
c 1
b 0
f 0
nc 2
nop 0
dl 0
loc 6
rs 9.4285
1
<?php
2
/**
3
 * Created on Oct 13, 2006
4
 *
5
 * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
6
 * Copyright © 2008 Brion Vibber <[email protected]>
7
 * Copyright © 2014 Brad Jorsch <[email protected]>
8
 *
9
 * This program is free software; you can redistribute it and/or modify
10
 * it under the terms of the GNU General Public License as published by
11
 * the Free Software Foundation; either version 2 of the License, or
12
 * (at your option) any later version.
13
 *
14
 * This program is distributed in the hope that it will be useful,
15
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
 * GNU General Public License for more details.
18
 *
19
 * You should have received a copy of the GNU General Public License along
20
 * with this program; if not, write to the Free Software Foundation, Inc.,
21
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
22
 * http://www.gnu.org/copyleft/gpl.html
23
 *
24
 * @file
25
 */
26
27
use MediaWiki\MediaWikiServices;
28
29
/**
30
 * @ingroup API
31
 */
32
class ApiOpenSearch extends ApiBase {
33
	use SearchApi;
34
35
	private $format = null;
36
	private $fm = null;
37
38
	/** @var array list of api allowed params */
39
	private $allowedParams = null;
40
41
	/**
42
	 * Get the output format
43
	 *
44
	 * @return string
45
	 */
46
	protected function getFormat() {
47
		if ( $this->format === null ) {
48
			$params = $this->extractRequestParams();
49
			$format = $params['format'];
50
51
			$allowedParams = $this->getAllowedParams();
52
			if ( !in_array( $format, $allowedParams['format'][ApiBase::PARAM_TYPE] ) ) {
53
				$format = $allowedParams['format'][ApiBase::PARAM_DFLT];
54
			}
55
56
			if ( substr( $format, -2 ) === 'fm' ) {
57
				$this->format = substr( $format, 0, -2 );
58
				$this->fm = 'fm';
59
			} else {
60
				$this->format = $format;
61
				$this->fm = '';
62
			}
63
		}
64
		return $this->format;
65
	}
66
67
	public function getCustomPrinter() {
68
		switch ( $this->getFormat() ) {
69
			case 'json':
70
				return new ApiOpenSearchFormatJson(
71
					$this->getMain(), $this->fm, $this->getParameter( 'warningsaserror' )
72
				);
73
74
			case 'xml':
75
				$printer = $this->getMain()->createPrinterByName( 'xml' . $this->fm );
76
				$printer->setRootElement( 'SearchSuggestion' );
77
				return $printer;
78
79
			default:
80
				ApiBase::dieDebug( __METHOD__, "Unsupported format '{$this->getFormat()}'" );
81
		}
82
	}
83
84
	public function execute() {
85
		$params = $this->extractRequestParams();
86
		$search = $params['search'];
87
		$suggest = $params['suggest'];
88
		$results = [];
89
		if ( !$suggest || $this->getConfig()->get( 'EnableOpenSearchSuggest' ) ) {
90
			// Open search results may be stored for a very long time
91
			$this->getMain()->setCacheMaxAge( $this->getConfig()->get( 'SearchSuggestCacheExpiry' ) );
92
			$this->getMain()->setCacheMode( 'public' );
93
			$results = $this->search( $search, $params );
94
95
			// Allow hooks to populate extracts and images
96
			Hooks::run( 'ApiOpenSearchSuggest', [ &$results ] );
97
98
			// Trim extracts, if necessary
99
			$length = $this->getConfig()->get( 'OpenSearchDescriptionLength' );
100
			foreach ( $results as &$r ) {
101
				if ( is_string( $r['extract'] ) && !$r['extract trimmed'] ) {
102
					$r['extract'] = self::trimExtract( $r['extract'], $length );
103
				}
104
			}
105
		}
106
107
		// Populate result object
108
		$this->populateResult( $search, $results );
109
	}
110
111
	/**
112
	 * Perform the search
113
	 * @param string $search the search query
114
	 * @param array $params api request params
115
	 * @return array search results. Keys are integers.
116
	 */
117
	private function search( $search, array $params ) {
118
		$searchEngine = $this->buildSearchEngine( $params );
119
		$titles = $searchEngine->extractTitles( $searchEngine->completionSearchWithVariants( $search ) );
120
		$results = [];
121
122
		if ( !$titles ) {
123
			return $results;
124
		}
125
126
		// Special pages need unique integer ids in the return list, so we just
127
		// assign them negative numbers because those won't clash with the
128
		// always positive articleIds that non-special pages get.
129
		$nextSpecialPageId = -1;
130
131
		if ( $params['redirects'] === null ) {
132
			// Backwards compatibility, don't resolve for JSON.
133
			$resolveRedir = $this->getFormat() !== 'json';
134
		} else {
135
			$resolveRedir = $params['redirects'] === 'resolve';
136
		}
137
138
		if ( $resolveRedir ) {
139
			// Query for redirects
140
			$redirects = [];
141
			$lb = new LinkBatch( $titles );
142
			if ( !$lb->isEmpty() ) {
143
				$db = $this->getDB();
144
				$res = $db->select(
145
					[ 'page', 'redirect' ],
146
					[ 'page_namespace', 'page_title', 'rd_namespace', 'rd_title' ],
147
					[
148
						'rd_from = page_id',
149
						'rd_interwiki IS NULL OR rd_interwiki = ' . $db->addQuotes( '' ),
150
						$lb->constructSet( 'page', $db ),
151
					],
152
					__METHOD__
153
				);
154
				foreach ( $res as $row ) {
0 ignored issues
show
Bug introduced by
The expression $res of type object<ResultWrapper>|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
155
					$redirects[$row->page_namespace][$row->page_title] =
156
						[ $row->rd_namespace, $row->rd_title ];
157
				}
158
			}
159
160
			// Bypass any redirects
161
			$seen = [];
162
			foreach ( $titles as $title ) {
163
				$ns = $title->getNamespace();
164
				$dbkey = $title->getDBkey();
165
				$from = null;
166
				if ( isset( $redirects[$ns][$dbkey] ) ) {
167
					list( $ns, $dbkey ) = $redirects[$ns][$dbkey];
168
					$from = $title;
169
					$title = Title::makeTitle( $ns, $dbkey );
170
				}
171
				if ( !isset( $seen[$ns][$dbkey] ) ) {
172
					$seen[$ns][$dbkey] = true;
173
					$resultId = $title->getArticleID();
174
					if ( $resultId === 0 ) {
175
						$resultId = $nextSpecialPageId;
176
						$nextSpecialPageId -= 1;
177
					}
178
					$results[$resultId] = [
179
						'title' => $title,
180
						'redirect from' => $from,
181
						'extract' => false,
182
						'extract trimmed' => false,
183
						'image' => false,
184
						'url' => wfExpandUrl( $title->getFullURL(), PROTO_CURRENT ),
185
					];
186
				}
187
			}
188
		} else {
189
			foreach ( $titles as $title ) {
190
				$resultId = $title->getArticleID();
191
				if ( $resultId === 0 ) {
192
					$resultId = $nextSpecialPageId;
193
					$nextSpecialPageId -= 1;
194
				}
195
				$results[$resultId] = [
196
					'title' => $title,
197
					'redirect from' => null,
198
					'extract' => false,
199
					'extract trimmed' => false,
200
					'image' => false,
201
					'url' => wfExpandUrl( $title->getFullURL(), PROTO_CURRENT ),
202
				];
203
			}
204
		}
205
206
		return $results;
207
	}
208
209
	/**
210
	 * @param string $search
211
	 * @param array &$results
212
	 */
213
	protected function populateResult( $search, &$results ) {
214
		$result = $this->getResult();
215
216
		switch ( $this->getFormat() ) {
217
			case 'json':
218
				// http://www.opensearch.org/Specifications/OpenSearch/Extensions/Suggestions/1.1
219
				$result->addArrayType( null, 'array' );
220
				$result->addValue( null, 0, strval( $search ) );
221
				$terms = [];
222
				$descriptions = [];
223
				$urls = [];
224
				foreach ( $results as $r ) {
225
					$terms[] = $r['title']->getPrefixedText();
226
					$descriptions[] = strval( $r['extract'] );
227
					$urls[] = $r['url'];
228
				}
229
				$result->addValue( null, 1, $terms );
230
				$result->addValue( null, 2, $descriptions );
231
				$result->addValue( null, 3, $urls );
232
				break;
233
234
			case 'xml':
235
				// http://msdn.microsoft.com/en-us/library/cc891508%28v=vs.85%29.aspx
236
				$imageKeys = [
237
					'source' => true,
238
					'alt' => true,
239
					'width' => true,
240
					'height' => true,
241
					'align' => true,
242
				];
243
				$items = [];
244
				foreach ( $results as $r ) {
245
					$item = [
246
						'Text' => $r['title']->getPrefixedText(),
247
						'Url' => $r['url'],
248
					];
249
					if ( is_string( $r['extract'] ) && $r['extract'] !== '' ) {
250
						$item['Description'] = $r['extract'];
251
					}
252
					if ( is_array( $r['image'] ) && isset( $r['image']['source'] ) ) {
253
						$item['Image'] = array_intersect_key( $r['image'], $imageKeys );
254
					}
255
					ApiResult::setSubelementsList( $item, array_keys( $item ) );
256
					$items[] = $item;
257
				}
258
				ApiResult::setIndexedTagName( $items, 'Item' );
259
				$result->addValue( null, 'version', '2.0' );
260
				$result->addValue( null, 'xmlns', 'http://opensearch.org/searchsuggest2' );
261
				$result->addValue( null, 'Query', strval( $search ) );
262
				$result->addSubelementsList( null, 'Query' );
263
				$result->addValue( null, 'Section', $items );
264
				break;
265
266
			default:
267
				ApiBase::dieDebug( __METHOD__, "Unsupported format '{$this->getFormat()}'" );
268
		}
269
	}
270
271
	public function getAllowedParams() {
272
		if ( $this->allowedParams !== null ) {
273
			return $this->allowedParams;
274
		}
275
		$this->allowedParams = [
276
			'search' => null,
277
			'limit' => [
278
				ApiBase::PARAM_DFLT => $this->getConfig()->get( 'OpenSearchDefaultLimit' ),
279
				ApiBase::PARAM_TYPE => 'limit',
280
				ApiBase::PARAM_MIN => 1,
281
				ApiBase::PARAM_MAX => 100,
282
				ApiBase::PARAM_MAX2 => 100
283
			],
284
			'namespace' => [
285
				ApiBase::PARAM_DFLT => NS_MAIN,
286
				ApiBase::PARAM_TYPE => 'namespace',
287
				ApiBase::PARAM_ISMULTI => true
288
			],
289
			'suggest' => false,
290
			'redirects' => [
291
				ApiBase::PARAM_TYPE => [ 'return', 'resolve' ],
292
			],
293
			'format' => [
294
				ApiBase::PARAM_DFLT => 'json',
295
				ApiBase::PARAM_TYPE => [ 'json', 'jsonfm', 'xml', 'xmlfm' ],
296
			],
297
			'warningsaserror' => false,
298
		];
299
300
		$profileParam = $this->buildProfileApiParam( SearchEngine::COMPLETION_PROFILE_TYPE,
301
			'apihelp-query+prefixsearch-param-profile' );
302
		if ( $profileParam ) {
303
			$this->allowedParams['profile'] = $profileParam;
304
		}
305
		return $this->allowedParams;
306
	}
307
308
	public function getSearchProfileParams() {
309
		if ( isset( $this->getAllowedParams()['profile'] ) ) {
310
			return [ SearchEngine::COMPLETION_PROFILE_TYPE => 'profile' ];
311
		}
312
		return [];
313
	}
314
315
	protected function getExamplesMessages() {
316
		return [
317
			'action=opensearch&search=Te'
318
				=> 'apihelp-opensearch-example-te',
319
		];
320
	}
321
322
	public function getHelpUrls() {
323
		return 'https://www.mediawiki.org/wiki/API:Opensearch';
324
	}
325
326
	/**
327
	 * Trim an extract to a sensible length.
328
	 *
329
	 * Adapted from Extension:OpenSearchXml, which adapted it from
330
	 * Extension:ActiveAbstract.
331
	 *
332
	 * @param string $text
333
	 * @param int $length Target length; actual result will continue to the end of a sentence.
334
	 * @return string
335
	 */
336
	public static function trimExtract( $text, $length ) {
337
		static $regex = null;
338
339
		if ( $regex === null ) {
340
			$endchars = [
341
				'([^\d])\.\s', '\!\s', '\?\s', // regular ASCII
342
				'。', // full-width ideographic full-stop
343
				'.', '!', '?', // double-width roman forms
344
				'。', // half-width ideographic full stop
345
			];
346
			$endgroup = implode( '|', $endchars );
347
			$end = "(?:$endgroup)";
348
			$sentence = ".{{$length},}?$end+";
349
			$regex = "/^($sentence)/u";
350
		}
351
352
		$matches = [];
353
		if ( preg_match( $regex, $text, $matches ) ) {
354
			return trim( $matches[1] );
355
		} else {
356
			// Just return the first line
357
			return trim( explode( "\n", $text )[0] );
358
		}
359
	}
360
361
	/**
362
	 * Fetch the template for a type.
363
	 *
364
	 * @param string $type MIME type
365
	 * @return string
366
	 * @throws MWException
367
	 */
368
	public static function getOpenSearchTemplate( $type ) {
369
		$config = MediaWikiServices::getInstance()->getSearchEngineConfig();
370
		$template = $config->getConfig()->get( 'OpenSearchTemplate' );
371
372
		if ( $template && $type === 'application/x-suggestions+json' ) {
373
			return $template;
374
		}
375
376
		$ns = implode( '|', $config->defaultNamespaces() );
377
		if ( !$ns ) {
378
			$ns = '0';
379
		}
380
381
		switch ( $type ) {
382
			case 'application/x-suggestions+json':
383
				return $config->getConfig()->get( 'CanonicalServer' ) . wfScript( 'api' )
384
					. '?action=opensearch&search={searchTerms}&namespace=' . $ns;
385
386
			case 'application/x-suggestions+xml':
387
				return $config->getConfig()->get( 'CanonicalServer' ) . wfScript( 'api' )
388
					. '?action=opensearch&format=xml&search={searchTerms}&namespace=' . $ns;
389
390
			default:
391
				throw new MWException( __METHOD__ . ": Unknown type '$type'" );
392
		}
393
	}
394
}
395
396
class ApiOpenSearchFormatJson extends ApiFormatJson {
397
	private $warningsAsError = false;
398
399
	public function __construct( ApiMain $main, $fm, $warningsAsError ) {
400
		parent::__construct( $main, "json$fm" );
401
		$this->warningsAsError = $warningsAsError;
402
	}
403
404
	public function execute() {
405
		if ( !$this->getResult()->getResultData( 'error' ) ) {
406
			$result = $this->getResult();
407
408
			// Ignore warnings or treat as errors, as requested
409
			$warnings = $result->removeValue( 'warnings', null );
410
			if ( $this->warningsAsError && $warnings ) {
411
				$this->dieUsage(
412
					'Warnings cannot be represented in OpenSearch JSON format', 'warnings', 0,
413
					[ 'warnings' => $warnings ]
414
				);
415
			}
416
417
			// Ignore any other unexpected keys (e.g. from $wgDebugToolbar)
418
			$remove = array_keys( array_diff_key(
419
				$result->getResultData(),
420
				[ 0 => 'search', 1 => 'terms', 2 => 'descriptions', 3 => 'urls' ]
421
			) );
422
			foreach ( $remove as $key ) {
423
				$result->removeValue( $key, null );
424
			}
425
		}
426
427
		parent::execute();
428
	}
429
}
430