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