wiki.techinc.nl/includes/libs/ParamValidator/ParamValidator.php
Umherirrender 2664eeb632 Clean up spacing of doc comments
Align the doc stars and normalize start and end tokens

Change-Id: Ib0d92e128e7b882bb5b838bd00c74fc16ef14303
2019-08-05 22:29:50 +00:00

522 lines
16 KiB
PHP

<?php
namespace Wikimedia\ParamValidator;
use DomainException;
use InvalidArgumentException;
use Wikimedia\Assert\Assert;
use Wikimedia\ObjectFactory;
/**
* Service for formatting and validating API parameters
*
* A settings array is simply an array with keys being the relevant PARAM_*
* constants from this class, TypeDef, and its subclasses.
*
* As a general overview of the architecture here:
* - ParamValidator handles some general validation of the parameter,
* then hands off to a TypeDef subclass to validate the specific representation
* based on the parameter's type.
* - TypeDef subclasses handle conversion between the string representation
* submitted by the client and the output PHP data types, validating that the
* strings are valid representations of the intended type as they do so.
* - ValidationException is used to report fatal errors in the validation back
* to the caller, since the return value represents the successful result of
* the validation and might be any type or class.
* - The Callbacks interface allows ParamValidator to reach out and fetch data
* it needs to perform the validation. Currently that includes:
* - Fetching the value of the parameter being validated (largely since a generic
* caller cannot know whether it needs to fetch a string from $_GET/$_POST or
* an array from $_FILES).
* - Reporting of non-fatal warnings back to the caller.
* - Fetching the "high limits" flag when necessary, to avoid the need for loading
* the user unnecessarily.
*
* @since 1.34
*/
class ParamValidator {
/**
* @name Constants for parameter settings arrays
* These constants are keys in the settings array that define how the
* parameters coming in from the request are to be interpreted.
*
* If a constant is associated with a ValidationException, the failure code
* and data are described. ValidationExceptions are typically thrown, but
* those indicated as "non-fatal" are instead passed to
* Callbacks::recordCondition().
*
* Additional constants may be defined by TypeDef subclasses, or by other
* libraries for controlling things like auto-generated parameter documentation.
* For purposes of namespacing the constants, the values of all constants
* defined by this library begin with 'param-'.
*
* @{
*/
/** (mixed) Default value of the parameter. If omitted, null is the default. */
const PARAM_DEFAULT = 'param-default';
/**
* (string|array) Type of the parameter.
* Must be a registered type or an array of enumerated values (in which case the "enum"
* type must be registered). If omitted, the default is the PHP type of the default value
* (see PARAM_DEFAULT).
*/
const PARAM_TYPE = 'param-type';
/**
* (bool) Indicate that the parameter is required.
*
* ValidationException codes:
* - 'missingparam': The parameter is omitted/empty (and no default was set). No data.
*/
const PARAM_REQUIRED = 'param-required';
/**
* (bool) Indicate that the parameter is multi-valued.
*
* A multi-valued parameter may be submitted in one of several formats. All
* of the following result a value of `[ 'a', 'b', 'c' ]`.
* - "a|b|c", i.e. pipe-separated.
* - "\x1Fa\x1Fb\x1Fc", i.e. separated by U+001F, with a signalling U+001F at the start.
* - As a string[], e.g. from a query string like "foo[]=a&foo[]=b&foo[]=c".
*
* Each of the multiple values is passed individually to the TypeDef.
* $options will contain a 'values-list' key holding the entire list.
*
* By default duplicates are removed from the resulting parameter list. Use
* PARAM_ALLOW_DUPLICATES to override that behavior.
*
* ValidationException codes:
* - 'toomanyvalues': More values were supplied than are allowed. See
* PARAM_ISMULTI_LIMIT1, PARAM_ISMULTI_LIMIT2, and constructor option
* 'ismultiLimits'. Data:
* - 'limit': The limit that was exceeded.
* - 'unrecognizedvalues': Non-fatal. Invalid values were passed and
* PARAM_IGNORE_INVALID_VALUES was set. Data:
* - 'values': The unrecognized values.
*/
const PARAM_ISMULTI = 'param-ismulti';
/**
* (int) Maximum number of multi-valued parameter values allowed
*
* PARAM_ISMULTI_LIMIT1 is the normal limit, and PARAM_ISMULTI_LIMIT2 is
* the limit when useHighLimits() returns true.
*
* ValidationException codes:
* - 'toomanyvalues': The limit was exceeded. Data:
* - 'limit': The limit that was exceeded.
*/
const PARAM_ISMULTI_LIMIT1 = 'param-ismulti-limit1';
/**
* (int) Maximum number of multi-valued parameter values allowed for users
* allowed high limits.
*
* PARAM_ISMULTI_LIMIT1 is the normal limit, and PARAM_ISMULTI_LIMIT2 is
* the limit when useHighLimits() returns true.
*
* ValidationException codes:
* - 'toomanyvalues': The limit was exceeded. Data:
* - 'limit': The limit that was exceeded.
*/
const PARAM_ISMULTI_LIMIT2 = 'param-ismulti-limit2';
/**
* (bool|string) Whether a magic "all values" value exists for multi-valued
* enumerated types, and if so what that value is.
*
* When PARAM_TYPE has a defined set of values and PARAM_ISMULTI is true,
* this allows for an asterisk ('*') to be passed in place of a pipe-separated list of
* every possible value. If a string is set, it will be used in place of the asterisk.
*/
const PARAM_ALL = 'param-all';
/**
* (bool) Allow the same value to be set more than once when PARAM_ISMULTI is true?
*
* If not truthy, the set of values will be passed through
* `array_values( array_unique() )`. The default is falsey.
*/
const PARAM_ALLOW_DUPLICATES = 'param-allow-duplicates';
/**
* (bool) Indicate that the parameter's value should not be logged.
*
* ValidationException codes: (non-fatal)
* - 'param-sensitive': Always recorded.
*/
const PARAM_SENSITIVE = 'param-sensitive';
/**
* (bool) Indicate that a deprecated parameter was used.
*
* ValidationException codes: (non-fatal)
* - 'param-deprecated': Always recorded.
*/
const PARAM_DEPRECATED = 'param-deprecated';
/**
* (bool) Whether to ignore invalid values.
*
* This controls whether certain ValidationExceptions are considered fatal
* or non-fatal. The default is false.
*/
const PARAM_IGNORE_INVALID_VALUES = 'param-ignore-invalid-values';
/** @} */
/** Magic "all values" value when PARAM_ALL is true. */
const ALL_DEFAULT_STRING = '*';
/** A list of standard type names and types that may be passed as `$typeDefs` to __construct(). */
public static $STANDARD_TYPES = [
'boolean' => [ 'class' => TypeDef\BooleanDef::class ],
'checkbox' => [ 'class' => TypeDef\PresenceBooleanDef::class ],
'integer' => [ 'class' => TypeDef\IntegerDef::class ],
'limit' => [ 'class' => TypeDef\LimitDef::class ],
'float' => [ 'class' => TypeDef\FloatDef::class ],
'double' => [ 'class' => TypeDef\FloatDef::class ],
'string' => [ 'class' => TypeDef\StringDef::class ],
'password' => [ 'class' => TypeDef\PasswordDef::class ],
'NULL' => [
'class' => TypeDef\StringDef::class,
'args' => [ [
'allowEmptyWhenRequired' => true,
] ],
],
'timestamp' => [ 'class' => TypeDef\TimestampDef::class ],
'upload' => [ 'class' => TypeDef\UploadDef::class ],
'enum' => [ 'class' => TypeDef\EnumDef::class ],
];
/** @var Callbacks */
private $callbacks;
/** @var ObjectFactory */
private $objectFactory;
/** @var (TypeDef|array)[] Map parameter type names to TypeDef objects or ObjectFactory specs */
private $typeDefs = [];
/** @var int Default values for PARAM_ISMULTI_LIMIT1 */
private $ismultiLimit1;
/** @var int Default values for PARAM_ISMULTI_LIMIT2 */
private $ismultiLimit2;
/**
* @param Callbacks $callbacks
* @param ObjectFactory $objectFactory To turn specs into TypeDef objects
* @param array $options Associative array of additional settings
* - 'typeDefs': (array) As for addTypeDefs(). If omitted, self::$STANDARD_TYPES will be used.
* Pass an empty array if you want to start with no registered types.
* - 'ismultiLimits': (int[]) Two ints, being the default values for PARAM_ISMULTI_LIMIT1 and
* PARAM_ISMULTI_LIMIT2. If not given, defaults to `[ 50, 500 ]`.
*/
public function __construct(
Callbacks $callbacks,
ObjectFactory $objectFactory,
array $options = []
) {
$this->callbacks = $callbacks;
$this->objectFactory = $objectFactory;
$this->addTypeDefs( $options['typeDefs'] ?? self::$STANDARD_TYPES );
$this->ismultiLimit1 = $options['ismultiLimits'][0] ?? 50;
$this->ismultiLimit2 = $options['ismultiLimits'][1] ?? 500;
}
/**
* List known type names
* @return string[]
*/
public function knownTypes() {
return array_keys( $this->typeDefs );
}
/**
* Register multiple type handlers
*
* @see addTypeDef()
* @param array $typeDefs Associative array mapping `$name` to `$typeDef`.
*/
public function addTypeDefs( array $typeDefs ) {
foreach ( $typeDefs as $name => $def ) {
$this->addTypeDef( $name, $def );
}
}
/**
* Register a type handler
*
* To allow code to omit PARAM_TYPE in settings arrays to derive the type
* from PARAM_DEFAULT, it is strongly recommended that the following types be
* registered: "boolean", "integer", "double", "string", "NULL", and "enum".
*
* When using ObjectFactory specs, the following extra arguments are passed:
* - The Callbacks object for this ParamValidator instance.
*
* @param string $name Type name
* @param TypeDef|array $typeDef Type handler or ObjectFactory spec to create one.
*/
public function addTypeDef( $name, $typeDef ) {
Assert::parameterType(
implode( '|', [ TypeDef::class, 'array' ] ),
$typeDef,
'$typeDef'
);
if ( isset( $this->typeDefs[$name] ) ) {
throw new InvalidArgumentException( "Type '$name' is already registered" );
}
$this->typeDefs[$name] = $typeDef;
}
/**
* Register a type handler, overriding any existing handler
* @see addTypeDef
* @param string $name Type name
* @param TypeDef|array|null $typeDef As for addTypeDef, or null to unregister a type.
*/
public function overrideTypeDef( $name, $typeDef ) {
Assert::parameterType(
implode( '|', [ TypeDef::class, 'array', 'null' ] ),
$typeDef,
'$typeDef'
);
if ( $typeDef === null ) {
unset( $this->typeDefs[$name] );
} else {
$this->typeDefs[$name] = $typeDef;
}
}
/**
* Test if a type is registered
* @param string $name Type name
* @return bool
*/
public function hasTypeDef( $name ) {
return isset( $this->typeDefs[$name] );
}
/**
* Get the TypeDef for a type
* @param string|array $type Any array is considered equivalent to the string "enum".
* @return TypeDef|null
*/
public function getTypeDef( $type ) {
if ( is_array( $type ) ) {
$type = 'enum';
}
if ( !isset( $this->typeDefs[$type] ) ) {
return null;
}
$def = $this->typeDefs[$type];
if ( !$def instanceof TypeDef ) {
$def = $this->objectFactory->createObject( $def, [
'extraArgs' => [ $this->callbacks ],
'assertClass' => TypeDef::class,
] );
$this->typeDefs[$type] = $def;
}
return $def;
}
/**
* Normalize a parameter settings array
* @param array|mixed $settings Default value or an array of settings
* using PARAM_* constants.
* @return array
*/
public function normalizeSettings( $settings ) {
// Shorthand
if ( !is_array( $settings ) ) {
$settings = [
self::PARAM_DEFAULT => $settings,
];
}
// When type is not given, determine it from the type of the PARAM_DEFAULT
if ( !isset( $settings[self::PARAM_TYPE] ) ) {
$settings[self::PARAM_TYPE] = gettype( $settings[self::PARAM_DEFAULT] ?? null );
}
$typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] );
if ( $typeDef ) {
$settings = $typeDef->normalizeSettings( $settings );
}
return $settings;
}
/**
* Fetch and valiate a parameter value using a settings array
*
* @param string $name Parameter name
* @param array|mixed $settings Default value or an array of settings
* using PARAM_* constants.
* @param array $options Options array, passed through to the TypeDef and Callbacks.
* @return mixed Validated parameter value
* @throws ValidationException if the value is invalid
*/
public function getValue( $name, $settings, array $options = [] ) {
$settings = $this->normalizeSettings( $settings );
$typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] );
if ( !$typeDef ) {
throw new DomainException(
"Param $name's type is unknown - {$settings[self::PARAM_TYPE]}"
);
}
$value = $typeDef->getValue( $name, $settings, $options );
if ( $value !== null ) {
if ( !empty( $settings[self::PARAM_SENSITIVE] ) ) {
$this->callbacks->recordCondition(
new ValidationException( $name, $value, $settings, 'param-sensitive', [] ),
$options
);
}
// Set a warning if a deprecated parameter has been passed
if ( !empty( $settings[self::PARAM_DEPRECATED] ) ) {
$this->callbacks->recordCondition(
new ValidationException( $name, $value, $settings, 'param-deprecated', [] ),
$options
);
}
} elseif ( isset( $settings[self::PARAM_DEFAULT] ) ) {
$value = $settings[self::PARAM_DEFAULT];
}
return $this->validateValue( $name, $value, $settings, $options );
}
/**
* Valiate a parameter value using a settings array
*
* @param string $name Parameter name
* @param null|mixed $value Parameter value
* @param array|mixed $settings Default value or an array of settings
* using PARAM_* constants.
* @param array $options Options array, passed through to the TypeDef and Callbacks.
* - An additional option, 'values-list', will be set when processing the
* values of a multi-valued parameter.
* @return mixed Validated parameter value(s)
* @throws ValidationException if the value is invalid
*/
public function validateValue( $name, $value, $settings, array $options = [] ) {
$settings = $this->normalizeSettings( $settings );
$typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] );
if ( !$typeDef ) {
throw new DomainException(
"Param $name's type is unknown - {$settings[self::PARAM_TYPE]}"
);
}
if ( $value === null ) {
if ( !empty( $settings[self::PARAM_REQUIRED] ) ) {
throw new ValidationException( $name, $value, $settings, 'missingparam', [] );
}
return null;
}
// Non-multi
if ( empty( $settings[self::PARAM_ISMULTI] ) ) {
return $typeDef->validate( $name, $value, $settings, $options );
}
// Split the multi-value and validate each parameter
$limit1 = $settings[self::PARAM_ISMULTI_LIMIT1] ?? $this->ismultiLimit1;
$limit2 = $settings[self::PARAM_ISMULTI_LIMIT2] ?? $this->ismultiLimit2;
$valuesList = is_array( $value ) ? $value : self::explodeMultiValue( $value, $limit2 + 1 );
// Handle PARAM_ALL
$enumValues = $typeDef->getEnumValues( $name, $settings, $options );
if ( is_array( $enumValues ) && isset( $settings[self::PARAM_ALL] ) &&
count( $valuesList ) === 1
) {
$allValue = is_string( $settings[self::PARAM_ALL] )
? $settings[self::PARAM_ALL]
: self::ALL_DEFAULT_STRING;
if ( $valuesList[0] === $allValue ) {
return $enumValues;
}
}
// Avoid checking useHighLimits() unless it's actually necessary
$sizeLimit = count( $valuesList ) > $limit1 && $this->callbacks->useHighLimits( $options )
? $limit2
: $limit1;
if ( count( $valuesList ) > $sizeLimit ) {
throw new ValidationException( $name, $valuesList, $settings, 'toomanyvalues', [
'limit' => $sizeLimit
] );
}
$options['values-list'] = $valuesList;
$validValues = [];
$invalidValues = [];
foreach ( $valuesList as $v ) {
try {
$validValues[] = $typeDef->validate( $name, $v, $settings, $options );
} catch ( ValidationException $ex ) {
if ( empty( $settings[self::PARAM_IGNORE_INVALID_VALUES] ) ) {
throw $ex;
}
$invalidValues[] = $v;
}
}
if ( $invalidValues ) {
$this->callbacks->recordCondition(
new ValidationException( $name, $value, $settings, 'unrecognizedvalues', [
'values' => $invalidValues,
] ),
$options
);
}
// Throw out duplicates if requested
if ( empty( $settings[self::PARAM_ALLOW_DUPLICATES] ) ) {
$validValues = array_values( array_unique( $validValues ) );
}
return $validValues;
}
/**
* Split a multi-valued parameter string, like explode()
*
* Note that, unlike explode(), this will return an empty array when given
* an empty string.
*
* @param string $value
* @param int $limit
* @return string[]
*/
public static function explodeMultiValue( $value, $limit ) {
if ( $value === '' || $value === "\x1f" ) {
return [];
}
if ( substr( $value, 0, 1 ) === "\x1f" ) {
$sep = "\x1f";
$value = substr( $value, 1 );
} else {
$sep = '|';
}
return explode( $sep, $value, $limit );
}
}