Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.26% covered (success)
98.26%
113 / 115
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Validator
98.26% covered (success)
98.26%
113 / 115
75.00% covered (warning)
75.00%
6 / 8
26
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 validateParams
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 detectExtraneousBodyFields
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
5
 validateBodyParams
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
4
 validateBody
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
5.01
 getParameterTypeSchemas
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getParameterSpec
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 getParameterSchema
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3namespace MediaWiki\Rest\Validator;
4
5use MediaWiki\ParamValidator\TypeDef\ArrayDef;
6use MediaWiki\ParamValidator\TypeDef\NamespaceDef;
7use MediaWiki\ParamValidator\TypeDef\TitleDef;
8use MediaWiki\ParamValidator\TypeDef\UserDef;
9use MediaWiki\Permissions\Authority;
10use MediaWiki\Rest\Handler;
11use MediaWiki\Rest\HttpException;
12use MediaWiki\Rest\LocalizedHttpException;
13use MediaWiki\Rest\RequestInterface;
14use Wikimedia\Message\ListParam;
15use Wikimedia\Message\ListType;
16use Wikimedia\Message\MessageValue;
17use Wikimedia\ObjectFactory\ObjectFactory;
18use Wikimedia\ParamValidator\ParamValidator;
19use Wikimedia\ParamValidator\TypeDef;
20use Wikimedia\ParamValidator\TypeDef\BooleanDef;
21use Wikimedia\ParamValidator\TypeDef\EnumDef;
22use Wikimedia\ParamValidator\TypeDef\ExpiryDef;
23use Wikimedia\ParamValidator\TypeDef\FloatDef;
24use Wikimedia\ParamValidator\TypeDef\IntegerDef;
25use Wikimedia\ParamValidator\TypeDef\PasswordDef;
26use Wikimedia\ParamValidator\TypeDef\StringDef;
27use Wikimedia\ParamValidator\TypeDef\TimestampDef;
28use Wikimedia\ParamValidator\TypeDef\UploadDef;
29use 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 */
38class 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}