Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
98.26% |
113 / 115 |
|
75.00% |
6 / 8 |
CRAP | |
0.00% |
0 / 1 |
| Validator | |
98.26% |
113 / 115 |
|
75.00% |
6 / 8 |
26 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| validateParams | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
4 | |||
| detectExtraneousBodyFields | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
5 | |||
| validateBodyParams | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
4 | |||
| validateBody | |
93.75% |
15 / 16 |
|
0.00% |
0 / 1 |
5.01 | |||
| getParameterTypeSchemas | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getParameterSpec | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
| getParameterSchema | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
4 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace MediaWiki\Rest\Validator; |
| 4 | |
| 5 | use MediaWiki\ParamValidator\TypeDef\ArrayDef; |
| 6 | use MediaWiki\ParamValidator\TypeDef\NamespaceDef; |
| 7 | use MediaWiki\ParamValidator\TypeDef\TitleDef; |
| 8 | use MediaWiki\ParamValidator\TypeDef\UserDef; |
| 9 | use MediaWiki\Permissions\Authority; |
| 10 | use MediaWiki\Rest\Handler; |
| 11 | use MediaWiki\Rest\HttpException; |
| 12 | use MediaWiki\Rest\LocalizedHttpException; |
| 13 | use MediaWiki\Rest\RequestInterface; |
| 14 | use Wikimedia\Message\ListParam; |
| 15 | use Wikimedia\Message\ListType; |
| 16 | use Wikimedia\Message\MessageValue; |
| 17 | use Wikimedia\ObjectFactory\ObjectFactory; |
| 18 | use Wikimedia\ParamValidator\ParamValidator; |
| 19 | use Wikimedia\ParamValidator\TypeDef; |
| 20 | use Wikimedia\ParamValidator\TypeDef\BooleanDef; |
| 21 | use Wikimedia\ParamValidator\TypeDef\EnumDef; |
| 22 | use Wikimedia\ParamValidator\TypeDef\ExpiryDef; |
| 23 | use Wikimedia\ParamValidator\TypeDef\FloatDef; |
| 24 | use Wikimedia\ParamValidator\TypeDef\IntegerDef; |
| 25 | use Wikimedia\ParamValidator\TypeDef\PasswordDef; |
| 26 | use Wikimedia\ParamValidator\TypeDef\StringDef; |
| 27 | use Wikimedia\ParamValidator\TypeDef\TimestampDef; |
| 28 | use Wikimedia\ParamValidator\TypeDef\UploadDef; |
| 29 | use Wikimedia\ParamValidator\ValidationException; |
| 30 | |
| 31 | /** |
| 32 | * Wrapper for ParamValidator |
| 33 | * |
| 34 | * It's intended to be used in the REST API classes by composition. |
| 35 | * |
| 36 | * @since 1.34 |
| 37 | */ |
| 38 | class Validator { |
| 39 | |
| 40 | /** |
| 41 | * (array) ParamValidator array to specify the known sources of the parameter. |
| 42 | * 'post' refers to application/x-www-form-urlencoded or multipart/form-data encoded parameters |
| 43 | * in the body of a POST request (in other words, parameters in PHP's $_POST). For other kinds |
| 44 | * of POST parameters, such as JSON fields, use BodyValidator instead of ParamValidator. |
| 45 | * This list must correspond to the switch statement in ParamValidatorCallbacks::getParamsFromSource. |
| 46 | * |
| 47 | * @since 1.42 |
| 48 | */ |
| 49 | public const KNOWN_PARAM_SOURCES = [ 'path', 'query', 'body', 'post' ]; |
| 50 | |
| 51 | /** |
| 52 | * (string) ParamValidator constant for use as a key in a param settings array |
| 53 | * to specify the source of the parameter. |
| 54 | * Value must be one of the values in KNOWN_PARAM_SOURCES. |
| 55 | */ |
| 56 | public const PARAM_SOURCE = 'rest-param-source'; |
| 57 | |
| 58 | /** |
| 59 | * Parameter description to use in generated documentation |
| 60 | */ |
| 61 | public const PARAM_DESCRIPTION = 'rest-param-description'; |
| 62 | |
| 63 | /** @var array Type defs for ParamValidator */ |
| 64 | private const TYPE_DEFS = [ |
| 65 | 'boolean' => [ 'class' => BooleanDef::class ], |
| 66 | 'enum' => [ 'class' => EnumDef::class ], |
| 67 | 'integer' => [ 'class' => IntegerDef::class ], |
| 68 | 'float' => [ 'class' => FloatDef::class ], |
| 69 | 'double' => [ 'class' => FloatDef::class ], |
| 70 | 'NULL' => [ |
| 71 | 'class' => StringDef::class, |
| 72 | 'args' => [ [ |
| 73 | StringDef::OPT_ALLOW_EMPTY => true, |
| 74 | ] ], |
| 75 | ], |
| 76 | 'password' => [ 'class' => PasswordDef::class ], |
| 77 | 'string' => [ 'class' => StringDef::class ], |
| 78 | 'timestamp' => [ 'class' => TimestampDef::class ], |
| 79 | 'upload' => [ 'class' => UploadDef::class ], |
| 80 | 'expiry' => [ 'class' => ExpiryDef::class ], |
| 81 | 'namespace' => [ |
| 82 | 'class' => NamespaceDef::class, |
| 83 | 'services' => [ 'NamespaceInfo' ], |
| 84 | ], |
| 85 | 'title' => [ |
| 86 | 'class' => TitleDef::class, |
| 87 | 'services' => [ 'TitleFactory' ], |
| 88 | ], |
| 89 | 'user' => [ |
| 90 | 'class' => UserDef::class, |
| 91 | 'services' => [ 'UserIdentityLookup', 'TitleParser', 'UserNameUtils' ] |
| 92 | ], |
| 93 | 'array' => [ |
| 94 | 'class' => ArrayDef::class, |
| 95 | ], |
| 96 | ]; |
| 97 | |
| 98 | /** @var string[] HTTP request methods that we expect never to have a payload */ |
| 99 | private const NO_BODY_METHODS = [ 'GET', 'HEAD' ]; |
| 100 | |
| 101 | /** @var string[] HTTP request methods that we expect always to have a payload */ |
| 102 | private const BODY_METHODS = [ 'POST', 'PUT' ]; |
| 103 | |
| 104 | // NOTE: per RFC 7231 (https://www.rfc-editor.org/rfc/rfc7231#section-4.3.5), sending a body |
| 105 | // with the DELETE method "has no defined semantics". We allow it, as it is useful for |
| 106 | // passing the csrf token required by some authentication methods. |
| 107 | |
| 108 | /** @var string[] Content types handled via $_POST */ |
| 109 | private const FORM_DATA_CONTENT_TYPES = [ |
| 110 | 'application/x-www-form-urlencoded', |
| 111 | 'multipart/form-data', |
| 112 | ]; |
| 113 | |
| 114 | private ParamValidator $paramValidator; |
| 115 | |
| 116 | /** |
| 117 | * @param ObjectFactory $objectFactory |
| 118 | * @param RequestInterface $request |
| 119 | * @param Authority $authority |
| 120 | * @internal |
| 121 | */ |
| 122 | public function __construct( |
| 123 | ObjectFactory $objectFactory, |
| 124 | RequestInterface $request, |
| 125 | Authority $authority |
| 126 | ) { |
| 127 | $this->paramValidator = new ParamValidator( |
| 128 | new ParamValidatorCallbacks( $request, $authority ), |
| 129 | $objectFactory, |
| 130 | [ |
| 131 | 'typeDefs' => self::TYPE_DEFS, |
| 132 | ] |
| 133 | ); |
| 134 | } |
| 135 | |
| 136 | /** |
| 137 | * Validate parameters. |
| 138 | * Params with the source specified as 'body' will be ignored. |
| 139 | * Use validateBodyParams() for these. |
| 140 | * |
| 141 | * @see validateBodyParams |
| 142 | * @param array[] $paramSettings Parameter settings |
| 143 | * @return array Validated parameters |
| 144 | * @throws HttpException on validation failure |
| 145 | */ |
| 146 | public function validateParams( array $paramSettings ) { |
| 147 | $validatedParams = []; |
| 148 | foreach ( $paramSettings as $name => $settings ) { |
| 149 | try { |
| 150 | $source = $settings[Handler::PARAM_SOURCE] ?? 'unspecified'; |
| 151 | if ( $source === 'body' ) { |
| 152 | continue; |
| 153 | } |
| 154 | |
| 155 | $validatedParams[$name] = $this->paramValidator->getValue( $name, $settings, [ |
| 156 | 'source' => $source, |
| 157 | ] ); |
| 158 | } catch ( ValidationException $e ) { |
| 159 | // NOTE: error data structure must match the one used by validateBodyParams |
| 160 | throw new LocalizedHttpException( $e->getFailureMessage(), 400, [ |
| 161 | 'error' => 'parameter-validation-failed', |
| 162 | 'name' => $e->getParamName(), |
| 163 | 'value' => $e->getParamValue(), |
| 164 | 'failureCode' => $e->getFailureMessage()->getCode(), |
| 165 | 'failureData' => $e->getFailureMessage()->getData(), |
| 166 | ] ); |
| 167 | } |
| 168 | } |
| 169 | return $validatedParams; |
| 170 | } |
| 171 | |
| 172 | /** |
| 173 | * Throw an HttpException if there are unexpected body fields. |
| 174 | * |
| 175 | * Note that this will ignore all body fields if $paramSettings does not |
| 176 | * declare any body parameters, to avoid failures when clients send spurious |
| 177 | * data to handlers that do not support body validation at all. This |
| 178 | * behavior may change in the future. |
| 179 | * |
| 180 | * @param array $paramSettings |
| 181 | * @param array $parsedBody |
| 182 | * |
| 183 | * @throws LocalizedHttpException if there are unexpected body fields. |
| 184 | */ |
| 185 | public function detectExtraneousBodyFields( array $paramSettings, array $parsedBody ) { |
| 186 | $validatedKeys = []; |
| 187 | $remainingBodyFields = $parsedBody; |
| 188 | foreach ( $paramSettings as $name => $settings ) { |
| 189 | $source = $settings[Handler::PARAM_SOURCE] ?? 'unspecified'; |
| 190 | |
| 191 | if ( $source !== 'body' ) { |
| 192 | continue; |
| 193 | } |
| 194 | |
| 195 | $validatedKeys[] = $name; |
| 196 | unset( $remainingBodyFields[$name] ); |
| 197 | } |
| 198 | $unvalidatedKeys = array_keys( $remainingBodyFields ); |
| 199 | |
| 200 | // Throw if there are unvalidated keys left and there are body params defined. |
| 201 | // If there are no known body params declared, we just ignore any body |
| 202 | // data coming from the client. This works around that fact that "post" |
| 203 | // params also show up in the parsed body. That means that mixing "body" |
| 204 | // and "post" params will trigger an error here. Any "post" params should |
| 205 | // be converted to "body". |
| 206 | if ( $validatedKeys && $unvalidatedKeys ) { |
| 207 | throw new LocalizedHttpException( |
| 208 | new MessageValue( |
| 209 | 'rest-extraneous-body-fields', |
| 210 | [ new ListParam( ListType::COMMA, $unvalidatedKeys ) ] |
| 211 | ), |
| 212 | 400, |
| 213 | [ // match fields used by validateBodyParams() |
| 214 | 'error' => 'parameter-validation-failed', |
| 215 | 'failureCode' => 'extraneous-body-fields', |
| 216 | 'name' => reset( $unvalidatedKeys ), |
| 217 | 'failureData' => $unvalidatedKeys, |
| 218 | ] |
| 219 | ); |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | /** |
| 224 | * Validate body fields. |
| 225 | * Only params with the source specified as 'body' will be processed, |
| 226 | * use validateParams() for parameters coming from the path, from query, etc. |
| 227 | * |
| 228 | * @since 1.42 |
| 229 | * |
| 230 | * @see validateParams |
| 231 | * @see validateBody |
| 232 | * @param array[] $paramSettings Parameter settings. |
| 233 | * @param bool $enforceTypes $enforceTypes Whether the types of primitive values should |
| 234 | * be enforced. If set to false, parameters values are allowed to be |
| 235 | * strings. |
| 236 | * @return array Validated parameters |
| 237 | * @throws HttpException on validation failure |
| 238 | */ |
| 239 | public function validateBodyParams( array $paramSettings, bool $enforceTypes = true ) { |
| 240 | $validatedParams = []; |
| 241 | foreach ( $paramSettings as $name => $settings ) { |
| 242 | $source = $settings[Handler::PARAM_SOURCE] ?? 'body'; |
| 243 | if ( $source !== 'body' ) { |
| 244 | continue; |
| 245 | } |
| 246 | |
| 247 | try { |
| 248 | $validatedParams[ $name ] = $this->paramValidator->getValue( |
| 249 | $name, |
| 250 | $settings, |
| 251 | [ |
| 252 | 'source' => $source, |
| 253 | TypeDef::OPT_ENFORCE_JSON_TYPES => $enforceTypes, |
| 254 | StringDef::OPT_ALLOW_EMPTY => $enforceTypes, |
| 255 | ] |
| 256 | ); |
| 257 | } catch ( ValidationException $e ) { |
| 258 | $msg = $e->getFailureMessage(); |
| 259 | $wrappedMsg = new MessageValue( |
| 260 | 'rest-body-validation-error', |
| 261 | [ $e->getFailureMessage() ] |
| 262 | ); |
| 263 | |
| 264 | // NOTE: error data structure must match the one used by validateParams |
| 265 | throw new LocalizedHttpException( $wrappedMsg, 400, [ |
| 266 | 'error' => 'parameter-validation-failed', |
| 267 | 'name' => $e->getParamName(), |
| 268 | 'value' => $e->getParamValue(), |
| 269 | 'failureCode' => $msg->getCode(), |
| 270 | 'failureData' => $msg->getData(), |
| 271 | ] ); |
| 272 | } |
| 273 | } |
| 274 | return $validatedParams; |
| 275 | } |
| 276 | |
| 277 | /** |
| 278 | * Validate the body of a request. |
| 279 | * |
| 280 | * This may return a data structure representing the parsed body. When used |
| 281 | * in the context of Handler::validateParams(), the returned value will be |
| 282 | * available to the handler via Handler::getValidatedBody(). |
| 283 | * |
| 284 | * @deprecated since 1.43, use validateBodyParams instead. |
| 285 | * |
| 286 | * @param RequestInterface $request |
| 287 | * @param Handler $handler Used to call {@see Handler::getBodyValidator} |
| 288 | * @return mixed|null Return value from {@see BodyValidator::validateBody} |
| 289 | * @throws HttpException on validation failure |
| 290 | */ |
| 291 | public function validateBody( RequestInterface $request, Handler $handler ) { |
| 292 | wfDeprecated( __METHOD__, '1.43' ); |
| 293 | |
| 294 | $method = strtoupper( trim( $request->getMethod() ) ); |
| 295 | |
| 296 | // If the method should never have a body, don't bother validating. |
| 297 | if ( in_array( $method, self::NO_BODY_METHODS, true ) ) { |
| 298 | return null; |
| 299 | } |
| 300 | |
| 301 | // Get the content type |
| 302 | [ $ct ] = explode( ';', $request->getHeaderLine( 'Content-Type' ), 2 ); |
| 303 | $ct = strtolower( trim( $ct ) ); |
| 304 | if ( $ct === '' ) { |
| 305 | // No Content-Type was supplied. RFC 7231 § 3.1.1.5 allows this, but |
| 306 | // since it's probably a client error let's return a 415, unless the |
| 307 | // body is known to be empty. |
| 308 | $body = $request->getBody(); |
| 309 | if ( $body->getSize() === 0 ) { |
| 310 | return null; |
| 311 | } else { |
| 312 | throw new LocalizedHttpException( new MessageValue( "rest-requires-content-type-header" ), 415, [ |
| 313 | 'error' => 'no-content-type', |
| 314 | ] ); |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | // Form data is parsed into $_POST and $_FILES by PHP and from there is accessed as parameters, |
| 319 | // don't bother trying to handle these via BodyValidator too. |
| 320 | if ( in_array( $ct, RequestInterface::FORM_DATA_CONTENT_TYPES, true ) ) { |
| 321 | return null; |
| 322 | } |
| 323 | |
| 324 | // Validate the body. BodyValidator throws an HttpException on failure. |
| 325 | return $handler->getBodyValidator( $ct )->validateBody( $request ); |
| 326 | } |
| 327 | |
| 328 | private const PARAM_TYPE_SCHEMAS = [ |
| 329 | 'boolean-param' => [ 'type' => 'boolean' ], |
| 330 | 'enum-param' => [ 'type' => 'string' ], |
| 331 | 'integer-param' => [ 'type' => 'integer' ], |
| 332 | 'float-param' => [ 'type' => 'number', 'format' => 'float' ], |
| 333 | 'double-param' => [ 'type' => 'number', 'format' => 'double' ], |
| 334 | // 'NULL-param' => [ 'type' => 'null' ], // FIXME |
| 335 | 'password-param' => [ 'type' => 'string' ], |
| 336 | 'string-param' => [ 'type' => 'string' ], |
| 337 | 'timestamp-param' => [ 'type' => 'string', 'format' => 'mw-timestamp' ], |
| 338 | 'upload-param' => [ 'type' => 'string', 'format' => 'mw-upload' ], |
| 339 | 'expiry-param' => [ 'type' => 'string', 'format' => 'mw-expiry' ], |
| 340 | 'namespace-param' => [ 'type' => 'integer' ], |
| 341 | 'title-param' => [ 'type' => 'string', 'format' => 'mw-title' ], |
| 342 | 'user-param' => [ 'type' => 'string', 'format' => 'mw-user' ], |
| 343 | 'array-param' => [ 'type' => 'object' ], |
| 344 | ]; |
| 345 | |
| 346 | /** |
| 347 | * Returns JSON Schema description of all known parameter types. |
| 348 | * The name of the schema is the name of the parameter type with "-param" appended. |
| 349 | * |
| 350 | * @see https://swagger.io/specification/#schema-object |
| 351 | * @see self::TYPE_DEFS |
| 352 | * |
| 353 | * @return array |
| 354 | */ |
| 355 | public static function getParameterTypeSchemas(): array { |
| 356 | return self::PARAM_TYPE_SCHEMAS; |
| 357 | } |
| 358 | |
| 359 | /** |
| 360 | * Convert a param settings array into an OpenAPI Parameter Object specification structure. |
| 361 | * @see https://swagger.io/specification/#parameter-object |
| 362 | * |
| 363 | * @param string $name |
| 364 | * @param array $paramSetting |
| 365 | * |
| 366 | * @return array |
| 367 | */ |
| 368 | public static function getParameterSpec( string $name, array $paramSetting ): array { |
| 369 | $schema = self::getParameterSchema( $paramSetting ); |
| 370 | |
| 371 | // TODO: generate a warning if the source is not specified! |
| 372 | $location = $paramSetting[ self::PARAM_SOURCE ] ?? 'unspecified'; |
| 373 | |
| 374 | $param = [ |
| 375 | 'name' => $name, |
| 376 | 'description' => $paramSetting[ self::PARAM_DESCRIPTION ] ?? "$name parameter", |
| 377 | 'in' => $location, |
| 378 | 'schema' => $schema |
| 379 | ]; |
| 380 | |
| 381 | // TODO: generate a warning if required is false for a pth param |
| 382 | $param['required'] = $location === 'path' |
| 383 | || ( $paramSetting[ ParamValidator::PARAM_REQUIRED ] ?? false ); |
| 384 | |
| 385 | return $param; |
| 386 | } |
| 387 | |
| 388 | /** |
| 389 | * Convert a param settings array into an OpenAPI schema structure. |
| 390 | * @see https://swagger.io/specification/#schema-object |
| 391 | * |
| 392 | * @param array $paramSetting |
| 393 | * |
| 394 | * @return array |
| 395 | */ |
| 396 | public static function getParameterSchema( array $paramSetting ): array { |
| 397 | $type = $paramSetting[ ParamValidator::PARAM_TYPE ] ?? 'string'; |
| 398 | |
| 399 | if ( is_array( $type ) ) { |
| 400 | if ( $type === [] ) { |
| 401 | // Hack for empty enums. In path and query parameters, |
| 402 | // the empty string is often the same as "no value". |
| 403 | // TODO: generate a warning! |
| 404 | $type = [ '' ]; |
| 405 | } |
| 406 | |
| 407 | $schema = [ |
| 408 | 'type' => 'string', |
| 409 | 'enum' => $type |
| 410 | ]; |
| 411 | } elseif ( isset( $paramSetting[ ArrayDef::PARAM_SCHEMA ] ) ) { |
| 412 | $schema = $paramSetting[ ArrayDef::PARAM_SCHEMA ]; |
| 413 | } else { |
| 414 | // TODO: multi-value params?! |
| 415 | $schema = self::PARAM_TYPE_SCHEMAS["{$type}-param"] ?? []; |
| 416 | } |
| 417 | |
| 418 | return $schema; |
| 419 | } |
| 420 | |
| 421 | } |