Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.24% covered (success)
94.24%
278 / 295
77.08% covered (warning)
77.08%
37 / 48
CRAP
0.00% covered (danger)
0.00%
0 / 1
Handler
94.24% covered (success)
94.24%
278 / 295
77.08% covered (warning)
77.08%
37 / 48
113.36
0.00% covered (danger)
0.00%
0 / 1
 initContext
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 initServices
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 initSession
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 initForExecute
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 processRequestBody
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 getPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSupportedPathParams
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getRouter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getModule
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRouteUrl
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 urlEncodeTitle
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getRequest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAuthority
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getResponseFactory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSession
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validate
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 detectExtraneousBodyFields
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 checkSession
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 getConditionalHeaderUtil
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 checkPreconditions
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 applyConditionalResponseHeaders
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 applyCacheControl
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
6
 getParamSettings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getBodyParamSettings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveDescription
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getOpenApiSpec
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
8
 getRequestSpec
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 getRequestBodySchema
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
8.02
 getResponseBodySchema
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getResponseBodySchemaFileName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 resolveResponseDescription
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 generateResponseSpec
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 getBodyValidator
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getValidatedParams
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getValidatedBody
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseBodyData
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
13
 getSupportedRequestTypes
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getHookContainer
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHookRunner
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLastModified
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getETag
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasRepresentation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 needsReadAccess
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 needsWriteAccess
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 requireSafeAgainstCsrf
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 postInitSetup
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 postValidationSetup
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
n/a
0 / 0
n/a
0 / 0
0
1<?php
2
3namespace MediaWiki\Rest;
4
5use DateTime;
6use MediaWiki\Debug\MWDebug;
7use MediaWiki\HookContainer\HookContainer;
8use MediaWiki\HookContainer\HookRunner;
9use MediaWiki\Permissions\Authority;
10use MediaWiki\Rest\Module\Module;
11use MediaWiki\Rest\Validator\BodyValidator;
12use MediaWiki\Rest\Validator\NullBodyValidator;
13use MediaWiki\Rest\Validator\Validator;
14use MediaWiki\Session\Session;
15use UtfNormal\Validator as UtfNormalValidator;
16use Wikimedia\Assert\Assert;
17use Wikimedia\Message\MessageValue;
18use Wikimedia\ParamValidator\ParamValidator;
19
20/**
21 * Base class for REST route handlers.
22 *
23 * @stable to extend.
24 */
25abstract class Handler {
26
27    /**
28     * @see Validator::KNOWN_PARAM_SOURCES
29     */
30    public const KNOWN_PARAM_SOURCES = Validator::KNOWN_PARAM_SOURCES;
31
32    /**
33     * @see Validator::PARAM_SOURCE
34     */
35    public const PARAM_SOURCE = Validator::PARAM_SOURCE;
36
37    /**
38     * @see Validator::PARAM_DESCRIPTION
39     */
40    public const PARAM_DESCRIPTION = Validator::PARAM_DESCRIPTION;
41
42    public const OPENAPI_DESCRIPTION_KEY = 'description';
43
44    public const RESPONSE_BODY_DESCRIPTION_KEY = 'x-i18n-description';
45
46    /** @var Module */
47    private $module;
48
49    /** @var RequestInterface */
50    private $request;
51
52    /** @var Authority */
53    private $authority;
54
55    /** @var string */
56    private $path;
57
58    /** @var array */
59    private $config;
60
61    /** @var ResponseFactory */
62    private $responseFactory;
63
64    /** @var array|null */
65    private $validatedParams;
66
67    /** @var mixed|null */
68    private $validatedBody;
69
70    /** @var ConditionalHeaderUtil */
71    private $conditionalHeaderUtil;
72
73    /** @var HookContainer */
74    private $hookContainer;
75
76    /** @var Session */
77    private $session;
78
79    /** @var HookRunner */
80    private $hookRunner;
81
82    /**
83     * Injects information about the handler's context in the Module.
84     * The framework should call this right after the object was constructed.
85     *
86     * First function of the initialization function, must be called before
87     * initServices().
88     *
89     * @param Module $module
90     * @param string $path
91     * @param array $routeConfig information about the route declaration.
92     *
93     * @internal
94     */
95    final public function initContext( Module $module, string $path, array $routeConfig ) {
96        Assert::precondition(
97            $this->authority === null,
98            'initContext() must be called before initServices()'
99        );
100
101        $this->module = $module;
102        $this->path = $path;
103        $this->config = $routeConfig;
104    }
105
106    /**
107     * Inject service objects.
108     *
109     * Second function of the initialization function, must be called after
110     * initContext() and before initSession().
111     *
112     * @param Authority $authority
113     * @param ResponseFactory $responseFactory
114     * @param HookContainer $hookContainer
115     *
116     * @internal
117     */
118    final public function initServices(
119        Authority $authority, ResponseFactory $responseFactory, HookContainer $hookContainer
120    ) {
121        // Warn if a subclass overrides getBodyValidator()
122        MWDebug::detectDeprecatedOverride(
123            $this,
124            __CLASS__,
125            'getBodyValidator',
126            '1.43'
127        );
128
129        Assert::precondition(
130            $this->module !== null,
131            'initServices() must not be called before initContext()'
132        );
133        Assert::precondition(
134            $this->session === null,
135            'initServices() must be called before initSession()'
136        );
137
138        $this->authority = $authority;
139        $this->responseFactory = $responseFactory;
140        $this->hookContainer = $hookContainer;
141        $this->hookRunner = new HookRunner( $hookContainer );
142    }
143
144    /**
145     * Inject session information.
146     *
147     * Third function of the initialization function, must be called after
148     * initServices() and before initForExecute().
149     *
150     * @param Session $session
151     *
152     * @internal
153     */
154    final public function initSession( Session $session ) {
155        Assert::precondition(
156            $this->authority !== null,
157            'initSession() must not be called before initContext()'
158        );
159        Assert::precondition(
160            $this->request === null,
161            'initSession() must be called before initForExecute()'
162        );
163
164        $this->session = $session;
165    }
166
167    /**
168     * Initialise for execution based on the given request.
169     *
170     * Last function of the initialization function, must be called after
171     * initSession() and before validate() and checkPreconditions().
172     *
173     * This function will call postInitSetup() to allow subclasses to
174     * perform their own initialization.
175     *
176     * The request object is updated with parsed body data if needed.
177     *
178     * @internal
179     *
180     * @param RequestInterface $request
181     *
182     * @throws HttpException if the handler does not accept the request for
183     *         some reason.
184     */
185    final public function initForExecute( RequestInterface $request ) {
186        Assert::precondition(
187            $this->session !== null,
188            'initForExecute() must not be called before initSession()'
189        );
190
191        if ( $request->getParsedBody() === null ) {
192            $this->processRequestBody( $request );
193        }
194
195        $this->request = $request;
196
197        $this->postInitSetup();
198    }
199
200    /**
201     * Process the request's request body and set the parsed body data
202     * if appropriate.
203     *
204     * @see parseBodyData()
205     *
206     * @throws HttpException if the request body is not acceptable.
207     */
208    private function processRequestBody( RequestInterface $request ) {
209        // fail if the request method is in NO_BODY_METHODS but has body
210        $requestMethod = $request->getMethod();
211        if ( in_array( $requestMethod, RequestInterface::NO_BODY_METHODS ) ) {
212            // check if the request has a body
213            if ( $request->hasBody() ) {
214                // NOTE: Don't throw, see T359509.
215                // TODO: Ignore only empty bodies, log a warning or fail if
216                //       there is actual content.
217                return;
218            }
219        }
220
221        // fail if the request method expects a body but has no body
222        if ( in_array( $requestMethod, RequestInterface::BODY_METHODS ) ) {
223            // check if it has no body
224            if ( !$request->hasBody() ) {
225                throw new LocalizedHttpException(
226                    new MessageValue(
227                        "rest-request-body-expected",
228                        [ $requestMethod ]
229                    ),
230                    411
231                );
232            }
233        }
234
235        // call parsedbody
236        if ( $request->hasBody() ) {
237            $parsedBody = $this->parseBodyData( $request );
238            // Set the parsed body data on the request object
239            $request->setParsedBody( $parsedBody );
240        }
241    }
242
243    /**
244     * Returns the path this handler is bound to relative to the module prefix.
245     * Includes path variables.
246     *
247     * @return string
248     */
249    public function getPath(): string {
250        return $this->path;
251    }
252
253    /**
254     * Get a list of parameter placeholders present in the route's path
255     * as returned by getPath(). Note that this is independent of the parameters
256     * defined by getParamSettings(): required path parameters defined in
257     * getParamSettings() should be present in the path, but there is no
258     * mechanism to ensure that they are.
259     *
260     * @return string[]
261     */
262    public function getSupportedPathParams(): array {
263        $path = $this->getPath();
264
265        preg_match_all( '/\{(.*?)\}/', $path, $matches, PREG_PATTERN_ORDER );
266
267        return $matches[1] ?? [];
268    }
269
270    /**
271     * Get the Router.
272     *
273     * @return Router
274     */
275    protected function getRouter(): Router {
276        return $this->module->getRouter();
277    }
278
279    /**
280     * Get the Module this handler belongs to.
281     * Will fail hard if called before initContext().
282     *
283     * @return Module
284     */
285    protected function getModule(): Module {
286        return $this->module;
287    }
288
289    /**
290     * Get the URL of this handler's endpoint.
291     * Supports the substitution of path parameters, and additions of query parameters.
292     *
293     * @see Router::getRouteUrl()
294     *
295     * @param string[] $pathParams Path parameters to be injected into the path
296     * @param string[] $queryParams Query parameters to be attached to the URL
297     *
298     * @return string
299     */
300    protected function getRouteUrl( $pathParams = [], $queryParams = [] ): string {
301        $path = $this->getPath();
302        return $this->getRouter()->getRouteUrl( $path, $pathParams, $queryParams );
303    }
304
305    /**
306     * URL-encode titles in a "pretty" way.
307     *
308     * Keeps intact ;@$!*(),~: (urlencode does not, but wfUrlencode does).
309     * Encodes spaces as underscores (wfUrlencode does not).
310     * Encodes slashes (wfUrlencode does not, but keeping them messes with REST paths).
311     * Encodes pluses (this is not necessary, and may change).
312     *
313     * @see wfUrlencode
314     *
315     * @param string $title
316     *
317     * @return string
318     */
319    protected function urlEncodeTitle( $title ) {
320        $title = str_replace( ' ', '_', $title );
321        $title = urlencode( $title );
322
323        // %3B_a_%40_b_%24_c_%21_d_%2A_e_%28_f_%29_g_%2C_h_~_i_%3A
324        $replace = [ '%3B', '%40', '%24', '%21', '%2A', '%28', '%29', '%2C', '%7E', '%3A' ];
325        $with = [ ';', '@', '$', '!', '*', '(', ')', ',', '~', ':' ];
326
327        return str_replace( $replace, $with, $title );
328    }
329
330    /**
331     * Get the current request. The return type declaration causes it to raise
332     * a fatal error if initForExecute() has not yet been called.
333     *
334     * @return RequestInterface
335     */
336    public function getRequest(): RequestInterface {
337        return $this->request;
338    }
339
340    /**
341     * Get the current acting authority. The return type declaration causes it to raise
342     * a fatal error if initServices() has not yet been called.
343     *
344     * @since 1.36
345     * @return Authority
346     */
347    public function getAuthority(): Authority {
348        return $this->authority;
349    }
350
351    /**
352     * Get the configuration array for the current route. The return type
353     * declaration causes it to raise a fatal error if initContext() has not
354     * been called.
355     *
356     * @return array
357     */
358    public function getConfig(): array {
359        return $this->config;
360    }
361
362    /**
363     * Get the ResponseFactory which can be used to generate Response objects.
364     * This will raise a fatal error if initServices() has not been
365     * called.
366     *
367     * @return ResponseFactory
368     */
369    public function getResponseFactory(): ResponseFactory {
370        return $this->responseFactory;
371    }
372
373    /**
374     * Get the Session.
375     * This will raise a fatal error if initSession() has not been
376     * called.
377     *
378     * @return Session
379     */
380    public function getSession(): Session {
381        return $this->session;
382    }
383
384    /**
385     * Validate the request parameters/attributes and body. If there is a validation
386     * failure, a response with an error message should be returned or an
387     * HttpException should be thrown.
388     *
389     * @stable to override
390     * @param Validator $restValidator
391     * @throws HttpException On validation failure.
392     */
393    public function validate( Validator $restValidator ) {
394        $this->validatedParams = $restValidator->validateParams(
395            $this->getParamSettings()
396        );
397
398        $bodyType = $this->request->getBodyType();
399        $legacyBodyValidator = $bodyType === null ? null
400            : $this->getBodyValidator( $bodyType );
401
402        if ( $legacyBodyValidator && !$legacyBodyValidator instanceof NullBodyValidator ) {
403            $this->validatedBody = $restValidator->validateBody( $this->request, $this );
404        } else {
405            // Allow type coercion if the request body is form data.
406            // For JSON requests, insist on proper types.
407            $enforceTypes = !in_array(
408                $this->request->getBodyType(),
409                RequestInterface::FORM_DATA_CONTENT_TYPES
410            );
411
412            $this->validatedBody = $restValidator->validateBodyParams(
413                $this->getBodyParamSettings(),
414                $enforceTypes
415            );
416
417            // If there is a body, check if it contains extra fields.
418            if ( $this->getRequest()->hasBody() ) {
419                $this->detectExtraneousBodyFields( $restValidator );
420            }
421        }
422
423        $this->postValidationSetup();
424    }
425
426    /**
427     * Subclasses may override this to disable or modify checks for extraneous
428     * body fields.
429     *
430     * @since 1.42
431     * @stable to override
432     * @param Validator $restValidator
433     * @throws HttpException On validation failure.
434     */
435    protected function detectExtraneousBodyFields( Validator $restValidator ) {
436        $parsedBody = $this->getRequest()->getParsedBody();
437
438        if ( !$parsedBody ) {
439            // nothing to do
440            return;
441        }
442
443        $restValidator->detectExtraneousBodyFields(
444            $this->getBodyParamSettings(),
445            $parsedBody
446        );
447    }
448
449    /**
450     * Check the session (and session provider)
451     * @throws HttpException on failed check
452     * @internal
453     */
454    public function checkSession() {
455        if ( !$this->session->getProvider()->safeAgainstCsrf() ) {
456            if ( $this->requireSafeAgainstCsrf() ) {
457                throw new LocalizedHttpException(
458                    new MessageValue( 'rest-requires-safe-against-csrf' ),
459                    400
460                );
461            }
462        } elseif ( !empty( $this->validatedBody['token'] ) ) {
463            throw new LocalizedHttpException(
464                new MessageValue( 'rest-extraneous-csrf-token' ),
465                400
466            );
467        }
468    }
469
470    /**
471     * Get a ConditionalHeaderUtil object.
472     *
473     * On the first call to this method, the object will be initialized with
474     * validator values by calling getETag(), getLastModified() and
475     * hasRepresentation().
476     *
477     * @return ConditionalHeaderUtil
478     */
479    protected function getConditionalHeaderUtil() {
480        if ( $this->conditionalHeaderUtil === null ) {
481            $this->conditionalHeaderUtil = new ConditionalHeaderUtil;
482            $this->conditionalHeaderUtil->setValidators(
483                $this->getETag(),
484                $this->getLastModified(),
485                $this->hasRepresentation()
486            );
487        }
488        return $this->conditionalHeaderUtil;
489    }
490
491    /**
492     * Check the conditional request headers and generate a response if appropriate.
493     * This is called by the Router before execute() and may be overridden.
494     *
495     * @stable to override
496     *
497     * @return ResponseInterface|null
498     */
499    public function checkPreconditions() {
500        $status = $this->getConditionalHeaderUtil()->checkPreconditions( $this->getRequest() );
501        if ( $status ) {
502            $response = $this->getResponseFactory()->create();
503            $response->setStatus( $status );
504            return $response;
505        }
506
507        return null;
508    }
509
510    /**
511     * Apply verifier headers to the response, per RFC 7231 §7.2.
512     * This is called after execute() returns.
513     *
514     * For GET and HEAD requests, the default behavior is to set the ETag and
515     * Last-Modified headers based on the values returned by getETag() and
516     * getLastModified() when they were called before execute() was run.
517     *
518     * Other request methods are assumed to be state-changing, so no headers
519     * will be set by default.
520     *
521     * This may be overridden to modify the verifier headers sent in the response.
522     * However, handlers that modify the resource's state would typically just
523     * set the ETag and Last-Modified headers in the execute() method.
524     *
525     * @stable to override
526     *
527     * @param ResponseInterface $response
528     */
529    public function applyConditionalResponseHeaders( ResponseInterface $response ) {
530        $method = $this->getRequest()->getMethod();
531        if ( $method === 'GET' || $method === 'HEAD' ) {
532            $this->getConditionalHeaderUtil()->applyResponseHeaders( $response );
533        }
534    }
535
536    /**
537     * Apply cache control to enforce privacy.
538     *
539     * @param ResponseInterface $response
540     */
541    public function applyCacheControl( ResponseInterface $response ) {
542        // NOTE: keep this consistent with the logic in OutputPage::sendCacheControl
543
544        // If the response sets cookies, it must not be cached in proxies.
545        // If there's an active cookie-based session (logged-in user or anonymous user with
546        // session-scoped cookies), it is not safe to cache either, as the session manager may set
547        // cookies in the response, or the response itself may vary on user-specific variables,
548        // for example on private wikis where the 'read' permission is restricted. (T264631)
549        if ( $response->getHeaderLine( 'Set-Cookie' ) || $this->getSession()->isPersistent() ) {
550            $response->setHeader( 'Cache-Control', 'private,must-revalidate,s-maxage=0' );
551        }
552
553        if ( !$response->getHeaderLine( 'Cache-Control' ) ) {
554            $rqMethod = $this->getRequest()->getMethod();
555            if ( $rqMethod !== 'GET' && $rqMethod !== 'HEAD' ) {
556                // Responses to requests other than GET or HEAD should not be cacheable by default.
557                $response->setHeader( 'Cache-Control', 'private,no-cache,s-maxage=0' );
558            }
559        }
560    }
561
562    /**
563     * Fetch ParamValidator settings for parameters
564     *
565     * Every setting must include self::PARAM_SOURCE to specify which part of
566     * the request is to contain the parameter.
567     *
568     * Can be used for the request body as well, by setting self::PARAM_SOURCE
569     * to "post". Note that the values of "post" parameters will be accessible
570     * through getValidatedParams(). "post" parameters are used with
571     * form data (application/x-www-form-urlencoded or multipart/form-data).
572     *
573     * For "query" parameters, a PARAM_REQUIRED setting of "false" means the caller
574     * does not have to supply the parameter. For "path" parameters, the path matcher will always
575     * require the caller to supply all path parameters for a route, regardless of the
576     * PARAM_REQUIRED setting. However, "path" parameters may be specified in getParamSettings()
577     * as non-required to indicate that the handler services multiple routes, some of which may
578     * not supply the parameter.
579     *
580     * @stable to override
581     *
582     * @return array[] Associative array mapping parameter names to
583     *  ParamValidator settings arrays
584     */
585    public function getParamSettings() {
586        return [];
587    }
588
589    /**
590     * Fetch ParamValidator settings for body fields. Parameters defined
591     * by this method are used to validate the request body. The parameter
592     * values will become available through getValidatedBody().
593     *
594     * Subclasses may override this method to specify what fields they support
595     * in the request body. All parameter settings returned by this method must
596     * have self::PARAM_SOURCE set to 'body'.
597     *
598     * @return array[]
599     */
600    public function getBodyParamSettings(): array {
601        return [];
602    }
603
604    /**
605     * Returns the translated description string if possible, the untranslated string if one is
606     * available, or null otherwise.
607     *
608     * @param array $schema a schema array
609     * @param string $descKey key name of the description field in $setting
610     *
611     * @return ?string
612     */
613    private function resolveDescription( array $schema, string $descKey ): ?string {
614        $desc = null;
615
616        if ( array_key_exists( $descKey, $schema ) ) {
617            if ( $schema[ $descKey ] instanceof MessageValue ) {
618                // TODO: consider if we want to request a specific preferred language
619                $desc = $this->responseFactory->getFormattedMessage( $schema[ $descKey ] );
620            } else {
621                $desc = $schema[ $descKey ];
622            }
623        }
624
625        return $desc;
626    }
627
628    /**
629     * Returns an OpenAPI Operation Object specification structure as an associative array.
630     *
631     * @see https://swagger.io/specification/#operation-object
632     *
633     * By default, this will contain information about the supported parameters, as well as
634     * the response for status 200.
635     *
636     * Subclasses may override this to provide additional information.
637     *
638     * @since 1.42
639     * @stable to override
640     *
641     * @param string $method The HTTP method to produce a spec for ("get", "post", etc).
642     *        Useful for handlers that behave differently depending on the
643     *        request method.
644     *
645     * @return array
646     */
647    public function getOpenApiSpec( string $method ): array {
648        $parameters = [];
649
650        $supportedPathParams = array_flip( $this->getSupportedPathParams() );
651
652        foreach ( $this->getParamSettings() as $name => $paramSetting ) {
653            $source = $paramSetting[ Validator::PARAM_SOURCE ] ?? '';
654
655            if ( $source !== 'query' && $source !== 'path' ) {
656                continue;
657            }
658
659            if ( $source === 'path' && !isset( $supportedPathParams[$name] ) ) {
660                // Skip optional path param not used in the current path
661                continue;
662            }
663
664            $paramSetting[ Validator::PARAM_DESCRIPTION ] = $this->resolveDescription(
665                $paramSetting, Validator::PARAM_DESCRIPTION
666            );
667
668            $param = Validator::getParameterSpec(
669                $name,
670                $paramSetting
671            );
672
673            $parameters[] = $param;
674        }
675
676        $spec = [
677            'parameters' => $parameters,
678            'responses' => $this->generateResponseSpec( $method ),
679        ];
680
681        if ( !in_array( $method, RequestInterface::NO_BODY_METHODS ) ) {
682            $requestBody = $this->getRequestSpec( $method );
683            if ( $requestBody ) {
684                $spec['requestBody'] = $requestBody;
685            }
686        }
687
688        // TODO: Allow additional information about parameters and responses to
689        //       be provided in the route definition.
690        $oas = $this->getConfig()['OAS'] ?? [];
691        $spec += $oas;
692
693        return $spec;
694    }
695
696    /**
697     * Returns an OpenAPI Request Body Object specification structure as an associative array.
698     *
699     * @see https://swagger.io/specification/#request-body-object
700     *
701     * This is based on the getBodyParamSettings() and getSupportedRequestTypes().
702     *
703     * Subclasses may override this to provide additional information about the
704     * structure of responses, or to add support for additional mediaTypes.
705     *
706     * @stable to override getBodySchema() to generate a schema for each
707     * supported media type as returned by getSupportedBodyTypes().
708     *
709     * @param string $method
710     *
711     * @return ?array
712     */
713    protected function getRequestSpec( string $method ): ?array {
714        $mediaTypes = [];
715
716        foreach ( $this->getSupportedRequestTypes() as $type ) {
717            $schema = $this->getRequestBodySchema( $type );
718
719            if ( $schema ) {
720                $mediaTypes[$type] = [ 'schema' => $schema ];
721            }
722        }
723
724        if ( !$mediaTypes ) {
725            return null;
726        }
727
728        return [
729            // TODO: some DELETE handlers may require a body that contains a token
730            // FIXME: check if there are required body params!
731            'required' => in_array( $method, RequestInterface::BODY_METHODS ),
732            'content' => $mediaTypes
733        ];
734    }
735
736    /**
737     * Returns a content schema per the OpenAPI spec.
738     * @see https://swagger.io/specification/#schema-object
739     *
740     * Per default, this provides schemas for JSON requests and form data, based
741     * on the parameter declarations returned by getParamSettings().
742     *
743     * Subclasses may override this to provide additional information about the
744     * structure of responses, or to add support for additional mediaTypes.
745     *
746     * @stable to override
747     * @return array
748     */
749    protected function getRequestBodySchema( string $mediaType ): array {
750        if ( $mediaType === RequestInterface::FORM_URLENCODED_CONTENT_TYPE ) {
751            $allowedSources = [ 'body', 'post' ];
752        } elseif ( $mediaType === RequestInterface::MULTIPART_FORM_DATA_CONTENT_TYPE ) {
753            $allowedSources = [ 'body', 'post' ];
754        } else {
755            $allowedSources = [ 'body' ];
756        }
757
758        $paramSettings = $this->getBodyParamSettings();
759
760        $properties = [];
761        $required = [];
762
763        foreach ( $paramSettings as $name => $settings ) {
764            $source = $settings[ Validator::PARAM_SOURCE ] ?? '';
765            $isRequired = $settings[ ParamValidator::PARAM_REQUIRED ] ?? false;
766
767            if ( !in_array( $source, $allowedSources ) ) {
768                // TODO: post parameters also work as body parameters...
769                continue;
770            }
771
772            $properties[$name] = Validator::getParameterSchema( $settings );
773            $properties[$name][self::OPENAPI_DESCRIPTION_KEY] =
774                $this->resolveDescription( $settings, Validator::PARAM_DESCRIPTION )
775                ?? "$name parameter";
776
777            if ( $isRequired ) {
778                $required[] = $name;
779            }
780        }
781
782        if ( !$properties ) {
783            return [];
784        }
785
786        $schema = [
787            'type' => 'object',
788            'properties' => $properties,
789        ];
790
791        if ( $required ) {
792            $schema['required'] = $required;
793        }
794
795        return $schema;
796    }
797
798    /**
799     * Returns an OpenAPI Schema Object specification structure as an associative array.
800     *
801     * @see https://swagger.io/specification/#schema-object
802     *
803     * Returns null by default. Subclasses that return a JSON response should
804     * implement this method to return a schema of the response body.
805     *
806     * @param string $method The HTTP method to produce a spec for ("get", "post", etc).
807     *
808     * @stable to override
809     * @return ?array
810     */
811    protected function getResponseBodySchema( string $method ): ?array {
812        $file = $this->getResponseBodySchemaFileName( $method );
813        return $file ? Module::loadJsonFile( $file ) : null;
814    }
815
816    /**
817     * Returns the path and name of a JSON file containing an OpenAPI Schema Object
818     * specification structure.
819     *
820     * @see https://swagger.io/specification/#schema-object
821     *
822     * Returns null by default. Subclasses with a suitable JSON file should implement this method.
823     *
824     * @param string $method The HTTP method to produce a spec for ("get", "post", etc).
825     *
826     * @stable to override
827     * @since 1.43
828     * @return ?string
829     */
830    protected function getResponseBodySchemaFileName( string $method ): ?string {
831        return null;
832    }
833
834    /**
835     * If possible, adds a translated description to the schema, and removes the custom key
836     *
837     * @param array $schema The response schema
838     *
839     * @return array the adjusted schema, or the unchanged schema if no adjustments were made
840     */
841    private function resolveResponseDescription( array $schema ): array {
842        $key = self::RESPONSE_BODY_DESCRIPTION_KEY;
843        if ( array_key_exists( $key, $schema ) && is_string( $schema[$key] ) ) {
844            // Add the description to the top of the schema, for visibility in raw specs
845            $desc = [ self::OPENAPI_DESCRIPTION_KEY => new MessageValue( $schema[$key] ) ];
846            $schema = $desc + $schema;
847            $schema[self::OPENAPI_DESCRIPTION_KEY] = $this->resolveDescription(
848                $schema, self::OPENAPI_DESCRIPTION_KEY
849            );
850            unset( $schema[$key] );
851        }
852
853        return $schema;
854    }
855
856    /**
857     * Returns an OpenAPI Responses Object specification structure as an associative array.
858     *
859     * @see https://swagger.io/specification/#responses-object
860     *
861     * By default, this will contain basic information response for status 200, 400, and 500.
862     * The getResponseBodySchema() method is used to determine the structure of the response for status 200.
863     *
864     * Subclasses may override this to provide additional information about the structure of responses.
865     *
866     * @param string $method The HTTP method to produce a spec for ("get", "post", etc).
867     *
868     * @stable to override
869     * @return array
870     */
871    protected function generateResponseSpec( string $method ): array {
872        $ok = [ self::OPENAPI_DESCRIPTION_KEY => 'OK' ];
873
874        $bodySchema = $this->getResponseBodySchema( $method );
875
876        if ( $bodySchema ) {
877            $bodySchema = $this->resolveResponseDescription( $bodySchema );
878            if ( array_key_exists( 'properties', $bodySchema ) ) {
879                // TODO: each property can be an object with its own properties, and so on.
880                //  Consider recursively resolving nested descriptions.
881                foreach ( $bodySchema['properties'] as &$definition ) {
882                    $definition = $this->resolveResponseDescription( $definition );
883                }
884            }
885
886            $ok['content']['application/json']['schema'] = $bodySchema;
887        }
888
889        // XXX: we should add info about redirects, and maybe a default for errors?
890        return [
891            '200' => $ok,
892            '400' => [ '$ref' => '#/components/responses/GenericErrorResponse' ],
893            '500' => [ '$ref' => '#/components/responses/GenericErrorResponse' ],
894        ];
895    }
896
897    /**
898     * Fetch the BodyValidator
899     *
900     * @deprecated since 1.43, return body properties from getBodyParamSettings().
901     * Subclasses that need full control over body data parsing should override
902     * parseBodyData() or implement validation in the execute() method based on
903     * the unparsed body data returned by getRequest()->getBody().
904     *
905     * @param string $contentType Content type of the request.
906     * @return BodyValidator A {@see NullBodyValidator} in this default implementation
907     * @throws HttpException It's possible to fail early here when e.g. $contentType is unsupported,
908     *  or later when {@see BodyValidator::validateBody} is called
909     */
910    public function getBodyValidator( $contentType ) {
911        // NOTE: When removing this method, also remove the BodyValidator interface and
912        //       all classes implementing it!
913        return new NullBodyValidator();
914    }
915
916    /**
917     * Fetch the validated parameters. This must be called after validate() is
918     * called. During execute() is fine.
919     *
920     * @return array Array mapping parameter names to validated values
921     * @throws \RuntimeException If validate() has not been called
922     */
923    public function getValidatedParams() {
924        if ( $this->validatedParams === null ) {
925            throw new \RuntimeException( 'getValidatedParams() called before validate()' );
926        }
927        return $this->validatedParams;
928    }
929
930    /**
931     * Fetch the validated body
932     * @return mixed|null Value returned by the body validator, or null if validate() was
933     *  not called yet, validation failed, there was no body, or the body was form data.
934     */
935    public function getValidatedBody() {
936        return $this->validatedBody;
937    }
938
939    /**
940     * Returns the parsed body of the request.
941     * Should only be called if $request->hasBody() returns true.
942     *
943     * The default implementation handles application/x-www-form-urlencoded
944     * and multipart/form-data by calling $request->getPostParams(),
945     * if the list returned by getSupportedRequestTypes() includes these types.
946     *
947     * The default implementation handles application/json by parsing
948     * the body content as JSON. Only object structures (maps) are supported,
949     * other types will trigger an HttpException with status 400.
950     *
951     * Other content types will trigger a HttpException with status 415 per
952     * default.
953     *
954     * Subclasses may override this method to support parsing additional
955     * content types or to disallow content types by throwing an HttpException
956     * with status 415. Subclasses may also return null to indicate that they
957     * support reading the content, but intend to handle it as an unparsed
958     * stream in their implementation of the execute() method.
959     *
960     * Subclasses that override this method to support additional request types
961     * should also override getSupportedRequestTypes() to allow  that support
962     * to be documented in the OpenAPI spec.
963     *
964     * @since 1.42
965     *
966     * @throws HttpException If the content type is not supported or the content
967     *         is malformed.
968     *
969     * @return array|null The body content represented as an associative array,
970     *         or null if the request body is accepted unparsed.
971     */
972    public function parseBodyData( RequestInterface $request ): ?array {
973        // Parse the body based on its content type
974        $contentType = $request->getBodyType();
975
976        // HACK: If the Handler uses a custom BodyValidator, the
977        // getBodyValidator() is also responsible for checking whether
978        // the content type is valid, and for parsing the body.
979        // See T359149.
980        // TODO: remove once no subclasses override getBodyValidator() anymore
981        $bodyValidator = $this->getBodyValidator( $contentType ?? 'unknown/unknown' );
982        if ( !$bodyValidator instanceof NullBodyValidator ) {
983            // TODO: Trigger a deprecation warning.
984            return null;
985        }
986
987        $supportedTypes = $this->getSupportedRequestTypes();
988        if ( $contentType !== null && !in_array( $contentType, $supportedTypes ) ) {
989            throw new LocalizedHttpException(
990                new MessageValue( 'rest-unsupported-content-type', [ $contentType ] ),
991                415
992            );
993        }
994
995        // if it's supported and ends with "+json", we can probably parse it like a normal application/json request
996        $contentType = str_ends_with( $contentType ?? '', '+json' )
997            ? RequestInterface::JSON_CONTENT_TYPE
998            : $contentType;
999
1000        switch ( $contentType ) {
1001            case RequestInterface::FORM_URLENCODED_CONTENT_TYPE:
1002            case RequestInterface::MULTIPART_FORM_DATA_CONTENT_TYPE:
1003                $params = $request->getPostParams();
1004                foreach ( $params as $key => $value ) {
1005                    $params[ $key ] = UtfNormalValidator::cleanUp( $value );
1006                    // TODO: Warn if normalization was applied
1007                }
1008                return $params;
1009            case RequestInterface::JSON_CONTENT_TYPE:
1010                $jsonStream = $request->getBody();
1011                $jsonString = (string)$jsonStream;
1012                $normalizedJsonString = UtfNormalValidator::cleanUp( $jsonString );
1013                $parsedBody = json_decode( $normalizedJsonString, true );
1014                if ( !is_array( $parsedBody ) ) {
1015                    throw new LocalizedHttpException(
1016                        new MessageValue(
1017                            'rest-json-body-parse-error',
1018                            [ 'not a valid JSON object' ]
1019                        ),
1020                        400
1021                    );
1022                }
1023                // TODO: Warn if normalization was applied
1024                return $parsedBody;
1025            case null:
1026                // Specifying no Content-Type is fine if the body is empty
1027                if ( $request->getBody()->getSize() === 0 ) {
1028                    return null;
1029                }
1030            // no break, else fall through to the error below.
1031            default:
1032                throw new LocalizedHttpException(
1033                    new MessageValue( 'rest-unsupported-content-type', [ $contentType ?? '(null)' ] ),
1034                    415
1035                );
1036        }
1037    }
1038
1039    /**
1040     * Returns the content types that should be accepted by parseBodyData().
1041     *
1042     * Subclasses that support request types other than application/json
1043     * should override this method.
1044     *
1045     * If "application/x-www-form-urlencoded" or "multipart/form-data" are
1046     * returned, parseBodyData() will use $request->getPostParams() to determine
1047     * the body data.
1048     *
1049     * @note The return value of this method is ignored for requests
1050     * using a method listed in Validator::NO_BODY_METHODS,
1051     * in particular for the GET method.
1052     *
1053     * @note for backwards compatibility, the default implementation of this
1054     * method will examine the parameter definitions returned by getParamSettings()
1055     * to see if any of the parameters are declared as "post" parameters. If this
1056     * is the case, support for "application/x-www-form-urlencoded" and
1057     * "multipart/form-data" is added. This may change in future releases.
1058     * It is preferred to use "body" parameters and override this method explicitly
1059     * when support for form data is desired.
1060     *
1061     * @stable to override
1062     *
1063     * @return string[] A list of content-types
1064     */
1065    public function getSupportedRequestTypes(): array {
1066        $types = [
1067            RequestInterface::JSON_CONTENT_TYPE
1068        ];
1069
1070        // TODO: remove this once "post" parameters are no longer supported! T362850
1071        foreach ( $this->getParamSettings() as $settings ) {
1072            if ( ( $settings[self::PARAM_SOURCE] ?? null ) === 'post' ) {
1073                $types[] = RequestInterface::FORM_URLENCODED_CONTENT_TYPE;
1074                $types[] = RequestInterface::MULTIPART_FORM_DATA_CONTENT_TYPE;
1075                break;
1076            }
1077        }
1078
1079        return $types;
1080    }
1081
1082    /**
1083     * Get a HookContainer, for running extension hooks or for hook metadata.
1084     *
1085     * @since 1.35
1086     * @return HookContainer
1087     */
1088    protected function getHookContainer() {
1089        return $this->hookContainer;
1090    }
1091
1092    /**
1093     * Get a HookRunner for running core hooks.
1094     *
1095     * @internal This is for use by core only. Hook interfaces may be removed
1096     *   without notice.
1097     * @since 1.35
1098     * @return HookRunner
1099     */
1100    protected function getHookRunner() {
1101        return $this->hookRunner;
1102    }
1103
1104    /**
1105     * The subclass should override this to provide the maximum last modified
1106     * timestamp of the requested resource. This is called before execute() in
1107     * order to decide whether to send a 304. If the request is going to
1108     * change the state of the resource, the time returned must represent
1109     * the last modification date before the change. In other words, it must
1110     * provide the timestamp of the entity that the change is going to be
1111     * applied to.
1112     *
1113     * For GET and HEAD requests, this value will automatically be included
1114     * in the response in the Last-Modified header.
1115     *
1116     * Handlers that modify the resource and want to return a Last-Modified
1117     * header representing the new state in the response should set the header
1118     * in the execute() method.
1119     *
1120     * See RFC 7231 §7.2 and RFC 7232 §2.3 for semantics.
1121     *
1122     * @stable to override
1123     *
1124     * @return string|int|float|DateTime|null
1125     */
1126    protected function getLastModified() {
1127        return null;
1128    }
1129
1130    /**
1131     * The subclass should override this to provide an ETag for the current
1132     * state of the requested resource. This is called before execute() in
1133     * order to decide whether to send a 304. If the request is going to
1134     * change the state of the resource, the ETag returned must represent
1135     * the state before the change. In other words, it must identify
1136     * the entity that the change is going to be applied to.
1137     *
1138     * For GET and HEAD requests, this ETag will also be included in the
1139     * response.
1140     *
1141     * Handlers that modify the resource and want to return an ETag
1142     * header representing the new state in the response should set the header
1143     * in the execute() method. However, note that responses to PUT requests
1144     * must not return an ETag unless the new content of the resource is exactly
1145     * the data that was sent by the client in the request body.
1146     *
1147     * This must be a complete ETag, including double quotes.
1148     * See RFC 7231 §7.2 and RFC 7232 §2.3 for semantics.
1149     *
1150     * @stable to override
1151     *
1152     * @return string|null
1153     */
1154    protected function getETag() {
1155        return null;
1156    }
1157
1158    /**
1159     * The subclass should override this to indicate whether the resource
1160     * exists. This is used for wildcard validators, for example "If-Match: *"
1161     * fails if the resource does not exist.
1162     *
1163     * In a state-changing request, the return value of this method should
1164     * reflect the state before the requested change is applied.
1165     *
1166     * @stable to override
1167     *
1168     * @return bool|null
1169     */
1170    protected function hasRepresentation() {
1171        return null;
1172    }
1173
1174    /**
1175     * Indicates whether this route requires read rights.
1176     *
1177     * The handler should override this if it does not need to read from the
1178     * wiki. This is uncommon, but may be useful for login and other account
1179     * management APIs.
1180     *
1181     * @stable to override
1182     *
1183     * @return bool
1184     */
1185    public function needsReadAccess() {
1186        return true;
1187    }
1188
1189    /**
1190     * Indicates whether this route requires write access to the wiki.
1191     *
1192     * Handlers may override this method to return false if and only if the operation they
1193     * implement is "safe" per RFC 7231 section 4.2.1. A handler's operation is "safe" if
1194     * it is essentially read-only, i.e. the client does not request nor expect any state
1195     * change that would be observable in the responses to future requests.
1196     *
1197     * Implementations of this method must always return the same value, regardless of the
1198     * parameters passed to the constructor or system state.
1199     *
1200     * Handlers for GET, HEAD, OPTIONS, and TRACE requests should each implement a "safe"
1201     * operation. Handlers of PUT and DELETE requests should each implement a non-"safe"
1202     * operation. Note that handlers of POST requests can implement a "safe" operation,
1203     * particularly in the case where large input parameters are required.
1204     *
1205     * The information provided by this method is used to perform basic authorization checks
1206     * and to determine whether cross-origin requests are safe.
1207     *
1208     * @stable to override
1209     *
1210     * @return bool
1211     */
1212    public function needsWriteAccess() {
1213        return true;
1214    }
1215
1216    /**
1217     * Indicates whether this route can be accessed only by session providers safe vs csrf
1218     *
1219     * The handler should override this if the route must only be accessed by session
1220     * providers that are safe against csrf.
1221     *
1222     * A return value of false does not necessarily mean the route is vulnerable to csrf attacks.
1223     * It means the route can be accessed by session providers that are not automatically safe
1224     * against csrf attacks, so the possibility of csrf attacks must be considered.
1225     *
1226     * @stable to override
1227     *
1228     * @return bool
1229     */
1230    public function requireSafeAgainstCsrf() {
1231        return false;
1232    }
1233
1234    /**
1235     * The handler can override this to do any necessary setup after the init functions
1236     * are called to inject dependencies.
1237     *
1238     * @stable to override
1239     * @throws HttpException if the handler does not accept the request for
1240     *         some reason.
1241     */
1242    protected function postInitSetup() {
1243    }
1244
1245    /**
1246     * The handler can override this to do any necessary setup after validate()
1247     * has been called. This gives the handler an opportunity to do initialization
1248     * based on parameters before pre-execution calls like getLastModified() or getETag().
1249     *
1250     * @stable to override
1251     * @since 1.36
1252     */
1253    protected function postValidationSetup() {
1254    }
1255
1256    /**
1257     * Execute the handler. This is called after parameter validation. The
1258     * return value can either be a Response or any type accepted by
1259     * ResponseFactory::createFromReturnValue().
1260     *
1261     * To automatically construct an error response, execute() should throw a
1262     * \MediaWiki\Rest\HttpException. Such exceptions will not be logged like
1263     * a normal exception.
1264     *
1265     * If execute() throws any other kind of exception, the exception will be
1266     * logged and a generic 500 error page will be shown.
1267     *
1268     * @stable to override
1269     *
1270     * @return mixed
1271     */
1272    abstract public function execute();
1273}