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