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