ParamValidator: Flag as unstable for 1.34
[lhc/web/wiklou.git] / includes / libs / ParamValidator / ParamValidator.php
1 <?php
2
3 namespace Wikimedia\ParamValidator;
4
5 use DomainException;
6 use InvalidArgumentException;
7 use Wikimedia\Assert\Assert;
8 use Wikimedia\ObjectFactory;
9
10 /**
11 * Service for formatting and validating API parameters
12 *
13 * A settings array is simply an array with keys being the relevant PARAM_*
14 * constants from this class, TypeDef, and its subclasses.
15 *
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.
34 *
35 * @since 1.34
36 * @unstable
37 */
38 class ParamValidator {
39
40 /**
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.
44 *
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().
49 *
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-'.
54 *
55 * @{
56 */
57
58 /** (mixed) Default value of the parameter. If omitted, null is the default. */
59 const PARAM_DEFAULT = 'param-default';
60
61 /**
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).
66 */
67 const PARAM_TYPE = 'param-type';
68
69 /**
70 * (bool) Indicate that the parameter is required.
71 *
72 * ValidationException codes:
73 * - 'missingparam': The parameter is omitted/empty (and no default was set). No data.
74 */
75 const PARAM_REQUIRED = 'param-required';
76
77 /**
78 * (bool) Indicate that the parameter is multi-valued.
79 *
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".
85 *
86 * Each of the multiple values is passed individually to the TypeDef.
87 * $options will contain a 'values-list' key holding the entire list.
88 *
89 * By default duplicates are removed from the resulting parameter list. Use
90 * PARAM_ALLOW_DUPLICATES to override that behavior.
91 *
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.
100 */
101 const PARAM_ISMULTI = 'param-ismulti';
102
103 /**
104 * (int) Maximum number of multi-valued parameter values allowed
105 *
106 * PARAM_ISMULTI_LIMIT1 is the normal limit, and PARAM_ISMULTI_LIMIT2 is
107 * the limit when useHighLimits() returns true.
108 *
109 * ValidationException codes:
110 * - 'toomanyvalues': The limit was exceeded. Data:
111 * - 'limit': The limit that was exceeded.
112 */
113 const PARAM_ISMULTI_LIMIT1 = 'param-ismulti-limit1';
114
115 /**
116 * (int) Maximum number of multi-valued parameter values allowed for users
117 * allowed high limits.
118 *
119 * PARAM_ISMULTI_LIMIT1 is the normal limit, and PARAM_ISMULTI_LIMIT2 is
120 * the limit when useHighLimits() returns true.
121 *
122 * ValidationException codes:
123 * - 'toomanyvalues': The limit was exceeded. Data:
124 * - 'limit': The limit that was exceeded.
125 */
126 const PARAM_ISMULTI_LIMIT2 = 'param-ismulti-limit2';
127
128 /**
129 * (bool|string) Whether a magic "all values" value exists for multi-valued
130 * enumerated types, and if so what that value is.
131 *
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.
135 */
136 const PARAM_ALL = 'param-all';
137
138 /**
139 * (bool) Allow the same value to be set more than once when PARAM_ISMULTI is true?
140 *
141 * If not truthy, the set of values will be passed through
142 * `array_values( array_unique() )`. The default is falsey.
143 */
144 const PARAM_ALLOW_DUPLICATES = 'param-allow-duplicates';
145
146 /**
147 * (bool) Indicate that the parameter's value should not be logged.
148 *
149 * ValidationException codes: (non-fatal)
150 * - 'param-sensitive': Always recorded.
151 */
152 const PARAM_SENSITIVE = 'param-sensitive';
153
154 /**
155 * (bool) Indicate that a deprecated parameter was used.
156 *
157 * ValidationException codes: (non-fatal)
158 * - 'param-deprecated': Always recorded.
159 */
160 const PARAM_DEPRECATED = 'param-deprecated';
161
162 /**
163 * (bool) Whether to ignore invalid values.
164 *
165 * This controls whether certain ValidationExceptions are considered fatal
166 * or non-fatal. The default is false.
167 */
168 const PARAM_IGNORE_INVALID_VALUES = 'param-ignore-invalid-values';
169
170 /** @} */
171
172 /** Magic "all values" value when PARAM_ALL is true. */
173 const ALL_DEFAULT_STRING = '*';
174
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 ],
185 'NULL' => [
186 'class' => TypeDef\StringDef::class,
187 'args' => [ [
188 'allowEmptyWhenRequired' => true,
189 ] ],
190 ],
191 'timestamp' => [ 'class' => TypeDef\TimestampDef::class ],
192 'upload' => [ 'class' => TypeDef\UploadDef::class ],
193 'enum' => [ 'class' => TypeDef\EnumDef::class ],
194 ];
195
196 /** @var Callbacks */
197 private $callbacks;
198
199 /** @var ObjectFactory */
200 private $objectFactory;
201
202 /** @var (TypeDef|array)[] Map parameter type names to TypeDef objects or ObjectFactory specs */
203 private $typeDefs = [];
204
205 /** @var int Default values for PARAM_ISMULTI_LIMIT1 */
206 private $ismultiLimit1;
207
208 /** @var int Default values for PARAM_ISMULTI_LIMIT2 */
209 private $ismultiLimit2;
210
211 /**
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 ]`.
219 */
220 public function __construct(
221 Callbacks $callbacks,
222 ObjectFactory $objectFactory,
223 array $options = []
224 ) {
225 $this->callbacks = $callbacks;
226 $this->objectFactory = $objectFactory;
227
228 $this->addTypeDefs( $options['typeDefs'] ?? self::$STANDARD_TYPES );
229 $this->ismultiLimit1 = $options['ismultiLimits'][0] ?? 50;
230 $this->ismultiLimit2 = $options['ismultiLimits'][1] ?? 500;
231 }
232
233 /**
234 * List known type names
235 * @return string[]
236 */
237 public function knownTypes() {
238 return array_keys( $this->typeDefs );
239 }
240
241 /**
242 * Register multiple type handlers
243 *
244 * @see addTypeDef()
245 * @param array $typeDefs Associative array mapping `$name` to `$typeDef`.
246 */
247 public function addTypeDefs( array $typeDefs ) {
248 foreach ( $typeDefs as $name => $def ) {
249 $this->addTypeDef( $name, $def );
250 }
251 }
252
253 /**
254 * Register a type handler
255 *
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".
259 *
260 * When using ObjectFactory specs, the following extra arguments are passed:
261 * - The Callbacks object for this ParamValidator instance.
262 *
263 * @param string $name Type name
264 * @param TypeDef|array $typeDef Type handler or ObjectFactory spec to create one.
265 */
266 public function addTypeDef( $name, $typeDef ) {
267 Assert::parameterType(
268 implode( '|', [ TypeDef::class, 'array' ] ),
269 $typeDef,
270 '$typeDef'
271 );
272
273 if ( isset( $this->typeDefs[$name] ) ) {
274 throw new InvalidArgumentException( "Type '$name' is already registered" );
275 }
276 $this->typeDefs[$name] = $typeDef;
277 }
278
279 /**
280 * Register a type handler, overriding any existing handler
281 * @see addTypeDef
282 * @param string $name Type name
283 * @param TypeDef|array|null $typeDef As for addTypeDef, or null to unregister a type.
284 */
285 public function overrideTypeDef( $name, $typeDef ) {
286 Assert::parameterType(
287 implode( '|', [ TypeDef::class, 'array', 'null' ] ),
288 $typeDef,
289 '$typeDef'
290 );
291
292 if ( $typeDef === null ) {
293 unset( $this->typeDefs[$name] );
294 } else {
295 $this->typeDefs[$name] = $typeDef;
296 }
297 }
298
299 /**
300 * Test if a type is registered
301 * @param string $name Type name
302 * @return bool
303 */
304 public function hasTypeDef( $name ) {
305 return isset( $this->typeDefs[$name] );
306 }
307
308 /**
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
312 */
313 public function getTypeDef( $type ) {
314 if ( is_array( $type ) ) {
315 $type = 'enum';
316 }
317
318 if ( !isset( $this->typeDefs[$type] ) ) {
319 return null;
320 }
321
322 $def = $this->typeDefs[$type];
323 if ( !$def instanceof TypeDef ) {
324 $def = $this->objectFactory->createObject( $def, [
325 'extraArgs' => [ $this->callbacks ],
326 'assertClass' => TypeDef::class,
327 ] );
328 $this->typeDefs[$type] = $def;
329 }
330
331 return $def;
332 }
333
334 /**
335 * Normalize a parameter settings array
336 * @param array|mixed $settings Default value or an array of settings
337 * using PARAM_* constants.
338 * @return array
339 */
340 public function normalizeSettings( $settings ) {
341 // Shorthand
342 if ( !is_array( $settings ) ) {
343 $settings = [
344 self::PARAM_DEFAULT => $settings,
345 ];
346 }
347
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 );
351 }
352
353 $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] );
354 if ( $typeDef ) {
355 $settings = $typeDef->normalizeSettings( $settings );
356 }
357
358 return $settings;
359 }
360
361 /**
362 * Fetch and valiate a parameter value using a settings array
363 *
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
370 */
371 public function getValue( $name, $settings, array $options = [] ) {
372 $settings = $this->normalizeSettings( $settings );
373
374 $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] );
375 if ( !$typeDef ) {
376 throw new DomainException(
377 "Param $name's type is unknown - {$settings[self::PARAM_TYPE]}"
378 );
379 }
380
381 $value = $typeDef->getValue( $name, $settings, $options );
382
383 if ( $value !== null ) {
384 if ( !empty( $settings[self::PARAM_SENSITIVE] ) ) {
385 $this->callbacks->recordCondition(
386 new ValidationException( $name, $value, $settings, 'param-sensitive', [] ),
387 $options
388 );
389 }
390
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', [] ),
395 $options
396 );
397 }
398 } elseif ( isset( $settings[self::PARAM_DEFAULT] ) ) {
399 $value = $settings[self::PARAM_DEFAULT];
400 }
401
402 return $this->validateValue( $name, $value, $settings, $options );
403 }
404
405 /**
406 * Valiate a parameter value using a settings array
407 *
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
417 */
418 public function validateValue( $name, $value, $settings, array $options = [] ) {
419 $settings = $this->normalizeSettings( $settings );
420
421 $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] );
422 if ( !$typeDef ) {
423 throw new DomainException(
424 "Param $name's type is unknown - {$settings[self::PARAM_TYPE]}"
425 );
426 }
427
428 if ( $value === null ) {
429 if ( !empty( $settings[self::PARAM_REQUIRED] ) ) {
430 throw new ValidationException( $name, $value, $settings, 'missingparam', [] );
431 }
432 return null;
433 }
434
435 // Non-multi
436 if ( empty( $settings[self::PARAM_ISMULTI] ) ) {
437 return $typeDef->validate( $name, $value, $settings, $options );
438 }
439
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 );
444
445 // Handle PARAM_ALL
446 $enumValues = $typeDef->getEnumValues( $name, $settings, $options );
447 if ( is_array( $enumValues ) && isset( $settings[self::PARAM_ALL] ) &&
448 count( $valuesList ) === 1
449 ) {
450 $allValue = is_string( $settings[self::PARAM_ALL] )
451 ? $settings[self::PARAM_ALL]
452 : self::ALL_DEFAULT_STRING;
453 if ( $valuesList[0] === $allValue ) {
454 return $enumValues;
455 }
456 }
457
458 // Avoid checking useHighLimits() unless it's actually necessary
459 $sizeLimit = count( $valuesList ) > $limit1 && $this->callbacks->useHighLimits( $options )
460 ? $limit2
461 : $limit1;
462 if ( count( $valuesList ) > $sizeLimit ) {
463 throw new ValidationException( $name, $valuesList, $settings, 'toomanyvalues', [
464 'limit' => $sizeLimit
465 ] );
466 }
467
468 $options['values-list'] = $valuesList;
469 $validValues = [];
470 $invalidValues = [];
471 foreach ( $valuesList as $v ) {
472 try {
473 $validValues[] = $typeDef->validate( $name, $v, $settings, $options );
474 } catch ( ValidationException $ex ) {
475 if ( empty( $settings[self::PARAM_IGNORE_INVALID_VALUES] ) ) {
476 throw $ex;
477 }
478 $invalidValues[] = $v;
479 }
480 }
481 if ( $invalidValues ) {
482 $this->callbacks->recordCondition(
483 new ValidationException( $name, $value, $settings, 'unrecognizedvalues', [
484 'values' => $invalidValues,
485 ] ),
486 $options
487 );
488 }
489
490 // Throw out duplicates if requested
491 if ( empty( $settings[self::PARAM_ALLOW_DUPLICATES] ) ) {
492 $validValues = array_values( array_unique( $validValues ) );
493 }
494
495 return $validValues;
496 }
497
498 /**
499 * Split a multi-valued parameter string, like explode()
500 *
501 * Note that, unlike explode(), this will return an empty array when given
502 * an empty string.
503 *
504 * @param string $value
505 * @param int $limit
506 * @return string[]
507 */
508 public static function explodeMultiValue( $value, $limit ) {
509 if ( $value === '' || $value === "\x1f" ) {
510 return [];
511 }
512
513 if ( substr( $value, 0, 1 ) === "\x1f" ) {
514 $sep = "\x1f";
515 $value = substr( $value, 1 );
516 } else {
517 $sep = '|';
518 }
519
520 return explode( $sep, $value, $limit );
521 }
522
523 }