Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.13% covered (success)
92.13%
246 / 267
78.95% covered (warning)
78.95%
30 / 38
CRAP
0.00% covered (danger)
0.00%
0 / 1
Router
92.13% covered (success)
92.13%
246 / 267
78.95% covered (warning)
78.95%
30 / 38
95.03
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getRelativePath
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 splitPath
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 fetchCachedModuleMap
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 fetchCachedModuleData
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 cacheModuleMap
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 cacheModuleData
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getModuleDataCacheKey
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getModuleMapCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getModuleMapHash
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 buildModuleMap
91.43% covered (success)
91.43%
32 / 35
0.00% covered (danger)
0.00%
0 / 1
11.08
 getModuleFileTimestamps
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getModuleMap
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getModuleInfo
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getModuleManager
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getModuleIds
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getModuleForPath
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getModuleForRequest
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
6.35
 getModule
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
5.06
 getRoutePath
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getRouteUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPrivateRouteUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getModuleBaseUrl
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 getModuleSpecUrl
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
6.01
 substPathParams
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 execute
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
4.32
 createResponseFromException
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 createRedirectResponse
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 doExecute
58.33% covered (warning)
58.33%
7 / 12
0.00% covered (danger)
0.00%
0 / 1
5.16
 prepareHandler
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 setCors
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setStats
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 instantiateModule
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
2
 isRestbaseCompatEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 varyOnRestbaseCompat
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTracingData
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 getModuleResponseFactoryByName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getModuleResponseFactory
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace MediaWiki\Rest;
4
5use MediaWiki\Config\ServiceOptions;
6use MediaWiki\HookContainer\HookContainer;
7use MediaWiki\Http\Telemetry;
8use MediaWiki\MainConfigNames;
9use MediaWiki\MainConfigSchema;
10use MediaWiki\Permissions\Authority;
11use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface;
12use MediaWiki\Rest\Module\ExtraRoutesModule;
13use MediaWiki\Rest\Module\Module;
14use MediaWiki\Rest\Module\ModuleManager;
15use MediaWiki\Rest\Module\SpecBasedModule;
16use MediaWiki\Rest\PathTemplateMatcher\ModuleConfigurationException;
17use MediaWiki\Rest\Reporter\ErrorReporter;
18use MediaWiki\Rest\Validator\Validator;
19use MediaWiki\Session\Session;
20use MediaWiki\Utils\UrlUtils;
21use Throwable;
22use UnexpectedValueException;
23use Wikimedia\Assert\Assert;
24use Wikimedia\Message\MessageValue;
25use Wikimedia\ObjectCache\BagOStuff;
26use Wikimedia\ObjectFactory\ObjectFactory;
27use Wikimedia\Stats\StatsFactory;
28
29/**
30 * The REST router is responsible for gathering module configuration, matching
31 * an input path against the defined modules, and constructing
32 * and executing the relevant module for a request.
33 */
34class Router {
35    public const ROUTE_MODULE_SPEC = '/specs/v0/module/{module}';
36
37    private const PREFIX_PATTERN = '!^/([-_.\w]+(?:/v[-_.\w]+)?)(/.*)$!';
38
39    public const DEFAULT_ERROR_SCHEMA = '1.0';
40
41    private const ERROR_FORMATTERS = [
42        'restbase' => [ 'class' => RestbaseCompatErrorFormatter::class ],
43        '1.0' => [ 'class' => ErrorFormatterV1::class ],
44        '2.0' => [ 'class' => ErrorFormatterV2::class ],
45    ];
46
47    /** @var string[] */
48    private $routeFiles;
49
50    /** @var null|array[] */
51    private $moduleMap = null;
52
53    /** @var Module[] */
54    private $modules = [];
55
56    /** @var int[]|null */
57    private $moduleFileTimestamps = null;
58
59    /** @var string */
60    private $baseUrl;
61
62    /** @var string */
63    private $privateBaseUrl;
64
65    /** @var string */
66    private $rootPath;
67
68    /** @var string */
69    private $scriptPath;
70
71    /** @var string|null */
72    private $configHash = null;
73
74    /** @var CorsUtils|null */
75    private $cors = null;
76
77    /** @var ?StatsFactory */
78    private $stats = null;
79
80    /**
81     * @internal
82     */
83    public const CONSTRUCTOR_OPTIONS = [
84        MainConfigNames::CanonicalServer,
85        MainConfigNames::InternalServer,
86        MainConfigNames::RestPath,
87        MainConfigNames::ScriptPath,
88        MainConfigNames::Sitename,
89        MainConfigNames::EmergencyContact,
90        MainConfigNames::RestTermsOfServiceUrl,
91    ];
92
93    /**
94     * @param ModuleManager $moduleManager
95     * @param array[] $extraRoutes
96     * @param ServiceOptions $options
97     * @param BagOStuff $cacheBag A cache in which to store the matcher trees
98     * @param array $textFormatters
99     * @param bool $showExceptionDetails
100     * @param BasicAuthorizerInterface $basicAuth
101     * @param Authority $authority
102     * @param ObjectFactory $objectFactory
103     * @param Validator $restValidator
104     * @param ErrorReporter $errorReporter
105     * @param HookContainer $hookContainer
106     * @param Session $session
107     * @param UrlUtils $urlUtils
108     * @internal
109     */
110    public function __construct(
111        private readonly ModuleManager $moduleManager,
112        private readonly array $extraRoutes,
113        private readonly ServiceOptions $options,
114        private readonly BagOStuff $cacheBag,
115        private readonly array $textFormatters,
116        private readonly bool $showExceptionDetails,
117        private readonly BasicAuthorizerInterface $basicAuth,
118        private readonly Authority $authority,
119        private readonly ObjectFactory $objectFactory,
120        private readonly Validator $restValidator,
121        private ErrorReporter $errorReporter,
122        private readonly HookContainer $hookContainer,
123        private readonly Session $session,
124        private readonly UrlUtils $urlUtils,
125    ) {
126        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
127
128        $this->routeFiles = $moduleManager->getRouteFiles();
129        $this->baseUrl = $options->get( MainConfigNames::CanonicalServer );
130        $this->privateBaseUrl = $options->get( MainConfigNames::InternalServer );
131        $this->rootPath = $options->get( MainConfigNames::RestPath );
132        $this->scriptPath = $options->get( MainConfigNames::ScriptPath );
133
134        Assert::parameter( count( $textFormatters ) > 0, '$textFormatters', 'must not be empty' );
135    }
136
137    /**
138     * Remove the REST path prefix. Return the part of the path with the
139     * prefix removed, or false if the prefix did not match.
140     * Both the $this->rootPath and the default REST path are accepted,
141     * so on a site that uses /api as the RestPath, requests to /w/rest.php
142     * still work. This is equivalent to supporting both /wiki and /w/index.php
143     * for page views.
144     *
145     * @param string $path
146     * @return false|string
147     */
148    private function getRelativePath( $path ) {
149        $allowed = [
150            $this->rootPath,
151            MainConfigSchema::getDefaultRestPath( $this->scriptPath )
152        ];
153
154        foreach ( $allowed as $prefix ) {
155            if ( str_starts_with( $path, $prefix ) ) {
156                return substr( $path, strlen( $prefix ) );
157            }
158        }
159
160        return false;
161    }
162
163    /**
164     * @param string $fullPath
165     *
166     * @return string[] [ string $module, string $path ]
167     */
168    private function splitPath( string $fullPath ): array {
169        $pathWithModule = $this->getRelativePath( $fullPath );
170
171        if ( $pathWithModule === false ) {
172            throw new LocalizedHttpException(
173                ( new MessageValue( 'rest-prefix-mismatch' ) )
174                    ->plaintextParams( $fullPath, $this->rootPath ),
175                404
176            );
177        }
178
179        if ( preg_match( self::PREFIX_PATTERN, $pathWithModule, $matches ) ) {
180            [ , $module, $pathUnderModule ] = $matches;
181        } else {
182            // No prefix found in the given path, assume prefix-less module.
183            $module = '';
184            $pathUnderModule = $pathWithModule;
185        }
186
187        if ( $module !== '' && !$this->getModuleInfo( $module ) ) {
188            // Prefix doesn't match any module, try the prefix-less module...
189            // TODO: At some point in the future, we'll want to warn and redirect...
190            $module = '';
191            $pathUnderModule = $pathWithModule;
192        }
193
194        return [ $module, $pathUnderModule ];
195    }
196
197    /**
198     * Get the cache data, or false if it is missing or invalid
199     */
200    private function fetchCachedModuleMap(): ?array {
201        $moduleMapCacheKey = $this->getModuleMapCacheKey();
202        $cacheData = $this->cacheBag->get( $moduleMapCacheKey );
203        if ( $cacheData && $cacheData[Module::CACHE_CONFIG_HASH_KEY] === $this->getModuleMapHash() ) {
204            unset( $cacheData[Module::CACHE_CONFIG_HASH_KEY] );
205            return $cacheData;
206        } else {
207            return null;
208        }
209    }
210
211    private function fetchCachedModuleData( string $module ): ?array {
212        $moduleDataCacheKey = $this->getModuleDataCacheKey( $module );
213        $cacheData = $this->cacheBag->get( $moduleDataCacheKey );
214        return $cacheData ?: null;
215    }
216
217    private function cacheModuleMap( array $map ) {
218        $map[Module::CACHE_CONFIG_HASH_KEY] = $this->getModuleMapHash();
219        $moduleMapCacheKey = $this->getModuleMapCacheKey();
220        $this->cacheBag->set( $moduleMapCacheKey, $map );
221    }
222
223    private function cacheModuleData( string $module, array $map ) {
224        $moduleDataCacheKey = $this->getModuleDataCacheKey( $module );
225        $this->cacheBag->set( $moduleDataCacheKey, $map );
226    }
227
228    private function getModuleDataCacheKey( string $module ): string {
229        if ( $module === '' ) {
230            // Proper key for the prefix-less module.
231            $module = '-';
232        }
233        return $this->cacheBag->makeKey( __CLASS__, 'module', $module );
234    }
235
236    private function getModuleMapCacheKey(): string {
237        return $this->cacheBag->makeKey( __CLASS__, 'map', '2' );
238    }
239
240    /**
241     * Get a config version hash for cache invalidation
242     */
243    private function getModuleMapHash(): string {
244        if ( $this->configHash === null ) {
245            $this->configHash = md5( json_encode( [
246                $this->extraRoutes,
247                $this->getModuleFileTimestamps()
248            ] ) );
249        }
250        return $this->configHash;
251    }
252
253    private function buildModuleMap(): array {
254        $modules = [];
255        $noPrefixFiles = [];
256        $id = ''; // should not be used, make Phan happy
257
258        foreach ( $this->routeFiles as $file ) {
259            // NOTE: we end up loading the file here (for the meta-data) as well
260            // as in the Module object (for the routes). But since we have
261            // caching on both levels, that shouldn't matter.
262            $spec = Module::loadJsonFile( $file );
263
264            if ( isset( $spec['mwapi'] ) || isset( $spec['moduleId'] ) || isset( $spec['routes'] ) ) {
265                // OpenAPI 3, with some extras like the "module" field
266                if ( !isset( $spec['moduleId'] ) ) {
267                    throw new ModuleConfigurationException(
268                        "Missing 'moduleId' field in $file"
269                    );
270                }
271
272                $id = $spec['moduleId'];
273
274                $moduleInfo = [
275                    'class' => SpecBasedModule::class,
276                    'pathPrefix' => $id,
277                    'specFile' => $file,
278                    'errorSchemaVersion' => $spec['errorSchemaVersion'] ?? null,
279                ];
280            } else {
281                // Old-style route file containing a flat list of routes.
282                $noPrefixFiles[] = $file;
283                $moduleInfo = null;
284            }
285
286            if ( $moduleInfo ) {
287                // Config and ModuleManager's CORE_ROUTE_FILES may legitimately refer to the same
288                // module file by different routes. The same moduleId in files with different
289                // names is almost certainly an error.
290                if (
291                    isset( $modules[$id] ) &&
292                    basename( $modules[$id]['specFile'] ) !== basename( $file )
293                ) {
294                    throw new ModuleConfigurationException(
295                        "Duplicate module $id in $file"
296                    );
297                }
298
299                $modules[$id] = $moduleInfo;
300            }
301        }
302
303        // The prefix-less module will be used when no prefix is matched.
304        // It provides a mechanism to integrate extra routes and route files
305        // registered by extensions.
306        if ( $noPrefixFiles || $this->extraRoutes ) {
307            $modules[''] = [
308                'class' => ExtraRoutesModule::class,
309                'pathPrefix' => '',
310                'routeFiles' => $noPrefixFiles,
311                'extraRoutes' => $this->extraRoutes,
312                'errorSchemaVersion' => null,
313            ];
314        }
315
316        return $modules;
317    }
318
319    /**
320     * Get an array of last modification times of the defined route files.
321     *
322     * @return int[] Last modification times
323     */
324    private function getModuleFileTimestamps() {
325        if ( $this->moduleFileTimestamps === null ) {
326            $this->moduleFileTimestamps = [];
327            foreach ( $this->routeFiles as $fileName ) {
328                $this->moduleFileTimestamps[$fileName] = filemtime( $fileName );
329            }
330        }
331        return $this->moduleFileTimestamps;
332    }
333
334    private function getModuleMap(): array {
335        if ( !$this->moduleMap ) {
336            $map = $this->fetchCachedModuleMap();
337
338            if ( !$map ) {
339                $map = $this->buildModuleMap();
340                $this->cacheModuleMap( $map );
341            }
342
343            $this->moduleMap = $map;
344        }
345        return $this->moduleMap;
346    }
347
348    private function getModuleInfo( string $module ): ?array {
349        $map = $this->getModuleMap();
350        return $map[$module] ?? null;
351    }
352
353    /**
354     * @return ModuleManager
355     */
356    public function getModuleManager(): ModuleManager {
357        return $this->moduleManager;
358    }
359
360    /**
361     * @return string[]
362     */
363    public function getModuleIds(): array {
364        return array_keys( $this->getModuleMap() );
365    }
366
367    /**
368     * Returns an uninitialized module by full path.
369     * @deprecated since 1.47, use getModuleForRequest() instead.
370     */
371    public function getModuleForPath( string $fullPath ): ?Module {
372        wfDeprecated( __METHOD__, '1.47' );
373
374        [ $moduleName, ] = $this->splitPath( $fullPath );
375        return $this->getModule( $moduleName );
376    }
377
378    /**
379     * Returns a module suitable for handling the given request.
380     *
381     * @param RequestInterface $request
382     * @param string|null $name The module name, if known. Will be derived from
383     *        $request if not given.
384     *
385     * @return Module|null
386     * @since since 1.47
387     */
388    public function getModuleForRequest( RequestInterface $request, ?string $name ): ?Module {
389        if ( $name === null ) {
390            $fullPath = $request->getUri()->getPath();
391            [ $name, ] = $this->splitPath( $fullPath );
392        }
393
394        $module = $this->getModule( $name );
395        $info = $this->getModuleInfo( $name );
396
397        if ( !$module || !$info ) {
398            return null;
399        }
400
401        $responseFactory = $this->getModuleResponseFactory( $info, $request );
402        $module->initForExecute( $responseFactory );
403
404        if ( $this->cors ) {
405            $module->setCors( $this->cors );
406        }
407
408        if ( $this->stats ) {
409            $module->setStats( $this->stats );
410        }
411
412        return $module;
413    }
414
415    /**
416     * Returns an uninitialized module by name.
417     * @note To get a module that can be used for handling a request,
418     * use getModuleForRequest() instead.
419     */
420    public function getModule( string $name ): ?Module {
421        if ( isset( $this->modules[$name] ) ) {
422            return $this->modules[$name];
423        }
424
425        $info = $this->getModuleInfo( $name );
426
427        if ( !$info ) {
428            return null;
429        }
430
431        $module = $this->instantiateModule( $info, $name );
432
433        $cacheData = $this->fetchCachedModuleData( $name );
434
435        if ( $cacheData !== null ) {
436            $cacheOk = $module->initFromCacheData( $cacheData );
437        } else {
438            $cacheOk = false;
439        }
440
441        if ( !$cacheOk ) {
442            $cacheData = $module->getCacheData();
443            $this->cacheModuleData( $name, $cacheData );
444        }
445
446        $this->modules[$name] = $module;
447        return $module;
448    }
449
450    /**
451     * @since 1.42
452     * @todo This should be called getRelativeRouteUrl() since query parameters are included
453     */
454    public function getRoutePath(
455        string $pathWithModulePrefix,
456        array $pathParams = [],
457        array $queryParams = []
458    ): string {
459        $pathWithModulePrefix = self::substPathParams( $pathWithModulePrefix, $pathParams );
460        $path = $this->rootPath . $pathWithModulePrefix;
461        return wfAppendQuery( $path, $queryParams );
462    }
463
464    public function getRouteUrl(
465        string $pathWithModulePrefix,
466        array $pathParams = [],
467        array $queryParams = []
468    ): string {
469        return $this->baseUrl . $this->getRoutePath( $pathWithModulePrefix, $pathParams, $queryParams );
470    }
471
472    public function getPrivateRouteUrl(
473        string $pathWithModulePrefix,
474        array $pathParams = [],
475        array $queryParams = []
476    ): string {
477        return $this->privateBaseUrl . $this->getRoutePath( $pathWithModulePrefix, $pathParams, $queryParams );
478    }
479
480    /**
481     * Gets the absolute base URL for a given module ID.
482     *
483     * For external modules, UrlUtils expands any relative URL.
484     * For local modules, the route URL is generated from the module ID.
485     *
486     * @param string $moduleId The module ID
487     * @return string|null The absolute base URL, or null if the module is unresolvable
488     * @throws UnexpectedValueException If an external module has no base URL configured
489     * @since 1.47
490     */
491    public function getModuleBaseUrl( string $moduleId ): ?string {
492        $info = $this->moduleManager->getModuleInfo( $moduleId );
493        if ( !$info ) {
494            return null;
495        }
496
497        if ( $info->isExternal() ) {
498            $baseUrl = $info->getExternalBaseUrl();
499            if ( $baseUrl === null ) {
500                throw new UnexpectedValueException(
501                    "External module '$moduleId' has no base URL configured"
502                );
503            }
504            return $this->urlUtils->expand( $baseUrl );
505        }
506
507        return $this->getRouteUrl( '/' . $moduleId );
508    }
509
510    /**
511     * Gets the absolute OpenAPI specification URL for a given module ID.
512     *
513     * For external modules, UrlUtils expands the spec URL.
514     * For local modules, returns the default route URL defined by self::ROUTE_MODULE_SPEC.
515     *
516     * @param string $moduleId The module ID
517     * @return string|null The absolute spec URL, or null if the module is unresolvable
518     * @throws UnexpectedValueException If an external module has no spec URL configured
519     * @since 1.47
520     */
521    public function getModuleSpecUrl( string $moduleId ): ?string {
522        $info = $this->moduleManager->getModuleInfo( $moduleId );
523        if ( !$info ) {
524            return null;
525        }
526
527        if ( $info->isExternal() ) {
528            $specUrl = $info->getExternalSpecUrl();
529            if ( $specUrl === null ) {
530                throw new UnexpectedValueException(
531                    "External module '$moduleId' has no spec URL configured"
532                );
533            }
534            return $this->urlUtils->expand( $specUrl );
535        } elseif ( $moduleId === '' ) {
536            return $this->getRouteUrl( self::ROUTE_MODULE_SPEC, [ 'module' => '-' ] );
537        }
538
539        $specPath = $info->getLocalDescriptionSpecPath();
540        return ( $specPath !== null )
541            ? $this->getRouteUrl( '/' . $moduleId . $specPath )
542            : $this->getRouteUrl( self::ROUTE_MODULE_SPEC, [ 'module' => $moduleId ] );
543    }
544
545    /**
546     * Substitute parameters into a template string, following the
547     * requirements for path parameters: Spaces are encoded as %20 (not +)
548     * and slashes are encoded as %2F, other characters with special meaning
549     * are encoded as they would be for query parameters.
550     *
551     * @param string $route A path with {placeholders}
552     * @param array $pathParams
553     *
554     * @return string
555     * @since 1.47 (was protected/internal before that)
556     */
557    public static function substPathParams( string $route, array $pathParams ): string {
558        foreach ( $pathParams as $param => $value ) {
559            // NOTE: we use rawurlencode here, since execute() uses rawurldecode().
560            // Spaces in path params must be encoded to %20 (not +).
561            // Slashes must be encoded as %2F.
562            $route = str_replace( '{' . $param . '}', rawurlencode( (string)$value ), $route );
563        }
564        return $route;
565    }
566
567    public function execute( RequestInterface $request ): ResponseInterface {
568        try {
569            $fullPath = $request->getUri()->getPath();
570            $response = $this->doExecute( $fullPath, $request );
571        } catch ( HttpException $e ) {
572            $response = $this->createResponseFromException( $e, $request );
573        } catch ( Throwable $e ) {
574            $this->errorReporter->reportError( $e, null, $request );
575            $response = $this->createResponseFromException( $e, $request );
576        }
577
578        // TODO: Only send the vary header for handlers that opt into
579        //       restbase compat!
580        $this->varyOnRestbaseCompat( $response );
581
582        // Apply CORS headers to every response, including router-level errors
583        // (unknown module, prefix mismatch), redirects, and top-level
584        // exceptions that never reach a Module. Preflight responses created in
585        // Module::throwNoMatch() also pass through here to gain their
586        // Access-Control-Allow-Origin header.
587        if ( $this->cors ) {
588            $this->cors->modifyResponse( $request, $response );
589        }
590
591        return $response;
592    }
593
594    private function createResponseFromException( Throwable $e, RequestInterface $request ): ResponseInterface {
595        $responseFactory = $this->getModuleResponseFactory( [], $request );
596        return $responseFactory->createFromException( $e );
597    }
598
599    private function createRedirectResponse( string $target, int $code, RequestInterface $request ): ResponseInterface {
600        $responseFactory = $this->getModuleResponseFactory( [], $request );
601        return $responseFactory->createRedirect( $target, $code );
602    }
603
604    private function doExecute( string $fullPath, RequestInterface $request ): ResponseInterface {
605        [ $modulePrefix, $path ] = $this->splitPath( $fullPath );
606
607        // If there is no path at all, redirect to "/".
608        // That's the minimal path that can be routed.
609        if ( $modulePrefix === '' && $path === '' ) {
610            $target = $this->getRoutePath( '/' );
611            return $this->createRedirectResponse( $target, 308, $request );
612        }
613
614        $module = $this->getModuleForRequest( $request, $modulePrefix );
615
616        if ( !$module ) {
617            throw new LocalizedHttpException(
618                MessageValue::new( 'rest-unknown-module' )->plaintextParams( $modulePrefix ),
619                404,
620                [ 'prefix' => $modulePrefix ]
621            );
622        }
623
624        return $module->execute( $path, $request );
625    }
626
627    /**
628     * Prepare the handler by injecting relevant service objects and state
629     * into $handler.
630     *
631     * @internal
632     */
633    public function prepareHandler( Handler $handler ) {
634        // Injecting services in the Router class means we don't have to inject
635        // them into each Module.
636        $handler->initServices(
637            $this->authority,
638            $this->hookContainer
639        );
640
641        $handler->initSession( $this->session );
642    }
643
644    public function setCors( CorsUtils $cors ): self {
645        $this->cors = $cors;
646
647        return $this;
648    }
649
650    /**
651     * @internal
652     *
653     * @param StatsFactory $stats
654     *
655     * @return self
656     */
657    public function setStats( StatsFactory $stats ): self {
658        $this->stats = $stats;
659
660        return $this;
661    }
662
663    private function instantiateModule( array $info, string $name ): Module {
664        // NOTE: $this->textFormatters are in the order of preference.
665        //       See EntryPoint::getTextFormaters().
666        //       Use the first one.
667        $defaultFormatter = array_first( $this->textFormatters );
668        $jsonLocalizer = new JsonLocalizer( $defaultFormatter );
669
670        if ( $info['class'] === SpecBasedModule::class ) {
671            $module = new SpecBasedModule(
672                $info['specFile'],
673                $this,
674                $info['pathPrefix'] ?? $name,
675                $jsonLocalizer,
676                $this->basicAuth,
677                $this->objectFactory,
678                $this->restValidator,
679                $this->errorReporter,
680                $this->hookContainer
681            );
682        } else {
683            $module = new ExtraRoutesModule(
684                $info['routeFiles'] ?? [],
685                $info['extraRoutes'] ?? [],
686                $this,
687                $jsonLocalizer,
688                $this->basicAuth,
689                $this->objectFactory,
690                $this->restValidator,
691                $this->errorReporter,
692                $this->hookContainer,
693                new ServiceOptions( ExtraRoutesModule::CONSTRUCTOR_OPTIONS, $this->options )
694            );
695        }
696
697        return $module;
698    }
699
700    /**
701     * @internal
702     *
703     * @return bool
704     */
705    public function isRestbaseCompatEnabled( RequestInterface $request ): bool {
706        // See T374136
707        return $request->getHeaderLine( 'x-restbase-compat' ) === 'true';
708    }
709
710    private function varyOnRestbaseCompat( ResponseInterface $response ) {
711        // See T374136
712        $response->addHeader( 'Vary', 'x-restbase-compat' );
713    }
714
715    /**
716     * Provide information about the request, for use by ErrorFormatters.
717     * All data returned by this method may be sent to the client verbatim.
718     * However, the content of this array is not an API contract, it may
719     * change at any time. It's intended for diagnostic purposes on the
720     * client side, and, more importantly, when clients report errors
721     * upstream.
722     */
723    private function getTracingData( string $method, string $uri ): array {
724        $tracingData = [
725            'module' => 'mediawiki',
726            'method' => strtolower( $method ),
727            'uri' => $uri,
728            'request_id' => Telemetry::getInstance()->getRequestId()
729        ];
730
731        $url = $this->urlUtils->expand( $uri, PROTO_CANONICAL );
732        if ( $url !== null ) {
733            $tracingData['url'] = $url;
734        }
735
736        return $tracingData;
737    }
738
739    /**
740     * Like getModuleResponseFactory(), but for the module with the given name: the
741     * ResponseFactory that module would use for this request, with the
742     * error format selected by the module's errorSchemaVersion or, if the request
743     * asks for it, the RESTBase-compatible format (see isRestbaseCompatEnabled()).
744     *
745     * @internal For use by ModuleSpecHandler
746     */
747    public function getModuleResponseFactoryByName( string $moduleName, RequestInterface $request ): ResponseFactory {
748        return $this->getModuleResponseFactory( $this->getModuleInfo( $moduleName ) ?? [], $request );
749    }
750
751    private function getModuleResponseFactory( array $moduleInfo, RequestInterface $request ): ResponseFactory {
752        $schemaVer = $moduleInfo['errorSchemaVersion'] ?? null;
753
754        if ( $this->isRestbaseCompatEnabled( $request ) ) {
755            $schemaVer = 'restbase';
756        }
757
758        $schemaVer ??= self::DEFAULT_ERROR_SCHEMA;
759        $formatterSpec = self::ERROR_FORMATTERS[ $schemaVer ] ?? null;
760
761        if ( !$formatterSpec ) {
762            throw new ModuleConfigurationException( "Unsupported errorSchemaVersion: $schemaVer" );
763        }
764
765        $errorFormatter = $this->objectFactory->createObject(
766            $formatterSpec,
767            [
768                'assertClass' => ErrorFormatter::class,
769                'extraArgs' => [
770                    $this->textFormatters,
771                    $this->showExceptionDetails,
772                    $this->getTracingData( $request->getMethod(), (string)$request->getUri() )
773                ],
774            ]
775        );
776
777        return new ResponseFactory( $this->textFormatters, $errorFormatter );
778    }
779}