Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
98.00% |
98 / 100 |
|
71.43% |
5 / 7 |
CRAP | |
0.00% |
0 / 1 |
Validator | |
98.00% |
98 / 100 |
|
71.43% |
5 / 7 |
24 | |
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% |
17 / 17 |
|
100.00% |
1 / 1 |
5 | |||
validateBodyParams | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
4 | |||
validateBody | |
93.33% |
14 / 15 |
|
0.00% |
0 / 1 |
5.01 | |||
getParameterTypeSchemas | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getParameterSpec | |
100.00% |
19 / 19 |
|
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\DataMessageValue; |
14 | use Wikimedia\Message\ListParam; |
15 | use Wikimedia\Message\ListType; |
16 | use Wikimedia\Message\MessageValue; |
17 | use Wikimedia\ObjectFactory\ObjectFactory; |
18 | use Wikimedia\ParamValidator\ParamValidator; |
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 | '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 | } |