Localisation updates from https://translatewiki.net.
[mediawiki.git] / includes / api / ApiResult.php
blobb5939d08b02354f242eeace5c975dc035acf9cbc
1 <?php
2 /**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
18 * @file
21 namespace MediaWiki\Api;
23 use Exception;
24 use InvalidArgumentException;
25 use MediaWiki\MediaWikiServices;
26 use MediaWiki\Message\Message;
27 use RuntimeException;
28 use stdClass;
29 use UnexpectedValueException;
31 /**
32 * This class represents the result of the API operations.
33 * It simply wraps a nested array structure, adding some functions to simplify
34 * array's modifications. As various modules execute, they add different pieces
35 * of information to this result, structuring it as it will be given to the client.
37 * Each subarray may either be a dictionary - key-value pairs with unique keys,
38 * or lists, where the items are added using $data[] = $value notation.
40 * @since 1.25 this is no longer a subclass of ApiBase
41 * @ingroup API
43 class ApiResult implements ApiSerializable {
45 /**
46 * Override existing value in addValue(), setValue(), and similar functions
47 * @since 1.21
49 public const OVERRIDE = 1;
51 /**
52 * For addValue(), setValue() and similar functions, if the value does not
53 * exist, add it as the first element. In case the new value has no name
54 * (numerical index), all indexes will be renumbered.
55 * @since 1.21
57 public const ADD_ON_TOP = 2;
59 /**
60 * For addValue() and similar functions, do not check size while adding a value
61 * Don't use this unless you REALLY know what you're doing.
62 * Values added while the size checking was disabled will never be counted.
63 * Ignored for setValue() and similar functions.
64 * @since 1.24
66 public const NO_SIZE_CHECK = 4;
68 /**
69 * For addValue(), setValue() and similar functions, do not validate data.
70 * Also disables size checking. If you think you need to use this, you're
71 * probably wrong.
72 * @since 1.25
74 public const NO_VALIDATE = self::NO_SIZE_CHECK | 8;
76 /**
77 * Key for the 'indexed tag name' metadata item. Value is string.
78 * @since 1.25
80 public const META_INDEXED_TAG_NAME = '_element';
82 /**
83 * Key for the 'subelements' metadata item. Value is string[].
84 * @since 1.25
86 public const META_SUBELEMENTS = '_subelements';
88 /**
89 * Key for the 'preserve keys' metadata item. Value is string[].
90 * @since 1.25
92 public const META_PRESERVE_KEYS = '_preservekeys';
94 /**
95 * Key for the 'content' metadata item. Value is string.
96 * @since 1.25
98 public const META_CONTENT = '_content';
101 * Key for the 'type' metadata item. Value is one of the following strings:
102 * - default: Like 'array' if all (non-metadata) keys are numeric with no
103 * gaps, otherwise like 'assoc'.
104 * - array: Keys are used for ordering, but are not output. In a format
105 * like JSON, outputs as [].
106 * - assoc: In a format like JSON, outputs as {}.
107 * - kvp: For a format like XML where object keys have a restricted
108 * character set, use an alternative output format. For example,
109 * <container><item name="key">value</item></container> rather than
110 * <container key="value" />
111 * - BCarray: Like 'array' normally, 'default' in backwards-compatibility mode.
112 * - BCassoc: Like 'assoc' normally, 'default' in backwards-compatibility mode.
113 * - BCkvp: Like 'kvp' normally. In backwards-compatibility mode, forces
114 * the alternative output format for all formats, for example
115 * [{"name":key,"*":value}] in JSON. META_KVP_KEY_NAME must also be set.
116 * @since 1.25
118 public const META_TYPE = '_type';
121 * Key for the metadata item whose value specifies the name used for the
122 * kvp key in the alternative output format with META_TYPE 'kvp' or
123 * 'BCkvp', i.e. the "name" in <container><item name="key">value</item></container>.
124 * Value is string.
125 * @since 1.25
127 public const META_KVP_KEY_NAME = '_kvpkeyname';
130 * Key for the metadata item that indicates that the KVP key should be
131 * added into an assoc value, i.e. {"key":{"val1":"a","val2":"b"}}
132 * transforms to {"name":"key","val1":"a","val2":"b"} rather than
133 * {"name":"key","value":{"val1":"a","val2":"b"}}.
134 * Value is boolean.
135 * @since 1.26
137 public const META_KVP_MERGE = '_kvpmerge';
140 * Key for the 'BC bools' metadata item. Value is string[].
141 * Note no setter is provided.
142 * @since 1.25
144 public const META_BC_BOOLS = '_BC_bools';
147 * Key for the 'BC subelements' metadata item. Value is string[].
148 * Note no setter is provided.
149 * @since 1.25
151 public const META_BC_SUBELEMENTS = '_BC_subelements';
153 /** @var mixed */
154 private $data;
155 private int $size;
156 /** @var int|false */
157 private $maxSize;
158 private ApiErrorFormatter $errorFormatter;
161 * @param int|false $maxSize Maximum result "size", or false for no limit
163 public function __construct( $maxSize ) {
164 $this->maxSize = $maxSize;
165 $this->reset();
169 * @since 1.25
170 * @param ApiErrorFormatter $formatter
172 public function setErrorFormatter( ApiErrorFormatter $formatter ) {
173 $this->errorFormatter = $formatter;
177 * Allow for adding one ApiResult into another
178 * @since 1.25
179 * @return mixed
181 public function serializeForApiResult() {
182 return $this->data;
185 /***************************************************************************/
186 // region Content
187 /** @name Content */
190 * Clear the current result data.
192 public function reset() {
193 $this->data = [
194 self::META_TYPE => 'assoc', // Usually what's desired
196 $this->size = 0;
200 * Get the result data array
202 * The returned value should be considered read-only.
204 * Transformations include:
206 * Custom: (callable) Applied before other transformations. Signature is
207 * function ( &$data, &$metadata ), return value is ignored. Called for
208 * each nested array.
210 * BC: (array) This transformation does various adjustments to bring the
211 * output in line with the pre-1.25 result format. The value array is a
212 * list of flags: 'nobool', 'no*', 'nosub'.
213 * - Boolean-valued items are changed to '' if true or removed if false,
214 * unless listed in META_BC_BOOLS. This may be skipped by including
215 * 'nobool' in the value array.
216 * - The tag named by META_CONTENT is renamed to '*', and META_CONTENT is
217 * set to '*'. This may be skipped by including 'no*' in the value
218 * array.
219 * - Tags listed in META_BC_SUBELEMENTS will have their values changed to
220 * [ '*' => $value ]. This may be skipped by including 'nosub' in
221 * the value array.
222 * - If META_TYPE is 'BCarray', set it to 'default'
223 * - If META_TYPE is 'BCassoc', set it to 'default'
224 * - If META_TYPE is 'BCkvp', perform the transformation (even if
225 * the Types transformation is not being applied).
227 * Types: (assoc) Apply transformations based on META_TYPE. The values
228 * array is an associative array with the following possible keys:
229 * - AssocAsObject: (bool) If true, return arrays with META_TYPE 'assoc'
230 * as objects.
231 * - ArmorKVP: (string) If provided, transform arrays with META_TYPE 'kvp'
232 * and 'BCkvp' into arrays of two-element arrays, something like this:
233 * $output = [];
234 * foreach ( $input as $key => $value ) {
235 * $pair = [];
236 * $pair[$META_KVP_KEY_NAME ?: $ArmorKVP_value] = $key;
237 * ApiResult::setContentValue( $pair, 'value', $value );
238 * $output[] = $pair;
241 * Strip: (string) Strips metadata keys from the result.
242 * - 'all': Strip all metadata, recursively
243 * - 'base': Strip metadata at the top-level only.
244 * - 'none': Do not strip metadata.
245 * - 'bc': Like 'all', but leave certain pre-1.25 keys.
247 * @since 1.25
248 * @param array|string|null $path Path to fetch, see ApiResult::addValue
249 * @param array $transforms See above
250 * @return mixed Result data, or null if not found
252 public function getResultData( $path = [], $transforms = [] ) {
253 $path = (array)$path;
254 if ( !$path ) {
255 return self::applyTransformations( $this->data, $transforms );
258 $last = array_pop( $path );
259 $ret = &$this->path( $path, 'dummy' );
260 if ( !isset( $ret[$last] ) ) {
261 return null;
262 } elseif ( is_array( $ret[$last] ) ) {
263 return self::applyTransformations( $ret[$last], $transforms );
264 } else {
265 return $ret[$last];
270 * Get the size of the result, i.e. the amount of bytes in it
271 * @return int
273 public function getSize() {
274 return $this->size;
278 * Add an output value to the array by name.
280 * Verifies that value with the same name has not been added before.
282 * @since 1.25
283 * @param array &$arr To add $value to
284 * @param string|int|null $name Index of $arr to add $value at,
285 * or null to use the next numeric index.
286 * @param mixed $value
287 * @param int $flags Zero or more OR-ed flags like OVERRIDE | ADD_ON_TOP.
289 public static function setValue( array &$arr, $name, $value, $flags = 0 ) {
290 if ( ( $flags & self::NO_VALIDATE ) !== self::NO_VALIDATE ) {
291 $value = self::validateValue( $value );
294 if ( $name === null ) {
295 if ( $flags & self::ADD_ON_TOP ) {
296 array_unshift( $arr, $value );
297 } else {
298 $arr[] = $value;
300 return;
303 $exists = isset( $arr[$name] );
304 if ( !$exists || ( $flags & self::OVERRIDE ) ) {
305 if ( !$exists && ( $flags & self::ADD_ON_TOP ) ) {
306 $arr = [ $name => $value ] + $arr;
307 } else {
308 $arr[$name] = $value;
310 } elseif ( is_array( $arr[$name] ) && is_array( $value ) ) {
311 $conflicts = array_intersect_key( $arr[$name], $value );
312 if ( !$conflicts ) {
313 $arr[$name] += $value;
314 } else {
315 $keys = implode( ', ', array_keys( $conflicts ) );
316 throw new RuntimeException(
317 "Conflicting keys ($keys) when attempting to merge element $name"
320 } elseif ( $value !== $arr[$name] ) {
321 throw new RuntimeException(
322 "Attempting to add element $name=$value, existing value is {$arr[$name]}"
328 * Validate a value for addition to the result
329 * @param mixed $value
330 * @return array|mixed|string
332 private static function validateValue( $value ) {
333 if ( is_object( $value ) ) {
334 // Note we use is_callable() here instead of instanceof because
335 // ApiSerializable is an informal protocol (see docs there for details).
336 if ( is_callable( [ $value, 'serializeForApiResult' ] ) ) {
337 $oldValue = $value;
338 $value = $value->serializeForApiResult();
339 if ( is_object( $value ) ) {
340 throw new UnexpectedValueException(
341 get_class( $oldValue ) . '::serializeForApiResult() returned an object of class ' .
342 get_class( $value )
346 // Recursive call instead of fall-through so we can throw a
347 // better exception message.
348 try {
349 return self::validateValue( $value );
350 } catch ( Exception $ex ) {
351 throw new UnexpectedValueException(
352 get_class( $oldValue ) . '::serializeForApiResult() returned an invalid value: ' .
353 $ex->getMessage(),
358 } elseif ( $value instanceof \Wikimedia\Message\MessageParam ) {
359 // HACK Support code that puts $msg->getParams() directly into API responses
360 // (e.g. ApiErrorFormatter::formatRawMessage()).
361 $codec = MediaWikiServices::getInstance()->getJsonCodec();
362 $value = $value->getType() === 'text' ? $value->getValue() : $codec->serialize( $value );
363 } elseif ( is_callable( [ $value, '__toString' ] ) ) {
364 $value = (string)$value;
365 } else {
366 $value = (array)$value + [ self::META_TYPE => 'assoc' ];
370 if ( is_string( $value ) ) {
371 // Optimization: avoid querying the service locator for each value.
372 static $contentLanguage = null;
373 if ( !$contentLanguage ) {
374 $contentLanguage = MediaWikiServices::getInstance()->getContentLanguage();
376 $value = $contentLanguage->normalize( $value );
377 } elseif ( is_array( $value ) ) {
378 foreach ( $value as $k => $v ) {
379 $value[$k] = self::validateValue( $v );
381 } elseif ( $value !== null && !is_scalar( $value ) ) {
382 $type = get_debug_type( $value );
383 throw new InvalidArgumentException( "Cannot add $type to ApiResult" );
384 } elseif ( is_float( $value ) && !is_finite( $value ) ) {
385 throw new InvalidArgumentException( 'Cannot add non-finite floats to ApiResult' );
388 return $value;
392 * Add value to the output data at the given path.
394 * Path can be an indexed array, each element specifying the branch at which to add the new
395 * value. Setting $path to [ 'a', 'b', 'c' ] is equivalent to data['a']['b']['c'] = $value.
396 * If $path is null, the value will be inserted at the data root.
398 * @param array|string|int|null $path
399 * @param string|int|null $name See ApiResult::setValue()
400 * @param mixed $value
401 * @param int $flags Zero or more OR-ed flags like OVERRIDE | ADD_ON_TOP.
402 * This parameter used to be boolean, and the value of OVERRIDE=1 was specifically
403 * chosen so that it would be backwards compatible with the new method signature.
404 * @return bool True if $value fits in the result, false if not
405 * @since 1.21 int $flags replaced boolean $override
407 public function addValue( $path, $name, $value, $flags = 0 ) {
408 $arr = &$this->path( $path, ( $flags & self::ADD_ON_TOP ) ? 'prepend' : 'append' );
410 if ( !( $flags & self::NO_SIZE_CHECK ) ) {
411 // self::size needs the validated value. Then flag
412 // to not re-validate later.
413 $value = self::validateValue( $value );
414 $flags |= self::NO_VALIDATE;
416 $newsize = $this->size + self::size( $value );
417 if ( $this->maxSize !== false && $newsize > $this->maxSize ) {
418 $this->errorFormatter->addWarning(
419 'result', [ 'apiwarn-truncatedresult', Message::numParam( $this->maxSize ) ]
421 return false;
423 $this->size = $newsize;
426 self::setValue( $arr, $name, $value, $flags );
427 return true;
431 * Remove an output value to the array by name.
432 * @param array &$arr To remove $value from
433 * @param string|int $name Index of $arr to remove
434 * @return mixed Old value, or null
436 public static function unsetValue( array &$arr, $name ) {
437 $ret = null;
438 if ( isset( $arr[$name] ) ) {
439 $ret = $arr[$name];
440 unset( $arr[$name] );
442 return $ret;
446 * Remove value from the output data at the given path.
448 * @since 1.25
449 * @param array|string|null $path See ApiResult::addValue()
450 * @param string|int|null $name Index to remove at $path.
451 * If null, $path itself is removed.
452 * @param int $flags Flags used when adding the value
453 * @return mixed Old value, or null
455 public function removeValue( $path, $name, $flags = 0 ) {
456 $path = (array)$path;
457 if ( $name === null ) {
458 if ( !$path ) {
459 throw new InvalidArgumentException( 'Cannot remove the data root' );
461 $name = array_pop( $path );
463 $ret = self::unsetValue( $this->path( $path, 'dummy' ), $name );
464 if ( !( $flags & self::NO_SIZE_CHECK ) ) {
465 $newsize = $this->size - self::size( $ret );
466 $this->size = max( $newsize, 0 );
468 return $ret;
472 * Add an output value to the array by name and mark as META_CONTENT.
474 * @since 1.25
475 * @param array &$arr To add $value to
476 * @param string|int $name Index of $arr to add $value at.
477 * @param mixed $value
478 * @param int $flags Zero or more OR-ed flags like OVERRIDE | ADD_ON_TOP.
480 public static function setContentValue( array &$arr, $name, $value, $flags = 0 ) {
481 if ( $name === null ) {
482 throw new InvalidArgumentException( 'Content value must be named' );
484 self::setContentField( $arr, $name, $flags );
485 self::setValue( $arr, $name, $value, $flags );
489 * Add value to the output data at the given path and mark as META_CONTENT
491 * @since 1.25
492 * @param array|string|null $path See ApiResult::addValue()
493 * @param string|int $name See ApiResult::setValue()
494 * @param mixed $value
495 * @param int $flags Zero or more OR-ed flags like OVERRIDE | ADD_ON_TOP.
496 * @return bool True if $value fits in the result, false if not
498 public function addContentValue( $path, $name, $value, $flags = 0 ) {
499 if ( $name === null ) {
500 throw new InvalidArgumentException( 'Content value must be named' );
502 $this->addContentField( $path, $name, $flags );
503 return $this->addValue( $path, $name, $value, $flags );
507 * Add the numeric limit for a limit=max to the result.
509 * @since 1.25
510 * @param string $moduleName
511 * @param int $limit
513 public function addParsedLimit( $moduleName, $limit ) {
514 // Add value, allowing overwriting
515 $this->addValue( 'limits', $moduleName, $limit,
516 self::OVERRIDE | self::NO_SIZE_CHECK );
519 // endregion -- end of Content
521 /***************************************************************************/
522 // region Metadata
523 /** @name Metadata */
526 * Set the name of the content field name (META_CONTENT)
528 * @since 1.25
529 * @param array &$arr
530 * @param string|int $name Name of the field
531 * @param int $flags Zero or more OR-ed flags like OVERRIDE | ADD_ON_TOP.
533 public static function setContentField( array &$arr, $name, $flags = 0 ) {
534 if ( isset( $arr[self::META_CONTENT] ) &&
535 isset( $arr[$arr[self::META_CONTENT]] ) &&
536 !( $flags & self::OVERRIDE )
538 throw new RuntimeException(
539 "Attempting to set content element as $name when " . $arr[self::META_CONTENT] .
540 ' is already set as the content element'
543 $arr[self::META_CONTENT] = $name;
547 * Set the name of the content field name (META_CONTENT)
549 * @since 1.25
550 * @param array|string|null $path See ApiResult::addValue()
551 * @param string|int $name Name of the field
552 * @param int $flags Zero or more OR-ed flags like OVERRIDE | ADD_ON_TOP.
554 public function addContentField( $path, $name, $flags = 0 ) {
555 $arr = &$this->path( $path, ( $flags & self::ADD_ON_TOP ) ? 'prepend' : 'append' );
556 self::setContentField( $arr, $name, $flags );
560 * Causes the elements with the specified names to be output as
561 * subelements rather than attributes.
562 * @since 1.25 is static
563 * @param array &$arr
564 * @param array|string|int $names The element name(s) to be output as subelements
566 public static function setSubelementsList( array &$arr, $names ) {
567 if ( !isset( $arr[self::META_SUBELEMENTS] ) ) {
568 $arr[self::META_SUBELEMENTS] = (array)$names;
569 } else {
570 $arr[self::META_SUBELEMENTS] = array_merge( $arr[self::META_SUBELEMENTS], (array)$names );
575 * Causes the elements with the specified names to be output as
576 * subelements rather than attributes.
577 * @since 1.25
578 * @param array|string|null $path See ApiResult::addValue()
579 * @param array|string|int $names The element name(s) to be output as subelements
581 public function addSubelementsList( $path, $names ) {
582 $arr = &$this->path( $path );
583 self::setSubelementsList( $arr, $names );
587 * Causes the elements with the specified names to be output as
588 * attributes (when possible) rather than as subelements.
589 * @since 1.25
590 * @param array &$arr
591 * @param array|string|int $names The element name(s) to not be output as subelements
593 public static function unsetSubelementsList( array &$arr, $names ) {
594 if ( isset( $arr[self::META_SUBELEMENTS] ) ) {
595 $arr[self::META_SUBELEMENTS] = array_diff( $arr[self::META_SUBELEMENTS], (array)$names );
600 * Causes the elements with the specified names to be output as
601 * attributes (when possible) rather than as subelements.
602 * @since 1.25
603 * @param array|string|null $path See ApiResult::addValue()
604 * @param array|string|int $names The element name(s) to not be output as subelements
606 public function removeSubelementsList( $path, $names ) {
607 $arr = &$this->path( $path );
608 self::unsetSubelementsList( $arr, $names );
612 * Set the tag name for numeric-keyed values in XML format
613 * @since 1.25 is static
614 * @param array &$arr
615 * @param string $tag Tag name
617 public static function setIndexedTagName( array &$arr, $tag ) {
618 if ( !is_string( $tag ) ) {
619 throw new InvalidArgumentException( 'Bad tag name' );
621 $arr[self::META_INDEXED_TAG_NAME] = $tag;
625 * Set the tag name for numeric-keyed values in XML format
626 * @since 1.25
627 * @param array|string|null $path See ApiResult::addValue()
628 * @param string $tag Tag name
630 public function addIndexedTagName( $path, $tag ) {
631 $arr = &$this->path( $path );
632 self::setIndexedTagName( $arr, $tag );
636 * Set indexed tag name on $arr and all subarrays
638 * @since 1.25
639 * @param array &$arr
640 * @param string $tag Tag name
642 public static function setIndexedTagNameRecursive( array &$arr, $tag ) {
643 if ( !is_string( $tag ) ) {
644 throw new InvalidArgumentException( 'Bad tag name' );
646 $arr[self::META_INDEXED_TAG_NAME] = $tag;
647 foreach ( $arr as $k => &$v ) {
648 if ( is_array( $v ) && !self::isMetadataKey( $k ) ) {
649 self::setIndexedTagNameRecursive( $v, $tag );
655 * Set indexed tag name on $path and all subarrays
657 * @since 1.25
658 * @param array|string|null $path See ApiResult::addValue()
659 * @param string $tag Tag name
661 public function addIndexedTagNameRecursive( $path, $tag ) {
662 $arr = &$this->path( $path );
663 self::setIndexedTagNameRecursive( $arr, $tag );
667 * Preserve specified keys.
669 * This prevents XML name mangling and preventing keys from being removed
670 * by self::stripMetadata().
672 * @since 1.25
673 * @param array &$arr
674 * @param array|string $names The element name(s) to preserve
676 public static function setPreserveKeysList( array &$arr, $names ) {
677 if ( !isset( $arr[self::META_PRESERVE_KEYS] ) ) {
678 $arr[self::META_PRESERVE_KEYS] = (array)$names;
679 } else {
680 $arr[self::META_PRESERVE_KEYS] = array_merge( $arr[self::META_PRESERVE_KEYS], (array)$names );
685 * Preserve specified keys.
686 * @since 1.25
687 * @see self::setPreserveKeysList()
688 * @param array|string|null $path See ApiResult::addValue()
689 * @param array|string $names The element name(s) to preserve
691 public function addPreserveKeysList( $path, $names ) {
692 $arr = &$this->path( $path );
693 self::setPreserveKeysList( $arr, $names );
697 * Don't preserve specified keys.
698 * @since 1.25
699 * @see self::setPreserveKeysList()
700 * @param array &$arr
701 * @param array|string $names The element name(s) to not preserve
703 public static function unsetPreserveKeysList( array &$arr, $names ) {
704 if ( isset( $arr[self::META_PRESERVE_KEYS] ) ) {
705 $arr[self::META_PRESERVE_KEYS] = array_diff( $arr[self::META_PRESERVE_KEYS], (array)$names );
710 * Don't preserve specified keys.
711 * @since 1.25
712 * @see self::setPreserveKeysList()
713 * @param array|string|null $path See ApiResult::addValue()
714 * @param array|string $names The element name(s) to not preserve
716 public function removePreserveKeysList( $path, $names ) {
717 $arr = &$this->path( $path );
718 self::unsetPreserveKeysList( $arr, $names );
722 * Set the array data type
724 * @since 1.25
725 * @param array &$arr
726 * @param string $type See ApiResult::META_TYPE
727 * @param string|null $kvpKeyName See ApiResult::META_KVP_KEY_NAME
729 public static function setArrayType( array &$arr, $type, $kvpKeyName = null ) {
730 if ( !in_array( $type, [
731 'default', 'array', 'assoc', 'kvp', 'BCarray', 'BCassoc', 'BCkvp'
732 ], true ) ) {
733 throw new InvalidArgumentException( 'Bad type' );
735 $arr[self::META_TYPE] = $type;
736 if ( is_string( $kvpKeyName ) ) {
737 $arr[self::META_KVP_KEY_NAME] = $kvpKeyName;
742 * Set the array data type for a path
743 * @since 1.25
744 * @param array|string|null $path See ApiResult::addValue()
745 * @param string $tag See ApiResult::META_TYPE
746 * @param string|null $kvpKeyName See ApiResult::META_KVP_KEY_NAME
748 public function addArrayType( $path, $tag, $kvpKeyName = null ) {
749 $arr = &$this->path( $path );
750 self::setArrayType( $arr, $tag, $kvpKeyName );
754 * Set the array data type recursively
755 * @since 1.25
756 * @param array &$arr
757 * @param string $type See ApiResult::META_TYPE
758 * @param string|null $kvpKeyName See ApiResult::META_KVP_KEY_NAME
760 public static function setArrayTypeRecursive( array &$arr, $type, $kvpKeyName = null ) {
761 self::setArrayType( $arr, $type, $kvpKeyName );
762 foreach ( $arr as $k => &$v ) {
763 if ( is_array( $v ) && !self::isMetadataKey( $k ) ) {
764 self::setArrayTypeRecursive( $v, $type, $kvpKeyName );
770 * Set the array data type for a path recursively
771 * @since 1.25
772 * @param array|string|null $path See ApiResult::addValue()
773 * @param string $tag See ApiResult::META_TYPE
774 * @param string|null $kvpKeyName See ApiResult::META_KVP_KEY_NAME
776 public function addArrayTypeRecursive( $path, $tag, $kvpKeyName = null ) {
777 $arr = &$this->path( $path );
778 self::setArrayTypeRecursive( $arr, $tag, $kvpKeyName );
781 // endregion -- end of Metadata
783 /***************************************************************************/
784 // region Utility
785 /** @name Utility */
788 * Test whether a key should be considered metadata
790 * @param string|int $key
791 * @return bool
793 public static function isMetadataKey( $key ) {
794 // Optimization: This is a very hot and highly optimized code path. Note that ord() only
795 // considers the first character and also works with empty strings and integers.
796 // 95 corresponds to the '_' character.
797 return ord( $key ) === 95;
801 * Apply transformations to an array, returning the transformed array.
803 * @see ApiResult::getResultData()
804 * @since 1.25
805 * @param array $dataIn
806 * @param array $transforms
807 * @return array|stdClass
809 protected static function applyTransformations( array $dataIn, array $transforms ) {
810 $strip = $transforms['Strip'] ?? 'none';
811 if ( $strip === 'base' ) {
812 $transforms['Strip'] = 'none';
814 $transformTypes = $transforms['Types'] ?? null;
815 if ( $transformTypes !== null && !is_array( $transformTypes ) ) {
816 throw new InvalidArgumentException( __METHOD__ . ':Value for "Types" must be an array' );
819 $metadata = [];
820 $data = self::stripMetadataNonRecursive( $dataIn, $metadata );
822 if ( isset( $transforms['Custom'] ) ) {
823 if ( !is_callable( $transforms['Custom'] ) ) {
824 throw new InvalidArgumentException( __METHOD__ . ': Value for "Custom" must be callable' );
826 call_user_func_array( $transforms['Custom'], [ &$data, &$metadata ] );
829 if ( ( isset( $transforms['BC'] ) || $transformTypes !== null ) &&
830 isset( $metadata[self::META_TYPE] ) && $metadata[self::META_TYPE] === 'BCkvp' &&
831 !isset( $metadata[self::META_KVP_KEY_NAME] )
833 throw new UnexpectedValueException( 'Type "BCkvp" used without setting ' .
834 'ApiResult::META_KVP_KEY_NAME metadata item' );
837 // BC transformations
838 $boolKeys = null;
839 if ( isset( $transforms['BC'] ) ) {
840 if ( !is_array( $transforms['BC'] ) ) {
841 throw new InvalidArgumentException( __METHOD__ . ':Value for "BC" must be an array' );
843 if ( !in_array( 'nobool', $transforms['BC'], true ) ) {
844 $boolKeys = isset( $metadata[self::META_BC_BOOLS] )
845 ? array_fill_keys( $metadata[self::META_BC_BOOLS], true )
846 : [];
849 if ( !in_array( 'no*', $transforms['BC'], true ) &&
850 isset( $metadata[self::META_CONTENT] ) && $metadata[self::META_CONTENT] !== '*'
852 $k = $metadata[self::META_CONTENT];
853 $data['*'] = $data[$k];
854 unset( $data[$k] );
855 $metadata[self::META_CONTENT] = '*';
858 if ( !in_array( 'nosub', $transforms['BC'], true ) &&
859 isset( $metadata[self::META_BC_SUBELEMENTS] )
861 foreach ( $metadata[self::META_BC_SUBELEMENTS] as $k ) {
862 if ( isset( $data[$k] ) ) {
863 $data[$k] = [
864 '*' => $data[$k],
865 self::META_CONTENT => '*',
866 self::META_TYPE => 'assoc',
872 if ( isset( $metadata[self::META_TYPE] ) ) {
873 switch ( $metadata[self::META_TYPE] ) {
874 case 'BCarray':
875 case 'BCassoc':
876 $metadata[self::META_TYPE] = 'default';
877 break;
878 case 'BCkvp':
879 $transformTypes['ArmorKVP'] = $metadata[self::META_KVP_KEY_NAME];
880 break;
885 // Figure out type, do recursive calls, and do boolean transform if necessary
886 $defaultType = 'array';
887 $maxKey = -1;
888 foreach ( $data as $k => &$v ) {
889 $v = is_array( $v ) ? self::applyTransformations( $v, $transforms ) : $v;
890 if ( $boolKeys !== null && is_bool( $v ) && !isset( $boolKeys[$k] ) ) {
891 if ( !$v ) {
892 unset( $data[$k] );
893 continue;
895 $v = '';
897 if ( is_string( $k ) ) {
898 $defaultType = 'assoc';
899 } elseif ( $k > $maxKey ) {
900 $maxKey = $k;
903 unset( $v );
905 // Determine which metadata to keep
906 switch ( $strip ) {
907 case 'all':
908 case 'base':
909 $keepMetadata = [];
910 break;
911 case 'none':
912 $keepMetadata = &$metadata;
913 break;
914 case 'bc':
915 // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal Type mismatch on pass-by-ref args
916 $keepMetadata = array_intersect_key( $metadata, [
917 self::META_INDEXED_TAG_NAME => 1,
918 self::META_SUBELEMENTS => 1,
919 ] );
920 break;
921 default:
922 throw new InvalidArgumentException( __METHOD__ . ': Unknown value for "Strip"' );
925 // No type transformation
926 if ( $transformTypes === null ) {
927 return $data + $keepMetadata;
930 if ( $defaultType === 'array' && $maxKey !== count( $data ) - 1 ) {
931 $defaultType = 'assoc';
934 // Override type, if provided
935 $type = $defaultType;
936 if ( isset( $metadata[self::META_TYPE] ) && $metadata[self::META_TYPE] !== 'default' ) {
937 $type = $metadata[self::META_TYPE];
939 if ( ( $type === 'kvp' || $type === 'BCkvp' ) &&
940 empty( $transformTypes['ArmorKVP'] )
942 $type = 'assoc';
943 } elseif ( $type === 'BCarray' ) {
944 $type = 'array';
945 } elseif ( $type === 'BCassoc' ) {
946 $type = 'assoc';
949 // Apply transformation
950 switch ( $type ) {
951 case 'assoc':
952 $metadata[self::META_TYPE] = 'assoc';
953 $data += $keepMetadata;
954 return empty( $transformTypes['AssocAsObject'] ) ? $data : (object)$data;
956 case 'array':
957 // Sort items in ascending order by key. Note that $data may contain a mix of number and string keys,
958 // for which the sorting behavior of krsort() with SORT_REGULAR is inconsistent between PHP versions.
959 // Given a comparison of a string key and a number key, PHP < 8.2 coerces the string key into a number
960 // (which yields zero if the string was non-numeric), and then performs the comparison,
961 // while PHP >= 8.2 makes the behavior consistent with stricter numeric comparisons introduced by
962 // PHP 8.0 in that if the string key is non-numeric, it converts the number key into a string
963 // and compares those two strings instead. We therefore use a custom comparison function
964 // implementing PHP >= 8.2 ordering semantics to ensure consistent ordering of items
965 // irrespective of the PHP version (T326480).
966 uksort( $data, static function ( $a, $b ): int {
967 // In a comparison of a number or numeric string with a non-numeric string,
968 // coerce both values into a string prior to comparing and compare the resulting strings.
969 // Note that PHP prior to 8.0 did not consider numeric strings with trailing whitespace
970 // to be numeric, so trim the inputs prior to the numeric checks to make the behavior
971 // consistent across PHP versions.
972 if ( is_numeric( trim( $a ) ) xor is_numeric( trim( $b ) ) ) {
973 return (string)$a <=> (string)$b;
976 return $a <=> $b;
977 } );
979 $data = array_values( $data );
980 $metadata[self::META_TYPE] = 'array';
981 // @phan-suppress-next-line PhanTypeMismatchReturnNullable Type mismatch on pass-by-ref args
982 return $data + $keepMetadata;
984 case 'kvp':
985 case 'BCkvp':
986 $key = $metadata[self::META_KVP_KEY_NAME] ?? $transformTypes['ArmorKVP'];
987 $valKey = isset( $transforms['BC'] ) ? '*' : 'value';
988 $assocAsObject = !empty( $transformTypes['AssocAsObject'] );
989 $merge = !empty( $metadata[self::META_KVP_MERGE] );
991 $ret = [];
992 foreach ( $data as $k => $v ) {
993 if ( $merge && ( is_array( $v ) || is_object( $v ) ) ) {
994 $vArr = (array)$v;
995 if ( isset( $vArr[self::META_TYPE] ) ) {
996 $mergeType = $vArr[self::META_TYPE];
997 } elseif ( is_object( $v ) ) {
998 $mergeType = 'assoc';
999 } else {
1000 $keys = array_keys( $vArr );
1001 sort( $keys, SORT_NUMERIC );
1002 $mergeType = ( $keys === array_keys( $keys ) ) ? 'array' : 'assoc';
1004 } else {
1005 $mergeType = 'n/a';
1007 if ( $mergeType === 'assoc' ) {
1008 // @phan-suppress-next-line PhanPossiblyUndeclaredVariable vArr set when used
1009 $item = $vArr + [
1010 $key => $k,
1012 if ( $strip === 'none' ) {
1013 self::setPreserveKeysList( $item, [ $key ] );
1015 } else {
1016 $item = [
1017 $key => $k,
1018 $valKey => $v,
1020 if ( $strip === 'none' ) {
1021 $item += [
1022 self::META_PRESERVE_KEYS => [ $key ],
1023 self::META_CONTENT => $valKey,
1024 self::META_TYPE => 'assoc',
1028 $ret[] = $assocAsObject ? (object)$item : $item;
1030 $metadata[self::META_TYPE] = 'array';
1032 // @phan-suppress-next-line PhanTypeMismatchReturnNullable Type mismatch on pass-by-ref args
1033 return $ret + $keepMetadata;
1035 default:
1036 throw new UnexpectedValueException( "Unknown type '$type'" );
1041 * Recursively remove metadata keys from a data array or object
1043 * Note this removes all potential metadata keys, not just the defined
1044 * ones.
1046 * @since 1.25
1047 * @param array|stdClass $data
1048 * @return array|stdClass
1050 public static function stripMetadata( $data ) {
1051 if ( is_array( $data ) || is_object( $data ) ) {
1052 $isObj = is_object( $data );
1053 if ( $isObj ) {
1054 $data = (array)$data;
1056 $preserveKeys = isset( $data[self::META_PRESERVE_KEYS] )
1057 ? (array)$data[self::META_PRESERVE_KEYS]
1058 : [];
1059 foreach ( $data as $k => $v ) {
1060 if ( self::isMetadataKey( $k ) && !in_array( $k, $preserveKeys, true ) ) {
1061 unset( $data[$k] );
1062 } elseif ( is_array( $v ) || is_object( $v ) ) {
1063 $data[$k] = self::stripMetadata( $v );
1066 if ( $isObj ) {
1067 $data = (object)$data;
1070 return $data;
1074 * Remove metadata keys from a data array or object, non-recursive
1076 * Note this removes all potential metadata keys, not just the defined
1077 * ones.
1079 * @since 1.25
1080 * @param array|stdClass $data
1081 * @param array|null &$metadata Store metadata here, if provided
1082 * @return array|stdClass
1084 public static function stripMetadataNonRecursive( $data, &$metadata = null ) {
1085 if ( !is_array( $metadata ) ) {
1086 $metadata = [];
1088 if ( is_array( $data ) || is_object( $data ) ) {
1089 $isObj = is_object( $data );
1090 if ( $isObj ) {
1091 $data = (array)$data;
1093 $preserveKeys = isset( $data[self::META_PRESERVE_KEYS] )
1094 ? (array)$data[self::META_PRESERVE_KEYS]
1095 : [];
1096 foreach ( $data as $k => $v ) {
1097 if ( self::isMetadataKey( $k ) && !in_array( $k, $preserveKeys, true ) ) {
1098 $metadata[$k] = $v;
1099 unset( $data[$k] );
1102 if ( $isObj ) {
1103 $data = (object)$data;
1106 return $data;
1110 * Get the 'real' size of a result item. This means the strlen() of the item,
1111 * or the sum of the strlen()s of the elements if the item is an array.
1112 * @param mixed $value Validated value (see self::validateValue())
1113 * @return int
1115 private static function size( $value ) {
1116 $s = 0;
1117 if ( is_array( $value ) ) {
1118 foreach ( $value as $k => $v ) {
1119 if ( !self::isMetadataKey( $k ) ) {
1120 $s += self::size( $v );
1123 } elseif ( is_scalar( $value ) ) {
1124 $s = strlen( $value );
1127 return $s;
1131 * Return a reference to the internal data at $path
1133 * @param array|string|null $path
1134 * @param string $create
1135 * If 'append', append empty arrays.
1136 * If 'prepend', prepend empty arrays.
1137 * If 'dummy', return a dummy array.
1138 * Else, raise an error.
1139 * @return array
1141 private function &path( $path, $create = 'append' ) {
1142 $path = (array)$path;
1143 $ret = &$this->data;
1144 foreach ( $path as $i => $k ) {
1145 if ( !isset( $ret[$k] ) ) {
1146 switch ( $create ) {
1147 case 'append':
1148 $ret[$k] = [];
1149 break;
1150 case 'prepend':
1151 $ret = [ $k => [] ] + $ret;
1152 break;
1153 case 'dummy':
1154 $tmp = [];
1155 return $tmp;
1156 default:
1157 $fail = implode( '.', array_slice( $path, 0, $i + 1 ) );
1158 throw new InvalidArgumentException( "Path $fail does not exist" );
1161 if ( !is_array( $ret[$k] ) ) {
1162 $fail = implode( '.', array_slice( $path, 0, $i + 1 ) );
1163 throw new InvalidArgumentException( "Path $fail is not an array" );
1165 $ret = &$ret[$k];
1167 return $ret;
1171 * Add the correct metadata to an array of vars we want to export through
1172 * the API.
1174 * @param array $vars
1175 * @param bool $forceHash
1176 * @return array
1178 public static function addMetadataToResultVars( $vars, $forceHash = true ) {
1179 // Process subarrays and determine if this is a JS [] or {}
1180 $hash = $forceHash;
1181 $maxKey = -1;
1182 $bools = [];
1183 foreach ( $vars as $k => $v ) {
1184 if ( is_array( $v ) || is_object( $v ) ) {
1185 $vars[$k] = self::addMetadataToResultVars( (array)$v, is_object( $v ) );
1186 } elseif ( is_bool( $v ) ) {
1187 // Better here to use real bools even in BC formats
1188 $bools[] = $k;
1190 if ( is_string( $k ) ) {
1191 $hash = true;
1192 } elseif ( $k > $maxKey ) {
1193 $maxKey = $k;
1196 if ( !$hash && $maxKey !== count( $vars ) - 1 ) {
1197 $hash = true;
1200 // Set metadata appropriately
1201 if ( $hash ) {
1202 // Get the list of keys we actually care about. Unfortunately, we can't support
1203 // certain keys that conflict with ApiResult metadata.
1204 $keys = array_diff( array_keys( $vars ), [
1205 self::META_TYPE, self::META_PRESERVE_KEYS, self::META_KVP_KEY_NAME,
1206 self::META_INDEXED_TAG_NAME, self::META_BC_BOOLS
1207 ] );
1209 return [
1210 self::META_TYPE => 'kvp',
1211 self::META_KVP_KEY_NAME => 'key',
1212 self::META_PRESERVE_KEYS => $keys,
1213 self::META_BC_BOOLS => $bools,
1214 self::META_INDEXED_TAG_NAME => 'var',
1215 ] + $vars;
1216 } else {
1217 return [
1218 self::META_TYPE => 'array',
1219 self::META_BC_BOOLS => $bools,
1220 self::META_INDEXED_TAG_NAME => 'value',
1221 ] + $vars;
1226 * Format an expiry timestamp for API output
1227 * @since 1.29
1228 * @param string $expiry Expiry timestamp, likely from the database
1229 * @param string $infinity Use this string for infinite expiry
1230 * (only use this to maintain backward compatibility with existing output)
1231 * @return string Formatted expiry
1233 public static function formatExpiry( $expiry, $infinity = 'infinity' ) {
1234 static $dbInfinity;
1235 $dbInfinity ??= MediaWikiServices::getInstance()->getConnectionProvider()
1236 ->getReplicaDatabase()
1237 ->getInfinity();
1239 if ( $expiry === '' || $expiry === null || $expiry === false ||
1240 wfIsInfinity( $expiry ) || $expiry === $dbInfinity
1242 return $infinity;
1243 } else {
1244 return wfTimestamp( TS_ISO_8601, $expiry );
1248 // endregion -- end of Utility
1253 * This file uses VisualStudio style region/endregion fold markers which are
1254 * recognised by PHPStorm. If modelines are enabled, the following editor
1255 * configuration will also enable folding in vim, if it is in the last 5 lines
1256 * of the file. We also use "@name" which creates sections in Doxygen.
1258 * vim: foldmarker=//\ region,//\ endregion foldmethod=marker
1261 /** @deprecated class alias since 1.43 */
1262 class_alias( ApiResult::class, 'ApiResult' );