Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.09% covered (success)
96.09%
123 / 128
70.00% covered (warning)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
BaseInterwikiResolver
96.09% covered (success)
96.09%
123 / 128
70.00% covered (warning)
70.00%
7 / 10
29
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getSisterProjectPrefixes
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getSisterProjectConfigs
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getInterwikiPrefix
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getSameProjectWikiByLang
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getSameProjectConfigByLang
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 getMatrix
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 loadMatrix
n/a
0 / 0
n/a
0 / 0
0
 loadConfigFromAPI
92.11% covered (success)
92.11%
35 / 38
0.00% covered (danger)
0.00%
0 / 1
11.06
 sendConfigDumpRequest
98.15% covered (success)
98.15%
53 / 54
0.00% covered (danger)
0.00%
0 / 1
7
 minimalSearchConfig
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace CirrusSearch;
4
5use BagOStuff;
6use MediaWiki\Interwiki\InterwikiLookup;
7use MediaWiki\Logger\LoggerFactory;
8use MediaWiki\MediaWikiServices;
9use MultiHttpClient;
10use WANObjectCache;
11
12/**
13 * Base InterwikiResolver class.
14 * Subclasses just need to provide the full matrix array
15 * by implementing loadMatrix(), the resulting matrix will
16 * be stored by this base class.
17 */
18abstract class BaseInterwikiResolver implements InterwikiResolver {
19    private const CONFIG_CACHE_TTL = 600;
20
21    /** @var array[]|null full IW matrix (@see loadMatrix()) */
22    private $matrix;
23
24    /** @var SearchConfig main wiki config */
25    protected $config;
26
27    /** @var bool use cirrus config dump API */
28    private $useConfigDumpApi;
29
30    /**
31     * @var MultiHttpClient http client to fetch config of other wikis
32     */
33    private $httpClient;
34
35    /**
36     * @var InterwikiLookup
37     */
38    private $interwikiLookup;
39
40    /**
41     * @var BagOStuff
42     */
43    private $srvCache;
44
45    /**
46     * @param SearchConfig $config
47     * @param \MultiHttpClient|null $client http client to fetch cirrus config
48     * @param WANObjectCache|null $wanCache Cache object for caching repeated requests
49     * @param BagOStuff|null $srvCache Local server cache object for caching repeated requests
50     * @param InterwikiLookup|null $iwLookup
51     * @throws \Exception
52     */
53    public function __construct(
54        SearchConfig $config,
55        MultiHttpClient $client = null,
56        WANObjectCache $wanCache = null,
57        BagOStuff $srvCache = null,
58        InterwikiLookup $iwLookup = null
59    ) {
60        $this->config = $config;
61        $this->useConfigDumpApi = $this->config->get( 'CirrusSearchFetchConfigFromApi' );
62        $this->httpClient = $client ??
63            MediaWikiServices::getInstance()->getHttpRequestFactory()->createMultiClient( [
64                'connTimeout' => $this->config->get( 'CirrusSearchInterwikiHTTPConnectTimeout' ),
65                'reqTimeout' => $this->config->get( 'CirrusSearchInterwikiHTTPTimeout' )
66            ] );
67        $this->interwikiLookup = $iwLookup ?? MediaWikiServices::getInstance()->getInterwikiLookup();
68        $this->srvCache = $srvCache ?? MediaWikiServices::getInstance()->getLocalServerObjectCache();
69    }
70
71    /**
72     * @return string[]
73     */
74    public function getSisterProjectPrefixes() {
75        $matrix = $this->getMatrix();
76        return $matrix['sister_projects'] ?? [];
77    }
78
79    /**
80     * @return SearchConfig[] configs of sister project indexed by interwiki prefix
81     */
82    public function getSisterProjectConfigs() {
83        $prefixes = $this->getSisterProjectPrefixes();
84        return $this->loadConfigFromAPI( $prefixes, [], [ $this, 'minimalSearchConfig' ] );
85    }
86
87    /**
88     * @param string $wikiId
89     * @return string|null
90     */
91    public function getInterwikiPrefix( $wikiId ) {
92        $matrix = $this->getMatrix();
93        return $matrix['prefixes_by_wiki'][$wikiId] ?? null;
94    }
95
96    /**
97     * @param string $lang
98     * @return string[] a two elements array [ 'prefix', 'language' ]
99     */
100    public function getSameProjectWikiByLang( $lang ) {
101        $matrix = $this->getMatrix();
102        // Most of the time the language is equal to the interwiki prefix.
103        // But it's not always the case, use the language_map to identify the interwiki prefix first.
104        $lang = $matrix['language_map'][$lang] ?? $lang;
105        return isset( $matrix['cross_language'][$lang] ) ? [ $matrix['cross_language'][$lang], $lang ] : [];
106    }
107
108    /**
109     * @param string $lang
110     * @return SearchConfig[] single element array: [ interwiki => SearchConfig ]
111     */
112    public function getSameProjectConfigByLang( $lang ) {
113        $wikiAndPrefix = $this->getSameProjectWikiByLang( $lang );
114        if ( !$wikiAndPrefix ) {
115            return [];
116        }
117        [ $wiki, $prefix ] = $wikiAndPrefix;
118        return $this->loadConfigFromAPI(
119            [ $prefix => $wiki ],
120            [],
121            [ $this, 'minimalSearchConfig' ] );
122    }
123
124    /** @return array[] */
125    private function getMatrix() {
126        if ( $this->matrix === null ) {
127            $this->matrix = $this->loadMatrix();
128
129        }
130        return $this->matrix;
131    }
132
133    /**
134     * Load the interwiki matric information
135     * The returned array must include the following keys:
136     * - sister_projects: an array with the list of sister wikis indexed by
137     *   interwiki prefix
138     * - cross_language: an array with the list of wikis running the same
139     *   project/site indexed by interwiki prefix
140     * - language_map: an array with the list of interwiki prefixes where
141     *   where the language code of the wiki does not match the prefix
142     * - prefixes_by_wiki: an array with the list of interwiki indexed
143     *   by wikiID
144     *
145     * The result of this method is stored in the current InterwikiResolver instance
146     * so it can be called only once per request.
147     *
148     * return array[]
149     */
150    abstract protected function loadMatrix();
151
152    /**
153     * @param string[] $wikis
154     * @param string[] $hashConfigFlags constructor flags for SearchConfig
155     * @param callable $fallbackConfig function to load the config if the
156     * api is not usable or if a failure occurs
157     * @return SearchConfig[] config indexed by iw prefix
158     */
159    private function loadConfigFromAPI( $wikis, array $hashConfigFlags, $fallbackConfig ) {
160        $endpoints = [];
161        foreach ( $wikis as $prefix => $wiki ) {
162            $iw = $this->interwikiLookup->fetch( $prefix );
163            if ( !$iw || !$this->useConfigDumpApi || !$iw->isLocal() ) {
164                continue;
165            }
166            $api = $iw->getAPI();
167            if ( !$api ) {
168                $parts = parse_url( $iw->getURL() );
169                if ( !isset( $parts['host'] ) ) {
170                    continue;
171                }
172                $api = $parts['scheme'] ?? 'http';
173                $api .= '://' . $parts['host'];
174                $api .= isset( $parts['port'] ) ? ':' . $parts['port'] : '';
175                $api .= '/w/api.php';
176            }
177            $endpoints[$prefix] = [ 'url' => $api, 'wiki' => $wiki ];
178        }
179
180        if ( $endpoints ) {
181            $prefixes = array_keys( $endpoints );
182            asort( $prefixes );
183            $cacheKey = implode( '-', $prefixes );
184            $configs = $this->srvCache->getWithSetCallback(
185                $this->srvCache->makeKey( 'cirrussearch-load-iw-config', $cacheKey ),
186                self::CONFIG_CACHE_TTL,
187                function () use ( $endpoints ) {
188                    return $this->sendConfigDumpRequest( $endpoints );
189                }
190            );
191        } else {
192            $configs = [];
193        }
194        $retValue = [];
195        foreach ( $wikis as $prefix => $wiki ) {
196            if ( isset( $configs[$prefix] ) ) {
197                $config = $configs[$prefix];
198                $config['_wikiID'] = $wiki;
199
200                $retValue[$prefix] = new HashSearchConfig(
201                    $config,
202                    array_merge( $hashConfigFlags, [ HashSearchConfig::FLAG_INHERIT ] )
203                );
204            } else {
205                $retValue[$prefix] = $fallbackConfig( $wiki, $hashConfigFlags );
206            }
207        }
208        return $retValue;
209    }
210
211    /**
212     * @param array[] $endpoints list of arrays containing 'url' and 'wiki', indexed by iw prefix
213     * @return array[] list of array containing extracted config vars, failed wikis
214     * are not returned.
215     */
216    private function sendConfigDumpRequest( $endpoints ) {
217        $logger = LoggerFactory::getInstance( 'CirrusSearch' );
218        $reqs = [];
219        foreach ( $endpoints as $prefix => $info ) {
220            $reqs[$prefix] = [
221                'method' => 'GET',
222                'url' => $info['url'],
223                'query' => [
224                    'action' => 'cirrus-config-dump',
225                    'format' => 'json',
226                    'formatversion' => '2',
227                ]
228            ];
229        }
230        if ( !$reqs ) {
231            return [];
232        }
233        $responses = $this->httpClient->runMulti( $reqs );
234        $configs = [];
235        foreach ( $responses as $prefix => $response ) {
236            if ( $response['response']['code'] !== 200 ) {
237                $logger->warning(
238                    'Failed to fetch config for {wiki} at {url}: ' .
239                    'http status {httpstatus} : {clienterror}',
240                    [
241                        'wiki' => $endpoints[$prefix]['wiki'],
242                        'url' => $endpoints[$prefix]['url'],
243                        'httpstatus' => $response['response']['code'],
244                        'clienterror' => $response['response']['error']
245                    ]
246                );
247                continue;
248            }
249
250            $data = json_decode( $response['response']['body'], true );
251            if ( json_last_error() !== JSON_ERROR_NONE ) {
252                $logger->warning(
253                    'Failed to fetch config for {wiki} at {url}: ' .
254                    'json error code {jsonerrorcode}',
255                    [
256                        'wiki' => $endpoints[$prefix]['wiki'],
257                        'url' => $endpoints[$prefix]['url'],
258                        'jsonerrorcode' => json_last_error()
259                    ]
260                );
261                continue;
262            }
263
264            if ( isset( $data['error'] ) ) {
265                $logger->warning(
266                    'Failed to fetch config for {wiki} at {url}: {apierrormessage}',
267                    [
268                        'wiki' => $endpoints[$prefix]['wiki'],
269                        'url' => $endpoints[$prefix]['url'],
270                        'apierrormessage' => $data['error']
271                    ]
272                );
273                continue;
274            }
275            unset( $data['warnings'] );
276            $configs[$prefix] = $data;
277        }
278        return $configs;
279    }
280
281    /**
282     * Minimal config needed to run a search on a target wiki
283     * living on the same cluster as the host wiki
284     *
285     * @param string $wiki
286     * @param string[] $hashConfigFlags constructor flags for HashSearchConfig
287     * @return SearchConfig
288     */
289    protected function minimalSearchConfig( $wiki, array $hashConfigFlags ) {
290        return new HashSearchConfig(
291            [
292                '_wikiID' => $wiki,
293                'CirrusSearchIndexBaseName' => $wiki,
294            ],
295            array_merge( [ HashSearchConfig::FLAG_INHERIT ], $hashConfigFlags )
296        );
297    }
298}