Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.69% covered (success)
94.69%
392 / 414
75.00% covered (warning)
75.00%
30 / 40
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiResult
94.69% covered (success)
94.69%
392 / 414
75.00% covered (warning)
75.00%
30 / 40
213.43
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setErrorFormatter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 serializeForApiResult
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reset
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getResultData
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 getSize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setValue
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
12
 validateValue
97.22% covered (success)
97.22%
35 / 36
0.00% covered (danger)
0.00%
0 / 1
15
 addValue
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 unsetValue
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 removeValue
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 setContentValue
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 addContentValue
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 addParsedLimit
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setContentField
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 addContentField
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 setSubelementsList
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 addSubelementsList
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 unsetSubelementsList
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 removeSubelementsList
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setIndexedTagName
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 addIndexedTagName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setIndexedTagNameRecursive
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 addIndexedTagNameRecursive
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setPreserveKeysList
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 addPreserveKeysList
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 unsetPreserveKeysList
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 removePreserveKeysList
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setArrayType
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 addArrayType
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setArrayTypeRecursive
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 addArrayTypeRecursive
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isMetadataKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 applyTransformations
96.48% covered (success)
96.48%
137 / 142
0.00% covered (danger)
0.00%
0 / 1
68
 stripMetadata
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
11
 stripMetadataNonRecursive
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
10
 size
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 path
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
8.06
 addMetadataToResultVars
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
10
 formatExpiry
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
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.
7 *
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.
12 *
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
17 *
18 * @file
19 */
20
21use MediaWiki\MediaWikiServices;
22
23/**
24 * This class represents the result of the API operations.
25 * It simply wraps a nested array structure, adding some functions to simplify
26 * array's modifications. As various modules execute, they add different pieces
27 * of information to this result, structuring it as it will be given to the client.
28 *
29 * Each subarray may either be a dictionary - key-value pairs with unique keys,
30 * or lists, where the items are added using $data[] = $value notation.
31 *
32 * @since 1.25 this is no longer a subclass of ApiBase
33 * @ingroup API
34 */
35class ApiResult implements ApiSerializable {
36
37    /**
38     * Override existing value in addValue(), setValue(), and similar functions
39     * @since 1.21
40     */
41    public const OVERRIDE = 1;
42
43    /**
44     * For addValue(), setValue() and similar functions, if the value does not
45     * exist, add it as the first element. In case the new value has no name
46     * (numerical index), all indexes will be renumbered.
47     * @since 1.21
48     */
49    public const ADD_ON_TOP = 2;
50
51    /**
52     * For addValue() and similar functions, do not check size while adding a value
53     * Don't use this unless you REALLY know what you're doing.
54     * Values added while the size checking was disabled will never be counted.
55     * Ignored for setValue() and similar functions.
56     * @since 1.24
57     */
58    public const NO_SIZE_CHECK = 4;
59
60    /**
61     * For addValue(), setValue() and similar functions, do not validate data.
62     * Also disables size checking. If you think you need to use this, you're
63     * probably wrong.
64     * @since 1.25
65     */
66    public const NO_VALIDATE = self::NO_SIZE_CHECK | 8;
67
68    /**
69     * Key for the 'indexed tag name' metadata item. Value is string.
70     * @since 1.25
71     */
72    public const META_INDEXED_TAG_NAME = '_element';
73
74    /**
75     * Key for the 'subelements' metadata item. Value is string[].
76     * @since 1.25
77     */
78    public const META_SUBELEMENTS = '_subelements';
79
80    /**
81     * Key for the 'preserve keys' metadata item. Value is string[].
82     * @since 1.25
83     */
84    public const META_PRESERVE_KEYS = '_preservekeys';
85
86    /**
87     * Key for the 'content' metadata item. Value is string.
88     * @since 1.25
89     */
90    public const META_CONTENT = '_content';
91
92    /**
93     * Key for the 'type' metadata item. Value is one of the following strings:
94     *  - default: Like 'array' if all (non-metadata) keys are numeric with no
95     *    gaps, otherwise like 'assoc'.
96     *  - array: Keys are used for ordering, but are not output. In a format
97     *    like JSON, outputs as [].
98     *  - assoc: In a format like JSON, outputs as {}.
99     *  - kvp: For a format like XML where object keys have a restricted
100     *    character set, use an alternative output format. For example,
101     *    <container><item name="key">value</item></container> rather than
102     *    <container key="value" />
103     *  - BCarray: Like 'array' normally, 'default' in backwards-compatibility mode.
104     *  - BCassoc: Like 'assoc' normally, 'default' in backwards-compatibility mode.
105     *  - BCkvp: Like 'kvp' normally. In backwards-compatibility mode, forces
106     *    the alternative output format for all formats, for example
107     *    [{"name":key,"*":value}] in JSON. META_KVP_KEY_NAME must also be set.
108     * @since 1.25
109     */
110    public const META_TYPE = '_type';
111
112    /**
113     * Key for the metadata item whose value specifies the name used for the
114     * kvp key in the alternative output format with META_TYPE 'kvp' or
115     * 'BCkvp', i.e. the "name" in <container><item name="key">value</item></container>.
116     * Value is string.
117     * @since 1.25
118     */
119    public const META_KVP_KEY_NAME = '_kvpkeyname';
120
121    /**
122     * Key for the metadata item that indicates that the KVP key should be
123     * added into an assoc value, i.e. {"key":{"val1":"a","val2":"b"}}
124     * transforms to {"name":"key","val1":"a","val2":"b"} rather than
125     * {"name":"key","value":{"val1":"a","val2":"b"}}.
126     * Value is boolean.
127     * @since 1.26
128     */
129    public const META_KVP_MERGE = '_kvpmerge';
130
131    /**
132     * Key for the 'BC bools' metadata item. Value is string[].
133     * Note no setter is provided.
134     * @since 1.25
135     */
136    public const META_BC_BOOLS = '_BC_bools';
137
138    /**
139     * Key for the 'BC subelements' metadata item. Value is string[].
140     * Note no setter is provided.
141     * @since 1.25
142     */
143    public const META_BC_SUBELEMENTS = '_BC_subelements';
144
145    private $data;
146    private int $size;
147    /** @var int|false */
148    private $maxSize;
149    private $errorFormatter;
150
151    /**
152     * @param int|false $maxSize Maximum result "size", or false for no limit
153     */
154    public function __construct( $maxSize ) {
155        $this->maxSize = $maxSize;
156        $this->reset();
157    }
158
159    /**
160     * @since 1.25
161     * @param ApiErrorFormatter $formatter
162     */
163    public function setErrorFormatter( ApiErrorFormatter $formatter ) {
164        $this->errorFormatter = $formatter;
165    }
166
167    /**
168     * Allow for adding one ApiResult into another
169     * @since 1.25
170     * @return mixed
171     */
172    public function serializeForApiResult() {
173        return $this->data;
174    }
175
176    /***************************************************************************/
177    // region   Content
178    /** @name   Content */
179
180    /**
181     * Clear the current result data.
182     */
183    public function reset() {
184        $this->data = [
185            self::META_TYPE => 'assoc', // Usually what's desired
186        ];
187        $this->size = 0;
188    }
189
190    /**
191     * Get the result data array
192     *
193     * The returned value should be considered read-only.
194     *
195     * Transformations include:
196     *
197     * Custom: (callable) Applied before other transformations. Signature is
198     *  function ( &$data, &$metadata ), return value is ignored. Called for
199     *  each nested array.
200     *
201     * BC: (array) This transformation does various adjustments to bring the
202     *  output in line with the pre-1.25 result format. The value array is a
203     *  list of flags: 'nobool', 'no*', 'nosub'.
204     *  - Boolean-valued items are changed to '' if true or removed if false,
205     *    unless listed in META_BC_BOOLS. This may be skipped by including
206     *    'nobool' in the value array.
207     *  - The tag named by META_CONTENT is renamed to '*', and META_CONTENT is
208     *    set to '*'. This may be skipped by including 'no*' in the value
209     *    array.
210     *  - Tags listed in META_BC_SUBELEMENTS will have their values changed to
211     *    [ '*' => $value ]. This may be skipped by including 'nosub' in
212     *    the value array.
213     *  - If META_TYPE is 'BCarray', set it to 'default'
214     *  - If META_TYPE is 'BCassoc', set it to 'default'
215     *  - If META_TYPE is 'BCkvp', perform the transformation (even if
216     *    the Types transformation is not being applied).
217     *
218     * Types: (assoc) Apply transformations based on META_TYPE. The values
219     * array is an associative array with the following possible keys:
220     *  - AssocAsObject: (bool) If true, return arrays with META_TYPE 'assoc'
221     *    as objects.
222     *  - ArmorKVP: (string) If provided, transform arrays with META_TYPE 'kvp'
223     *    and 'BCkvp' into arrays of two-element arrays, something like this:
224     *      $output = [];
225     *      foreach ( $input as $key => $value ) {
226     *          $pair = [];
227     *          $pair[$META_KVP_KEY_NAME ?: $ArmorKVP_value] = $key;
228     *          ApiResult::setContentValue( $pair, 'value', $value );
229     *          $output[] = $pair;
230     *      }
231     *
232     * Strip: (string) Strips metadata keys from the result.
233     *  - 'all': Strip all metadata, recursively
234     *  - 'base': Strip metadata at the top-level only.
235     *  - 'none': Do not strip metadata.
236     *  - 'bc': Like 'all', but leave certain pre-1.25 keys.
237     *
238     * @since 1.25
239     * @param array|string|null $path Path to fetch, see ApiResult::addValue
240     * @param array $transforms See above
241     * @return mixed Result data, or null if not found
242     */
243    public function getResultData( $path = [], $transforms = [] ) {
244        $path = (array)$path;
245        if ( !$path ) {
246            return self::applyTransformations( $this->data, $transforms );
247        }
248
249        $last = array_pop( $path );
250        $ret = &$this->path( $path, 'dummy' );
251        if ( !isset( $ret[$last] ) ) {
252            return null;
253        } elseif ( is_array( $ret[$last] ) ) {
254            return self::applyTransformations( $ret[$last], $transforms );
255        } else {
256            return $ret[$last];
257        }
258    }
259
260    /**
261     * Get the size of the result, i.e. the amount of bytes in it
262     * @return int
263     */
264    public function getSize() {
265        return $this->size;
266    }
267
268    /**
269     * Add an output value to the array by name.
270     *
271     * Verifies that value with the same name has not been added before.
272     *
273     * @since 1.25
274     * @param array &$arr To add $value to
275     * @param string|int|null $name Index of $arr to add $value at,
276     *   or null to use the next numeric index.
277     * @param mixed $value
278     * @param int $flags Zero or more OR-ed flags like OVERRIDE | ADD_ON_TOP.
279     */
280    public static function setValue( array &$arr, $name, $value, $flags = 0 ) {
281        if ( ( $flags & self::NO_VALIDATE ) !== self::NO_VALIDATE ) {
282            $value = self::validateValue( $value );
283        }
284
285        if ( $name === null ) {
286            if ( $flags & self::ADD_ON_TOP ) {
287                array_unshift( $arr, $value );
288            } else {
289                $arr[] = $value;
290            }
291            return;
292        }
293
294        $exists = isset( $arr[$name] );
295        if ( !$exists || ( $flags & self::OVERRIDE ) ) {
296            if ( !$exists && ( $flags & self::ADD_ON_TOP ) ) {
297                $arr = [ $name => $value ] + $arr;
298            } else {
299                $arr[$name] = $value;
300            }
301        } elseif ( is_array( $arr[$name] ) && is_array( $value ) ) {
302            $conflicts = array_intersect_key( $arr[$name], $value );
303            if ( !$conflicts ) {
304                $arr[$name] += $value;
305            } else {
306                $keys = implode( ', ', array_keys( $conflicts ) );
307                throw new RuntimeException(
308                    "Conflicting keys ($keys) when attempting to merge element $name"
309                );
310            }
311        } elseif ( $value !== $arr[$name] ) {
312            throw new RuntimeException(
313                "Attempting to add element $name=$value, existing value is {$arr[$name]}"
314            );
315        }
316    }
317
318    /**
319     * Validate a value for addition to the result
320     * @param mixed $value
321     * @return array|mixed|string
322     */
323    private static function validateValue( $value ) {
324        if ( is_object( $value ) ) {
325            // Note we use is_callable() here instead of instanceof because
326            // ApiSerializable is an informal protocol (see docs there for details).
327            if ( is_callable( [ $value, 'serializeForApiResult' ] ) ) {
328                $oldValue = $value;
329                $value = $value->serializeForApiResult();
330                if ( is_object( $value ) ) {
331                    throw new UnexpectedValueException(
332                        get_class( $oldValue ) . '::serializeForApiResult() returned an object of class ' .
333                            get_class( $value )
334                    );
335                }
336
337                // Recursive call instead of fall-through so we can throw a
338                // better exception message.
339                try {
340                    return self::validateValue( $value );
341                } catch ( Exception $ex ) {
342                    throw new UnexpectedValueException(
343                        get_class( $oldValue ) . '::serializeForApiResult() returned an invalid value: ' .
344                            $ex->getMessage(),
345                        0,
346                        $ex
347                    );
348                }
349            } elseif ( is_callable( [ $value, '__toString' ] ) ) {
350                $value = (string)$value;
351            } else {
352                $value = (array)$value + [ self::META_TYPE => 'assoc' ];
353            }
354        }
355
356        if ( is_string( $value ) ) {
357            // Optimization: avoid querying the service locator for each value.
358            static $contentLanguage = null;
359            if ( !$contentLanguage ) {
360                $contentLanguage = MediaWikiServices::getInstance()->getContentLanguage();
361            }
362            $value = $contentLanguage->normalize( $value );
363        } elseif ( is_array( $value ) ) {
364            foreach ( $value as $k => $v ) {
365                $value[$k] = self::validateValue( $v );
366            }
367        } elseif ( $value !== null && !is_scalar( $value ) ) {
368            $type = gettype( $value );
369            // phpcs:ignore MediaWiki.Usage.ForbiddenFunctions.is_resource
370            if ( is_resource( $value ) ) {
371                $type .= '(' . get_resource_type( $value ) . ')';
372            }
373            throw new InvalidArgumentException( "Cannot add $type to ApiResult" );
374        } elseif ( is_float( $value ) && !is_finite( $value ) ) {
375            throw new InvalidArgumentException( 'Cannot add non-finite floats to ApiResult' );
376        }
377
378        return $value;
379    }
380
381    /**
382     * Add value to the output data at the given path.
383     *
384     * Path can be an indexed array, each element specifying the branch at which to add the new
385     * value. Setting $path to [ 'a', 'b', 'c' ] is equivalent to data['a']['b']['c'] = $value.
386     * If $path is null, the value will be inserted at the data root.
387     *
388     * @param array|string|int|null $path
389     * @param string|int|null $name See ApiResult::setValue()
390     * @param mixed $value
391     * @param int $flags Zero or more OR-ed flags like OVERRIDE | ADD_ON_TOP.
392     *   This parameter used to be boolean, and the value of OVERRIDE=1 was specifically
393     *   chosen so that it would be backwards compatible with the new method signature.
394     * @return bool True if $value fits in the result, false if not
395     * @since 1.21 int $flags replaced boolean $override
396     */
397    public function addValue( $path, $name, $value, $flags = 0 ) {
398        $arr = &$this->path( $path, ( $flags & self::ADD_ON_TOP ) ? 'prepend' : 'append' );
399
400        if ( !( $flags & self::NO_SIZE_CHECK ) ) {
401            // self::size needs the validated value. Then flag
402            // to not re-validate later.
403            $value = self::validateValue( $value );
404            $flags |= self::NO_VALIDATE;
405
406            $newsize = $this->size + self::size( $value );
407            if ( $this->maxSize !== false && $newsize > $this->maxSize ) {
408                $this->errorFormatter->addWarning(
409                    'result', [ 'apiwarn-truncatedresult', Message::numParam( $this->maxSize ) ]
410                );
411                return false;
412            }
413            $this->size = $newsize;
414        }
415
416        self::setValue( $arr, $name, $value, $flags );
417        return true;
418    }
419
420    /**
421     * Remove an output value to the array by name.
422     * @param array &$arr To remove $value from
423     * @param string|int $name Index of $arr to remove
424     * @return mixed Old value, or null
425     */
426    public static function unsetValue( array &$arr, $name ) {
427        $ret = null;
428        if ( isset( $arr[$name] ) ) {
429            $ret = $arr[$name];
430            unset( $arr[$name] );
431        }
432        return $ret;
433    }
434
435    /**
436     * Remove value from the output data at the given path.
437     *
438     * @since 1.25
439     * @param array|string|null $path See ApiResult::addValue()
440     * @param string|int|null $name Index to remove at $path.
441     *   If null, $path itself is removed.
442     * @param int $flags Flags used when adding the value
443     * @return mixed Old value, or null
444     */
445    public function removeValue( $path, $name, $flags = 0 ) {
446        $path = (array)$path;
447        if ( $name === null ) {
448            if ( !$path ) {
449                throw new InvalidArgumentException( 'Cannot remove the data root' );
450            }
451            $name = array_pop( $path );
452        }
453        $ret = self::unsetValue( $this->path( $path, 'dummy' ), $name );
454        if ( !( $flags & self::NO_SIZE_CHECK ) ) {
455            $newsize = $this->size - self::size( $ret );
456            $this->size = max( $newsize, 0 );
457        }
458        return $ret;
459    }
460
461    /**
462     * Add an output value to the array by name and mark as META_CONTENT.
463     *
464     * @since 1.25
465     * @param array &$arr To add $value to
466     * @param string|int $name Index of $arr to add $value at.
467     * @param mixed $value
468     * @param int $flags Zero or more OR-ed flags like OVERRIDE | ADD_ON_TOP.
469     */
470    public static function setContentValue( array &$arr, $name, $value, $flags = 0 ) {
471        if ( $name === null ) {
472            throw new InvalidArgumentException( 'Content value must be named' );
473        }
474        self::setContentField( $arr, $name, $flags );
475        self::setValue( $arr, $name, $value, $flags );
476    }
477
478    /**
479     * Add value to the output data at the given path and mark as META_CONTENT
480     *
481     * @since 1.25
482     * @param array|string|null $path See ApiResult::addValue()
483     * @param string|int $name See ApiResult::setValue()
484     * @param mixed $value
485     * @param int $flags Zero or more OR-ed flags like OVERRIDE | ADD_ON_TOP.
486     * @return bool True if $value fits in the result, false if not
487     */
488    public function addContentValue( $path, $name, $value, $flags = 0 ) {
489        if ( $name === null ) {
490            throw new InvalidArgumentException( 'Content value must be named' );
491        }
492        $this->addContentField( $path, $name, $flags );
493        return $this->addValue( $path, $name, $value, $flags );
494    }
495
496    /**
497     * Add the numeric limit for a limit=max to the result.
498     *
499     * @since 1.25
500     * @param string $moduleName
501     * @param int $limit
502     */
503    public function addParsedLimit( $moduleName, $limit ) {
504        // Add value, allowing overwriting
505        $this->addValue( 'limits', $moduleName, $limit,
506            self::OVERRIDE | self::NO_SIZE_CHECK );
507    }
508
509    // endregion -- end of Content
510
511    /***************************************************************************/
512    // region   Metadata
513    /** @name   Metadata */
514
515    /**
516     * Set the name of the content field name (META_CONTENT)
517     *
518     * @since 1.25
519     * @param array &$arr
520     * @param string|int $name Name of the field
521     * @param int $flags Zero or more OR-ed flags like OVERRIDE | ADD_ON_TOP.
522     */
523    public static function setContentField( array &$arr, $name, $flags = 0 ) {
524        if ( isset( $arr[self::META_CONTENT] ) &&
525            isset( $arr[$arr[self::META_CONTENT]] ) &&
526            !( $flags & self::OVERRIDE )
527        ) {
528            throw new RuntimeException(
529                "Attempting to set content element as $name when " . $arr[self::META_CONTENT] .
530                    ' is already set as the content element'
531            );
532        }
533        $arr[self::META_CONTENT] = $name;
534    }
535
536    /**
537     * Set the name of the content field name (META_CONTENT)
538     *
539     * @since 1.25
540     * @param array|string|null $path See ApiResult::addValue()
541     * @param string|int $name Name of the field
542     * @param int $flags Zero or more OR-ed flags like OVERRIDE | ADD_ON_TOP.
543     */
544    public function addContentField( $path, $name, $flags = 0 ) {
545        $arr = &$this->path( $path, ( $flags & self::ADD_ON_TOP ) ? 'prepend' : 'append' );
546        self::setContentField( $arr, $name, $flags );
547    }
548
549    /**
550     * Causes the elements with the specified names to be output as
551     * subelements rather than attributes.
552     * @since 1.25 is static
553     * @param array &$arr
554     * @param array|string|int $names The element name(s) to be output as subelements
555     */
556    public static function setSubelementsList( array &$arr, $names ) {
557        if ( !isset( $arr[self::META_SUBELEMENTS] ) ) {
558            $arr[self::META_SUBELEMENTS] = (array)$names;
559        } else {
560            $arr[self::META_SUBELEMENTS] = array_merge( $arr[self::META_SUBELEMENTS], (array)$names );
561        }
562    }
563
564    /**
565     * Causes the elements with the specified names to be output as
566     * subelements rather than attributes.
567     * @since 1.25
568     * @param array|string|null $path See ApiResult::addValue()
569     * @param array|string|int $names The element name(s) to be output as subelements
570     */
571    public function addSubelementsList( $path, $names ) {
572        $arr = &$this->path( $path );
573        self::setSubelementsList( $arr, $names );
574    }
575
576    /**
577     * Causes the elements with the specified names to be output as
578     * attributes (when possible) rather than as subelements.
579     * @since 1.25
580     * @param array &$arr
581     * @param array|string|int $names The element name(s) to not be output as subelements
582     */
583    public static function unsetSubelementsList( array &$arr, $names ) {
584        if ( isset( $arr[self::META_SUBELEMENTS] ) ) {
585            $arr[self::META_SUBELEMENTS] = array_diff( $arr[self::META_SUBELEMENTS], (array)$names );
586        }
587    }
588
589    /**
590     * Causes the elements with the specified names to be output as
591     * attributes (when possible) rather than as subelements.
592     * @since 1.25
593     * @param array|string|null $path See ApiResult::addValue()
594     * @param array|string|int $names The element name(s) to not be output as subelements
595     */
596    public function removeSubelementsList( $path, $names ) {
597        $arr = &$this->path( $path );
598        self::unsetSubelementsList( $arr, $names );
599    }
600
601    /**
602     * Set the tag name for numeric-keyed values in XML format
603     * @since 1.25 is static
604     * @param array &$arr
605     * @param string $tag Tag name
606     */
607    public static function setIndexedTagName( array &$arr, $tag ) {
608        if ( !is_string( $tag ) ) {
609            throw new InvalidArgumentException( 'Bad tag name' );
610        }
611        $arr[self::META_INDEXED_TAG_NAME] = $tag;
612    }
613
614    /**
615     * Set the tag name for numeric-keyed values in XML format
616     * @since 1.25
617     * @param array|string|null $path See ApiResult::addValue()
618     * @param string $tag Tag name
619     */
620    public function addIndexedTagName( $path, $tag ) {
621        $arr = &$this->path( $path );
622        self::setIndexedTagName( $arr, $tag );
623    }
624
625    /**
626     * Set indexed tag name on $arr and all subarrays
627     *
628     * @since 1.25
629     * @param array &$arr
630     * @param string $tag Tag name
631     */
632    public static function setIndexedTagNameRecursive( array &$arr, $tag ) {
633        if ( !is_string( $tag ) ) {
634            throw new InvalidArgumentException( 'Bad tag name' );
635        }
636        $arr[self::META_INDEXED_TAG_NAME] = $tag;
637        foreach ( $arr as $k => &$v ) {
638            if ( is_array( $v ) && !self::isMetadataKey( $k ) ) {
639                self::setIndexedTagNameRecursive( $v, $tag );
640            }
641        }
642    }
643
644    /**
645     * Set indexed tag name on $path and all subarrays
646     *
647     * @since 1.25
648     * @param array|string|null $path See ApiResult::addValue()
649     * @param string $tag Tag name
650     */
651    public function addIndexedTagNameRecursive( $path, $tag ) {
652        $arr = &$this->path( $path );
653        self::setIndexedTagNameRecursive( $arr, $tag );
654    }
655
656    /**
657     * Preserve specified keys.
658     *
659     * This prevents XML name mangling and preventing keys from being removed
660     * by self::stripMetadata().
661     *
662     * @since 1.25
663     * @param array &$arr
664     * @param array|string $names The element name(s) to preserve
665     */
666    public static function setPreserveKeysList( array &$arr, $names ) {
667        if ( !isset( $arr[self::META_PRESERVE_KEYS] ) ) {
668            $arr[self::META_PRESERVE_KEYS] = (array)$names;
669        } else {
670            $arr[self::META_PRESERVE_KEYS] = array_merge( $arr[self::META_PRESERVE_KEYS], (array)$names );
671        }
672    }
673
674    /**
675     * Preserve specified keys.
676     * @since 1.25
677     * @see self::setPreserveKeysList()
678     * @param array|string|null $path See ApiResult::addValue()
679     * @param array|string $names The element name(s) to preserve
680     */
681    public function addPreserveKeysList( $path, $names ) {
682        $arr = &$this->path( $path );
683        self::setPreserveKeysList( $arr, $names );
684    }
685
686    /**
687     * Don't preserve specified keys.
688     * @since 1.25
689     * @see self::setPreserveKeysList()
690     * @param array &$arr
691     * @param array|string $names The element name(s) to not preserve
692     */
693    public static function unsetPreserveKeysList( array &$arr, $names ) {
694        if ( isset( $arr[self::META_PRESERVE_KEYS] ) ) {
695            $arr[self::META_PRESERVE_KEYS] = array_diff( $arr[self::META_PRESERVE_KEYS], (array)$names );
696        }
697    }
698
699    /**
700     * Don't preserve specified keys.
701     * @since 1.25
702     * @see self::setPreserveKeysList()
703     * @param array|string|null $path See ApiResult::addValue()
704     * @param array|string $names The element name(s) to not preserve
705     */
706    public function removePreserveKeysList( $path, $names ) {
707        $arr = &$this->path( $path );
708        self::unsetPreserveKeysList( $arr, $names );
709    }
710
711    /**
712     * Set the array data type
713     *
714     * @since 1.25
715     * @param array &$arr
716     * @param string $type See ApiResult::META_TYPE
717     * @param string|null $kvpKeyName See ApiResult::META_KVP_KEY_NAME
718     */
719    public static function setArrayType( array &$arr, $type, $kvpKeyName = null ) {
720        if ( !in_array( $type, [
721                'default', 'array', 'assoc', 'kvp', 'BCarray', 'BCassoc', 'BCkvp'
722                ], true ) ) {
723            throw new InvalidArgumentException( 'Bad type' );
724        }
725        $arr[self::META_TYPE] = $type;
726        if ( is_string( $kvpKeyName ) ) {
727            $arr[self::META_KVP_KEY_NAME] = $kvpKeyName;
728        }
729    }
730
731    /**
732     * Set the array data type for a path
733     * @since 1.25
734     * @param array|string|null $path See ApiResult::addValue()
735     * @param string $tag See ApiResult::META_TYPE
736     * @param string|null $kvpKeyName See ApiResult::META_KVP_KEY_NAME
737     */
738    public function addArrayType( $path, $tag, $kvpKeyName = null ) {
739        $arr = &$this->path( $path );
740        self::setArrayType( $arr, $tag, $kvpKeyName );
741    }
742
743    /**
744     * Set the array data type recursively
745     * @since 1.25
746     * @param array &$arr
747     * @param string $type See ApiResult::META_TYPE
748     * @param string|null $kvpKeyName See ApiResult::META_KVP_KEY_NAME
749     */
750    public static function setArrayTypeRecursive( array &$arr, $type, $kvpKeyName = null ) {
751        self::setArrayType( $arr, $type, $kvpKeyName );
752        foreach ( $arr as $k => &$v ) {
753            if ( is_array( $v ) && !self::isMetadataKey( $k ) ) {
754                self::setArrayTypeRecursive( $v, $type, $kvpKeyName );
755            }
756        }
757    }
758
759    /**
760     * Set the array data type for a path recursively
761     * @since 1.25
762     * @param array|string|null $path See ApiResult::addValue()
763     * @param string $tag See ApiResult::META_TYPE
764     * @param string|null $kvpKeyName See ApiResult::META_KVP_KEY_NAME
765     */
766    public function addArrayTypeRecursive( $path, $tag, $kvpKeyName = null ) {
767        $arr = &$this->path( $path );
768        self::setArrayTypeRecursive( $arr, $tag, $kvpKeyName );
769    }
770
771    // endregion -- end of Metadata
772
773    /***************************************************************************/
774    // region   Utility
775    /** @name   Utility */
776
777    /**
778     * Test whether a key should be considered metadata
779     *
780     * @param string|int $key
781     * @return bool
782     */
783    public static function isMetadataKey( $key ) {
784        // Optimization: This is a very hot and highly optimized code path. Note that ord() only
785        // considers the first character and also works with empty strings and integers.
786        // 95 corresponds to the '_' character.
787        return ord( $key ) === 95;
788    }
789
790    /**
791     * Apply transformations to an array, returning the transformed array.
792     *
793     * @see ApiResult::getResultData()
794     * @since 1.25
795     * @param array $dataIn
796     * @param array $transforms
797     * @return array|stdClass
798     */
799    protected static function applyTransformations( array $dataIn, array $transforms ) {
800        $strip = $transforms['Strip'] ?? 'none';
801        if ( $strip === 'base' ) {
802            $transforms['Strip'] = 'none';
803        }
804        $transformTypes = $transforms['Types'] ?? null;
805        if ( $transformTypes !== null && !is_array( $transformTypes ) ) {
806            throw new InvalidArgumentException( __METHOD__ . ':Value for "Types" must be an array' );
807        }
808
809        $metadata = [];
810        $data = self::stripMetadataNonRecursive( $dataIn, $metadata );
811
812        if ( isset( $transforms['Custom'] ) ) {
813            if ( !is_callable( $transforms['Custom'] ) ) {
814                throw new InvalidArgumentException( __METHOD__ . ': Value for "Custom" must be callable' );
815            }
816            call_user_func_array( $transforms['Custom'], [ &$data, &$metadata ] );
817        }
818
819        if ( ( isset( $transforms['BC'] ) || $transformTypes !== null ) &&
820            isset( $metadata[self::META_TYPE] ) && $metadata[self::META_TYPE] === 'BCkvp' &&
821            !isset( $metadata[self::META_KVP_KEY_NAME] )
822        ) {
823            throw new UnexpectedValueException( 'Type "BCkvp" used without setting ' .
824                'ApiResult::META_KVP_KEY_NAME metadata item' );
825        }
826
827        // BC transformations
828        $boolKeys = null;
829        if ( isset( $transforms['BC'] ) ) {
830            if ( !is_array( $transforms['BC'] ) ) {
831                throw new InvalidArgumentException( __METHOD__ . ':Value for "BC" must be an array' );
832            }
833            if ( !in_array( 'nobool', $transforms['BC'], true ) ) {
834                $boolKeys = isset( $metadata[self::META_BC_BOOLS] )
835                    ? array_fill_keys( $metadata[self::META_BC_BOOLS], true )
836                    : [];
837            }
838
839            if ( !in_array( 'no*', $transforms['BC'], true ) &&
840                isset( $metadata[self::META_CONTENT] ) && $metadata[self::META_CONTENT] !== '*'
841            ) {
842                $k = $metadata[self::META_CONTENT];
843                $data['*'] = $data[$k];
844                unset( $data[$k] );
845                $metadata[self::META_CONTENT] = '*';
846            }
847
848            if ( !in_array( 'nosub', $transforms['BC'], true ) &&
849                isset( $metadata[self::META_BC_SUBELEMENTS] )
850            ) {
851                foreach ( $metadata[self::META_BC_SUBELEMENTS] as $k ) {
852                    if ( isset( $data[$k] ) ) {
853                        $data[$k] = [
854                            '*' => $data[$k],
855                            self::META_CONTENT => '*',
856                            self::META_TYPE => 'assoc',
857                        ];
858                    }
859                }
860            }
861
862            if ( isset( $metadata[self::META_TYPE] ) ) {
863                switch ( $metadata[self::META_TYPE] ) {
864                    case 'BCarray':
865                    case 'BCassoc':
866                        $metadata[self::META_TYPE] = 'default';
867                        break;
868                    case 'BCkvp':
869                        $transformTypes['ArmorKVP'] = $metadata[self::META_KVP_KEY_NAME];
870                        break;
871                }
872            }
873        }
874
875        // Figure out type, do recursive calls, and do boolean transform if necessary
876        $defaultType = 'array';
877        $maxKey = -1;
878        foreach ( $data as $k => &$v ) {
879            $v = is_array( $v ) ? self::applyTransformations( $v, $transforms ) : $v;
880            if ( $boolKeys !== null && is_bool( $v ) && !isset( $boolKeys[$k] ) ) {
881                if ( !$v ) {
882                    unset( $data[$k] );
883                    continue;
884                }
885                $v = '';
886            }
887            if ( is_string( $k ) ) {
888                $defaultType = 'assoc';
889            } elseif ( $k > $maxKey ) {
890                $maxKey = $k;
891            }
892        }
893        unset( $v );
894
895        // Determine which metadata to keep
896        switch ( $strip ) {
897            case 'all':
898            case 'base':
899                $keepMetadata = [];
900                break;
901            case 'none':
902                $keepMetadata = &$metadata;
903                break;
904            case 'bc':
905                // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal Type mismatch on pass-by-ref args
906                $keepMetadata = array_intersect_key( $metadata, [
907                    self::META_INDEXED_TAG_NAME => 1,
908                    self::META_SUBELEMENTS => 1,
909                ] );
910                break;
911            default:
912                throw new InvalidArgumentException( __METHOD__ . ': Unknown value for "Strip"' );
913        }
914
915        // No type transformation
916        if ( $transformTypes === null ) {
917            return $data + $keepMetadata;
918        }
919
920        if ( $defaultType === 'array' && $maxKey !== count( $data ) - 1 ) {
921            $defaultType = 'assoc';
922        }
923
924        // Override type, if provided
925        $type = $defaultType;
926        if ( isset( $metadata[self::META_TYPE] ) && $metadata[self::META_TYPE] !== 'default' ) {
927            $type = $metadata[self::META_TYPE];
928        }
929        if ( ( $type === 'kvp' || $type === 'BCkvp' ) &&
930            empty( $transformTypes['ArmorKVP'] )
931        ) {
932            $type = 'assoc';
933        } elseif ( $type === 'BCarray' ) {
934            $type = 'array';
935        } elseif ( $type === 'BCassoc' ) {
936            $type = 'assoc';
937        }
938
939        // Apply transformation
940        switch ( $type ) {
941            case 'assoc':
942                $metadata[self::META_TYPE] = 'assoc';
943                $data += $keepMetadata;
944                return empty( $transformTypes['AssocAsObject'] ) ? $data : (object)$data;
945
946            case 'array':
947                // Sort items in ascending order by key. Note that $data may contain a mix of number and string keys,
948                // for which the sorting behavior of krsort() with SORT_REGULAR is inconsistent between PHP versions.
949                // Given a comparison of a string key and a number key, PHP < 8.2 coerces the string key into a number
950                // (which yields zero if the string was non-numeric), and then performs the comparison,
951                // while PHP >= 8.2 makes the behavior consistent with stricter numeric comparisons introduced by
952                // PHP 8.0 in that if the string key is non-numeric, it converts the number key into a string
953                // and compares those two strings instead. We therefore use a custom comparison function
954                // implementing PHP >= 8.2 ordering semantics to ensure consistent ordering of items
955                // irrespective of the PHP version (T326480).
956                uksort( $data, static function ( $a, $b ): int {
957                    // In a comparison of a number or numeric string with a non-numeric string,
958                    // coerce both values into a string prior to comparing and compare the resulting strings.
959                    // Note that PHP prior to 8.0 did not consider numeric strings with trailing whitespace
960                    // to be numeric, so trim the inputs prior to the numeric checks to make the behavior
961                    // consistent across PHP versions.
962                    if ( is_numeric( trim( $a ) ) xor is_numeric( trim( $b ) ) ) {
963                        return (string)$a <=> (string)$b;
964                    }
965
966                    return $a <=> $b;
967                } );
968
969                $data = array_values( $data );
970                $metadata[self::META_TYPE] = 'array';
971                // @phan-suppress-next-line PhanTypeMismatchReturnNullable Type mismatch on pass-by-ref args
972                return $data + $keepMetadata;
973
974            case 'kvp':
975            case 'BCkvp':
976                $key = $metadata[self::META_KVP_KEY_NAME] ?? $transformTypes['ArmorKVP'];
977                $valKey = isset( $transforms['BC'] ) ? '*' : 'value';
978                $assocAsObject = !empty( $transformTypes['AssocAsObject'] );
979                $merge = !empty( $metadata[self::META_KVP_MERGE] );
980
981                $ret = [];
982                foreach ( $data as $k => $v ) {
983                    if ( $merge && ( is_array( $v ) || is_object( $v ) ) ) {
984                        $vArr = (array)$v;
985                        if ( isset( $vArr[self::META_TYPE] ) ) {
986                            $mergeType = $vArr[self::META_TYPE];
987                        } elseif ( is_object( $v ) ) {
988                            $mergeType = 'assoc';
989                        } else {
990                            $keys = array_keys( $vArr );
991                            sort( $keys, SORT_NUMERIC );
992                            $mergeType = ( $keys === array_keys( $keys ) ) ? 'array' : 'assoc';
993                        }
994                    } else {
995                        $mergeType = 'n/a';
996                    }
997                    if ( $mergeType === 'assoc' ) {
998                        // @phan-suppress-next-line PhanPossiblyUndeclaredVariable vArr set when used
999                        $item = $vArr + [
1000                            $key => $k,
1001                        ];
1002                        if ( $strip === 'none' ) {
1003                            self::setPreserveKeysList( $item, [ $key ] );
1004                        }
1005                    } else {
1006                        $item = [
1007                            $key => $k,
1008                            $valKey => $v,
1009                        ];
1010                        if ( $strip === 'none' ) {
1011                            $item += [
1012                                self::META_PRESERVE_KEYS => [ $key ],
1013                                self::META_CONTENT => $valKey,
1014                                self::META_TYPE => 'assoc',
1015                            ];
1016                        }
1017                    }
1018                    $ret[] = $assocAsObject ? (object)$item : $item;
1019                }
1020                $metadata[self::META_TYPE] = 'array';
1021
1022                // @phan-suppress-next-line PhanTypeMismatchReturnNullable Type mismatch on pass-by-ref args
1023                return $ret + $keepMetadata;
1024
1025            default:
1026                throw new UnexpectedValueException( "Unknown type '$type'" );
1027        }
1028    }
1029
1030    /**
1031     * Recursively remove metadata keys from a data array or object
1032     *
1033     * Note this removes all potential metadata keys, not just the defined
1034     * ones.
1035     *
1036     * @since 1.25
1037     * @param array|stdClass $data
1038     * @return array|stdClass
1039     */
1040    public static function stripMetadata( $data ) {
1041        if ( is_array( $data ) || is_object( $data ) ) {
1042            $isObj = is_object( $data );
1043            if ( $isObj ) {
1044                $data = (array)$data;
1045            }
1046            $preserveKeys = isset( $data[self::META_PRESERVE_KEYS] )
1047                ? (array)$data[self::META_PRESERVE_KEYS]
1048                : [];
1049            foreach ( $data as $k => $v ) {
1050                if ( self::isMetadataKey( $k ) && !in_array( $k, $preserveKeys, true ) ) {
1051                    unset( $data[$k] );
1052                } elseif ( is_array( $v ) || is_object( $v ) ) {
1053                    $data[$k] = self::stripMetadata( $v );
1054                }
1055            }
1056            if ( $isObj ) {
1057                $data = (object)$data;
1058            }
1059        }
1060        return $data;
1061    }
1062
1063    /**
1064     * Remove metadata keys from a data array or object, non-recursive
1065     *
1066     * Note this removes all potential metadata keys, not just the defined
1067     * ones.
1068     *
1069     * @since 1.25
1070     * @param array|stdClass $data
1071     * @param array|null &$metadata Store metadata here, if provided
1072     * @return array|stdClass
1073     */
1074    public static function stripMetadataNonRecursive( $data, &$metadata = null ) {
1075        if ( !is_array( $metadata ) ) {
1076            $metadata = [];
1077        }
1078        if ( is_array( $data ) || is_object( $data ) ) {
1079            $isObj = is_object( $data );
1080            if ( $isObj ) {
1081                $data = (array)$data;
1082            }
1083            $preserveKeys = isset( $data[self::META_PRESERVE_KEYS] )
1084                ? (array)$data[self::META_PRESERVE_KEYS]
1085                : [];
1086            foreach ( $data as $k => $v ) {
1087                if ( self::isMetadataKey( $k ) && !in_array( $k, $preserveKeys, true ) ) {
1088                    $metadata[$k] = $v;
1089                    unset( $data[$k] );
1090                }
1091            }
1092            if ( $isObj ) {
1093                $data = (object)$data;
1094            }
1095        }
1096        return $data;
1097    }
1098
1099    /**
1100     * Get the 'real' size of a result item. This means the strlen() of the item,
1101     * or the sum of the strlen()s of the elements if the item is an array.
1102     * @param mixed $value Validated value (see self::validateValue())
1103     * @return int
1104     */
1105    private static function size( $value ) {
1106        $s = 0;
1107        if ( is_array( $value ) ) {
1108            foreach ( $value as $k => $v ) {
1109                if ( !self::isMetadataKey( $k ) ) {
1110                    $s += self::size( $v );
1111                }
1112            }
1113        } elseif ( is_scalar( $value ) ) {
1114            $s = strlen( $value );
1115        }
1116
1117        return $s;
1118    }
1119
1120    /**
1121     * Return a reference to the internal data at $path
1122     *
1123     * @param array|string|null $path
1124     * @param string $create
1125     *   If 'append', append empty arrays.
1126     *   If 'prepend', prepend empty arrays.
1127     *   If 'dummy', return a dummy array.
1128     *   Else, raise an error.
1129     * @return array
1130     */
1131    private function &path( $path, $create = 'append' ) {
1132        $path = (array)$path;
1133        $ret = &$this->data;
1134        foreach ( $path as $i => $k ) {
1135            if ( !isset( $ret[$k] ) ) {
1136                switch ( $create ) {
1137                    case 'append':
1138                        $ret[$k] = [];
1139                        break;
1140                    case 'prepend':
1141                        $ret = [ $k => [] ] + $ret;
1142                        break;
1143                    case 'dummy':
1144                        $tmp = [];
1145                        return $tmp;
1146                    default:
1147                        $fail = implode( '.', array_slice( $path, 0, $i + 1 ) );
1148                        throw new InvalidArgumentException( "Path $fail does not exist" );
1149                }
1150            }
1151            if ( !is_array( $ret[$k] ) ) {
1152                $fail = implode( '.', array_slice( $path, 0, $i + 1 ) );
1153                throw new InvalidArgumentException( "Path $fail is not an array" );
1154            }
1155            $ret = &$ret[$k];
1156        }
1157        return $ret;
1158    }
1159
1160    /**
1161     * Add the correct metadata to an array of vars we want to export through
1162     * the API.
1163     *
1164     * @param array $vars
1165     * @param bool $forceHash
1166     * @return array
1167     */
1168    public static function addMetadataToResultVars( $vars, $forceHash = true ) {
1169        // Process subarrays and determine if this is a JS [] or {}
1170        $hash = $forceHash;
1171        $maxKey = -1;
1172        $bools = [];
1173        foreach ( $vars as $k => $v ) {
1174            if ( is_array( $v ) || is_object( $v ) ) {
1175                $vars[$k] = self::addMetadataToResultVars( (array)$v, is_object( $v ) );
1176            } elseif ( is_bool( $v ) ) {
1177                // Better here to use real bools even in BC formats
1178                $bools[] = $k;
1179            }
1180            if ( is_string( $k ) ) {
1181                $hash = true;
1182            } elseif ( $k > $maxKey ) {
1183                $maxKey = $k;
1184            }
1185        }
1186        if ( !$hash && $maxKey !== count( $vars ) - 1 ) {
1187            $hash = true;
1188        }
1189
1190        // Set metadata appropriately
1191        if ( $hash ) {
1192            // Get the list of keys we actually care about. Unfortunately, we can't support
1193            // certain keys that conflict with ApiResult metadata.
1194            $keys = array_diff( array_keys( $vars ), [
1195                self::META_TYPE, self::META_PRESERVE_KEYS, self::META_KVP_KEY_NAME,
1196                self::META_INDEXED_TAG_NAME, self::META_BC_BOOLS
1197            ] );
1198
1199            return [
1200                self::META_TYPE => 'kvp',
1201                self::META_KVP_KEY_NAME => 'key',
1202                self::META_PRESERVE_KEYS => $keys,
1203                self::META_BC_BOOLS => $bools,
1204                self::META_INDEXED_TAG_NAME => 'var',
1205            ] + $vars;
1206        } else {
1207            return [
1208                self::META_TYPE => 'array',
1209                self::META_BC_BOOLS => $bools,
1210                self::META_INDEXED_TAG_NAME => 'value',
1211            ] + $vars;
1212        }
1213    }
1214
1215    /**
1216     * Format an expiry timestamp for API output
1217     * @since 1.29
1218     * @param string $expiry Expiry timestamp, likely from the database
1219     * @param string $infinity Use this string for infinite expiry
1220     *  (only use this to maintain backward compatibility with existing output)
1221     * @return string Formatted expiry
1222     */
1223    public static function formatExpiry( $expiry, $infinity = 'infinity' ) {
1224        static $dbInfinity;
1225        $dbInfinity ??= MediaWikiServices::getInstance()->getConnectionProvider()
1226            ->getReplicaDatabase()
1227            ->getInfinity();
1228
1229        if ( $expiry === '' || $expiry === null || $expiry === false ||
1230            wfIsInfinity( $expiry ) || $expiry === $dbInfinity
1231        ) {
1232            return $infinity;
1233        } else {
1234            return wfTimestamp( TS_ISO_8601, $expiry );
1235        }
1236    }
1237
1238    // endregion -- end of Utility
1239
1240}
1241
1242/*
1243 * This file uses VisualStudio style region/endregion fold markers which are
1244 * recognised by PHPStorm. If modelines are enabled, the following editor
1245 * configuration will also enable folding in vim, if it is in the last 5 lines
1246 * of the file. We also use "@name" which creates sections in Doxygen.
1247 *
1248 * vim: foldmarker=//\ region,//\ endregion foldmethod=marker
1249 */