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