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.
37 class ParamValidator
{
40 * @name Constants for parameter settings arrays
41 * These constants are keys in the settings array that define how the
42 * parameters coming in from the request are to be interpreted.
44 * If a constant is associated with a ValidationException, the failure code
45 * and data are described. ValidationExceptions are typically thrown, but
46 * those indicated as "non-fatal" are instead passed to
47 * Callbacks::recordCondition().
49 * Additional constants may be defined by TypeDef subclasses, or by other
50 * libraries for controlling things like auto-generated parameter documentation.
51 * For purposes of namespacing the constants, the values of all constants
52 * defined by this library begin with 'param-'.
57 /** (mixed) Default value of the parameter. If omitted, null is the default. */
58 const PARAM_DEFAULT
= 'param-default';
61 * (string|array) Type of the parameter.
62 * Must be a registered type or an array of enumerated values (in which case the "enum"
63 * type must be registered). If omitted, the default is the PHP type of the default value
64 * (see PARAM_DEFAULT).
66 const PARAM_TYPE
= 'param-type';
69 * (bool) Indicate that the parameter is required.
71 * ValidationException codes:
72 * - 'missingparam': The parameter is omitted/empty (and no default was set). No data.
74 const PARAM_REQUIRED
= 'param-required';
77 * (bool) Indicate that the parameter is multi-valued.
79 * A multi-valued parameter may be submitted in one of several formats. All
80 * of the following result a value of `[ 'a', 'b', 'c' ]`.
81 * - "a|b|c", i.e. pipe-separated.
82 * - "\x1Fa\x1Fb\x1Fc", i.e. separated by U+001F, with a signalling U+001F at the start.
83 * - As a string[], e.g. from a query string like "foo[]=a&foo[]=b&foo[]=c".
85 * Each of the multiple values is passed individually to the TypeDef.
86 * $options will contain a 'values-list' key holding the entire list.
88 * By default duplicates are removed from the resulting parameter list. Use
89 * PARAM_ALLOW_DUPLICATES to override that behavior.
91 * ValidationException codes:
92 * - 'toomanyvalues': More values were supplied than are allowed. See
93 * PARAM_ISMULTI_LIMIT1, PARAM_ISMULTI_LIMIT2, and constructor option
94 * 'ismultiLimits'. Data:
95 * - 'limit': The limit that was exceeded.
96 * - 'unrecognizedvalues': Non-fatal. Invalid values were passed and
97 * PARAM_IGNORE_INVALID_VALUES was set. Data:
98 * - 'values': The unrecognized values.
100 const PARAM_ISMULTI
= 'param-ismulti';
103 * (int) Maximum number of multi-valued parameter values allowed
105 * PARAM_ISMULTI_LIMIT1 is the normal limit, and PARAM_ISMULTI_LIMIT2 is
106 * the limit when useHighLimits() returns true.
108 * ValidationException codes:
109 * - 'toomanyvalues': The limit was exceeded. Data:
110 * - 'limit': The limit that was exceeded.
112 const PARAM_ISMULTI_LIMIT1
= 'param-ismulti-limit1';
115 * (int) Maximum number of multi-valued parameter values allowed for users
116 * allowed high limits.
118 * PARAM_ISMULTI_LIMIT1 is the normal limit, and PARAM_ISMULTI_LIMIT2 is
119 * the limit when useHighLimits() returns true.
121 * ValidationException codes:
122 * - 'toomanyvalues': The limit was exceeded. Data:
123 * - 'limit': The limit that was exceeded.
125 const PARAM_ISMULTI_LIMIT2
= 'param-ismulti-limit2';
128 * (bool|string) Whether a magic "all values" value exists for multi-valued
129 * enumerated types, and if so what that value is.
131 * When PARAM_TYPE has a defined set of values and PARAM_ISMULTI is true,
132 * this allows for an asterisk ('*') to be passed in place of a pipe-separated list of
133 * every possible value. If a string is set, it will be used in place of the asterisk.
135 const PARAM_ALL
= 'param-all';
138 * (bool) Allow the same value to be set more than once when PARAM_ISMULTI is true?
140 * If not truthy, the set of values will be passed through
141 * `array_values( array_unique() )`. The default is falsey.
143 const PARAM_ALLOW_DUPLICATES
= 'param-allow-duplicates';
146 * (bool) Indicate that the parameter's value should not be logged.
148 * ValidationException codes: (non-fatal)
149 * - 'param-sensitive': Always recorded.
151 const PARAM_SENSITIVE
= 'param-sensitive';
154 * (bool) Indicate that a deprecated parameter was used.
156 * ValidationException codes: (non-fatal)
157 * - 'param-deprecated': Always recorded.
159 const PARAM_DEPRECATED
= 'param-deprecated';
162 * (bool) Whether to ignore invalid values.
164 * This controls whether certain ValidationExceptions are considered fatal
165 * or non-fatal. The default is false.
167 const PARAM_IGNORE_INVALID_VALUES
= 'param-ignore-invalid-values';
171 /** Magic "all values" value when PARAM_ALL is true. */
172 const ALL_DEFAULT_STRING
= '*';
174 /** A list of standard type names and types that may be passed as `$typeDefs` to __construct(). */
175 public static $STANDARD_TYPES = [
176 'boolean' => [ 'class' => TypeDef\BooleanDef
::class ],
177 'checkbox' => [ 'class' => TypeDef\PresenceBooleanDef
::class ],
178 'integer' => [ 'class' => TypeDef\IntegerDef
::class ],
179 'limit' => [ 'class' => TypeDef\LimitDef
::class ],
180 'float' => [ 'class' => TypeDef\FloatDef
::class ],
181 'double' => [ 'class' => TypeDef\FloatDef
::class ],
182 'string' => [ 'class' => TypeDef\StringDef
::class ],
183 'password' => [ 'class' => TypeDef\PasswordDef
::class ],
185 'class' => TypeDef\StringDef
::class,
187 'allowEmptyWhenRequired' => true,
190 'timestamp' => [ 'class' => TypeDef\TimestampDef
::class ],
191 'upload' => [ 'class' => TypeDef\UploadDef
::class ],
192 'enum' => [ 'class' => TypeDef\EnumDef
::class ],
195 /** @var Callbacks */
198 /** @var ObjectFactory */
199 private $objectFactory;
201 /** @var (TypeDef|array)[] Map parameter type names to TypeDef objects or ObjectFactory specs */
202 private $typeDefs = [];
204 /** @var int Default values for PARAM_ISMULTI_LIMIT1 */
205 private $ismultiLimit1;
207 /** @var int Default values for PARAM_ISMULTI_LIMIT2 */
208 private $ismultiLimit2;
211 * @param Callbacks $callbacks
212 * @param ObjectFactory $objectFactory To turn specs into TypeDef objects
213 * @param array $options Associative array of additional settings
214 * - 'typeDefs': (array) As for addTypeDefs(). If omitted, self::$STANDARD_TYPES will be used.
215 * Pass an empty array if you want to start with no registered types.
216 * - 'ismultiLimits': (int[]) Two ints, being the default values for PARAM_ISMULTI_LIMIT1 and
217 * PARAM_ISMULTI_LIMIT2. If not given, defaults to `[ 50, 500 ]`.
219 public function __construct(
220 Callbacks
$callbacks,
221 ObjectFactory
$objectFactory,
224 $this->callbacks
= $callbacks;
225 $this->objectFactory
= $objectFactory;
227 $this->addTypeDefs( $options['typeDefs'] ?? self
::$STANDARD_TYPES );
228 $this->ismultiLimit1
= $options['ismultiLimits'][0] ??
50;
229 $this->ismultiLimit2
= $options['ismultiLimits'][1] ??
500;
233 * List known type names
236 public function knownTypes() {
237 return array_keys( $this->typeDefs
);
241 * Register multiple type handlers
244 * @param array $typeDefs Associative array mapping `$name` to `$typeDef`.
246 public function addTypeDefs( array $typeDefs ) {
247 foreach ( $typeDefs as $name => $def ) {
248 $this->addTypeDef( $name, $def );
253 * Register a type handler
255 * To allow code to omit PARAM_TYPE in settings arrays to derive the type
256 * from PARAM_DEFAULT, it is strongly recommended that the following types be
257 * registered: "boolean", "integer", "double", "string", "NULL", and "enum".
259 * When using ObjectFactory specs, the following extra arguments are passed:
260 * - The Callbacks object for this ParamValidator instance.
262 * @param string $name Type name
263 * @param TypeDef|array $typeDef Type handler or ObjectFactory spec to create one.
265 public function addTypeDef( $name, $typeDef ) {
266 Assert
::parameterType(
267 implode( '|', [ TypeDef
::class, 'array' ] ),
272 if ( isset( $this->typeDefs
[$name] ) ) {
273 throw new InvalidArgumentException( "Type '$name' is already registered" );
275 $this->typeDefs
[$name] = $typeDef;
279 * Register a type handler, overriding any existing handler
281 * @param string $name Type name
282 * @param TypeDef|array|null $typeDef As for addTypeDef, or null to unregister a type.
284 public function overrideTypeDef( $name, $typeDef ) {
285 Assert
::parameterType(
286 implode( '|', [ TypeDef
::class, 'array', 'null' ] ),
291 if ( $typeDef === null ) {
292 unset( $this->typeDefs
[$name] );
294 $this->typeDefs
[$name] = $typeDef;
299 * Test if a type is registered
300 * @param string $name Type name
303 public function hasTypeDef( $name ) {
304 return isset( $this->typeDefs
[$name] );
308 * Get the TypeDef for a type
309 * @param string|array $type Any array is considered equivalent to the string "enum".
310 * @return TypeDef|null
312 public function getTypeDef( $type ) {
313 if ( is_array( $type ) ) {
317 if ( !isset( $this->typeDefs
[$type] ) ) {
321 $def = $this->typeDefs
[$type];
322 if ( !$def instanceof TypeDef
) {
323 $def = $this->objectFactory
->createObject( $def, [
324 'extraArgs' => [ $this->callbacks
],
325 'assertClass' => TypeDef
::class,
327 $this->typeDefs
[$type] = $def;
334 * Normalize a parameter settings array
335 * @param array|mixed $settings Default value or an array of settings
336 * using PARAM_* constants.
339 public function normalizeSettings( $settings ) {
341 if ( !is_array( $settings ) ) {
343 self
::PARAM_DEFAULT
=> $settings,
347 // When type is not given, determine it from the type of the PARAM_DEFAULT
348 if ( !isset( $settings[self
::PARAM_TYPE
] ) ) {
349 $settings[self
::PARAM_TYPE
] = gettype( $settings[self
::PARAM_DEFAULT
] ??
null );
352 $typeDef = $this->getTypeDef( $settings[self
::PARAM_TYPE
] );
354 $settings = $typeDef->normalizeSettings( $settings );
361 * Fetch and valiate a parameter value using a settings array
363 * @param string $name Parameter name
364 * @param array|mixed $settings Default value or an array of settings
365 * using PARAM_* constants.
366 * @param array $options Options array, passed through to the TypeDef and Callbacks.
367 * @return mixed Validated parameter value
368 * @throws ValidationException if the value is invalid
370 public function getValue( $name, $settings, array $options = [] ) {
371 $settings = $this->normalizeSettings( $settings );
373 $typeDef = $this->getTypeDef( $settings[self
::PARAM_TYPE
] );
375 throw new DomainException(
376 "Param $name's type is unknown - {$settings[self::PARAM_TYPE]}"
380 $value = $typeDef->getValue( $name, $settings, $options );
382 if ( $value !== null ) {
383 if ( !empty( $settings[self
::PARAM_SENSITIVE
] ) ) {
384 $this->callbacks
->recordCondition(
385 new ValidationException( $name, $value, $settings, 'param-sensitive', [] ),
390 // Set a warning if a deprecated parameter has been passed
391 if ( !empty( $settings[self
::PARAM_DEPRECATED
] ) ) {
392 $this->callbacks
->recordCondition(
393 new ValidationException( $name, $value, $settings, 'param-deprecated', [] ),
397 } elseif ( isset( $settings[self
::PARAM_DEFAULT
] ) ) {
398 $value = $settings[self
::PARAM_DEFAULT
];
401 return $this->validateValue( $name, $value, $settings, $options );
405 * Valiate a parameter value using a settings array
407 * @param string $name Parameter name
408 * @param null|mixed $value Parameter value
409 * @param array|mixed $settings Default value or an array of settings
410 * using PARAM_* constants.
411 * @param array $options Options array, passed through to the TypeDef and Callbacks.
412 * - An additional option, 'values-list', will be set when processing the
413 * values of a multi-valued parameter.
414 * @return mixed Validated parameter value(s)
415 * @throws ValidationException if the value is invalid
417 public function validateValue( $name, $value, $settings, array $options = [] ) {
418 $settings = $this->normalizeSettings( $settings );
420 $typeDef = $this->getTypeDef( $settings[self
::PARAM_TYPE
] );
422 throw new DomainException(
423 "Param $name's type is unknown - {$settings[self::PARAM_TYPE]}"
427 if ( $value === null ) {
428 if ( !empty( $settings[self
::PARAM_REQUIRED
] ) ) {
429 throw new ValidationException( $name, $value, $settings, 'missingparam', [] );
435 if ( empty( $settings[self
::PARAM_ISMULTI
] ) ) {
436 return $typeDef->validate( $name, $value, $settings, $options );
439 // Split the multi-value and validate each parameter
440 $limit1 = $settings[self
::PARAM_ISMULTI_LIMIT1
] ??
$this->ismultiLimit1
;
441 $limit2 = $settings[self
::PARAM_ISMULTI_LIMIT2
] ??
$this->ismultiLimit2
;
442 $valuesList = is_array( $value ) ?
$value : self
::explodeMultiValue( $value, $limit2 +
1 );
445 $enumValues = $typeDef->getEnumValues( $name, $settings, $options );
446 if ( is_array( $enumValues ) && isset( $settings[self
::PARAM_ALL
] ) &&
447 count( $valuesList ) === 1
449 $allValue = is_string( $settings[self
::PARAM_ALL
] )
450 ?
$settings[self
::PARAM_ALL
]
451 : self
::ALL_DEFAULT_STRING
;
452 if ( $valuesList[0] === $allValue ) {
457 // Avoid checking useHighLimits() unless it's actually necessary
458 $sizeLimit = count( $valuesList ) > $limit1 && $this->callbacks
->useHighLimits( $options )
461 if ( count( $valuesList ) > $sizeLimit ) {
462 throw new ValidationException( $name, $valuesList, $settings, 'toomanyvalues', [
463 'limit' => $sizeLimit
467 $options['values-list'] = $valuesList;
470 foreach ( $valuesList as $v ) {
472 $validValues[] = $typeDef->validate( $name, $v, $settings, $options );
473 } catch ( ValidationException
$ex ) {
474 if ( empty( $settings[self
::PARAM_IGNORE_INVALID_VALUES
] ) ) {
477 $invalidValues[] = $v;
480 if ( $invalidValues ) {
481 $this->callbacks
->recordCondition(
482 new ValidationException( $name, $value, $settings, 'unrecognizedvalues', [
483 'values' => $invalidValues,
489 // Throw out duplicates if requested
490 if ( empty( $settings[self
::PARAM_ALLOW_DUPLICATES
] ) ) {
491 $validValues = array_values( array_unique( $validValues ) );
498 * Split a multi-valued parameter string, like explode()
500 * Note that, unlike explode(), this will return an empty array when given
503 * @param string $value
507 public static function explodeMultiValue( $value, $limit ) {
508 if ( $value === '' ||
$value === "\x1f" ) {
512 if ( substr( $value, 0, 1 ) === "\x1f" ) {
514 $value = substr( $value, 1 );
519 return explode( $sep, $value, $limit );