MediaWiki  master
ApiOpenSearch.php
Go to the documentation of this file.
1 <?php
30 
34 class ApiOpenSearch extends ApiBase {
35  use SearchApi;
36 
37  private $format = null;
38  private $fm = null;
39 
41  private $allowedParams = null;
42 
44  private $linkBatchFactory;
45 
53  public function __construct(
54  ApiMain $mainModule,
55  $moduleName,
56  LinkBatchFactory $linkBatchFactory,
57  SearchEngineConfig $searchEngineConfig,
58  SearchEngineFactory $searchEngineFactory
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  }
66 
72  protected function getFormat() {
73  if ( $this->format === null ) {
74  $params = $this->extractRequestParams();
75  $format = $params['format'];
76 
77  $allowedParams = $this->getAllowedParams();
78  if ( !in_array( $format, $allowedParams['format'][ParamValidator::PARAM_TYPE] ) ) {
79  $format = $allowedParams['format'][ParamValidator::PARAM_DEFAULT];
80  }
81 
82  if ( str_ends_with( $format, 'fm' ) ) {
83  $this->format = substr( $format, 0, -2 );
84  $this->fm = 'fm';
85  } else {
86  $this->format = $format;
87  $this->fm = '';
88  }
89  }
90  return $this->format;
91  }
92 
93  public function getCustomPrinter() {
94  switch ( $this->getFormat() ) {
95  case 'json':
96  return new ApiOpenSearchFormatJson(
97  $this->getMain(), $this->fm, $this->getParameter( 'warningsaserror' )
98  );
99 
100  case 'xml':
101  $printer = $this->getMain()->createPrinterByName( 'xml' . $this->fm );
102  '@phan-var ApiFormatXml $printer';
104  $printer->setRootElement( 'SearchSuggestion' );
105  return $printer;
106 
107  default:
108  ApiBase::dieDebug( __METHOD__, "Unsupported format '{$this->getFormat()}'" );
109  }
110  }
111 
112  public function execute() {
113  $params = $this->extractRequestParams();
114  $search = $params['search'];
115 
116  // Open search results may be stored for a very long time
117  $this->getMain()->setCacheMaxAge(
118  $this->getConfig()->get( MainConfigNames::SearchSuggestCacheExpiry ) );
119  $this->getMain()->setCacheMode( 'public' );
120  $results = $this->search( $search, $params );
121 
122  // Allow hooks to populate extracts and images
123  $this->getHookRunner()->onApiOpenSearchSuggest( $results );
124 
125  // Trim extracts, if necessary
126  $length = $this->getConfig()->get( MainConfigNames::OpenSearchDescriptionLength );
127  foreach ( $results as &$r ) {
128  if ( is_string( $r['extract'] ) && !$r['extract trimmed'] ) {
129  $r['extract'] = self::trimExtract( $r['extract'], $length );
130  }
131  }
132 
133  // Populate result object
134  $this->populateResult( $search, $results );
135  }
136 
145  private function search( $search, array $params ) {
146  $searchEngine = $this->buildSearchEngine( $params );
147  $titles = $searchEngine->extractTitles( $searchEngine->completionSearchWithVariants( $search ) );
148  $results = [];
149 
150  if ( !$titles ) {
151  return $results;
152  }
153 
154  // Special pages need unique integer ids in the return list, so we just
155  // assign them negative numbers because those won't clash with the
156  // always positive articleIds that non-special pages get.
157  $nextSpecialPageId = -1;
158 
159  if ( $params['redirects'] === null ) {
160  // Backwards compatibility, don't resolve for JSON.
161  $resolveRedir = $this->getFormat() !== 'json';
162  } else {
163  $resolveRedir = $params['redirects'] === 'resolve';
164  }
165 
166  if ( $resolveRedir ) {
167  // Query for redirects
168  $redirects = [];
169  $lb = $this->linkBatchFactory->newLinkBatch( $titles );
170  if ( !$lb->isEmpty() ) {
171  $db = $this->getDB();
172  $res = $db->newSelectQueryBuilder()
173  ->select( [ 'page_namespace', 'page_title', 'rd_namespace', 'rd_title' ] )
174  ->from( 'page' )
175  ->where( [
176  'rd_interwiki IS NULL OR rd_interwiki = ' . $db->addQuotes( '' ),
177  $lb->constructSet( 'page', $db )
178  ] )
179  ->join( 'redirect', null, [ 'rd_from = page_id' ] )
180  ->caller( __METHOD__ )
181  ->fetchResultSet();
182  foreach ( $res as $row ) {
183  $redirects[$row->page_namespace][$row->page_title] =
184  [ $row->rd_namespace, $row->rd_title ];
185  }
186  }
187 
188  // Bypass any redirects
189  $seen = [];
190  foreach ( $titles as $title ) {
191  $ns = $title->getNamespace();
192  $dbkey = $title->getDBkey();
193  $from = null;
194  if ( isset( $redirects[$ns][$dbkey] ) ) {
195  [ $ns, $dbkey ] = $redirects[$ns][$dbkey];
196  $from = $title;
197  $title = Title::makeTitle( $ns, $dbkey );
198  }
199  if ( !isset( $seen[$ns][$dbkey] ) ) {
200  $seen[$ns][$dbkey] = true;
201  $resultId = $title->getArticleID();
202  if ( $resultId === 0 ) {
203  $resultId = $nextSpecialPageId;
204  $nextSpecialPageId--;
205  }
206  $results[$resultId] = [
207  'title' => $title,
208  'redirect from' => $from,
209  'extract' => false,
210  'extract trimmed' => false,
211  'image' => false,
212  'url' => wfExpandUrl( $title->getFullURL(), PROTO_CURRENT ),
213  ];
214  }
215  }
216  } else {
217  foreach ( $titles as $title ) {
218  $resultId = $title->getArticleID();
219  if ( $resultId === 0 ) {
220  $resultId = $nextSpecialPageId;
221  $nextSpecialPageId--;
222  }
223  $results[$resultId] = [
224  'title' => $title,
225  'redirect from' => null,
226  'extract' => false,
227  'extract trimmed' => false,
228  'image' => false,
229  'url' => wfExpandUrl( $title->getFullURL(), PROTO_CURRENT ),
230  ];
231  }
232  }
233 
234  return $results;
235  }
236 
241  protected function populateResult( $search, &$results ) {
242  $result = $this->getResult();
243 
244  switch ( $this->getFormat() ) {
245  case 'json':
246  // http://www.opensearch.org/Specifications/OpenSearch/Extensions/Suggestions/1.1
247  $result->addArrayType( null, 'array' );
248  $result->addValue( null, 0, strval( $search ) );
249  $terms = [];
250  $descriptions = [];
251  $urls = [];
252  foreach ( $results as $r ) {
253  $terms[] = $r['title']->getPrefixedText();
254  $descriptions[] = strval( $r['extract'] );
255  $urls[] = $r['url'];
256  }
257  $result->addValue( null, 1, $terms );
258  $result->addValue( null, 2, $descriptions );
259  $result->addValue( null, 3, $urls );
260  break;
261 
262  case 'xml':
263  // https://msdn.microsoft.com/en-us/library/cc891508(v=vs.85).aspx
264  $imageKeys = [
265  'source' => true,
266  'alt' => true,
267  'width' => true,
268  'height' => true,
269  'align' => true,
270  ];
271  $items = [];
272  foreach ( $results as $r ) {
273  $item = [
274  'Text' => $r['title']->getPrefixedText(),
275  'Url' => $r['url'],
276  ];
277  if ( is_string( $r['extract'] ) && $r['extract'] !== '' ) {
278  $item['Description'] = $r['extract'];
279  }
280  if ( is_array( $r['image'] ) && isset( $r['image']['source'] ) ) {
281  $item['Image'] = array_intersect_key( $r['image'], $imageKeys );
282  }
283  ApiResult::setSubelementsList( $item, array_keys( $item ) );
284  $items[] = $item;
285  }
286  ApiResult::setIndexedTagName( $items, 'Item' );
287  $result->addValue( null, 'version', '2.0' );
288  $result->addValue( null, 'xmlns', 'http://opensearch.org/searchsuggest2' );
289  $result->addValue( null, 'Query', strval( $search ) );
290  $result->addSubelementsList( null, 'Query' );
291  $result->addValue( null, 'Section', $items );
292  break;
293 
294  default:
295  ApiBase::dieDebug( __METHOD__, "Unsupported format '{$this->getFormat()}'" );
296  }
297  }
298 
299  public function getAllowedParams() {
300  if ( $this->allowedParams !== null ) {
301  return $this->allowedParams;
302  }
303  $this->allowedParams = $this->buildCommonApiParams( false ) + [
304  'suggest' => [
305  ParamValidator::PARAM_DEFAULT => false,
306  // Deprecated since 1.35
307  ParamValidator::PARAM_DEPRECATED => true,
308  ],
309  'redirects' => [
310  ParamValidator::PARAM_TYPE => [ 'return', 'resolve' ],
311  ],
312  'format' => [
313  ParamValidator::PARAM_DEFAULT => 'json',
314  ParamValidator::PARAM_TYPE => [ 'json', 'jsonfm', 'xml', 'xmlfm' ],
315  ],
316  'warningsaserror' => false,
317  ];
318 
319  // Use open search specific default limit
320  $this->allowedParams['limit'][ParamValidator::PARAM_DEFAULT] = $this->getConfig()->get(
321  MainConfigNames::OpenSearchDefaultLimit
322  );
323 
324  return $this->allowedParams;
325  }
326 
327  public function getSearchProfileParams() {
328  return [
329  'profile' => [
330  'profile-type' => SearchEngine::COMPLETION_PROFILE_TYPE,
331  'help-message' => 'apihelp-query+prefixsearch-param-profile'
332  ],
333  ];
334  }
335 
336  protected function getExamplesMessages() {
337  return [
338  'action=opensearch&search=Te'
339  => 'apihelp-opensearch-example-te',
340  ];
341  }
342 
343  public function getHelpUrls() {
344  return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Opensearch';
345  }
346 
357  public static function trimExtract( $text, $length ) {
358  static $regex = null;
359 
360  if ( $regex === null ) {
361  $endchars = [
362  '([^\d])\.\s', '\!\s', '\?\s', // regular ASCII
363  '。', // full-width ideographic full-stop
364  '.', '!', '?', // double-width roman forms
365  '。', // half-width ideographic full stop
366  ];
367  $endgroup = implode( '|', $endchars );
368  $end = "(?:$endgroup)";
369  $sentence = ".{{$length},}?$end+";
370  $regex = "/^($sentence)/u";
371  }
372 
373  $matches = [];
374  if ( preg_match( $regex, $text, $matches ) ) {
375  return trim( $matches[1] );
376  } else {
377  // Just return the first line
378  return trim( explode( "\n", $text )[0] );
379  }
380  }
381 
389  public static function getOpenSearchTemplate( $type ) {
390  $config = MediaWikiServices::getInstance()->getSearchEngineConfig();
391  $template = $config->getConfig()->get( MainConfigNames::OpenSearchTemplate );
392 
393  if ( $template && $type === 'application/x-suggestions+json' ) {
394  return $template;
395  }
396 
397  $ns = implode( '|', $config->defaultNamespaces() );
398  if ( !$ns ) {
399  $ns = '0';
400  }
401 
402  switch ( $type ) {
403  case 'application/x-suggestions+json':
404  return $config->getConfig()->get( MainConfigNames::CanonicalServer ) .
405  wfScript( 'api' ) . '?action=opensearch&search={searchTerms}&namespace=' . $ns;
406 
407  case 'application/x-suggestions+xml':
408  return $config->getConfig()->get( MainConfigNames::CanonicalServer ) .
409  wfScript( 'api' ) .
410  '?action=opensearch&format=xml&search={searchTerms}&namespace=' . $ns;
411 
412  default:
413  throw new MWException( __METHOD__ . ": Unknown type '$type'" );
414  }
415  }
416 }
const PROTO_CURRENT
Definition: Defines.php:198
wfExpandUrl( $url, $defaultProto=PROTO_CURRENT)
Expand a potentially local URL to a fully-qualified URL.
wfScript( $script='index')
Get the path to a specified script file, respecting file extensions; this is a wrapper around $wgScri...
$matches
buildSearchEngine(array $params=null)
Build the search engine to use.
Definition: SearchApi.php:175
buildCommonApiParams( $isScrollable=true)
The set of api parameters that are shared between api calls that call the SearchEngine.
Definition: SearchApi.php:66
This abstract class implements many basic API functions, and is the base of all API classes.
Definition: ApiBase.php:59
getParameter( $paramName, $parseLimit=true)
Get a value for the given parameter.
Definition: ApiBase.php:894
getDB()
Gets a default replica DB connection object.
Definition: ApiBase.php:660
static dieDebug( $method, $message)
Internal code errors should be reported with this method.
Definition: ApiBase.php:1701
getMain()
Get the main module.
Definition: ApiBase.php:522
getResult()
Get the result object.
Definition: ApiBase.php:637
extractRequestParams( $options=[])
Using getAllowedParams(), this function makes an array of the values provided by the user,...
Definition: ApiBase.php:773
getHookRunner()
Get an ApiHookRunner for running core API hooks.
Definition: ApiBase.php:719
This is the main API class, used for both external and internal processing.
Definition: ApiMain.php:59
static trimExtract( $text, $length)
Trim an extract to a sensible length.
getHelpUrls()
Return links to more detailed help pages about the module.
execute()
Evaluates the parameters, performs the requested query, and sets up the result.
getCustomPrinter()
If the module may only be used with a certain format module, it should override this method to return...
getExamplesMessages()
Returns usage examples for this module.
populateResult( $search, &$results)
__construct(ApiMain $mainModule, $moduleName, LinkBatchFactory $linkBatchFactory, SearchEngineConfig $searchEngineConfig, SearchEngineFactory $searchEngineFactory)
getFormat()
Get the output format.
getAllowedParams()
Returns an array of allowed parameters (parameter name) => (default value) or (parameter name) => (ar...
static getOpenSearchTemplate( $type)
Fetch the template for a type.
static setSubelementsList(array &$arr, $names)
Causes the elements with the specified names to be output as subelements rather than attributes.
Definition: ApiResult.php:553
static setIndexedTagName(array &$arr, $tag)
Set the tag name for numeric-keyed values in XML format.
Definition: ApiResult.php:604
MediaWiki exception.
Definition: MWException.php:32
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
Represents a title within MediaWiki.
Definition: Title.php:82
Configuration handling class for SearchEngine.
Factory class for SearchEngine.
const COMPLETION_PROFILE_TYPE
Profile type for completionSearch.
Service for formatting and validating API parameters.
trait SearchApi
Traits for API components that use a SearchEngine.
Definition: SearchApi.php:30