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