Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
94.12% |
128 / 136 |
|
78.95% |
15 / 19 |
CRAP | |
0.00% |
0 / 1 |
ApiErrorFormatter | |
94.12% |
128 / 136 |
|
78.95% |
15 / 19 |
51.53 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
isValidApiCode | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
newWithFormat | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getFormat | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getLanguage | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getDummyTitle | |
33.33% |
2 / 6 |
|
0.00% |
0 / 1 |
3.19 | |||
getContextTitle | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
setContextTitle | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
addWarning | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
addError | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
addMessagesFromStatus | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
7 | |||
getMessageFromException | |
90.00% |
18 / 20 |
|
0.00% |
0 / 1 |
5.03 | |||
formatException | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
formatMessage | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
arrayFromStatus | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
6 | |||
stripMarkup | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
formatRawMessage | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
3.01 | |||
formatMessageInternal | |
100.00% |
30 / 30 |
|
100.00% |
1 / 1 |
7 | |||
addWarningOrError | |
100.00% |
11 / 11 |
|
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 | |
24 | use MediaWiki\Language\RawMessage; |
25 | use MediaWiki\Page\PageReference; |
26 | use MediaWiki\Page\PageReferenceValue; |
27 | use 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 | */ |
36 | class 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 | } |