Merge "Refactor ContributionsSpecialPage->contributionsSub to support markup overrides"
[mediawiki.git] / includes / api / Validator / ApiParamValidator.php
blob8072d2d668ff8575cbbb52cfe4081ae1809b501c
1 <?php
3 namespace MediaWiki\Api\Validator;
5 use Exception;
6 use MediaWiki\Api\ApiBase;
7 use MediaWiki\Api\ApiMain;
8 use MediaWiki\Api\ApiMessage;
9 use MediaWiki\Api\ApiUsageException;
10 use MediaWiki\Message\Message;
11 use MediaWiki\ParamValidator\TypeDef\NamespaceDef;
12 use MediaWiki\ParamValidator\TypeDef\TagsDef;
13 use MediaWiki\ParamValidator\TypeDef\TitleDef;
14 use MediaWiki\ParamValidator\TypeDef\UserDef;
15 use Wikimedia\Message\DataMessageValue;
16 use Wikimedia\Message\MessageValue;
17 use Wikimedia\ObjectFactory\ObjectFactory;
18 use Wikimedia\ParamValidator\ParamValidator;
19 use Wikimedia\ParamValidator\TypeDef\EnumDef;
20 use Wikimedia\ParamValidator\TypeDef\ExpiryDef;
21 use Wikimedia\ParamValidator\TypeDef\IntegerDef;
22 use Wikimedia\ParamValidator\TypeDef\LimitDef;
23 use Wikimedia\ParamValidator\TypeDef\PasswordDef;
24 use Wikimedia\ParamValidator\TypeDef\PresenceBooleanDef;
25 use Wikimedia\ParamValidator\TypeDef\StringDef;
26 use Wikimedia\ParamValidator\TypeDef\TimestampDef;
27 use Wikimedia\ParamValidator\TypeDef\UploadDef;
28 use Wikimedia\ParamValidator\ValidationException;
29 use Wikimedia\RequestTimeout\TimeoutException;
31 /**
32 * This wraps a bunch of the API-specific parameter validation logic.
34 * It's intended to be used in ApiMain by composition.
36 * @since 1.35
37 * @ingroup API
39 class ApiParamValidator {
41 /** @var ParamValidator */
42 private $paramValidator;
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' ],
55 'NULL' => [
56 'class' => StringDef::class,
57 'args' => [ [
58 StringDef::OPT_ALLOW_EMPTY => true,
59 ] ],
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' ],
71 'text' => [ 'class' => StringDef::class ],
72 'timestamp' => [
73 'class' => TimestampDef::class,
74 'args' => [ [
75 'defaultFormat' => TS_MW,
76 ] ],
78 'title' => [
79 'class' => TitleDef::class,
80 'services' => [ 'TitleFactory' ],
82 'user' => [
83 'class' => UserDef::class,
84 'services' => [ 'UserIdentityLookup', 'TitleParser', 'UserNameUtils' ]
86 'upload' => [ 'class' => UploadDef::class ],
89 /**
90 * @internal
91 * @param ApiMain $main
92 * @param ObjectFactory $objectFactory
94 public function __construct( ApiMain $main, ObjectFactory $objectFactory ) {
95 $this->paramValidator = new ParamValidator(
96 new ApiParamValidatorCallbacks( $main ),
97 $objectFactory,
99 'typeDefs' => self::TYPE_DEFS,
100 'ismultiLimits' => [ ApiBase::LIMIT_SML1, ApiBase::LIMIT_SML2 ],
106 * List known type names
107 * @return string[]
109 public function knownTypes(): array {
110 return $this->paramValidator->knownTypes();
114 * Map deprecated styles for messages for ParamValidator
115 * @param array $settings
116 * @return array
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;
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' ]
136 unset( $v );
139 return $settings;
143 * Adjust certain settings where ParamValidator differs from historical Action API behavior
144 * @param array|mixed $settings
145 * @return array
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;
153 if ( !isset( $settings[IntegerDef::PARAM_IGNORE_RANGE] ) ) {
154 $settings[IntegerDef::PARAM_IGNORE_RANGE] = empty( $settings[ApiBase::PARAM_RANGE_ENFORCE] );
157 $settings = $this->mapDeprecatedSettingsMessages( $settings );
160 return $this->paramValidator->normalizeSettings( $settings );
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
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";
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()
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 );
197 $ret = $this->paramValidator->checkSettings(
198 $module->encodeParamName( $name ), $settings, $options
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 ] );
206 if ( !is_array( $settings ) ) {
207 $settings = [];
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] );
215 $path = $module->getModulePath();
216 $this->checkSettingsMessage(
217 $module, 'PARAM_HELP_MSG', $settings[ApiBase::PARAM_HELP_MSG] ?? "apihelp-$path-param-$name", $ret
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 );
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 );
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.";
263 $this->checkSettingsMessage( $module, "PARAM_HELP_MSG_PER_VALUE[$k]", $v, $ret );
265 foreach ( $settings[ParamValidator::PARAM_TYPE] as $p ) {
266 if ( array_key_exists( $p, $settings[ApiBase::PARAM_HELP_MSG_PER_VALUE] ) ) {
267 continue;
269 $path = $module->getModulePath();
270 $this->checkSettingsMessage(
271 $module,
272 "PARAM_HELP_MSG_PER_VALUE[$p]",
273 "apihelp-$path-paramvalue-$name-$p",
274 $ret
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}}";
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';
303 if ( isset( $settings2[ApiBase::PARAM_TEMPLATE_VARS] ) ) {
304 if ( $target === $name ) {
305 $ret['issues'][] = "PARAM_TEMPLATE_VARS[$key] cannot target the parameter itself";
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';
318 $keys = implode( '|', array_map(
319 static function ( $key ) {
320 return preg_quote( $key, '/' );
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";
329 } elseif ( !preg_match( '/^[^{}]+$/', $name ) ) {
330 $ret['issues'][] = "Parameter name may not contain '{' or '}' without PARAM_TEMPLATE_VARS";
333 return $ret;
337 * Convert a ValidationException to an ApiUsageException
338 * @param ApiBase $module
339 * @param ValidationException $ex
340 * @throws ApiUsageException always
341 * @return never
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(),
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
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 );
377 * Validate a parameter value using a settings array
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
388 public function validateValue(
389 ApiBase $module, string $name, $value, $settings, array $options = []
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 );
402 * Describe parameter settings in a machine-readable format.
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
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 );
418 * Describe parameter settings in human-readable format
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[]
427 public function getHelpInfo( ApiBase $module, string $name, $settings, array $options ): array {
428 $options['module'] = $module;
429 $name = $module->encodeParamName( $name );
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()
442 '@phan-var Message[] $ret'; // The above loop converts it
444 return $ret;