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