ApiOpenSearch::getHelpUrls()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
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 = $this->buildCommonApiParams( false ) + [
276
			'suggest' => false,
277
			'redirects' => [
278
				ApiBase::PARAM_TYPE => [ 'return', 'resolve' ],
279
			],
280
			'format' => [
281
				ApiBase::PARAM_DFLT => 'json',
282
				ApiBase::PARAM_TYPE => [ 'json', 'jsonfm', 'xml', 'xmlfm' ],
283
			],
284
			'warningsaserror' => false,
285
		];
286
287
		// Use open search specific default limit
288
		$this->allowedParams['limit'][ApiBase::PARAM_DFLT] = $this->getConfig()->get(
289
			'OpenSearchDefaultLimit'
290
		);
291
292
		return $this->allowedParams;
293
	}
294
295
	public function getSearchProfileParams() {
296
		return [
297
			'profile' => [
298
				'profile-type' => SearchEngine::COMPLETION_PROFILE_TYPE,
299
				'help-message' => 'apihelp-query+prefixsearch-param-profile'
300
			],
301
		];
302
	}
303
304
	protected function getExamplesMessages() {
305
		return [
306
			'action=opensearch&search=Te'
307
				=> 'apihelp-opensearch-example-te',
308
		];
309
	}
310
311
	public function getHelpUrls() {
312
		return 'https://www.mediawiki.org/wiki/API:Opensearch';
313
	}
314
315
	/**
316
	 * Trim an extract to a sensible length.
317
	 *
318
	 * Adapted from Extension:OpenSearchXml, which adapted it from
319
	 * Extension:ActiveAbstract.
320
	 *
321
	 * @param string $text
322
	 * @param int $length Target length; actual result will continue to the end of a sentence.
323
	 * @return string
324
	 */
325
	public static function trimExtract( $text, $length ) {
326
		static $regex = null;
327
328
		if ( $regex === null ) {
329
			$endchars = [
330
				'([^\d])\.\s', '\!\s', '\?\s', // regular ASCII
331
				'。', // full-width ideographic full-stop
332
				'.', '!', '?', // double-width roman forms
333
				'。', // half-width ideographic full stop
334
			];
335
			$endgroup = implode( '|', $endchars );
336
			$end = "(?:$endgroup)";
337
			$sentence = ".{{$length},}?$end+";
338
			$regex = "/^($sentence)/u";
339
		}
340
341
		$matches = [];
342
		if ( preg_match( $regex, $text, $matches ) ) {
343
			return trim( $matches[1] );
344
		} else {
345
			// Just return the first line
346
			return trim( explode( "\n", $text )[0] );
347
		}
348
	}
349
350
	/**
351
	 * Fetch the template for a type.
352
	 *
353
	 * @param string $type MIME type
354
	 * @return string
355
	 * @throws MWException
356
	 */
357
	public static function getOpenSearchTemplate( $type ) {
358
		$config = MediaWikiServices::getInstance()->getSearchEngineConfig();
359
		$template = $config->getConfig()->get( 'OpenSearchTemplate' );
360
361
		if ( $template && $type === 'application/x-suggestions+json' ) {
362
			return $template;
363
		}
364
365
		$ns = implode( '|', $config->defaultNamespaces() );
366
		if ( !$ns ) {
367
			$ns = '0';
368
		}
369
370
		switch ( $type ) {
371
			case 'application/x-suggestions+json':
372
				return $config->getConfig()->get( 'CanonicalServer' ) . wfScript( 'api' )
373
					. '?action=opensearch&search={searchTerms}&namespace=' . $ns;
374
375
			case 'application/x-suggestions+xml':
376
				return $config->getConfig()->get( 'CanonicalServer' ) . wfScript( 'api' )
377
					. '?action=opensearch&format=xml&search={searchTerms}&namespace=' . $ns;
378
379
			default:
380
				throw new MWException( __METHOD__ . ": Unknown type '$type'" );
381
		}
382
	}
383
}
384
385
class ApiOpenSearchFormatJson extends ApiFormatJson {
386
	private $warningsAsError = false;
387
388
	public function __construct( ApiMain $main, $fm, $warningsAsError ) {
389
		parent::__construct( $main, "json$fm" );
390
		$this->warningsAsError = $warningsAsError;
391
	}
392
393
	public function execute() {
394
		if ( !$this->getResult()->getResultData( 'error' ) ) {
395
			$result = $this->getResult();
396
397
			// Ignore warnings or treat as errors, as requested
398
			$warnings = $result->removeValue( 'warnings', null );
399
			if ( $this->warningsAsError && $warnings ) {
400
				$this->dieUsage(
401
					'Warnings cannot be represented in OpenSearch JSON format', 'warnings', 0,
402
					[ 'warnings' => $warnings ]
403
				);
404
			}
405
406
			// Ignore any other unexpected keys (e.g. from $wgDebugToolbar)
407
			$remove = array_keys( array_diff_key(
408
				$result->getResultData(),
409
				[ 0 => 'search', 1 => 'terms', 2 => 'descriptions', 3 => 'urls' ]
410
			) );
411
			foreach ( $remove as $key ) {
412
				$result->removeValue( $key, null );
413
			}
414
		}
415
416
		parent::execute();
417
	}
418
}
419