Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
333 / 333
100.00% covered (success)
100.00%
16 / 16
CRAP
100.00% covered (success)
100.00%
1 / 1
ParamValidator
100.00% covered (success)
100.00%
333 / 333
100.00% covered (success)
100.00%
16 / 16
112
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 knownTypes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addTypeDefs
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 addTypeDef
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 overrideTypeDef
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 hasTypeDef
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTypeDef
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 normalizeSettingsInternal
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 normalizeSettings
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 checkSettings
100.00% covered (success)
100.00%
76 / 76
100.00% covered (success)
100.00%
1 / 1
26
 getValue
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
6
 validateValue
100.00% covered (success)
100.00%
91 / 91
100.00% covered (success)
100.00%
1 / 1
27
 getParamInfo
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
11
 getHelpInfo
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
1 / 1
16
 explodeMultiValue
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 implodeMultiValue
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3namespace Wikimedia\ParamValidator;
4
5use DomainException;
6use InvalidArgumentException;
7use Wikimedia\Assert\Assert;
8use Wikimedia\Message\DataMessageValue;
9use Wikimedia\Message\MessageValue;
10use Wikimedia\Message\ParamType;
11use Wikimedia\Message\ScalarParam;
12use Wikimedia\ObjectFactory\ObjectFactory;
13
14/**
15 * Service for formatting and validating API parameters
16 *
17 * A settings array is simply an array with keys being the relevant PARAM_*
18 * constants from this class, TypeDef, and its subclasses.
19 *
20 * As a general overview of the architecture here:
21 *  - ParamValidator handles some general validation of the parameter,
22 *    then hands off to a TypeDef subclass to validate the specific representation
23 *    based on the parameter's type.
24 *  - TypeDef subclasses handle conversion between the string representation
25 *    submitted by the client and the output PHP data types, validating that the
26 *    strings are valid representations of the intended type as they do so.
27 *  - ValidationException is used to report fatal errors in the validation back
28 *    to the caller, since the return value represents the successful result of
29 *    the validation and might be any type or class.
30 *  - The Callbacks interface allows ParamValidator to reach out and fetch data
31 *    it needs to perform the validation. Currently that includes:
32 *    - Fetching the value of the parameter being validated (largely since a generic
33 *      caller cannot know whether it needs to fetch a string from $_GET/$_POST or
34 *      an array from $_FILES).
35 *    - Reporting of non-fatal warnings back to the caller.
36 *    - Fetching the "high limits" flag when necessary, to avoid the need for loading
37 *      the user unnecessarily.
38 *
39 * @since 1.34
40 * @unstable
41 */
42class ParamValidator {
43
44    // region    Constants for parameter settings arrays
45    /** @name    Constants for parameter settings arrays
46     * These constants are keys in the settings array that define how the
47     * parameters coming in from the request are to be interpreted.
48     *
49     * If a constant is associated with a failure code, the failure code
50     * and data are described. ValidationExceptions are typically thrown, but
51     * those indicated as "non-fatal" are instead passed to
52     * Callbacks::recordCondition().
53     *
54     * Additional constants may be defined by TypeDef subclasses, or by other
55     * libraries for controlling things like auto-generated parameter documentation.
56     * For purposes of namespacing the constants, the values of all constants
57     * defined by this library begin with 'param-'.
58     *
59     * @{
60     */
61
62    /**
63     * (mixed) Default value of the parameter. If omitted, null is the default.
64     *
65     * TypeDef::validate() will be informed when the default value was used by the presence of
66     * 'is-default' in $options.
67     */
68    public const PARAM_DEFAULT = 'param-default';
69
70    /**
71     * (string|array) Type of the parameter.
72     * Must be a registered type or an array of enumerated values (in which case the "enum"
73     * type must be registered). If omitted, the default is the PHP type of the default value
74     * (see PARAM_DEFAULT).
75     */
76    public const PARAM_TYPE = 'param-type';
77
78    /**
79     * (bool) Indicate that the parameter is required.
80     *
81     * Failure codes:
82     *  - 'missingparam': The parameter is omitted/empty (and no default was set). No data.
83     */
84    public const PARAM_REQUIRED = 'param-required';
85
86    /**
87     * (bool) Indicate that the parameter is multi-valued.
88     *
89     * A multi-valued parameter may be submitted in one of several formats. All
90     * of the following result in a value of `[ 'a', 'b', 'c' ]`.
91     *  - "a|b|c", i.e. pipe-separated.
92     *  - "\x1Fa\x1Fb\x1Fc", i.e. separated by U+001F, with a signalling U+001F at the start.
93     *  - As a string[], e.g. from a query string like "foo[]=a&foo[]=b&foo[]=c".
94     *
95     * Each of the multiple values is passed individually to the TypeDef.
96     * $options will contain a 'values-list' key holding the entire list.
97     *
98     * By default duplicates are removed from the resulting parameter list. Use
99     * PARAM_ALLOW_DUPLICATES to override that behavior.
100     *
101     * Failure codes:
102     *  - 'toomanyvalues': More values were supplied than are allowed. See
103     *    PARAM_ISMULTI_LIMIT1, PARAM_ISMULTI_LIMIT2, and constructor option
104     *    'ismultiLimits'. Data:
105     *     - 'limit': The limit currently in effect.
106     *     - 'lowlimit': The limit when high limits are not allowed.
107     *     - 'highlimit': The limit when high limits are allowed.
108     *  - 'unrecognizedvalues': Non-fatal. Invalid values were passed and
109     *    PARAM_IGNORE_UNRECOGNIZED_VALUES was set. Data:
110     *     - 'values': The unrecognized values.
111     */
112    public const PARAM_ISMULTI = 'param-ismulti';
113
114    /**
115     * (int) Maximum number of multi-valued parameter values allowed
116     *
117     * @see PARAM_ISMULTI
118     */
119    public const PARAM_ISMULTI_LIMIT1 = 'param-ismulti-limit1';
120
121    /**
122     * (int) Maximum number of multi-valued parameter values allowed for users
123     * allowed high limits.
124     *
125     * @see PARAM_ISMULTI
126     */
127    public const PARAM_ISMULTI_LIMIT2 = 'param-ismulti-limit2';
128
129    /**
130     * (bool|string) Whether a magic "all values" value exists for multi-valued
131     * enumerated types, and if so what that value is.
132     *
133     * When PARAM_TYPE has a defined set of values and PARAM_ISMULTI is true,
134     * this allows for an asterisk ('*') to be passed in place of a pipe-separated list of
135     * every possible value. If a string is set, it will be used in place of the asterisk.
136     */
137    public const PARAM_ALL = 'param-all';
138
139    /**
140     * (bool) Allow the same value to be set more than once when PARAM_ISMULTI is true?
141     *
142     * If not truthy, the set of values will be passed through
143     * `array_values( array_unique() )`. The default is falsey.
144     */
145    public const PARAM_ALLOW_DUPLICATES = 'param-allow-duplicates';
146
147    /**
148     * (bool) Indicate that the parameter's value should not be logged.
149     *
150     * Failure codes: (non-fatal)
151     *  - 'param-sensitive': Always recorded when the parameter is used.
152     */
153    public const PARAM_SENSITIVE = 'param-sensitive';
154
155    /**
156     * (bool) Indicate that a deprecated parameter was used.
157     *
158     * Failure codes: (non-fatal)
159     *  - 'param-deprecated': Always recorded when the parameter is used.
160     */
161    public const PARAM_DEPRECATED = 'param-deprecated';
162
163    /**
164     * (bool) Whether to downgrade "badvalue" errors to non-fatal when validating multi-valued
165     * parameters.
166     * @see PARAM_ISMULTI
167     */
168    public const PARAM_IGNORE_UNRECOGNIZED_VALUES = 'param-ignore-unrecognized-values';
169
170    /** @} */
171    // endregion -- end of Constants for parameter settings arrays
172
173    /**
174     * @see TypeDef::OPT_ENFORCE_JSON_TYPES
175     */
176    public const OPT_ENFORCE_JSON_TYPES = TypeDef::OPT_ENFORCE_JSON_TYPES;
177
178    /** Magic "all values" value when PARAM_ALL is true. */
179    public const ALL_DEFAULT_STRING = '*';
180
181    /** A list of standard type names and types that may be passed as `$typeDefs` to __construct(). */
182    public const STANDARD_TYPES = [
183        'boolean' => [ 'class' => TypeDef\BooleanDef::class ],
184        'checkbox' => [ 'class' => TypeDef\PresenceBooleanDef::class ],
185        'integer' => [ 'class' => TypeDef\IntegerDef::class ],
186        'limit' => [ 'class' => TypeDef\LimitDef::class ],
187        'float' => [ 'class' => TypeDef\FloatDef::class ],
188        'double' => [ 'class' => TypeDef\FloatDef::class ],
189        'string' => [ 'class' => TypeDef\StringDef::class ],
190        'password' => [ 'class' => TypeDef\PasswordDef::class ],
191        'NULL' => [
192            'class' => TypeDef\StringDef::class,
193            'args' => [ [
194                TypeDef\StringDef::OPT_ALLOW_EMPTY => true,
195            ] ],
196        ],
197        'timestamp' => [ 'class' => TypeDef\TimestampDef::class ],
198        'upload' => [ 'class' => TypeDef\UploadDef::class ],
199        'enum' => [ 'class' => TypeDef\EnumDef::class ],
200        'expiry' => [ 'class' => TypeDef\ExpiryDef::class ],
201    ];
202
203    /** @var Callbacks */
204    private $callbacks;
205
206    /** @var ObjectFactory */
207    private $objectFactory;
208
209    /** @var (TypeDef|array)[] Map parameter type names to TypeDef objects or ObjectFactory specs */
210    private $typeDefs = [];
211
212    /** @var int Default values for PARAM_ISMULTI_LIMIT1 */
213    private $ismultiLimit1;
214
215    /** @var int Default values for PARAM_ISMULTI_LIMIT2 */
216    private $ismultiLimit2;
217
218    /**
219     * @param Callbacks $callbacks
220     * @param ObjectFactory $objectFactory To turn specs into TypeDef objects
221     * @param array $options Associative array of additional settings
222     *  - 'typeDefs': (array) As for addTypeDefs(). If omitted, self::STANDARD_TYPES will be used.
223     *    Pass an empty array if you want to start with no registered types.
224     *  - 'ismultiLimits': (int[]) Two ints, being the default values for PARAM_ISMULTI_LIMIT1 and
225     *    PARAM_ISMULTI_LIMIT2. If not given, defaults to `[ 50, 500 ]`.
226     */
227    public function __construct(
228        Callbacks $callbacks,
229        ObjectFactory $objectFactory,
230        array $options = []
231    ) {
232        $this->callbacks = $callbacks;
233        $this->objectFactory = $objectFactory;
234
235        $this->addTypeDefs( $options['typeDefs'] ?? self::STANDARD_TYPES );
236        $this->ismultiLimit1 = $options['ismultiLimits'][0] ?? 50;
237        $this->ismultiLimit2 = $options['ismultiLimits'][1] ?? 500;
238    }
239
240    /**
241     * List known type names
242     * @return string[]
243     */
244    public function knownTypes() {
245        return array_keys( $this->typeDefs );
246    }
247
248    /**
249     * Register multiple type handlers
250     *
251     * @see addTypeDef()
252     * @param array $typeDefs Associative array mapping `$name` to `$typeDef`.
253     */
254    public function addTypeDefs( array $typeDefs ) {
255        foreach ( $typeDefs as $name => $def ) {
256            $this->addTypeDef( $name, $def );
257        }
258    }
259
260    /**
261     * Register a type handler
262     *
263     * To allow code to omit PARAM_TYPE in settings arrays to derive the type
264     * from PARAM_DEFAULT, it is strongly recommended that the following types be
265     * registered: "boolean", "integer", "double", "string", "NULL", and "enum".
266     *
267     * When using ObjectFactory specs, the following extra arguments are passed:
268     * - The Callbacks object for this ParamValidator instance.
269     *
270     * @param string $name Type name
271     * @param TypeDef|array $typeDef Type handler or ObjectFactory spec to create one.
272     */
273    public function addTypeDef( $name, $typeDef ) {
274        Assert::parameterType(
275            [ TypeDef::class, 'array' ],
276            $typeDef,
277            '$typeDef'
278        );
279
280        if ( isset( $this->typeDefs[$name] ) ) {
281            throw new InvalidArgumentException( "Type '$name' is already registered" );
282        }
283        $this->typeDefs[$name] = $typeDef;
284    }
285
286    /**
287     * Register a type handler, overriding any existing handler
288     * @see addTypeDef
289     * @param string $name Type name
290     * @param TypeDef|array|null $typeDef As for addTypeDef, or null to unregister a type.
291     */
292    public function overrideTypeDef( $name, $typeDef ) {
293        Assert::parameterType(
294            [ TypeDef::class, 'array', 'null' ],
295            $typeDef,
296            '$typeDef'
297        );
298
299        if ( $typeDef === null ) {
300            unset( $this->typeDefs[$name] );
301        } else {
302            $this->typeDefs[$name] = $typeDef;
303        }
304    }
305
306    /**
307     * Test if a type is registered
308     * @param string $name Type name
309     * @return bool
310     */
311    public function hasTypeDef( $name ) {
312        return isset( $this->typeDefs[$name] );
313    }
314
315    /**
316     * Get the TypeDef for a type
317     * @param string|array $type Any array is considered equivalent to the string "enum".
318     * @return TypeDef|null
319     */
320    public function getTypeDef( $type ) {
321        if ( is_array( $type ) ) {
322            $type = 'enum';
323        }
324
325        if ( !isset( $this->typeDefs[$type] ) ) {
326            return null;
327        }
328
329        $def = $this->typeDefs[$type];
330        if ( !$def instanceof TypeDef ) {
331            $def = $this->objectFactory->createObject( $def, [
332                'extraArgs' => [ $this->callbacks ],
333                'assertClass' => TypeDef::class,
334            ] );
335            $this->typeDefs[$type] = $def;
336        }
337
338        return $def;
339    }
340
341    /**
342     * Logic shared by normalizeSettings() and checkSettings()
343     * @param array|mixed $settings
344     * @return array
345     */
346    private function normalizeSettingsInternal( $settings ) {
347        // Shorthand
348        if ( !is_array( $settings ) ) {
349            $settings = [
350                self::PARAM_DEFAULT => $settings,
351            ];
352        }
353
354        // When type is not given, determine it from the type of the PARAM_DEFAULT
355        if ( !isset( $settings[self::PARAM_TYPE] ) ) {
356            $settings[self::PARAM_TYPE] = gettype( $settings[self::PARAM_DEFAULT] ?? null );
357        }
358
359        return $settings;
360    }
361
362    /**
363     * Normalize a parameter settings array
364     * @param array|mixed $settings Default value or an array of settings
365     *  using PARAM_* constants.
366     * @return array
367     */
368    public function normalizeSettings( $settings ) {
369        $settings = $this->normalizeSettingsInternal( $settings );
370
371        $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] );
372        if ( $typeDef ) {
373            $settings = $typeDef->normalizeSettings( $settings );
374        }
375
376        return $settings;
377    }
378
379    /**
380     * Validate a parameter settings array
381     *
382     * This is intended for validation of parameter settings during unit or
383     * integration testing, and should implement strict checks.
384     *
385     * The rest of the code should generally be more permissive.
386     *
387     * @param string $name Parameter name
388     * @param array|mixed $settings Default value or an array of settings
389     *  using PARAM_* constants.
390     * @param array $options Options array, passed through to the TypeDef and Callbacks.
391     * @return array
392     *  - 'issues': (string[]) Errors detected in $settings, as English text. If the settings
393     *    are valid, this will be the empty array.
394     *  - 'allowedKeys': (string[]) ParamValidator keys that are allowed in `$settings`.
395     *  - 'messages': (MessageValue[]) Messages to be checked for existence.
396     */
397    public function checkSettings( string $name, $settings, array $options ): array {
398        $settings = $this->normalizeSettingsInternal( $settings );
399        $issues = [];
400        $allowedKeys = [
401            self::PARAM_TYPE, self::PARAM_DEFAULT, self::PARAM_REQUIRED, self::PARAM_ISMULTI,
402            self::PARAM_SENSITIVE, self::PARAM_DEPRECATED, self::PARAM_IGNORE_UNRECOGNIZED_VALUES,
403        ];
404        $messages = [];
405
406        $type = $settings[self::PARAM_TYPE];
407        $typeDef = null;
408        if ( !is_string( $type ) && !is_array( $type ) ) {
409            $issues[self::PARAM_TYPE] = 'PARAM_TYPE must be a string or array, got ' . gettype( $type );
410        } else {
411            $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] );
412            if ( !$typeDef ) {
413                if ( is_array( $type ) ) {
414                    $type = 'enum';
415                }
416                $issues[self::PARAM_TYPE] = "Unknown/unregistered PARAM_TYPE \"$type\"";
417            }
418        }
419
420        if ( isset( $settings[self::PARAM_DEFAULT] ) ) {
421            try {
422                $this->validateValue(
423                    $name, $settings[self::PARAM_DEFAULT], $settings, [ 'is-default' => true ] + $options
424                );
425            } catch ( ValidationException $ex ) {
426                $issues[self::PARAM_DEFAULT] = 'Value for PARAM_DEFAULT does not validate (code '
427                    . $ex->getFailureMessage()->getCode() . ')';
428            }
429        }
430
431        if ( !is_bool( $settings[self::PARAM_REQUIRED] ?? false ) ) {
432            $issues[self::PARAM_REQUIRED] = 'PARAM_REQUIRED must be boolean, got '
433                . gettype( $settings[self::PARAM_REQUIRED] );
434        }
435
436        if ( !is_bool( $settings[self::PARAM_ISMULTI] ?? false ) ) {
437            $issues[self::PARAM_ISMULTI] = 'PARAM_ISMULTI must be boolean, got '
438                . gettype( $settings[self::PARAM_ISMULTI] );
439        }
440
441        if ( !empty( $settings[self::PARAM_ISMULTI] ) ) {
442            $allowedKeys[] = self::PARAM_ISMULTI_LIMIT1;
443            $allowedKeys[] = self::PARAM_ISMULTI_LIMIT2;
444            $allowedKeys[] = self::PARAM_ALL;
445            $allowedKeys[] = self::PARAM_ALLOW_DUPLICATES;
446
447            $limit1 = $settings[self::PARAM_ISMULTI_LIMIT1] ?? $this->ismultiLimit1;
448            $limit2 = $settings[self::PARAM_ISMULTI_LIMIT2] ?? $this->ismultiLimit2;
449            if ( !is_int( $limit1 ) ) {
450                $issues[self::PARAM_ISMULTI_LIMIT1] = 'PARAM_ISMULTI_LIMIT1 must be an integer, got '
451                    . gettype( $settings[self::PARAM_ISMULTI_LIMIT1] );
452            } elseif ( $limit1 <= 0 ) {
453                $issues[self::PARAM_ISMULTI_LIMIT1] =
454                    "PARAM_ISMULTI_LIMIT1 must be greater than 0, got $limit1";
455            }
456            if ( !is_int( $limit2 ) ) {
457                $issues[self::PARAM_ISMULTI_LIMIT2] = 'PARAM_ISMULTI_LIMIT2 must be an integer, got '
458                    . gettype( $settings[self::PARAM_ISMULTI_LIMIT2] );
459            } elseif ( $limit2 < $limit1 ) {
460                $issues[self::PARAM_ISMULTI_LIMIT2] =
461                    'PARAM_ISMULTI_LIMIT2 must be greater than or equal to PARAM_ISMULTI_LIMIT1, but '
462                    . "$limit2 < $limit1";
463            }
464
465            $all = $settings[self::PARAM_ALL] ?? false;
466            if ( !is_string( $all ) && !is_bool( $all ) ) {
467                $issues[self::PARAM_ALL] = 'PARAM_ALL must be a string or boolean, got ' . gettype( $all );
468            } elseif ( $all !== false && $typeDef ) {
469                if ( $all === true ) {
470                    $all = self::ALL_DEFAULT_STRING;
471                }
472                $values = $typeDef->getEnumValues( $name, $settings, $options );
473                if ( !is_array( $values ) ) {
474                    $issues[self::PARAM_ALL] = 'PARAM_ALL cannot be used with non-enumerated types';
475                } elseif ( in_array( $all, $values, true ) ) {
476                    $issues[self::PARAM_ALL] = 'Value for PARAM_ALL conflicts with an enumerated value';
477                }
478            }
479
480            if ( !is_bool( $settings[self::PARAM_ALLOW_DUPLICATES] ?? false ) ) {
481                $issues[self::PARAM_ALLOW_DUPLICATES] = 'PARAM_ALLOW_DUPLICATES must be boolean, got '
482                    . gettype( $settings[self::PARAM_ALLOW_DUPLICATES] );
483            }
484        }
485
486        if ( !is_bool( $settings[self::PARAM_SENSITIVE] ?? false ) ) {
487            $issues[self::PARAM_SENSITIVE] = 'PARAM_SENSITIVE must be boolean, got '
488                . gettype( $settings[self::PARAM_SENSITIVE] );
489        }
490
491        if ( !is_bool( $settings[self::PARAM_DEPRECATED] ?? false ) ) {
492            $issues[self::PARAM_DEPRECATED] = 'PARAM_DEPRECATED must be boolean, got '
493                . gettype( $settings[self::PARAM_DEPRECATED] );
494        }
495
496        if ( !is_bool( $settings[self::PARAM_IGNORE_UNRECOGNIZED_VALUES] ?? false ) ) {
497            $issues[self::PARAM_IGNORE_UNRECOGNIZED_VALUES] = 'PARAM_IGNORE_UNRECOGNIZED_VALUES must be '
498                . 'boolean, got ' . gettype( $settings[self::PARAM_IGNORE_UNRECOGNIZED_VALUES] );
499        }
500
501        $ret = [ 'issues' => $issues, 'allowedKeys' => $allowedKeys, 'messages' => $messages ];
502        if ( $typeDef ) {
503            $ret = $typeDef->checkSettings( $name, $settings, $options, $ret );
504        }
505
506        return $ret;
507    }
508
509    /**
510     * Fetch and validate a parameter value using a settings array
511     *
512     * @param string $name Parameter name
513     * @param array|mixed $settings Default value or an array of settings
514     *  using PARAM_* constants.
515     * @param array $options Options array, passed through to the TypeDef and Callbacks.
516     *  - An additional option, 'is-default', will be set when the value comes from PARAM_DEFAULT.
517     * @return mixed Validated parameter value
518     * @throws ValidationException if the value is invalid
519     */
520    public function getValue( $name, $settings, array $options = [] ) {
521        $settings = $this->normalizeSettings( $settings );
522
523        $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] );
524        if ( !$typeDef ) {
525            throw new DomainException(
526                "Param $name's type is unknown - {$settings[self::PARAM_TYPE]}"
527            );
528        }
529
530        $value = $typeDef->getValue( $name, $settings, $options );
531
532        if ( $value !== null ) {
533            if ( !empty( $settings[self::PARAM_SENSITIVE] ) ) {
534                $strValue = $typeDef->stringifyValue( $name, $value, $settings, $options );
535                $this->callbacks->recordCondition(
536                    DataMessageValue::new( 'paramvalidator-param-sensitive', [], 'param-sensitive' )
537                        ->plaintextParams( $name, $strValue ),
538                    $name, $value, $settings, $options
539                );
540            }
541
542            // Set a warning if a deprecated parameter has been passed
543            if ( !empty( $settings[self::PARAM_DEPRECATED] ) ) {
544                $strValue = $typeDef->stringifyValue( $name, $value, $settings, $options );
545                $this->callbacks->recordCondition(
546                    DataMessageValue::new( 'paramvalidator-param-deprecated', [], 'param-deprecated' )
547                        ->plaintextParams( $name, $strValue ),
548                    $name, $value, $settings, $options
549                );
550            }
551        } elseif ( isset( $settings[self::PARAM_DEFAULT] ) ) {
552            $value = $settings[self::PARAM_DEFAULT];
553            $options['is-default'] = true;
554        }
555
556        return $this->validateValue( $name, $value, $settings, $options );
557    }
558
559    /**
560     * Validate a parameter value using a settings array
561     *
562     * @param string $name Parameter name
563     * @param null|mixed $value Parameter value
564     * @param array|mixed $settings Default value or an array of settings
565     *  using PARAM_* constants.
566     * @param array $options Options array, passed through to the TypeDef and Callbacks.
567     *  - An additional option, 'values-list', will be set when processing the
568     *    values of a multi-valued parameter.
569     * @return mixed Validated parameter value(s)
570     * @throws ValidationException if the value is invalid
571     */
572    public function validateValue( $name, $value, $settings, array $options = [] ) {
573        $settings = $this->normalizeSettings( $settings );
574
575        $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] );
576        if ( !$typeDef ) {
577            throw new DomainException(
578                "Param $name's type is unknown - {$settings[self::PARAM_TYPE]}"
579            );
580        }
581
582        if ( $value === null ) {
583            if ( !empty( $settings[self::PARAM_REQUIRED] ) ) {
584                throw new ValidationException(
585                    DataMessageValue::new( 'paramvalidator-missingparam', [], 'missingparam' )
586                        ->plaintextParams( $name ),
587                    $name, $value, $settings
588                );
589            }
590            return null;
591        }
592
593        // Non-multi
594        if ( empty( $settings[self::PARAM_ISMULTI] ) ) {
595            if ( is_string( $value ) && str_starts_with( $value, "\x1f" ) ) {
596                throw new ValidationException(
597                    DataMessageValue::new( 'paramvalidator-notmulti', [], 'badvalue' )
598                        ->plaintextParams( $name, $value ),
599                    $name, $value, $settings
600                );
601            }
602
603            // T326764: If the type of the actual param value is different from
604            // the type that is defined via getParamSettings(), throw an exception
605            // because this is a type to value mismatch.
606            if ( is_array( $value ) && !$typeDef->supportsArrays() ) {
607                throw new ValidationException(
608                    DataMessageValue::new( 'paramvalidator-notmulti', [], 'badvalue' )
609                        ->plaintextParams( $name, gettype( $value ) ),
610                    $name, $value, $settings
611                );
612            }
613
614            return $typeDef->validate( $name, $value, $settings, $options );
615        }
616
617        // Split the multi-value and validate each parameter
618        $limit1 = $settings[self::PARAM_ISMULTI_LIMIT1] ?? $this->ismultiLimit1;
619        $limit2 = max( $limit1, $settings[self::PARAM_ISMULTI_LIMIT2] ?? $this->ismultiLimit2 );
620
621        if ( is_array( $value ) ) {
622            $valuesList = $value;
623        } elseif ( $options[ self::OPT_ENFORCE_JSON_TYPES ] ?? false ) {
624            throw new ValidationException(
625                DataMessageValue::new(
626                    'paramvalidator-multivalue-must-be-array',
627                    [],
628                    'multivalue-must-be-array'
629                )->plaintextParams( $name ),
630                $name, $value, $settings
631            );
632        } else {
633            $valuesList = self::explodeMultiValue( $value, $limit2 + 1 );
634        }
635
636        // Handle PARAM_ALL
637        $enumValues = $typeDef->getEnumValues( $name, $settings, $options );
638        if ( is_array( $enumValues ) && isset( $settings[self::PARAM_ALL] ) &&
639            count( $valuesList ) === 1
640        ) {
641            $allValue = is_string( $settings[self::PARAM_ALL] )
642                ? $settings[self::PARAM_ALL]
643                : self::ALL_DEFAULT_STRING;
644            if ( $valuesList[0] === $allValue ) {
645                return $enumValues;
646            }
647        }
648
649        // Avoid checking useHighLimits() unless it's actually necessary
650        $sizeLimit = (
651            $limit2 > $limit1 && count( $valuesList ) > $limit1 &&
652            $this->callbacks->useHighLimits( $options )
653        ) ? $limit2 : $limit1;
654        if ( count( $valuesList ) > $sizeLimit ) {
655            throw new ValidationException(
656                DataMessageValue::new( 'paramvalidator-toomanyvalues', [], 'toomanyvalues', [
657                    'parameter' => $name,
658                    'limit' => $sizeLimit,
659                    'lowlimit' => $limit1,
660                    'highlimit' => $limit2,
661                ] )->plaintextParams( $name )->numParams( $sizeLimit ),
662                $name, $valuesList, $settings
663            );
664        }
665
666        $options['values-list'] = $valuesList;
667        $validValues = [];
668        $invalidValues = [];
669        foreach ( $valuesList as $v ) {
670            try {
671                $validValues[] = $typeDef->validate( $name, $v, $settings, $options );
672            } catch ( ValidationException $ex ) {
673                if ( $ex->getFailureMessage()->getCode() !== 'badvalue' ||
674                    empty( $settings[self::PARAM_IGNORE_UNRECOGNIZED_VALUES] )
675                ) {
676                    throw $ex;
677                }
678                $invalidValues[] = $v;
679            }
680        }
681        if ( $invalidValues ) {
682            if ( is_array( $value ) ) {
683                $value = self::implodeMultiValue( $value );
684            }
685            $this->callbacks->recordCondition(
686                DataMessageValue::new( 'paramvalidator-unrecognizedvalues', [], 'unrecognizedvalues', [
687                    'values' => $invalidValues,
688                ] )
689                    ->plaintextParams( $name, $value )
690                    ->commaListParams( array_map( static function ( $v ) {
691                        return new ScalarParam( ParamType::PLAINTEXT, $v );
692                    }, $invalidValues ) )
693                    ->numParams( count( $invalidValues ) ),
694                $name, $value, $settings, $options
695            );
696        }
697
698        // Throw out duplicates if requested
699        if ( empty( $settings[self::PARAM_ALLOW_DUPLICATES] ) ) {
700            $validValues = array_values( array_unique( $validValues ) );
701        }
702
703        return $validValues;
704    }
705
706    /**
707     * Describe parameter settings in a machine-readable format.
708     *
709     * @param string $name Parameter name.
710     * @param array|mixed $settings Default value or an array of settings
711     *  using PARAM_* constants.
712     * @param array $options Options array.
713     * @return array
714     */
715    public function getParamInfo( $name, $settings, array $options ) {
716        $settings = $this->normalizeSettings( $settings );
717        $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] );
718        $info = [];
719
720        $info['type'] = $settings[self::PARAM_TYPE];
721        $info['required'] = !empty( $settings[self::PARAM_REQUIRED] );
722        if ( !empty( $settings[self::PARAM_DEPRECATED] ) ) {
723            $info['deprecated'] = true;
724        }
725        if ( !empty( $settings[self::PARAM_SENSITIVE] ) ) {
726            $info['sensitive'] = true;
727        }
728        if ( isset( $settings[self::PARAM_DEFAULT] ) ) {
729            $info['default'] = $settings[self::PARAM_DEFAULT];
730        }
731        $info['multi'] = !empty( $settings[self::PARAM_ISMULTI] );
732        if ( $info['multi'] ) {
733            $info['lowlimit'] = $settings[self::PARAM_ISMULTI_LIMIT1] ?? $this->ismultiLimit1;
734            $info['highlimit'] = max(
735                $info['lowlimit'], $settings[self::PARAM_ISMULTI_LIMIT2] ?? $this->ismultiLimit2
736            );
737            $info['limit'] =
738                $info['highlimit'] > $info['lowlimit'] && $this->callbacks->useHighLimits( $options )
739                    ? $info['highlimit']
740                    : $info['lowlimit'];
741
742            if ( !empty( $settings[self::PARAM_ALLOW_DUPLICATES] ) ) {
743                $info['allowsduplicates'] = true;
744            }
745
746            $allSpecifier = $settings[self::PARAM_ALL] ?? false;
747            if ( $allSpecifier !== false ) {
748                if ( !is_string( $allSpecifier ) ) {
749                    $allSpecifier = self::ALL_DEFAULT_STRING;
750                }
751                $info['allspecifier'] = $allSpecifier;
752            }
753        }
754
755        if ( $typeDef ) {
756            $info = array_merge( $info, $typeDef->getParamInfo( $name, $settings, $options ) );
757        }
758
759        // Filter out nulls (strictly)
760        return array_filter( $info, static fn ( $v ) => $v !== null );
761    }
762
763    /**
764     * Describe parameter settings in human-readable format
765     *
766     * @param string $name Parameter name being described.
767     * @param array|mixed $settings Default value or an array of settings
768     *  using PARAM_* constants.
769     * @param array $options Options array.
770     * @return MessageValue[]
771     */
772    public function getHelpInfo( $name, $settings, array $options ) {
773        $settings = $this->normalizeSettings( $settings );
774        $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] );
775
776        // Define ordering. Some are overwritten below, some expected from the TypeDef
777        $info = [
778            self::PARAM_DEPRECATED => null,
779            self::PARAM_REQUIRED => null,
780            self::PARAM_SENSITIVE => null,
781            self::PARAM_TYPE => null,
782            self::PARAM_ISMULTI => null,
783            self::PARAM_ISMULTI_LIMIT1 => null,
784            self::PARAM_ALL => null,
785            self::PARAM_DEFAULT => null,
786        ];
787
788        if ( !empty( $settings[self::PARAM_DEPRECATED] ) ) {
789            $info[self::PARAM_DEPRECATED] = MessageValue::new( 'paramvalidator-help-deprecated' );
790        }
791
792        if ( !empty( $settings[self::PARAM_REQUIRED] ) ) {
793            $info[self::PARAM_REQUIRED] = MessageValue::new( 'paramvalidator-help-required' );
794        }
795
796        if ( !empty( $settings[self::PARAM_ISMULTI] ) ) {
797            $info[self::PARAM_ISMULTI] = MessageValue::new( 'paramvalidator-help-multi-separate' );
798
799            $lowcount = $settings[self::PARAM_ISMULTI_LIMIT1] ?? $this->ismultiLimit1;
800            $highcount = max( $lowcount, $settings[self::PARAM_ISMULTI_LIMIT2] ?? $this->ismultiLimit2 );
801            $values = $typeDef ? $typeDef->getEnumValues( $name, $settings, $options ) : null;
802            if (
803                // Only mention the limits if they're likely to matter.
804                $values === null || count( $values ) > $lowcount ||
805                !empty( $settings[self::PARAM_ALLOW_DUPLICATES] )
806            ) {
807                if ( $highcount > $lowcount ) {
808                    $info[self::PARAM_ISMULTI_LIMIT1] = MessageValue::new( 'paramvalidator-help-multi-max' )
809                        ->numParams( $lowcount, $highcount );
810                } else {
811                    $info[self::PARAM_ISMULTI_LIMIT1] = MessageValue::new( 'paramvalidator-help-multi-max-simple' )
812                        ->numParams( $lowcount );
813                }
814            }
815
816            $allSpecifier = $settings[self::PARAM_ALL] ?? false;
817            if ( $allSpecifier !== false ) {
818                if ( !is_string( $allSpecifier ) ) {
819                    $allSpecifier = self::ALL_DEFAULT_STRING;
820                }
821                $info[self::PARAM_ALL] = MessageValue::new( 'paramvalidator-help-multi-all' )
822                    ->plaintextParams( $allSpecifier );
823            }
824        }
825
826        if ( isset( $settings[self::PARAM_DEFAULT] ) && $typeDef ) {
827            $value = $typeDef->stringifyValue( $name, $settings[self::PARAM_DEFAULT], $settings, $options );
828            if ( $value === '' ) {
829                $info[self::PARAM_DEFAULT] = MessageValue::new( 'paramvalidator-help-default-empty' );
830            } elseif ( $value !== null ) {
831                $info[self::PARAM_DEFAULT] = MessageValue::new( 'paramvalidator-help-default' )
832                    ->plaintextParams( $value );
833            }
834        }
835
836        if ( $typeDef ) {
837            $info = array_merge( $info, $typeDef->getHelpInfo( $name, $settings, $options ) );
838        }
839
840        // Put the default at the very end (the TypeDef may have added extra messages)
841        $default = $info[self::PARAM_DEFAULT];
842        unset( $info[self::PARAM_DEFAULT] );
843        $info[self::PARAM_DEFAULT] = $default;
844
845        // Filter out nulls
846        return array_filter( $info );
847    }
848
849    /**
850     * Split a multi-valued parameter string, like explode()
851     *
852     * Note that, unlike explode(), this will return an empty array when given
853     * an empty string.
854     *
855     * @param string $value
856     * @param int $limit
857     * @return string[]
858     */
859    public static function explodeMultiValue( $value, $limit ) {
860        if ( $value === '' || $value === "\x1f" ) {
861            return [];
862        }
863
864        if ( str_starts_with( $value, "\x1f" ) ) {
865            $sep = "\x1f";
866            $value = substr( $value, 1 );
867        } else {
868            $sep = '|';
869        }
870
871        return explode( $sep, $value, $limit );
872    }
873
874    /**
875     * Implode an array as a multi-valued parameter string, like implode()
876     *
877     * @param array $value
878     * @return string
879     */
880    public static function implodeMultiValue( array $value ) {
881        if ( $value === [ '' ] ) {
882            // There's no value that actually returns a single empty string.
883            // Best we can do is this that returns two, which will be deduplicated to one.
884            return '|';
885        }
886
887        foreach ( $value as $v ) {
888            if ( str_contains( $v, '|' ) ) {
889                return "\x1f" . implode( "\x1f", $value );
890            }
891        }
892        return implode( '|', $value );
893    }
894
895}