Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.29% covered (success)
98.29%
115 / 117
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Validator
98.29% covered (success)
98.29%
115 / 117
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%
19 / 19
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', 'header' ];
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                $type = $settings[ParamValidator::PARAM_TYPE] ?? 'unspecified';
156                $validatedParams[$name] = $this->paramValidator->getValue( $name, $settings, [
157                    'source' => $source,
158                    'type' => $type
159                ] );
160            } catch ( ValidationException $e ) {
161                // NOTE: error data structure must match the one used by validateBodyParams
162                throw new LocalizedHttpException( $e->getFailureMessage(), 400, [
163                    'error' => 'parameter-validation-failed',
164                    'name' => $e->getParamName(),
165                    'value' => $e->getParamValue(),
166                    'failureCode' => $e->getFailureMessage()->getCode(),
167                    'failureData' => $e->getFailureMessage()->getData(),
168                ] );
169            }
170        }
171        return $validatedParams;
172    }
173
174    /**
175     * Throw an HttpException if there are unexpected body fields.
176     *
177     * Note that this will ignore all body fields if $paramSettings does not
178     * declare any body parameters, to avoid failures when clients send spurious
179     * data to handlers that do not support body validation at all. This
180     * behavior may change in the future.
181     *
182     * @param array $paramSettings
183     * @param array $parsedBody
184     *
185     * @throws LocalizedHttpException if there are unexpected body fields.
186     */
187    public function detectExtraneousBodyFields( array $paramSettings, array $parsedBody ) {
188        $validatedKeys = [];
189        $remainingBodyFields = $parsedBody;
190        foreach ( $paramSettings as $name => $settings ) {
191            $source = $settings[Handler::PARAM_SOURCE] ?? 'unspecified';
192
193            if ( $source !== 'body' ) {
194                continue;
195            }
196
197            $validatedKeys[] = $name;
198            unset( $remainingBodyFields[$name] );
199        }
200        $unvalidatedKeys = array_keys( $remainingBodyFields );
201
202        // Throw if there are unvalidated keys left and there are body params defined.
203        // If there are no known body params declared, we just ignore any body
204        // data coming from the client. This works around that fact that "post"
205        // params also show up in the parsed body. That means that mixing "body"
206        // and "post" params will trigger an error here. Any "post" params should
207        // be converted to "body".
208        if ( $validatedKeys && $unvalidatedKeys ) {
209            throw new LocalizedHttpException(
210                new MessageValue(
211                    'rest-extraneous-body-fields',
212                    [ new ListParam( ListType::COMMA, $unvalidatedKeys ) ]
213                ),
214                400,
215                [ // match fields used by validateBodyParams()
216                    'error' => 'parameter-validation-failed',
217                    'failureCode' => 'extraneous-body-fields',
218                    'name' => reset( $unvalidatedKeys ),
219                    'failureData' => $unvalidatedKeys,
220                ]
221            );
222        }
223    }
224
225    /**
226     * Validate body fields.
227     * Only params with the source specified as 'body' will be processed,
228     * use validateParams() for parameters coming from the path, from query, etc.
229     *
230     * @since 1.42
231     *
232     * @see validateParams
233     * @see validateBody
234     * @param array[] $paramSettings Parameter settings.
235     * @param bool $enforceTypes $enforceTypes Whether the types of primitive values should
236     *         be enforced. If set to false, parameters values are allowed to be
237     *         strings.
238     * @return array Validated parameters
239     * @throws HttpException on validation failure
240     */
241    public function validateBodyParams( array $paramSettings, bool $enforceTypes = true ) {
242        $validatedParams = [];
243        foreach ( $paramSettings as $name => $settings ) {
244            $source = $settings[Handler::PARAM_SOURCE] ?? 'body';
245            if ( $source !== 'body' ) {
246                continue;
247            }
248
249            try {
250                $validatedParams[ $name ] = $this->paramValidator->getValue(
251                    $name,
252                    $settings,
253                    [
254                        'source' => $source,
255                        TypeDef::OPT_ENFORCE_JSON_TYPES => $enforceTypes,
256                        StringDef::OPT_ALLOW_EMPTY => $enforceTypes,
257                    ]
258                );
259            } catch ( ValidationException $e ) {
260                $msg = $e->getFailureMessage();
261                $wrappedMsg = new MessageValue(
262                    'rest-body-validation-error',
263                    [ $e->getFailureMessage() ]
264                );
265
266                // NOTE: error data structure must match the one used by validateParams
267                throw new LocalizedHttpException( $wrappedMsg, 400, [
268                    'error' => 'parameter-validation-failed',
269                    'name' => $e->getParamName(),
270                    'value' => $e->getParamValue(),
271                    'failureCode' => $msg->getCode(),
272                    'failureData' => $msg->getData(),
273                ] );
274            }
275        }
276        return $validatedParams;
277    }
278
279    /**
280     * Validate the body of a request.
281     *
282     * This may return a data structure representing the parsed body. When used
283     * in the context of Handler::validateParams(), the returned value will be
284     * available to the handler via Handler::getValidatedBody().
285     *
286     * @deprecated since 1.43, use validateBodyParams instead.
287     *
288     * @param RequestInterface $request
289     * @param Handler $handler Used to call {@see Handler::getBodyValidator}
290     * @return mixed|null Return value from {@see BodyValidator::validateBody}
291     * @throws HttpException on validation failure
292     */
293    public function validateBody( RequestInterface $request, Handler $handler ) {
294        wfDeprecated( __METHOD__, '1.43' );
295
296        $method = strtoupper( trim( $request->getMethod() ) );
297
298        // If the method should never have a body, don't bother validating.
299        if ( in_array( $method, self::NO_BODY_METHODS, true ) ) {
300            return null;
301        }
302
303        // Get the content type
304        [ $ct ] = explode( ';', $request->getHeaderLine( 'Content-Type' ), 2 );
305        $ct = strtolower( trim( $ct ) );
306        if ( $ct === '' ) {
307            // No Content-Type was supplied. RFC 7231 Â§ 3.1.1.5 allows this, but
308            // since it's probably a client error let's return a 415, unless the
309            // body is known to be empty.
310            $body = $request->getBody();
311            if ( $body->getSize() === 0 ) {
312                return null;
313            } else {
314                throw new LocalizedHttpException( new MessageValue( "rest-requires-content-type-header" ), 415, [
315                    'error' => 'no-content-type',
316                ] );
317            }
318        }
319
320        // Form data is parsed into $_POST and $_FILES by PHP and from there is accessed as parameters,
321        // don't bother trying to handle these via BodyValidator too.
322        if ( in_array( $ct, RequestInterface::FORM_DATA_CONTENT_TYPES, true ) ) {
323            return null;
324        }
325
326        // Validate the body. BodyValidator throws an HttpException on failure.
327        return $handler->getBodyValidator( $ct )->validateBody( $request );
328    }
329
330    private const PARAM_TYPE_SCHEMAS = [
331        'boolean-param' => [ 'type' => 'boolean' ],
332        'enum-param' => [ 'type' => 'string' ],
333        'integer-param' => [ 'type' => 'integer' ],
334        'float-param' => [ 'type' => 'number', 'format' => 'float' ],
335        'double-param' => [ 'type' => 'number', 'format' => 'double' ],
336        // 'NULL-param' => [ 'type' => 'null' ], // FIXME
337        'password-param' => [ 'type' => 'string' ],
338        'string-param' => [ 'type' => 'string' ],
339        'timestamp-param' => [ 'type' => 'string', 'format' => 'mw-timestamp' ],
340        'upload-param' => [ 'type' => 'string', 'format' => 'mw-upload' ],
341        'expiry-param' => [ 'type' => 'string', 'format' => 'mw-expiry' ],
342        'namespace-param' => [ 'type' => 'integer' ],
343        'title-param' => [ 'type' => 'string', 'format' => 'mw-title' ],
344        'user-param' => [ 'type' => 'string', 'format' => 'mw-user' ],
345        'array-param' => [ 'type' => 'object' ],
346    ];
347
348    /**
349     * Returns JSON Schema description of all known parameter types.
350     * The name of the schema is the name of the parameter type with "-param" appended.
351     *
352     * @see https://swagger.io/specification/#schema-object
353     * @see self::TYPE_DEFS
354     *
355     * @return array
356     */
357    public static function getParameterTypeSchemas(): array {
358        return self::PARAM_TYPE_SCHEMAS;
359    }
360
361    /**
362     * Convert a param settings array into an OpenAPI Parameter Object specification structure.
363     * @see https://swagger.io/specification/#parameter-object
364     *
365     * @param string $name
366     * @param array $paramSetting
367     *
368     * @return array
369     */
370    public static function getParameterSpec( string $name, array $paramSetting ): array {
371        $schema = self::getParameterSchema( $paramSetting );
372
373        // TODO: generate a warning if the source is not specified!
374        $location = $paramSetting[ self::PARAM_SOURCE ] ?? 'unspecified';
375
376        $param = [
377            'name' => $name,
378            'description' => $paramSetting[ self::PARAM_DESCRIPTION ] ?? "$name parameter",
379            'in' => $location,
380            'schema' => $schema
381        ];
382
383        // TODO: generate a warning if required is false for a pth param
384        $param['required'] = $location === 'path'
385            || ( $paramSetting[ ParamValidator::PARAM_REQUIRED ] ?? false );
386
387        return $param;
388    }
389
390    /**
391     * Convert a param settings array into an OpenAPI schema structure.
392     * @see https://swagger.io/specification/#schema-object
393     *
394     * @param array $paramSetting
395     *
396     * @return array
397     */
398    public static function getParameterSchema( array $paramSetting ): array {
399        $type = $paramSetting[ ParamValidator::PARAM_TYPE ] ?? 'string';
400
401        if ( is_array( $type ) ) {
402            if ( $type === [] ) {
403                // Hack for empty enums. In path and query parameters,
404                // the empty string is often the same as "no value".
405                // TODO: generate a warning!
406                $type = [ '' ];
407            }
408
409            $schema = [
410                'type' => 'string',
411                'enum' => $type
412            ];
413        } elseif ( isset( $paramSetting[ ArrayDef::PARAM_SCHEMA ] ) ) {
414            $schema = $paramSetting[ ArrayDef::PARAM_SCHEMA ];
415        } else {
416            // TODO: multi-value params?!
417            $schema = self::PARAM_TYPE_SCHEMAS["{$type}-param"] ?? [];
418        }
419
420        return $schema;
421    }
422
423}