Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.00% covered (success)
98.00%
98 / 100
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Validator
98.00% covered (success)
98.00%
98 / 100
71.43% covered (warning)
71.43%
5 / 7
24
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%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 validateBodyParams
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
4
 validateBody
93.33% covered (success)
93.33%
14 / 15
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%
19 / 19
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\DataMessageValue;
14use Wikimedia\Message\ListParam;
15use Wikimedia\Message\ListType;
16use Wikimedia\Message\MessageValue;
17use Wikimedia\ObjectFactory\ObjectFactory;
18use Wikimedia\ParamValidator\ParamValidator;
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                'allowEmptyWhenRequired' => 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    /** @var ParamValidator */
110    private $paramValidator;
111
112    /**
113     * @param ObjectFactory $objectFactory
114     * @param RequestInterface $request
115     * @param Authority $authority
116     * @internal
117     */
118    public function __construct(
119        ObjectFactory $objectFactory,
120        RequestInterface $request,
121        Authority $authority
122    ) {
123        $this->paramValidator = new ParamValidator(
124            new ParamValidatorCallbacks( $request, $authority ),
125            $objectFactory,
126            [
127                'typeDefs' => self::TYPE_DEFS,
128            ]
129        );
130    }
131
132    /**
133     * Validate parameters.
134     * Params with the source specified as 'body' will be ignored.
135     * Use validateBodyParams() for these.
136     *
137     * @see validateBodyParams
138     * @param array[] $paramSettings Parameter settings
139     * @return array Validated parameters
140     * @throws HttpException on validation failure
141     */
142    public function validateParams( array $paramSettings ) {
143        $validatedParams = [];
144        foreach ( $paramSettings as $name => $settings ) {
145            try {
146                $source = $settings[Handler::PARAM_SOURCE] ?? 'unspecified';
147                if ( $source === 'body' ) {
148                    continue;
149                }
150
151                $validatedParams[$name] = $this->paramValidator->getValue( $name, $settings, [
152                    'source' => $source,
153                ] );
154            } catch ( ValidationException $e ) {
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            if ( $source !== 'body' ) {
186                continue;
187            }
188
189            $validatedKeys[] = $name;
190            unset( $remainingBodyFields[$name] );
191        }
192        $unvalidatedKeys = array_keys( $remainingBodyFields );
193
194        // Throw if there are unvalidated keys left and there are body params defined.
195        if ( $validatedKeys && $unvalidatedKeys ) {
196            throw new LocalizedHttpException(
197                new MessageValue(
198                    'rest-extraneous-body-fields',
199                    [ new ListParam( ListType::COMMA, array_keys( $unvalidatedKeys ) ) ]
200                ),
201                400
202            );
203        }
204    }
205
206    /**
207     * Validate body fields.
208     * Only params with the source specified as 'body' will be processed,
209     * use validateParams() for parameters coming from the path, from query, etc.
210     *
211     * @since 1.42
212     *
213     * @see validateParams
214     * @see validateBody
215     * @param array[] $paramSettings Parameter settings.
216     * @return array Validated parameters
217     * @throws HttpException on validation failure
218     */
219    public function validateBodyParams( array $paramSettings ) {
220        $validatedParams = [];
221        foreach ( $paramSettings as $name => $settings ) {
222            $source = $settings[Handler::PARAM_SOURCE] ?? 'body';
223            if ( $source !== 'body' ) {
224                continue;
225            }
226
227            try {
228                $validatedParams[$name] = $this->paramValidator->getValue( $name, $settings, [
229                    'source' => $source,
230                ] );
231            } catch ( ValidationException $e ) {
232                $msg = $e->getFailureMessage();
233                $wrappedMsg = new DataMessageValue(
234                    'rest-body-validation-error',
235                    [ $e->getFailureMessage() ],
236                    $msg->getCode(),
237                    $msg->getData()
238                );
239
240                throw new LocalizedHttpException( $wrappedMsg, 400, [
241                    'error' => 'parameter-validation-failed',
242                    'name' => $e->getParamName(),
243                    'value' => $e->getParamValue(),
244                    'failureCode' => $e->getFailureMessage()->getCode(),
245                    'failureData' => $e->getFailureMessage()->getData(),
246                ] );
247            }
248        }
249        return $validatedParams;
250    }
251
252    /**
253     * Validate the body of a request.
254     *
255     * This may return a data structure representing the parsed body. When used
256     * in the context of Handler::validateParams(), the returned value will be
257     * available to the handler via Handler::getValidatedBody().
258     *
259     * @param RequestInterface $request
260     * @param Handler $handler Used to call {@see Handler::getBodyValidator}
261     * @return mixed|null Return value from {@see BodyValidator::validateBody}
262     * @throws HttpException on validation failure
263     */
264    public function validateBody( RequestInterface $request, Handler $handler ) {
265        $method = strtoupper( trim( $request->getMethod() ) );
266
267        // If the method should never have a body, don't bother validating.
268        if ( in_array( $method, self::NO_BODY_METHODS, true ) ) {
269            return null;
270        }
271
272        // Get the content type
273        [ $ct ] = explode( ';', $request->getHeaderLine( 'Content-Type' ), 2 );
274        $ct = strtolower( trim( $ct ) );
275        if ( $ct === '' ) {
276            // No Content-Type was supplied. RFC 7231 Â§ 3.1.1.5 allows this, but
277            // since it's probably a client error let's return a 415, unless the
278            // body is known to be empty.
279            $body = $request->getBody();
280            if ( $body->getSize() === 0 ) {
281                return null;
282            } else {
283                throw new LocalizedHttpException( new MessageValue( "rest-requires-content-type-header" ), 415, [
284                    'error' => 'no-content-type',
285                ] );
286            }
287        }
288
289        // Form data is parsed into $_POST and $_FILES by PHP and from there is accessed as parameters,
290        // don't bother trying to handle these via BodyValidator too.
291        if ( in_array( $ct, self::FORM_DATA_CONTENT_TYPES, true ) ) {
292            return null;
293        }
294
295        // Validate the body. BodyValidator throws an HttpException on failure.
296        return $handler->getBodyValidator( $ct )->validateBody( $request );
297    }
298
299    private const PARAM_TYPE_SCHEMAS = [
300        'boolean-param' => [ 'type' => 'boolean' ],
301        'enum-param' => [ 'type' => 'string' ],
302        'integer-param' => [ 'type' => 'integer' ],
303        'float-param' => [ 'type' => 'number', 'format' => 'float' ],
304        'double-param' => [ 'type' => 'number', 'format' => 'double' ],
305        // 'NULL-param' => [ 'type' => 'null' ], // FIXME
306        'password-param' => [ 'type' => 'string' ],
307        'string-param' => [ 'type' => 'string' ],
308        'timestamp-param' => [ 'type' => 'string', 'format' => 'mw-timestamp' ],
309        'upload-param' => [ 'type' => 'string', 'format' => 'mw-upload' ],
310        'expiry-param' => [ 'type' => 'string', 'format' => 'mw-expiry' ],
311        'title-param' => [ 'type' => 'string', 'format' => 'mw-title' ],
312        'user-param' => [ 'type' => 'string', 'format' => 'mw-user' ],
313        'array-param' => [ 'type' => 'object' ],
314    ];
315
316    /**
317     * Returns JSON Schema description of all known parameter types.
318     * The name of the schema is the name of the parameter type with "-param" appended.
319     *
320     * @see https://swagger.io/specification/#schema-object
321     * @see self::TYPE_DEFS
322     *
323     * @return array
324     */
325    public static function getParameterTypeSchemas(): array {
326        return self::PARAM_TYPE_SCHEMAS;
327    }
328
329    /**
330     * Convert a param settings array into an OpenAPI Parameter Object specification structure.
331     * @see https://swagger.io/specification/#parameter-object
332     *
333     * @param string $name
334     * @param array $paramSetting
335     *
336     * @return array
337     */
338    public static function getParameterSpec( string $name, array $paramSetting ): array {
339        $type = $paramSetting[ ParamValidator::PARAM_TYPE ] ?? 'string';
340
341        if ( is_array( $type ) ) {
342            if ( $type === [] ) {
343                // Hack for empty enums. In path and query parameters,
344                // the empty string is often the same as "no value".
345                // TODO: generate a warning!
346                $type = [ '' ];
347            }
348
349            $schema = [
350                'type' => 'string',
351                'enum' => $type
352            ];
353        } else {
354            // TODO: multi-value params?!
355            $schema = self::PARAM_TYPE_SCHEMAS["{$type}-param"] ?? [];
356        }
357
358        // TODO: generate a warning if the source is not specified!
359        $location = $paramSetting[ self::PARAM_SOURCE ] ?? 'unspecified';
360
361        $param = [
362            'name' => $name,
363            'description' => $paramSetting[ self::PARAM_DESCRIPTION ] ?? "$name parameter",
364            'in' => $location,
365            'schema' => $schema
366        ];
367
368        // TODO: generate a warning if required is false for a path param
369        $param['required'] = $location === 'path'
370            || ( $paramSetting[ ParamValidator::PARAM_REQUIRED ] ?? false );
371
372        return $param;
373    }
374
375}