3 namespace Wikimedia\ParamValidator
;
6 use InvalidArgumentException
;
7 use Wikimedia\Assert\Assert
;
8 use Wikimedia\ObjectFactory
;
11 * Service for formatting and validating API parameters
13 * A settings array is simply an array with keys being the relevant PARAM_*
14 * constants from this class, TypeDef, and its subclasses.
16 * As a general overview of the architecture here:
17 * - ParamValidator handles some general validation of the parameter,
18 * then hands off to a TypeDef subclass to validate the specific representation
19 * based on the parameter's type.
20 * - TypeDef subclasses handle conversion between the string representation
21 * submitted by the client and the output PHP data types, validating that the
22 * strings are valid representations of the intended type as they do so.
23 * - ValidationException is used to report fatal errors in the validation back
24 * to the caller, since the return value represents the successful result of
25 * the validation and might be any type or class.
26 * - The Callbacks interface allows ParamValidator to reach out and fetch data
27 * it needs to perform the validation. Currently that includes:
28 * - Fetching the value of the parameter being validated (largely since a generic
29 * caller cannot know whether it needs to fetch a string from $_GET/$_POST or
30 * an array from $_FILES).
31 * - Reporting of non-fatal warnings back to the caller.
32 * - Fetching the "high limits" flag when necessary, to avoid the need for loading
33 * the user unnecessarily.
38 class ParamValidator
{
41 * @name Constants for parameter settings arrays
42 * These constants are keys in the settings array that define how the
43 * parameters coming in from the request are to be interpreted.
45 * If a constant is associated with a ValidationException, the failure code
46 * and data are described. ValidationExceptions are typically thrown, but
47 * those indicated as "non-fatal" are instead passed to
48 * Callbacks::recordCondition().
50 * Additional constants may be defined by TypeDef subclasses, or by other
51 * libraries for controlling things like auto-generated parameter documentation.
52 * For purposes of namespacing the constants, the values of all constants
53 * defined by this library begin with 'param-'.
58 /** (mixed) Default value of the parameter. If omitted, null is the default. */
59 const PARAM_DEFAULT
= 'param-default';
62 * (string|array) Type of the parameter.
63 * Must be a registered type or an array of enumerated values (in which case the "enum"
64 * type must be registered). If omitted, the default is the PHP type of the default value
65 * (see PARAM_DEFAULT).
67 const PARAM_TYPE
= 'param-type';
70 * (bool) Indicate that the parameter is required.
72 * ValidationException codes:
73 * - 'missingparam': The parameter is omitted/empty (and no default was set). No data.
75 const PARAM_REQUIRED
= 'param-required';
78 * (bool) Indicate that the parameter is multi-valued.
80 * A multi-valued parameter may be submitted in one of several formats. All
81 * of the following result a value of `[ 'a', 'b', 'c' ]`.
82 * - "a|b|c", i.e. pipe-separated.
83 * - "\x1Fa\x1Fb\x1Fc", i.e. separated by U+001F, with a signalling U+001F at the start.
84 * - As a string[], e.g. from a query string like "foo[]=a&foo[]=b&foo[]=c".
86 * Each of the multiple values is passed individually to the TypeDef.
87 * $options will contain a 'values-list' key holding the entire list.
89 * By default duplicates are removed from the resulting parameter list. Use
90 * PARAM_ALLOW_DUPLICATES to override that behavior.
92 * ValidationException codes:
93 * - 'toomanyvalues': More values were supplied than are allowed. See
94 * PARAM_ISMULTI_LIMIT1, PARAM_ISMULTI_LIMIT2, and constructor option
95 * 'ismultiLimits'. Data:
96 * - 'limit': The limit that was exceeded.
97 * - 'unrecognizedvalues': Non-fatal. Invalid values were passed and
98 * PARAM_IGNORE_INVALID_VALUES was set. Data:
99 * - 'values': The unrecognized values.
101 const PARAM_ISMULTI
= 'param-ismulti';
104 * (int) Maximum number of multi-valued parameter values allowed
106 * PARAM_ISMULTI_LIMIT1 is the normal limit, and PARAM_ISMULTI_LIMIT2 is
107 * the limit when useHighLimits() returns true.
109 * ValidationException codes:
110 * - 'toomanyvalues': The limit was exceeded. Data:
111 * - 'limit': The limit that was exceeded.
113 const PARAM_ISMULTI_LIMIT1
= 'param-ismulti-limit1';
116 * (int) Maximum number of multi-valued parameter values allowed for users
117 * allowed high limits.
119 * PARAM_ISMULTI_LIMIT1 is the normal limit, and PARAM_ISMULTI_LIMIT2 is
120 * the limit when useHighLimits() returns true.
122 * ValidationException codes:
123 * - 'toomanyvalues': The limit was exceeded. Data:
124 * - 'limit': The limit that was exceeded.
126 const PARAM_ISMULTI_LIMIT2
= 'param-ismulti-limit2';
129 * (bool|string) Whether a magic "all values" value exists for multi-valued
130 * enumerated types, and if so what that value is.
132 * When PARAM_TYPE has a defined set of values and PARAM_ISMULTI is true,
133 * this allows for an asterisk ('*') to be passed in place of a pipe-separated list of
134 * every possible value. If a string is set, it will be used in place of the asterisk.
136 const PARAM_ALL
= 'param-all';
139 * (bool) Allow the same value to be set more than once when PARAM_ISMULTI is true?
141 * If not truthy, the set of values will be passed through
142 * `array_values( array_unique() )`. The default is falsey.
144 const PARAM_ALLOW_DUPLICATES
= 'param-allow-duplicates';
147 * (bool) Indicate that the parameter's value should not be logged.
149 * ValidationException codes: (non-fatal)
150 * - 'param-sensitive': Always recorded.
152 const PARAM_SENSITIVE
= 'param-sensitive';
155 * (bool) Indicate that a deprecated parameter was used.
157 * ValidationException codes: (non-fatal)
158 * - 'param-deprecated': Always recorded.
160 const PARAM_DEPRECATED
= 'param-deprecated';
163 * (bool) Whether to ignore invalid values.
165 * This controls whether certain ValidationExceptions are considered fatal
166 * or non-fatal. The default is false.
168 const PARAM_IGNORE_INVALID_VALUES
= 'param-ignore-invalid-values';
172 /** Magic "all values" value when PARAM_ALL is true. */
173 const ALL_DEFAULT_STRING
= '*';
175 /** A list of standard type names and types that may be passed as `$typeDefs` to __construct(). */
176 public static $STANDARD_TYPES = [
177 'boolean' => [ 'class' => TypeDef\BooleanDef
::class ],
178 'checkbox' => [ 'class' => TypeDef\PresenceBooleanDef
::class ],
179 'integer' => [ 'class' => TypeDef\IntegerDef
::class ],
180 'limit' => [ 'class' => TypeDef\LimitDef
::class ],
181 'float' => [ 'class' => TypeDef\FloatDef
::class ],
182 'double' => [ 'class' => TypeDef\FloatDef
::class ],
183 'string' => [ 'class' => TypeDef\StringDef
::class ],
184 'password' => [ 'class' => TypeDef\PasswordDef
::class ],
186 'class' => TypeDef\StringDef
::class,
188 'allowEmptyWhenRequired' => true,
191 'timestamp' => [ 'class' => TypeDef\TimestampDef
::class ],
192 'upload' => [ 'class' => TypeDef\UploadDef
::class ],
193 'enum' => [ 'class' => TypeDef\EnumDef
::class ],
196 /** @var Callbacks */
199 /** @var ObjectFactory */
200 private $objectFactory;
202 /** @var (TypeDef|array)[] Map parameter type names to TypeDef objects or ObjectFactory specs */
203 private $typeDefs = [];
205 /** @var int Default values for PARAM_ISMULTI_LIMIT1 */
206 private $ismultiLimit1;
208 /** @var int Default values for PARAM_ISMULTI_LIMIT2 */
209 private $ismultiLimit2;
212 * @param Callbacks $callbacks
213 * @param ObjectFactory $objectFactory To turn specs into TypeDef objects
214 * @param array $options Associative array of additional settings
215 * - 'typeDefs': (array) As for addTypeDefs(). If omitted, self::$STANDARD_TYPES will be used.
216 * Pass an empty array if you want to start with no registered types.
217 * - 'ismultiLimits': (int[]) Two ints, being the default values for PARAM_ISMULTI_LIMIT1 and
218 * PARAM_ISMULTI_LIMIT2. If not given, defaults to `[ 50, 500 ]`.
220 public function __construct(
221 Callbacks
$callbacks,
222 ObjectFactory
$objectFactory,
225 $this->callbacks
= $callbacks;
226 $this->objectFactory
= $objectFactory;
228 $this->addTypeDefs( $options['typeDefs'] ?? self
::$STANDARD_TYPES );
229 $this->ismultiLimit1
= $options['ismultiLimits'][0] ??
50;
230 $this->ismultiLimit2
= $options['ismultiLimits'][1] ??
500;
234 * List known type names
237 public function knownTypes() {
238 return array_keys( $this->typeDefs
);
242 * Register multiple type handlers
245 * @param array $typeDefs Associative array mapping `$name` to `$typeDef`.
247 public function addTypeDefs( array $typeDefs ) {
248 foreach ( $typeDefs as $name => $def ) {
249 $this->addTypeDef( $name, $def );
254 * Register a type handler
256 * To allow code to omit PARAM_TYPE in settings arrays to derive the type
257 * from PARAM_DEFAULT, it is strongly recommended that the following types be
258 * registered: "boolean", "integer", "double", "string", "NULL", and "enum".
260 * When using ObjectFactory specs, the following extra arguments are passed:
261 * - The Callbacks object for this ParamValidator instance.
263 * @param string $name Type name
264 * @param TypeDef|array $typeDef Type handler or ObjectFactory spec to create one.
266 public function addTypeDef( $name, $typeDef ) {
267 Assert
::parameterType(
268 implode( '|', [ TypeDef
::class, 'array' ] ),
273 if ( isset( $this->typeDefs
[$name] ) ) {
274 throw new InvalidArgumentException( "Type '$name' is already registered" );
276 $this->typeDefs
[$name] = $typeDef;
280 * Register a type handler, overriding any existing handler
282 * @param string $name Type name
283 * @param TypeDef|array|null $typeDef As for addTypeDef, or null to unregister a type.
285 public function overrideTypeDef( $name, $typeDef ) {
286 Assert
::parameterType(
287 implode( '|', [ TypeDef
::class, 'array', 'null' ] ),
292 if ( $typeDef === null ) {
293 unset( $this->typeDefs
[$name] );
295 $this->typeDefs
[$name] = $typeDef;
300 * Test if a type is registered
301 * @param string $name Type name
304 public function hasTypeDef( $name ) {
305 return isset( $this->typeDefs
[$name] );
309 * Get the TypeDef for a type
310 * @param string|array $type Any array is considered equivalent to the string "enum".
311 * @return TypeDef|null
313 public function getTypeDef( $type ) {
314 if ( is_array( $type ) ) {
318 if ( !isset( $this->typeDefs
[$type] ) ) {
322 $def = $this->typeDefs
[$type];
323 if ( !$def instanceof TypeDef
) {
324 $def = $this->objectFactory
->createObject( $def, [
325 'extraArgs' => [ $this->callbacks
],
326 'assertClass' => TypeDef
::class,
328 $this->typeDefs
[$type] = $def;
335 * Normalize a parameter settings array
336 * @param array|mixed $settings Default value or an array of settings
337 * using PARAM_* constants.
340 public function normalizeSettings( $settings ) {
342 if ( !is_array( $settings ) ) {
344 self
::PARAM_DEFAULT
=> $settings,
348 // When type is not given, determine it from the type of the PARAM_DEFAULT
349 if ( !isset( $settings[self
::PARAM_TYPE
] ) ) {
350 $settings[self
::PARAM_TYPE
] = gettype( $settings[self
::PARAM_DEFAULT
] ??
null );
353 $typeDef = $this->getTypeDef( $settings[self
::PARAM_TYPE
] );
355 $settings = $typeDef->normalizeSettings( $settings );
362 * Fetch and valiate a parameter value using a settings array
364 * @param string $name Parameter name
365 * @param array|mixed $settings Default value or an array of settings
366 * using PARAM_* constants.
367 * @param array $options Options array, passed through to the TypeDef and Callbacks.
368 * @return mixed Validated parameter value
369 * @throws ValidationException if the value is invalid
371 public function getValue( $name, $settings, array $options = [] ) {
372 $settings = $this->normalizeSettings( $settings );
374 $typeDef = $this->getTypeDef( $settings[self
::PARAM_TYPE
] );
376 throw new DomainException(
377 "Param $name's type is unknown - {$settings[self::PARAM_TYPE]}"
381 $value = $typeDef->getValue( $name, $settings, $options );
383 if ( $value !== null ) {
384 if ( !empty( $settings[self
::PARAM_SENSITIVE
] ) ) {
385 $this->callbacks
->recordCondition(
386 new ValidationException( $name, $value, $settings, 'param-sensitive', [] ),
391 // Set a warning if a deprecated parameter has been passed
392 if ( !empty( $settings[self
::PARAM_DEPRECATED
] ) ) {
393 $this->callbacks
->recordCondition(
394 new ValidationException( $name, $value, $settings, 'param-deprecated', [] ),
398 } elseif ( isset( $settings[self
::PARAM_DEFAULT
] ) ) {
399 $value = $settings[self
::PARAM_DEFAULT
];
402 return $this->validateValue( $name, $value, $settings, $options );
406 * Valiate a parameter value using a settings array
408 * @param string $name Parameter name
409 * @param null|mixed $value Parameter value
410 * @param array|mixed $settings Default value or an array of settings
411 * using PARAM_* constants.
412 * @param array $options Options array, passed through to the TypeDef and Callbacks.
413 * - An additional option, 'values-list', will be set when processing the
414 * values of a multi-valued parameter.
415 * @return mixed Validated parameter value(s)
416 * @throws ValidationException if the value is invalid
418 public function validateValue( $name, $value, $settings, array $options = [] ) {
419 $settings = $this->normalizeSettings( $settings );
421 $typeDef = $this->getTypeDef( $settings[self
::PARAM_TYPE
] );
423 throw new DomainException(
424 "Param $name's type is unknown - {$settings[self::PARAM_TYPE]}"
428 if ( $value === null ) {
429 if ( !empty( $settings[self
::PARAM_REQUIRED
] ) ) {
430 throw new ValidationException( $name, $value, $settings, 'missingparam', [] );
436 if ( empty( $settings[self
::PARAM_ISMULTI
] ) ) {
437 return $typeDef->validate( $name, $value, $settings, $options );
440 // Split the multi-value and validate each parameter
441 $limit1 = $settings[self
::PARAM_ISMULTI_LIMIT1
] ??
$this->ismultiLimit1
;
442 $limit2 = $settings[self
::PARAM_ISMULTI_LIMIT2
] ??
$this->ismultiLimit2
;
443 $valuesList = is_array( $value ) ?
$value : self
::explodeMultiValue( $value, $limit2 +
1 );
446 $enumValues = $typeDef->getEnumValues( $name, $settings, $options );
447 if ( is_array( $enumValues ) && isset( $settings[self
::PARAM_ALL
] ) &&
448 count( $valuesList ) === 1
450 $allValue = is_string( $settings[self
::PARAM_ALL
] )
451 ?
$settings[self
::PARAM_ALL
]
452 : self
::ALL_DEFAULT_STRING
;
453 if ( $valuesList[0] === $allValue ) {
458 // Avoid checking useHighLimits() unless it's actually necessary
459 $sizeLimit = count( $valuesList ) > $limit1 && $this->callbacks
->useHighLimits( $options )
462 if ( count( $valuesList ) > $sizeLimit ) {
463 throw new ValidationException( $name, $valuesList, $settings, 'toomanyvalues', [
464 'limit' => $sizeLimit
468 $options['values-list'] = $valuesList;
471 foreach ( $valuesList as $v ) {
473 $validValues[] = $typeDef->validate( $name, $v, $settings, $options );
474 } catch ( ValidationException
$ex ) {
475 if ( empty( $settings[self
::PARAM_IGNORE_INVALID_VALUES
] ) ) {
478 $invalidValues[] = $v;
481 if ( $invalidValues ) {
482 $this->callbacks
->recordCondition(
483 new ValidationException( $name, $value, $settings, 'unrecognizedvalues', [
484 'values' => $invalidValues,
490 // Throw out duplicates if requested
491 if ( empty( $settings[self
::PARAM_ALLOW_DUPLICATES
] ) ) {
492 $validValues = array_values( array_unique( $validValues ) );
499 * Split a multi-valued parameter string, like explode()
501 * Note that, unlike explode(), this will return an empty array when given
504 * @param string $value
508 public static function explodeMultiValue( $value, $limit ) {
509 if ( $value === '' ||
$value === "\x1f" ) {
513 if ( substr( $value, 0, 1 ) === "\x1f" ) {
515 $value = substr( $value, 1 );
520 return explode( $sep, $value, $limit );