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