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