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