Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.22% covered (warning)
77.22%
122 / 158
71.43% covered (warning)
71.43%
20 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
MathRestbaseInterface
77.22% covered (warning)
77.22%
122 / 158
71.43% covered (warning)
71.43%
20 / 28
97.79
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 batchGetMathML
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
6.04
 setPurge
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMathML
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getContent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 calculateHash
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 checkTeX
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 executeRestbaseCheckRequest
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 batchEvaluate
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
5.06
 getMultiHttpClient
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getUrl
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getSvg
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 checkConfig
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 getFullSvgUrl
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getCheckedTex
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSuccess
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getIdentifiers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getError
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTex
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setErrorMessage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWarnings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCheckRequest
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 evaluateRestbaseCheckResponse
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
7
 getMathoidStyle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getContentRequest
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
2.19
 evaluateContentResponse
25.00% covered (danger)
25.00%
3 / 12
0.00% covered (danger)
0.00%
0 / 1
6.80
 throwContentError
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * MediaWiki math extension
4 *
5 * @copyright 2002-2015 various MediaWiki contributors
6 * @license GPL-2.0-or-later
7 */
8
9namespace MediaWiki\Extension\Math;
10
11use Exception;
12use MediaWiki\Logger\LoggerFactory;
13use MediaWiki\MediaWikiServices;
14use Psr\Log\LoggerInterface;
15use stdClass;
16use Wikimedia\Http\MultiHttpClient;
17
18class MathRestbaseInterface {
19    /** @var string|false */
20    private $hash = false;
21    /** @var string */
22    private $tex;
23    /** @var string */
24    private $type;
25    private ?string $checkedTex = null;
26    /** @var bool|null */
27    private $success;
28    /** @var array */
29    private $identifiers;
30    /** @var stdClass|null */
31    private $error;
32    /** @var string|null */
33    private $mathoidStyle;
34    /** @var string|null */
35    private $mml;
36    /** @var array */
37    private $warnings = [];
38    /** @var bool is there a request to purge the existing mathematical content */
39    private $purge = false;
40    /** @var LoggerInterface */
41    private $logger;
42
43    /**
44     * @param string $tex
45     * @param string $type
46     */
47    public function __construct( $tex = '', $type = 'tex' ) {
48        $this->tex = $tex;
49        $this->type = $type;
50        $this->logger = LoggerFactory::getInstance( 'Math' );
51    }
52
53    /**
54     * Bundles several requests for fetching MathML.
55     * Does not send requests, if the input TeX is invalid.
56     * @param MathRestbaseInterface[] $rbis
57     * @param MultiHttpClient $multiHttpClient
58     */
59    private static function batchGetMathML( array $rbis, MultiHttpClient $multiHttpClient ) {
60        $requests = [];
61        $skips = [];
62        $i = 0;
63        foreach ( $rbis as $rbi ) {
64            /** @var MathRestbaseInterface $rbi */
65            if ( $rbi->getSuccess() ) {
66                $requests[] = $rbi->getContentRequest( 'mml' );
67            } else {
68                $skips[] = $i;
69            }
70            $i++;
71        }
72        $results = $multiHttpClient->runMulti( $requests );
73        $lenRbis = count( $rbis );
74        $j = 0;
75        for ( $i = 0; $i < $lenRbis; $i++ ) {
76            if ( !in_array( $i, $skips, true ) ) {
77                /** @var MathRestbaseInterface $rbi */
78                $rbi = $rbis[$i];
79                try {
80                    $response = $results[ $j ][ 'response' ];
81                    $mml = $rbi->evaluateContentResponse( 'mml', $response, $requests[$j] );
82                    $rbi->mml = $mml;
83                } catch ( MathRestbaseException $e ) {
84                    // FIXME: Why is this silenced? Doesn't this leave invalid data behind?
85                }
86                $j++;
87            }
88        }
89    }
90
91    /**
92     * Lets this instance know if this is a purge request. When set to true,
93     * it will cause the object to issue the first content request with a
94     * 'Cache-Control: no-cache' header to prompt the regeneration of the
95     * renders.
96     *
97     * @param bool $purge whether this is a purge request
98     */
99    public function setPurge( $purge = true ) {
100        $this->purge = $purge;
101    }
102
103    /**
104     * @return string MathML code
105     * @throws MathRestbaseException
106     */
107    public function getMathML() {
108        if ( !$this->mml ) {
109            $this->mml = $this->getContent( 'mml' );
110        }
111        return $this->mml;
112    }
113
114    /**
115     * @param string $type
116     * @return string
117     * @throws MathRestbaseException
118     */
119    private function getContent( $type ) {
120        $request = $this->getContentRequest( $type );
121        $multiHttpClient = $this->getMultiHttpClient();
122        $response = $multiHttpClient->run( $request );
123        return $this->evaluateContentResponse( $type, $response, $request );
124    }
125
126    /**
127     * @throws InvalidTeXException
128     */
129    private function calculateHash() {
130        if ( !$this->hash ) {
131            if ( !$this->checkTeX() ) {
132                throw new InvalidTeXException( "TeX input is invalid." );
133            }
134        }
135    }
136
137    /** @return bool */
138    public function checkTeX() {
139        $request = $this->getCheckRequest();
140        $requestResult = $this->executeRestbaseCheckRequest( $request );
141        return $this->evaluateRestbaseCheckResponse( $requestResult );
142    }
143
144    /**
145     * Performs a service request
146     * Generates error messages on failure
147     * @see MediaWiki\Http\HttpRequestFactory::post()
148     *
149     * @param array $request
150     * @return array
151     */
152    private function executeRestbaseCheckRequest( $request ) {
153        $multiHttpClient = $this->getMultiHttpClient();
154        $response = $multiHttpClient->run( $request );
155        if ( $response['code'] !== 200 ) {
156            $this->logger->info( 'Tex check failed', [
157                'post'  => $request['body'],
158                'error' => $response['error'],
159                'urlparams'   => $request['url']
160            ] );
161        }
162        return $response;
163    }
164
165    /**
166     * @param MathRestbaseInterface[] $rbis
167     */
168    public static function batchEvaluate( array $rbis ) {
169        if ( count( $rbis ) == 0 ) {
170            return;
171        }
172        $requests = [];
173        /** @var MathRestbaseInterface $first */
174        $first = $rbis[0];
175        $multiHttpClient = $first->getMultiHttpClient();
176        foreach ( $rbis as $rbi ) {
177            /** @var MathRestbaseInterface $rbi */
178            $requests[] = $rbi->getCheckRequest();
179        }
180        $results = $multiHttpClient->runMulti( $requests );
181        $i = 0;
182        foreach ( $results as $requestResponse ) {
183            /** @var MathRestbaseInterface $rbi */
184            $rbi = $rbis[$i++];
185            try {
186                $response = $requestResponse[ 'response' ];
187                $rbi->evaluateRestbaseCheckResponse( $response );
188            } catch ( Exception $e ) {
189            }
190        }
191        self::batchGetMathML( $rbis, $multiHttpClient );
192    }
193
194    private function getMultiHttpClient(): MultiHttpClient {
195        global $wgMathConcurrentReqs;
196        $multiHttpClient = MediaWikiServices::getInstance()->getHttpRequestFactory()->createMultiClient(
197            [ 'maxConnsPerHost' => $wgMathConcurrentReqs ] );
198
199        return $multiHttpClient;
200    }
201
202    /**
203     * The URL is generated according to the following logic:
204     *
205     * Case A: <code>$internal = false</code>, which means one needs a URL that is accessible from
206     * outside:
207     *
208     * --> Use <code>$wgMathFullRestbaseURL</code>. It must always be configured.
209     *
210     * Case B: <code>$internal = true</code>, which means one needs to access content from Restbase
211     * which does not need to be accessible from outside:
212     *
213     * --> Use the mount point when it is available and <code>$wgMathUseInternalRestbasePath =
214     * true</code>. If not, use <code>$wgMathFullRestbaseURL</code>.
215     *
216     * @param string $path
217     * @param bool|true $internal
218     * @return string
219     */
220    public function getUrl( $path, $internal = true ) {
221        global $wgMathInternalRestbaseURL, $wgMathFullRestbaseURL;
222        if ( $internal ) {
223            return "{$wgMathInternalRestbaseURL}v1/$path";
224        } else {
225            return "{$wgMathFullRestbaseURL}v1/$path";
226        }
227    }
228
229    /**
230     * @return string
231     * @throws MathRestbaseException
232     */
233    public function getSvg() {
234        return $this->getContent( 'svg' );
235    }
236
237    /**
238     * Generates a unique TeX string, renders it and gets it via a public URL.
239     * The method fails, if the public URL does not point to the same server, who did render
240     * the unique TeX input in the first place.
241     * @return bool
242     */
243    private function checkConfig() {
244        // Generates a TeX string that probably has not been generated before
245        $uniqueTeX = uniqid( 't=', true );
246        $testInterface = new MathRestbaseInterface( $uniqueTeX );
247        if ( !$testInterface->checkTeX() ) {
248            $this->logger->warning( 'Config check failed, since test expression was considered as invalid.',
249                [ 'uniqueTeX' => $uniqueTeX ] );
250            return false;
251        }
252
253        try {
254            $url = $testInterface->getFullSvgUrl();
255            $req = MediaWikiServices::getInstance()->getHttpRequestFactory()->create( $url, [], __METHOD__ );
256            $status = $req->execute();
257            if ( $status->isOK() ) {
258                return true;
259            }
260
261            $this->logger->warning( 'Config check failed, due to an invalid response code.',
262                [ 'responseCode' => $status ] );
263        } catch ( Exception $e ) {
264            $this->logger->warning( 'Config check failed, due to an exception.', [ $e ] );
265        }
266
267        return false;
268    }
269
270    /**
271     * Gets a publicly accessible link to the generated SVG image.
272     * @return string
273     * @throws InvalidTeXException
274     */
275    public function getFullSvgUrl() {
276        $this->calculateHash();
277        return $this->getUrl( "media/math/render/svg/{$this->hash}", false );
278    }
279
280    public function getCheckedTex(): ?string {
281        return $this->checkedTex;
282    }
283
284    public function getSuccess(): bool {
285        if ( $this->success === null ) {
286            $this->checkTeX();
287        }
288        return $this->success;
289    }
290
291    public function getIdentifiers(): ?array {
292        return $this->identifiers;
293    }
294
295    public function getError(): ?stdClass {
296        return $this->error;
297    }
298
299    public function getTex(): string {
300        return $this->tex;
301    }
302
303    public function getType(): string {
304        return $this->type;
305    }
306
307    private function setErrorMessage( string $msg ) {
308        $this->error = (object)[ 'error' => (object)[ 'message' => $msg ] ];
309    }
310
311    public function getWarnings(): array {
312        return $this->warnings;
313    }
314
315    public function getCheckRequest(): array {
316        return [
317            'method' => 'POST',
318            'body'   => [
319                'type' => $this->type,
320                'q'    => $this->tex
321            ],
322            'url'    => $this->getUrl( "media/math/check/{$this->type}" )
323        ];
324    }
325
326    public function evaluateRestbaseCheckResponse( array $response ): bool {
327        $json = json_decode( $response['body'] );
328        if ( $response['code'] === 200 &&
329                isset( $json->success ) &&
330                isset( $json->checked ) &&
331                isset( $json->identifiers ) ) {
332            $headers = $response['headers'];
333            $this->hash = $headers['x-resource-location'];
334            $this->success = $json->success;
335            $this->checkedTex = $json->checked;
336            $this->identifiers = $json->identifiers;
337            if ( isset( $json->warnings ) ) {
338                $this->warnings = $json->warnings;
339            }
340            return true;
341        }
342        if ( isset( $json->detail->success ) ) {
343            $this->success = $json->detail->success;
344            $this->error = $json->detail;
345            return false;
346        }
347        $this->success = false;
348        $this->setErrorMessage( 'Math extension cannot connect to Restbase.' );
349        $this->logger->error( 'Received invalid response from restbase.', [
350            'body' => $response['body'],
351            'code' => $response['code'] ] );
352        return false;
353    }
354
355    public function getMathoidStyle(): ?string {
356        return $this->mathoidStyle;
357    }
358
359    /**
360     * @param string $type
361     * @return array
362     * @throws InvalidTeXException
363     */
364    private function getContentRequest( $type ) {
365        $this->calculateHash();
366        $request = [
367            'method' => 'GET',
368            'url' => $this->getUrl( "media/math/render/$type/{$this->hash}" )
369        ];
370        if ( $this->purge ) {
371            $request['headers'] = [
372                'Cache-Control' => 'no-cache'
373            ];
374            $this->purge = false;
375        }
376        return $request;
377    }
378
379    /**
380     * @param string $type
381     * @param array $response
382     * @param array $request
383     * @return string
384     * @throws MathRestbaseException
385     */
386    private function evaluateContentResponse( $type, array $response, array $request ) {
387        if ( $response['code'] === 200 ) {
388            if ( array_key_exists( 'x-mathoid-style', $response['headers'] ) ) {
389                $this->mathoidStyle = $response['headers']['x-mathoid-style'];
390            }
391            return $response['body'];
392        }
393        // Remove "convenience" duplicate keys put in place by MultiHttpClient
394        unset( $response[0], $response[1], $response[2], $response[3], $response[4] );
395        $this->logger->error( 'Restbase math server problem', [
396            'urlparams' => $request['url'],
397            'response' => [ 'code' => $response['code'], 'body' => $response['body'] ],
398            'math_type' => $type,
399            'tex' => $this->tex
400        ] );
401        self::throwContentError( $type, $response['body'] );
402    }
403
404    /**
405     * @param string $type
406     * @param string $body
407     * @throws MathRestbaseException
408     * @return never
409     */
410    public static function throwContentError( $type, $body ) {
411        $detail = 'Server problem.';
412        $json = json_decode( $body );
413        if ( isset( $json->detail ) ) {
414            if ( is_array( $json->detail ) ) {
415                $detail = $json->detail[0];
416            } elseif ( is_string( $json->detail ) ) {
417                $detail = $json->detail;
418            }
419        }
420        throw new MathRestbaseException( "Cannot get $type$detail" );
421    }
422}