MediaWiki  master
ApiOpenSearch.php
Go to the documentation of this file.
1 <?php
31 
35 class ApiOpenSearch extends ApiBase {
36  use SearchApi;
37 
38  private $format = null;
39  private $fm = null;
40 
41  private LinkBatchFactory $linkBatchFactory;
42  private UrlUtils $urlUtils;
43 
52  public function __construct(
53  ApiMain $mainModule,
54  $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 
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';
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 
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  ->where( [
171  'rd_interwiki' => [ null, '' ],
172  $lb->constructSet( 'page', $db )
173  ] )
174  ->join( 'redirect', null, [ 'rd_from = page_id' ] )
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 
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' ],
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 
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 
382  public static function getOpenSearchTemplate( $type ) {
383  $config = MediaWikiServices::getInstance()->getSearchEngineConfig();
384  $template = $config->getConfig()->get( MainConfigNames::OpenSearchTemplate );
385 
386  if ( $template && $type === 'application/x-suggestions+json' ) {
387  return $template;
388  }
389 
390  $ns = implode( '|', $config->defaultNamespaces() );
391  if ( !$ns ) {
392  $ns = '0';
393  }
394 
395  switch ( $type ) {
396  case 'application/x-suggestions+json':
397  return $config->getConfig()->get( MainConfigNames::CanonicalServer ) .
398  wfScript( 'api' ) . '?action=opensearch&search={searchTerms}&namespace=' . $ns;
399 
400  case 'application/x-suggestions+xml':
401  return $config->getConfig()->get( MainConfigNames::CanonicalServer ) .
402  wfScript( 'api' ) .
403  '?action=opensearch&format=xml&search={searchTerms}&namespace=' . $ns;
404 
405  default:
406  throw new InvalidArgumentException( __METHOD__ . ": Unknown type '$type'" );
407  }
408  }
409 }
const PROTO_CURRENT
Definition: Defines.php:196
wfScript( $script='index')
Get the URL path to a MediaWiki entry point.
$matches
buildSearchEngine(array $params=null)
Build the search engine to use.
Definition: SearchApi.php:174
buildCommonApiParams( $isScrollable=true)
The set of api parameters that are shared between api calls that call the SearchEngine.
Definition: SearchApi.php:64
This abstract class implements many basic API functions, and is the base of all API classes.
Definition: ApiBase.php:62
getParameter( $paramName, $parseLimit=true)
Get a value for the given parameter.
Definition: ApiBase.php:929
getDB()
Gets a default replica DB connection object.
Definition: ApiBase.php:691
static dieDebug( $method, $message)
Internal code errors should be reported with this method.
Definition: ApiBase.php:1759
getMain()
Get the main module.
Definition: ApiBase.php:546
const PARAM_HELP_MSG_APPEND
((string|array|Message)[]) Specify additional i18n messages to append to the normal message for this ...
Definition: ApiBase.php:176
const PARAM_HELP_MSG_PER_VALUE
((string|array|Message)[]) When PARAM_TYPE is an array, or 'string' with PARAM_ISMULTI,...
Definition: ApiBase.php:209
getResult()
Get the result object.
Definition: ApiBase.php:667
extractRequestParams( $options=[])
Using getAllowedParams(), this function makes an array of the values provided by the user,...
Definition: ApiBase.php:807
getHookRunner()
Get an ApiHookRunner for running core API hooks.
Definition: ApiBase.php:752
This is the main API class, used for both external and internal processing.
Definition: ApiMain.php:64
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.
__construct(ApiMain $mainModule, $moduleName, LinkBatchFactory $linkBatchFactory, SearchEngineConfig $searchEngineConfig, SearchEngineFactory $searchEngineFactory, UrlUtils $urlUtils)
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)
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
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
Represents a title within MediaWiki.
Definition: Title.php:76
A service to expand, parse, and otherwise manipulate URLs.
Definition: UrlUtils.php:17
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