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