Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.32% covered (success)
94.32%
166 / 176
83.33% covered (warning)
83.33%
10 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiParamValidator
94.32% covered (success)
94.32%
166 / 176
83.33% covered (warning)
83.33%
10 / 12
64.75
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 overrideTypeDef
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 knownTypes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 mapDeprecatedSettingsMessages
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 normalizeSettings
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 checkSettingsMessage
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 checkSettings
91.35% covered (success)
91.35%
95 / 104
0.00% covered (danger)
0.00%
0 / 1
39.99
 convertValidationException
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getValue
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 validateValue
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getParamInfo
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getHelpInfo
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace MediaWiki\Api\Validator;
4
5use Exception;
6use MediaWiki\Api\ApiBase;
7use MediaWiki\Api\ApiMain;
8use MediaWiki\Api\ApiMessage;
9use MediaWiki\Api\ApiUsageException;
10use MediaWiki\Message\Message;
11use MediaWiki\ParamValidator\TypeDef\NamespaceDef;
12use MediaWiki\ParamValidator\TypeDef\TagsDef;
13use MediaWiki\ParamValidator\TypeDef\TitleDef;
14use MediaWiki\ParamValidator\TypeDef\UserDef;
15use Wikimedia\Message\DataMessageValue;
16use Wikimedia\Message\MessageValue;
17use Wikimedia\ObjectFactory\ObjectFactory;
18use Wikimedia\ParamValidator\ParamValidator;
19use Wikimedia\ParamValidator\TypeDef;
20use Wikimedia\ParamValidator\TypeDef\EnumDef;
21use Wikimedia\ParamValidator\TypeDef\ExpiryDef;
22use Wikimedia\ParamValidator\TypeDef\IntegerDef;
23use Wikimedia\ParamValidator\TypeDef\LimitDef;
24use Wikimedia\ParamValidator\TypeDef\PasswordDef;
25use Wikimedia\ParamValidator\TypeDef\PresenceBooleanDef;
26use Wikimedia\ParamValidator\TypeDef\StringDef;
27use Wikimedia\ParamValidator\TypeDef\TimestampDef;
28use Wikimedia\ParamValidator\TypeDef\UploadDef;
29use Wikimedia\ParamValidator\ValidationException;
30use Wikimedia\RequestTimeout\TimeoutException;
31use Wikimedia\Timestamp\TimestampFormat as TS;
32
33/**
34 * This wraps a bunch of the API-specific parameter validation logic.
35 *
36 * It's intended to be used in ApiMain by composition.
37 *
38 * @since 1.35
39 * @ingroup API
40 */
41class ApiParamValidator {
42
43    /** @var ParamValidator */
44    private $paramValidator;
45
46    /** Type defs for ParamValidator */
47    private const TYPE_DEFS = [
48        'boolean' => [ 'class' => PresenceBooleanDef::class ],
49        'enum' => [ 'class' => EnumDef::class ],
50        'expiry' => [ 'class' => ExpiryDef::class ],
51        'integer' => [ 'class' => IntegerDef::class ],
52        'limit' => [ 'class' => LimitDef::class ],
53        'namespace' => [
54            'class' => NamespaceDef::class,
55            'services' => [ 'NamespaceInfo' ],
56        ],
57        'NULL' => [
58            'class' => StringDef::class,
59            'args' => [ [
60                StringDef::OPT_ALLOW_EMPTY => true,
61            ] ],
62        ],
63        'password' => [ 'class' => PasswordDef::class ],
64        // Unlike 'string', the 'raw' type will not be subject to Unicode
65        // NFC normalization.
66        'raw' => [ 'class' => StringDef::class ],
67        'string' => [ 'class' => StringDef::class ],
68        'submodule' => [ 'class' => SubmoduleDef::class ],
69        'tags' => [
70            'class' => TagsDef::class,
71            'services' => [ 'ChangeTagsStore' ],
72        ],
73        'text' => [ 'class' => StringDef::class ],
74        'timestamp' => [
75            'class' => TimestampDef::class,
76            'args' => [ [
77                'defaultFormat' => TS::MW,
78            ] ],
79        ],
80        'title' => [
81            'class' => TitleDef::class,
82            'services' => [ 'TitleFactory' ],
83        ],
84        'user' => [
85            'class' => UserDef::class,
86            'services' => [ 'UserIdentityLookup', 'TitleParser', 'UserNameUtils' ]
87        ],
88        'upload' => [ 'class' => UploadDef::class ],
89    ];
90
91    /**
92     * @internal
93     * @param ApiMain $main
94     * @param ObjectFactory $objectFactory
95     */
96    public function __construct( ApiMain $main, ObjectFactory $objectFactory ) {
97        $this->paramValidator = new ParamValidator(
98            new ApiParamValidatorCallbacks( $main ),
99            $objectFactory,
100            [
101                'typeDefs' => self::TYPE_DEFS,
102                'ismultiLimits' => [ ApiBase::LIMIT_SML1, ApiBase::LIMIT_SML2 ],
103            ]
104        );
105    }
106
107    /**
108     * Register a type handler, overriding any existing handler.
109     *
110     * @since 1.47
111     * @see ParamValidator::overrideTypeDef
112     *
113     * @param string $name Type name
114     * @param TypeDef|array|null $typeDef As for addTypeDef, or null to unregister a type.
115     */
116    public function overrideTypeDef( string $name, $typeDef ) {
117        $this->paramValidator->overrideTypeDef( $name, $typeDef );
118    }
119
120    /**
121     * List known type names
122     * @return string[]
123     */
124    public function knownTypes(): array {
125        return $this->paramValidator->knownTypes();
126    }
127
128    /**
129     * Map deprecated styles for messages for ParamValidator
130     * @param array $settings
131     * @return array
132     */
133    private function mapDeprecatedSettingsMessages( array $settings ): array {
134        if ( isset( $settings[EnumDef::PARAM_DEPRECATED_VALUES] ) ) {
135            foreach ( $settings[EnumDef::PARAM_DEPRECATED_VALUES] as &$v ) {
136                if ( $v === null || $v === true || $v instanceof MessageValue ) {
137                    continue;
138                }
139
140                // Convert the message specification to a DataMessageValue. Flag in the data
141                // that it was so converted, so ApiParamValidatorCallbacks::recordCondition() can
142                // take that into account.
143                $msg = ApiMessage::create( $v );
144                $v = DataMessageValue::new(
145                    $msg->getKey(),
146                    $msg->getParams(),
147                    'bogus',
148                    [ '💩' => 'back-compat' ]
149                );
150            }
151            unset( $v );
152        }
153
154        return $settings;
155    }
156
157    /**
158     * Adjust certain settings where ParamValidator differs from historical Action API behavior
159     * @param array|mixed $settings
160     * @return array
161     */
162    public function normalizeSettings( $settings ): array {
163        if ( is_array( $settings ) ) {
164            if ( !isset( $settings[ParamValidator::PARAM_IGNORE_UNRECOGNIZED_VALUES] ) ) {
165                $settings[ParamValidator::PARAM_IGNORE_UNRECOGNIZED_VALUES] = true;
166            }
167
168            if ( !isset( $settings[IntegerDef::PARAM_IGNORE_RANGE] ) ) {
169                $settings[IntegerDef::PARAM_IGNORE_RANGE] = empty( $settings[ApiBase::PARAM_RANGE_ENFORCE] );
170            }
171
172            $settings = $this->mapDeprecatedSettingsMessages( $settings );
173        }
174
175        return $this->paramValidator->normalizeSettings( $settings );
176    }
177
178    /**
179     * Check an API settings message
180     * @param string $key
181     * @param string|array|Message $value Message definition, see Message::newFromSpecifier()
182     * @param array &$ret
183     */
184    private function checkSettingsMessage( string $key, $value, array &$ret ): void {
185        try {
186            $msg = Message::newFromSpecifier( $value );
187            $ret['messages'][] = MessageValue::newFromSpecifier( $msg );
188        } catch ( TimeoutException $e ) {
189            throw $e;
190        } catch ( Exception ) {
191            $ret['issues'][] = "Message specification for $key is not valid";
192        }
193    }
194
195    /**
196     * Check settings for the Action API.
197     * @param ApiBase $module
198     * @param array $params All module params to test
199     * @param string $name Parameter to test
200     * @param array $options Options array
201     * @return array As for ParamValidator::checkSettings()
202     */
203    public function checkSettings(
204        ApiBase $module, array $params, string $name, array $options
205    ): array {
206        $options['module'] = $module;
207        $settings = $params[$name];
208        if ( is_array( $settings ) ) {
209            $settings = $this->mapDeprecatedSettingsMessages( $settings );
210        }
211        $ret = $this->paramValidator->checkSettings(
212            $module->encodeParamName( $name ), $settings, $options
213        );
214
215        $ret['allowedKeys'] = array_merge( $ret['allowedKeys'], [
216            ApiBase::PARAM_RANGE_ENFORCE, ApiBase::PARAM_HELP_MSG, ApiBase::PARAM_HELP_MSG_APPEND,
217            ApiBase::PARAM_HELP_MSG_INFO, ApiBase::PARAM_HELP_MSG_PER_VALUE, ApiBase::PARAM_TEMPLATE_VARS,
218        ] );
219
220        if ( !is_array( $settings ) ) {
221            $settings = [];
222        }
223
224        if ( !is_bool( $settings[ApiBase::PARAM_RANGE_ENFORCE] ?? false ) ) {
225            $ret['issues'][ApiBase::PARAM_RANGE_ENFORCE] = 'PARAM_RANGE_ENFORCE must be boolean, got '
226                . gettype( $settings[ApiBase::PARAM_RANGE_ENFORCE] );
227        }
228
229        $path = $module->getModulePath();
230        $this->checkSettingsMessage(
231            'PARAM_HELP_MSG', $settings[ApiBase::PARAM_HELP_MSG] ?? "apihelp-$path-param-$name", $ret
232        );
233
234        if ( isset( $settings[ApiBase::PARAM_HELP_MSG_APPEND] ) ) {
235            if ( !is_array( $settings[ApiBase::PARAM_HELP_MSG_APPEND] ) ) {
236                $ret['issues'][ApiBase::PARAM_HELP_MSG_APPEND] = 'PARAM_HELP_MSG_APPEND must be an array, got '
237                    . gettype( $settings[ApiBase::PARAM_HELP_MSG_APPEND] );
238            } else {
239                foreach ( $settings[ApiBase::PARAM_HELP_MSG_APPEND] as $k => $v ) {
240                    $this->checkSettingsMessage( "PARAM_HELP_MSG_APPEND[$k]", $v, $ret );
241                }
242            }
243        }
244
245        if ( isset( $settings[ApiBase::PARAM_HELP_MSG_INFO] ) ) {
246            if ( !is_array( $settings[ApiBase::PARAM_HELP_MSG_INFO] ) ) {
247                $ret['issues'][ApiBase::PARAM_HELP_MSG_INFO] = 'PARAM_HELP_MSG_INFO must be an array, got '
248                    . gettype( $settings[ApiBase::PARAM_HELP_MSG_INFO] );
249            } else {
250                foreach ( $settings[ApiBase::PARAM_HELP_MSG_INFO] as $k => $v ) {
251                    if ( !is_array( $v ) ) {
252                        $ret['issues'][] = "PARAM_HELP_MSG_INFO[$k] must be an array, got " . gettype( $v );
253                    } elseif ( !is_string( $v[0] ) ) {
254                        $ret['issues'][] = "PARAM_HELP_MSG_INFO[$k][0] must be a string, got " . gettype( $v[0] );
255                    } else {
256                        $v[0] = "apihelp-{$path}-paraminfo-{$v[0]}";
257                        $this->checkSettingsMessage( "PARAM_HELP_MSG_INFO[$k]", $v, $ret );
258                    }
259                }
260            }
261        }
262
263        if ( isset( $settings[ApiBase::PARAM_HELP_MSG_PER_VALUE] ) ) {
264            // ! keep these checks in sync with \MediaWiki\Api\ApiBase::getFinalParamDescription
265            if ( !is_array( $settings[ApiBase::PARAM_HELP_MSG_PER_VALUE] ) ) {
266                $ret['issues'][ApiBase::PARAM_HELP_MSG_PER_VALUE] = 'PARAM_HELP_MSG_PER_VALUE must be an array,'
267                    . ' got ' . gettype( $settings[ApiBase::PARAM_HELP_MSG_PER_VALUE] );
268            } elseif ( !( is_array( $settings[ParamValidator::PARAM_TYPE] ?? '' ) || (
269                    $settings[ParamValidator::PARAM_TYPE] === 'string'
270                    && ( $settings[ParamValidator::PARAM_ISMULTI] ?? false )
271                ) ) ) {
272                $ret['issues'][ApiBase::PARAM_HELP_MSG_PER_VALUE] = 'PARAM_HELP_MSG_PER_VALUE can only be used '
273                    . 'with PARAM_TYPE as an array, or PARAM_TYPE = string and PARAM_ISMULTI = true';
274            } elseif ( $settings[ParamValidator::PARAM_TYPE] === 'string'
275                && ( $settings[ParamValidator::PARAM_ISMULTI] ?? false ) ) {
276                foreach ( $settings[ApiBase::PARAM_HELP_MSG_PER_VALUE] as $k => $v ) {
277                    $this->checkSettingsMessage( "PARAM_HELP_MSG_PER_VALUE[$k]", $v, $ret );
278                }
279            } else {
280                $values = array_map( 'strval', $settings[ParamValidator::PARAM_TYPE] );
281                foreach ( $settings[ApiBase::PARAM_HELP_MSG_PER_VALUE] as $k => $v ) {
282                    if ( !in_array( (string)$k, $values, true ) ) {
283                        // Or should this be allowed?
284                        $ret['issues'][] = "PARAM_HELP_MSG_PER_VALUE contains \"$k\", which is not in PARAM_TYPE.";
285                    }
286                    $this->checkSettingsMessage( "PARAM_HELP_MSG_PER_VALUE[$k]", $v, $ret );
287                }
288                foreach ( $settings[ParamValidator::PARAM_TYPE] as $p ) {
289                    if ( array_key_exists( $p, $settings[ApiBase::PARAM_HELP_MSG_PER_VALUE] ) ) {
290                        continue;
291                    }
292                    $path = $module->getModulePath();
293                    $this->checkSettingsMessage(
294                        "PARAM_HELP_MSG_PER_VALUE[$p]",
295                        "apihelp-$path-paramvalue-$name-$p",
296                        $ret
297                    );
298                }
299            }
300        }
301
302        if ( isset( $settings[ApiBase::PARAM_TEMPLATE_VARS] ) ) {
303            if ( !is_array( $settings[ApiBase::PARAM_TEMPLATE_VARS] ) ) {
304                $ret['issues'][ApiBase::PARAM_TEMPLATE_VARS] = 'PARAM_TEMPLATE_VARS must be an array,'
305                    . ' got ' . gettype( $settings[ApiBase::PARAM_TEMPLATE_VARS] );
306            } elseif ( $settings[ApiBase::PARAM_TEMPLATE_VARS] === [] ) {
307                $ret['issues'][ApiBase::PARAM_TEMPLATE_VARS] = 'PARAM_TEMPLATE_VARS cannot be the empty array';
308            } else {
309                foreach ( $settings[ApiBase::PARAM_TEMPLATE_VARS] as $key => $target ) {
310                    if ( !preg_match( '/^[^{}]+$/', $key ) ) {
311                        $ret['issues'][] = "PARAM_TEMPLATE_VARS keys may not contain '{' or '}', got \"$key\"";
312                    } elseif ( !str_contains( $name, '{' . $key . '}' ) ) {
313                        $ret['issues'][] = "Parameter name must contain PARAM_TEMPLATE_VARS key {{$key}}";
314                    }
315                    if ( !is_string( $target ) && !is_int( $target ) ) {
316                        $ret['issues'][] = "PARAM_TEMPLATE_VARS[$key] has invalid target type " . gettype( $target );
317                    } elseif ( !isset( $params[$target] ) ) {
318                        $ret['issues'][] = "PARAM_TEMPLATE_VARS[$key] target parameter \"$target\" does not exist";
319                    } else {
320                        $settings2 = $params[$target];
321                        if ( empty( $settings2[ParamValidator::PARAM_ISMULTI] ) ) {
322                            $ret['issues'][] = "PARAM_TEMPLATE_VARS[$key] target parameter \"$target\" must have "
323                                . 'PARAM_ISMULTI = true';
324                        }
325                        if ( isset( $settings2[ApiBase::PARAM_TEMPLATE_VARS] ) ) {
326                            if ( $target === $name ) {
327                                $ret['issues'][] = "PARAM_TEMPLATE_VARS[$key] cannot target the parameter itself";
328                            }
329                            if ( array_diff(
330                                $settings2[ApiBase::PARAM_TEMPLATE_VARS],
331                                $settings[ApiBase::PARAM_TEMPLATE_VARS]
332                            ) ) {
333                                $ret['issues'][] = "PARAM_TEMPLATE_VARS[$key]: Target's "
334                                    . 'PARAM_TEMPLATE_VARS must be a subset of the original';
335                            }
336                        }
337                    }
338                }
339
340                $keys = implode( '|', array_map(
341                    static function ( $key ) {
342                        return preg_quote( $key, '/' );
343                    },
344                    array_keys( $settings[ApiBase::PARAM_TEMPLATE_VARS] )
345                ) );
346                if ( !preg_match( '/^(?>[^{}]+|\{(?:' . $keys . ')\})+$/', $name ) ) {
347                    $ret['issues'][] = "Parameter name may not contain '{' or '}' other than '
348                        . 'as defined by PARAM_TEMPLATE_VARS";
349                }
350            }
351        } elseif ( !preg_match( '/^[^{}]+$/', $name ) ) {
352            $ret['issues'][] = "Parameter name may not contain '{' or '}' without PARAM_TEMPLATE_VARS";
353        }
354
355        return $ret;
356    }
357
358    /**
359     * Convert a ValidationException to an ApiUsageException
360     * @param ApiBase $module
361     * @param ValidationException $ex
362     * @throws ApiUsageException always
363     * @return never
364     */
365    private function convertValidationException( ApiBase $module, ValidationException $ex ): never {
366        $mv = $ex->getFailureMessage();
367        throw ApiUsageException::newWithMessage(
368            $module,
369            $mv,
370            $mv->getCode(),
371            $mv->getData(),
372            0,
373            $ex
374        );
375    }
376
377    /**
378     * Get and validate a value
379     * @param ApiBase $module
380     * @param string $name Parameter name, unprefixed
381     * @param array|mixed $settings Default value or an array of settings
382     *  using PARAM_* constants.
383     * @param array $options Options array
384     * @return mixed Validated parameter value
385     * @throws ApiUsageException if the value is invalid
386     */
387    public function getValue( ApiBase $module, string $name, $settings, array $options = [] ) {
388        $options['module'] = $module;
389        $name = $module->encodeParamName( $name );
390        $settings = $this->normalizeSettings( $settings );
391        try {
392            return $this->paramValidator->getValue( $name, $settings, $options );
393        } catch ( ValidationException $ex ) {
394            $this->convertValidationException( $module, $ex );
395        }
396    }
397
398    /**
399     * Validate a parameter value using a settings array
400     *
401     * @param ApiBase $module
402     * @param string $name Parameter name, unprefixed
403     * @param mixed $value Parameter value
404     * @param array|mixed $settings Default value or an array of settings
405     *  using PARAM_* constants.
406     * @param array $options Options array
407     * @return mixed Validated parameter value(s)
408     * @throws ApiUsageException if the value is invalid
409     */
410    public function validateValue(
411        ApiBase $module, string $name, $value, $settings, array $options = []
412    ) {
413        $options['module'] = $module;
414        $name = $module->encodeParamName( $name );
415        $settings = $this->normalizeSettings( $settings );
416        try {
417            return $this->paramValidator->validateValue( $name, $value, $settings, $options );
418        } catch ( ValidationException $ex ) {
419            $this->convertValidationException( $module, $ex );
420        }
421    }
422
423    /**
424     * Describe parameter settings in a machine-readable format.
425     *
426     * @param ApiBase $module
427     * @param string $name Parameter name.
428     * @param array|mixed $settings Default value or an array of settings
429     *  using PARAM_* constants.
430     * @param array $options Options array.
431     * @return array
432     */
433    public function getParamInfo( ApiBase $module, string $name, $settings, array $options ): array {
434        $options['module'] = $module;
435        $name = $module->encodeParamName( $name );
436        return $this->paramValidator->getParamInfo( $name, $settings, $options );
437    }
438
439    /**
440     * Describe parameter settings in human-readable format
441     *
442     * @param ApiBase $module
443     * @param string $name Parameter name being described.
444     * @param array|mixed $settings Default value or an array of settings
445     *  using PARAM_* constants.
446     * @param array $options Options array.
447     * @return Message[]
448     */
449    public function getHelpInfo( ApiBase $module, string $name, $settings, array $options ): array {
450        $options['module'] = $module;
451        $name = $module->encodeParamName( $name );
452
453        $ret = $this->paramValidator->getHelpInfo( $name, $settings, $options );
454        foreach ( $ret as &$m ) {
455            $k = $m->getKey();
456            $m = Message::newFromSpecifier( $m );
457            if ( str_starts_with( $k, 'paramvalidator-help-' ) ) {
458                $m = new Message(
459                    [ 'api-help-param-' . substr( $k, 20 ), $k ],
460                    $m->getParams()
461                );
462            }
463        }
464        '@phan-var Message[] $ret'; // The above loop converts it
465
466        return $ret;
467    }
468}