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