123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524 |
- <?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
- * @unstable
- */
- 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 );
- }
- }
|