Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.12% covered (success)
94.12%
128 / 136
78.95% covered (warning)
78.95%
15 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiErrorFormatter
94.12% covered (success)
94.12%
128 / 136
78.95% covered (warning)
78.95%
15 / 19
51.53
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 isValidApiCode
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 newWithFormat
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFormat
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLanguage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDummyTitle
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
3.19
 getContextTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 setContextTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addWarning
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 addError
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 addMessagesFromStatus
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 getMessageFromException
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
5.03
 formatException
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 formatMessage
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 arrayFromStatus
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 stripMarkup
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 formatRawMessage
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 formatMessageInternal
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
7
 addWarningOrError
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * This file contains the ApiErrorFormatter definition, plus implementations of
4 * specific formatters.
5 *
6 * This program is free software; you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation; either version 2 of the License, or
9 * (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License along
17 * with this program; if not, write to the Free Software Foundation, Inc.,
18 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 * http://www.gnu.org/copyleft/gpl.html
20 *
21 * @file
22 */
23
24use MediaWiki\Language\RawMessage;
25use MediaWiki\Page\PageReference;
26use MediaWiki\Page\PageReferenceValue;
27use MediaWiki\Parser\Sanitizer;
28
29/**
30 * Formats errors and warnings for the API, and add them to the associated
31 * ApiResult.
32 * @since 1.25
33 * @ingroup API
34 * @phan-file-suppress PhanUndeclaredMethod Undeclared methods in IApiMessage
35 */
36class ApiErrorFormatter {
37    /** @var PageReference Dummy title to silence warnings from MessageCache::parse() */
38    private static $dummyTitle = null;
39
40    /** @var ApiResult */
41    protected $result;
42
43    /** @var Language */
44    protected $lang;
45    /** @var PageReference|null page used for rendering error messages, or null to use the dummy title */
46    private $title = null;
47    protected $useDB = false;
48    protected $format = 'none';
49
50    /**
51     * @param ApiResult $result Into which data will be added
52     * @param Language $lang Used for i18n
53     * @param string $format
54     *  - plaintext: Error message as something vaguely like plaintext
55     *    (it's basically wikitext with HTML tags stripped and entities decoded)
56     *  - wikitext: Error message as wikitext
57     *  - html: Error message as HTML
58     *  - raw: Raw message key and parameters, no human-readable text
59     *  - none: Code and data only, no human-readable text
60     * @param bool $useDB Whether to use local translations for errors and warnings.
61     */
62    public function __construct( ApiResult $result, Language $lang, $format, $useDB = false ) {
63        $this->result = $result;
64        $this->lang = $lang;
65        $this->useDB = $useDB;
66        $this->format = $format;
67    }
68
69    /**
70     * Test whether a code is a valid API error code
71     *
72     * A valid code contains only ASCII letters, numbers, underscore, and
73     * hyphen and is not the empty string.
74     *
75     * For backwards compatibility, any code beginning 'internal_api_error_' is
76     * also allowed.
77     *
78     * @param string $code
79     * @return bool
80     */
81    public static function isValidApiCode( $code ) {
82        return is_string( $code ) && (
83            preg_match( '/^[a-zA-Z0-9_-]+$/', $code ) ||
84            // TODO: Deprecate this
85            preg_match( '/^internal_api_error_[^\0\r\n]+$/', $code )
86        );
87    }
88
89    /**
90     * Return a formatter like this one but with a different format
91     *
92     * @since 1.32
93     * @param string $format New format.
94     * @return ApiErrorFormatter
95     */
96    public function newWithFormat( $format ) {
97        return new self( $this->result, $this->lang, $format, $this->useDB );
98    }
99
100    /**
101     * Fetch the format for this formatter
102     * @since 1.32
103     * @return string
104     */
105    public function getFormat() {
106        return $this->format;
107    }
108
109    /**
110     * Fetch the Language for this formatter
111     * @since 1.29
112     * @return Language
113     */
114    public function getLanguage() {
115        return $this->lang;
116    }
117
118    /**
119     * Fetch a dummy title to set on Messages
120     * @return PageReference
121     */
122    protected function getDummyTitle(): PageReference {
123        if ( self::$dummyTitle === null ) {
124            self::$dummyTitle = PageReferenceValue::localReference(
125                NS_SPECIAL,
126                'Badtitle/' . __METHOD__
127            );
128        }
129        return self::$dummyTitle;
130    }
131
132    /**
133     * Get the page used for rendering error messages, e.g. for wikitext magic words like {{PAGENAME}}
134     * @since 1.37
135     * @return PageReference
136     */
137    public function getContextTitle(): PageReference {
138        return $this->title ?: $this->getDummyTitle();
139    }
140
141    /**
142     * Set the page used for rendering error messages, e.g. for wikitext magic words like {{PAGENAME}}
143     * @since 1.37
144     * @param PageReference $title
145     */
146    public function setContextTitle( PageReference $title ) {
147        $this->title = $title;
148    }
149
150    /**
151     * Add a warning to the result
152     * @param string|null $modulePath
153     * @param MessageSpecifier|array|string $msg Warning message. See ApiMessage::create().
154     * @param string|null $code See ApiMessage::create().
155     * @param array|null $data See ApiMessage::create().
156     */
157    public function addWarning( $modulePath, $msg, $code = null, $data = null ) {
158        $msg = ApiMessage::create( $msg, $code, $data )
159            ->inLanguage( $this->lang )
160            ->page( $this->getContextTitle() )
161            ->useDatabase( $this->useDB );
162        $this->addWarningOrError( 'warning', $modulePath, $msg );
163    }
164
165    /**
166     * Add an error to the result
167     * @param string|null $modulePath
168     * @param MessageSpecifier|array|string $msg Warning message. See ApiMessage::create().
169     * @param string|null $code See ApiMessage::create().
170     * @param array|null $data See ApiMessage::create().
171     */
172    public function addError( $modulePath, $msg, $code = null, $data = null ) {
173        $msg = ApiMessage::create( $msg, $code, $data )
174            ->inLanguage( $this->lang )
175            ->page( $this->getContextTitle() )
176            ->useDatabase( $this->useDB );
177        $this->addWarningOrError( 'error', $modulePath, $msg );
178    }
179
180    /**
181     * Add warnings and errors from a StatusValue object to the result
182     * @param string|null $modulePath
183     * @param StatusValue $status
184     * @param string[]|string $types 'warning' and/or 'error'
185     * @param string[] $filter Messages to filter out (since 1.33)
186     */
187    public function addMessagesFromStatus(
188        $modulePath, StatusValue $status, $types = [ 'warning', 'error' ], array $filter = []
189    ) {
190        if ( $status->isGood() || !$status->getErrors() ) {
191            return;
192        }
193
194        $types = (array)$types;
195        foreach ( $status->getErrors() as $error ) {
196            if ( !in_array( $error['type'], $types, true ) ) {
197                continue;
198            }
199
200            if ( $error['type'] === 'error' ) {
201                $tag = 'error';
202            } else {
203                // Assume any unknown type is a warning
204                $tag = 'warning';
205            }
206
207            $msg = ApiMessage::create( $error )
208                ->inLanguage( $this->lang )
209                ->page( $this->getContextTitle() )
210                ->useDatabase( $this->useDB );
211            if ( !in_array( $msg->getKey(), $filter, true ) ) {
212                $this->addWarningOrError( $tag, $modulePath, $msg );
213            }
214        }
215    }
216
217    /**
218     * Get an ApiMessage from a throwable
219     * @since 1.29
220     * @param Throwable $exception
221     * @param array $options
222     *  - wrap: (string|array|MessageSpecifier) Used to wrap the throwable's
223     *    message if it's not an ILocalizedException. The throwable's message
224     *    will be added as the final parameter.
225     *  - code: (string) Default code
226     *  - data: (array) Default extra data
227     * @return IApiMessage
228     */
229    public function getMessageFromException( Throwable $exception, array $options = [] ) {
230        $options += [ 'code' => null, 'data' => [] ];
231
232        if ( $exception instanceof ILocalizedException ) {
233            $msg = $exception->getMessageObject();
234            $params = [];
235        } elseif ( $exception instanceof MessageSpecifier ) {
236            $msg = Message::newFromSpecifier( $exception );
237            $params = [];
238        } else {
239            if ( isset( $options['wrap'] ) ) {
240                $msg = $options['wrap'];
241            } else {
242                $msg = new RawMessage( '$1' );
243                if ( !isset( $options['code'] ) ) {
244                    $class = preg_replace( '#^Wikimedia\\\\Rdbms\\\\#', '', get_class( $exception ) );
245                    $options['code'] = 'internal_api_error_' . $class;
246                    $options['data']['errorclass'] = get_class( $exception );
247                }
248            }
249            $params = [ wfEscapeWikiText( $exception->getMessage() ) ];
250        }
251        return ApiMessage::create( $msg, $options['code'], $options['data'] )
252            ->params( $params )
253            ->inLanguage( $this->lang )
254            ->page( $this->getContextTitle() )
255            ->useDatabase( $this->useDB );
256    }
257
258    /**
259     * Format a throwable as an array
260     * @since 1.29
261     * @param Throwable $exception
262     * @param array $options See self::getMessageFromException(), plus
263     *  - format: (string) Format override
264     * @return array
265     */
266    public function formatException( Throwable $exception, array $options = [] ) {
267        return $this->formatMessage(
268            // @phan-suppress-next-line PhanTypeMismatchArgument
269            $this->getMessageFromException( $exception, $options ),
270            $options['format'] ?? null
271        );
272    }
273
274    /**
275     * Format a message as an array
276     * @param Message|array|string $msg Message. See ApiMessage::create().
277     * @param string|null $format
278     * @return array
279     */
280    public function formatMessage( $msg, $format = null ) {
281        $msg = ApiMessage::create( $msg )
282            ->inLanguage( $this->lang )
283            ->page( $this->getContextTitle() )
284            ->useDatabase( $this->useDB );
285        return $this->formatMessageInternal( $msg, $format ?: $this->format );
286    }
287
288    /**
289     * Format messages from a StatusValue as an array
290     * @param StatusValue $status
291     * @param string $type 'warning' or 'error'
292     * @param string|null $format
293     * @return array
294     */
295    public function arrayFromStatus( StatusValue $status, $type = 'error', $format = null ) {
296        if ( $status->isGood() || !$status->getErrors() ) {
297            return [];
298        }
299
300        $result = new ApiResult( 1_000_000 );
301        $formatter = new ApiErrorFormatter(
302            $result, $this->lang, $format ?: $this->format, $this->useDB
303        );
304        $formatter->addMessagesFromStatus( null, $status, [ $type ] );
305        switch ( $type ) {
306            case 'error':
307                return (array)$result->getResultData( [ 'errors' ] );
308            case 'warning':
309                return (array)$result->getResultData( [ 'warnings' ] );
310        }
311    }
312
313    /**
314     * Turn wikitext into something resembling plaintext
315     * @since 1.29
316     * @param string $text
317     * @return string
318     */
319    public static function stripMarkup( $text ) {
320        // Turn semantic quoting tags to quotes
321        $ret = preg_replace( '!</?(var|kbd|samp|code)>!', '"', $text );
322
323        // Strip tags and decode.
324        return Sanitizer::stripAllTags( $ret );
325    }
326
327    /**
328     * Format a Message object for raw format
329     * @param MessageSpecifier $msg
330     * @return array
331     */
332    private function formatRawMessage( MessageSpecifier $msg ) {
333        $ret = [
334            'key' => $msg->getKey(),
335            'params' => $msg->getParams(),
336        ];
337        ApiResult::setIndexedTagName( $ret['params'], 'param' );
338
339        // Transform Messages as parameters in the style of Message::fooParam().
340        foreach ( $ret['params'] as $i => $param ) {
341            if ( $param instanceof MessageSpecifier ) {
342                $ret['params'][$i] = [ 'message' => $this->formatRawMessage( $param ) ];
343            }
344        }
345        return $ret;
346    }
347
348    /**
349     * Format a message as an array
350     * @since 1.29
351     * @param ApiMessage|ApiRawMessage $msg
352     * @param string|null $format
353     * @return array
354     */
355    protected function formatMessageInternal( $msg, $format ) {
356        $value = [ 'code' => $msg->getApiCode() ];
357        switch ( $format ) {
358            case 'plaintext':
359                $value += [
360                    'text' => self::stripMarkup( $msg->text() ),
361                    ApiResult::META_CONTENT => 'text',
362                ];
363                break;
364
365            case 'wikitext':
366                $value += [
367                    'text' => $msg->text(),
368                    ApiResult::META_CONTENT => 'text',
369                ];
370                break;
371
372            case 'html':
373                $value += [
374                    'html' => $msg->parse(),
375                    ApiResult::META_CONTENT => 'html',
376                ];
377                break;
378
379            case 'raw':
380                $value += $this->formatRawMessage( $msg );
381                break;
382
383            case 'none':
384                break;
385        }
386        $data = $msg->getApiData();
387        if ( $data ) {
388            $value['data'] = $msg->getApiData() + [
389                ApiResult::META_TYPE => 'assoc',
390            ];
391        }
392        return $value;
393    }
394
395    /**
396     * Actually add the warning or error to the result
397     * @param string $tag 'warning' or 'error'
398     * @param string|null $modulePath
399     * @param ApiMessage|ApiRawMessage $msg
400     */
401    protected function addWarningOrError( $tag, $modulePath, $msg ) {
402        $value = $this->formatMessageInternal( $msg, $this->format );
403        if ( $modulePath !== null ) {
404            $value += [ 'module' => $modulePath ];
405        }
406
407        $path = [ $tag . 's' ];
408        $existing = $this->result->getResultData( $path );
409        if ( $existing === null || !in_array( $value, $existing ) ) {
410            $flags = ApiResult::NO_SIZE_CHECK;
411            if ( $existing === null ) {
412                $flags |= ApiResult::ADD_ON_TOP;
413            }
414            $this->result->addValue( $path, null, $value, $flags );
415            $this->result->addIndexedTagName( $path, $tag );
416        }
417    }
418}