Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.90% covered (warning)
85.90%
67 / 78
55.56% covered (warning)
55.56%
5 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
ExtraRoutesModule
85.90% covered (warning)
85.90%
67 / 78
55.56% covered (warning)
55.56%
5 / 9
21.12
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 getConfigHash
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getRoutesFromFiles
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 getRouteFileTimestamps
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
 getDefinedPaths
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getAllRoutes
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 initRoutes
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 makeRouteInfo
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
3
 getOpenApiInfo
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Rest\Module;
4
5use AppendIterator;
6use ArrayIterator;
7use Iterator;
8use MediaWiki\HookContainer\HookContainer;
9use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface;
10use MediaWiki\Rest\Handler\RedirectHandler;
11use MediaWiki\Rest\JsonLocalizer;
12use MediaWiki\Rest\PathTemplateMatcher\ModuleConfigurationException;
13use MediaWiki\Rest\Reporter\ErrorReporter;
14use MediaWiki\Rest\ResponseFactory;
15use MediaWiki\Rest\RouteDefinitionException;
16use MediaWiki\Rest\Router;
17use MediaWiki\Rest\Validator\Validator;
18use Wikimedia\ObjectFactory\ObjectFactory;
19
20/**
21 * A Module that is based on flat route definitions in the form originally
22 * introduced in MW 1.35. This module acts as a "catch all" since it doesn't
23 * use a module prefix. So it handles all routes that do not explicitly belong
24 * to a module.
25 *
26 * This module responds to requests by matching the requested path against a
27 * list of known routes to identify the appropriate handler.
28 * The routes are loaded from the route definition files or in extension.json
29 * files using the RestRoutes key.
30 *
31 * Flat files just contain a list (a JSON array) or route definitions (see below).
32 * Annotated route definition files contain a map (a JSON object) with the
33 * following fields:
34 * - "module": the module name (string). The router uses this name to find the
35 *   correct module for handling a request by matching it against the prefix
36 *   of the request path. The module name must be unique.
37 * - "routes": a list (JSON array) or route definitions (see below).
38 *
39 * Each route definition maps a path pattern to a handler class. It is given as
40 * a map (JSON object) with the following fields:
41 * - "path": the path pattern (string) relative to the module prefix. Required.
42 *   The path may contain placeholders for path parameters.
43 * - "method": the HTTP method(s) or "verbs" supported by the route. If not given,
44 *   it is assumed that the route supports the "GET" method. The "OPTIONS" method
45 *   for CORS is supported implicitly.
46 * - "class" or "factory": The handler class (string) or factory function
47 *   (callable) of an "object spec" for use with ObjectFactory::createObject.
48 *   See there for the usage of additional fields like "services". If a shorthand
49 *   is used (see below), no object spec is needed.
50 *
51 * The following fields are supported as a shorthand notation:
52 * - "redirect": the route represents a redirect and will be handled by
53 *   the RedirectHandler class. The redirect is specified as a JSON object
54 *   that specifies the target "path", and optionally the redirect "code".
55 *
56 * More shorthands may be added in the future.
57 *
58 * Route definitions can contain additional fields to configure the handler.
59 * The handler can access the route definition by calling getConfig().
60 *
61 * @internal
62 * @since 1.43
63 */
64class ExtraRoutesModule extends MatcherBasedModule {
65
66    /** @var string[] */
67    private array $routeFiles;
68
69    /**
70     * @var array<int,array> A list of route definitions
71     */
72    private array $extraRoutes;
73
74    private JsonLocalizer $jsonLocalizer;
75
76    /**
77     * @var array<int,array>|null A list of route definitions loaded from
78     * the files specified by $routeFiles
79     */
80    private ?array $routesFromFiles = null;
81
82    /** @var int[]|null */
83    private ?array $routeFileTimestamps = null;
84
85    private ?string $configHash = null;
86
87    /**
88     * @param string[] $routeFiles List of names of JSON files containing routes
89     *        See the documentation of this class for a description of the file
90     *        format.
91     * @param array<int,array> $extraRoutes Extension route array. The content of
92     *        this array must be a list of route definitions. See the documentation
93     *        of this class for a description of the expected structure.
94     */
95    public function __construct(
96        array $routeFiles,
97        array $extraRoutes,
98        Router $router,
99        ResponseFactory $responseFactory,
100        BasicAuthorizerInterface $basicAuth,
101        ObjectFactory $objectFactory,
102        Validator $restValidator,
103        ErrorReporter $errorReporter,
104        HookContainer $hookContainer
105    ) {
106        parent::__construct(
107            $router,
108            '',
109            $responseFactory,
110            $basicAuth,
111            $objectFactory,
112            $restValidator,
113            $errorReporter,
114            $hookContainer
115        );
116        $this->routeFiles = $routeFiles;
117        $this->extraRoutes = $extraRoutes;
118        $this->jsonLocalizer = new JsonLocalizer( $responseFactory );
119    }
120
121    /**
122     * Get a config version hash for cache invalidation
123     */
124    protected function getConfigHash(): string {
125        if ( $this->configHash === null ) {
126            $this->configHash = md5( json_encode( [
127                'class' => __CLASS__,
128                'version' => 1,
129                'extraRoutes' => $this->extraRoutes,
130                'fileTimestamps' => $this->getRouteFileTimestamps()
131            ] ) );
132        }
133        return $this->configHash;
134    }
135
136    /**
137     * Load the defined JSON files and return the merged routes.
138     *
139     * @return array<int,array> A list of route definitions. See this class's
140     *         documentation for a description of the format of route definitions.
141     * @throws ModuleConfigurationException If a route file cannot be loaded or processed.
142     */
143    private function getRoutesFromFiles(): array {
144        if ( $this->routesFromFiles !== null ) {
145            return $this->routesFromFiles;
146        }
147
148        $this->routesFromFiles = [];
149        $this->routeFileTimestamps = [];
150        foreach ( $this->routeFiles as $fileName ) {
151            $this->routeFileTimestamps[$fileName] = filemtime( $fileName );
152
153            $routes = $this->loadJsonFile( $fileName );
154
155            $this->routesFromFiles = array_merge( $this->routesFromFiles, $routes );
156        }
157
158        return $this->routesFromFiles;
159    }
160
161    /**
162     * Get an array of last modification times of the defined route files.
163     *
164     * @return int[] Last modification times
165     */
166    private function getRouteFileTimestamps(): array {
167        if ( $this->routeFileTimestamps === null ) {
168            $this->routeFileTimestamps = [];
169            foreach ( $this->routeFiles as $fileName ) {
170                $this->routeFileTimestamps[$fileName] = filemtime( $fileName );
171            }
172        }
173        return $this->routeFileTimestamps;
174    }
175
176    /**
177     * @return array[]
178     */
179    public function getDefinedPaths(): array {
180        $paths = [];
181        foreach ( $this->getAllRoutes() as $spec ) {
182            $key = $spec['path'];
183
184            $methods = isset( $spec['method'] ) ? (array)$spec['method'] : [ 'GET' ];
185
186            $paths[$key] = array_merge( $paths[$key] ?? [], $methods );
187        }
188
189        return $paths;
190    }
191
192    /**
193     * @return Iterator<array>
194     */
195    private function getAllRoutes() {
196        $iterator = new AppendIterator;
197        $iterator->append( new ArrayIterator( $this->getRoutesFromFiles() ) );
198        $iterator->append( new ArrayIterator( $this->extraRoutes ) );
199        return $iterator;
200    }
201
202    protected function initRoutes(): void {
203        $routeDefs = $this->getAllRoutes();
204
205        foreach ( $routeDefs as $route ) {
206            if ( !isset( $route['path'] ) ) {
207                throw new RouteDefinitionException( 'Missing path' );
208            }
209
210            $path = $route['path'];
211            $method = $route['method'] ?? 'GET';
212            $info = $this->makeRouteInfo( $route );
213
214            $this->addRoute( $method, $path, $info );
215        }
216    }
217
218    /**
219     * Generate a route info array to be stored in the matcher tree,
220     * in the form expected by MatcherBasedModule::addRoute()
221     * and ultimately Module::getHandlerForPath().
222     */
223    private function makeRouteInfo( array $route ): array {
224        static $objectSpecKeys = [
225            'class',
226            'factory',
227            'services',
228            'optional_services',
229            'args',
230        ];
231
232        if ( isset( $route['redirect'] ) ) {
233            // Redirect shorthand
234            $info = [
235                'spec' => [ 'class' => RedirectHandler::class ],
236                'config' => $route,
237            ];
238        } else {
239            // Object spec at the top level
240            $info = [
241                'spec' => array_intersect_key( $route, array_flip( $objectSpecKeys ) ),
242                'config' => array_diff_key( $route, array_flip( $objectSpecKeys ) ),
243            ];
244        }
245        if ( isset( $route['openApiSpec'] ) ) {
246            $info['openApiSpec'] = $this->jsonLocalizer->localizeJson( $route['openApiSpec'] );
247        }
248
249        $info['path'] = $route['path'];
250        return $info;
251    }
252
253    /** @inheritDoc */
254    public function getOpenApiInfo() {
255        // Note that mwapi-1.0 is based on OAS 3.0, so it doesn't support the
256        // "summary" property introduced in 3.1.
257        return [
258            'title' => $this->jsonLocalizer->getFormattedMessage( 'rest-module-extra-routes-title' ),
259            'description' => $this->jsonLocalizer->getFormattedMessage( 'rest-module-extra-routes-desc' ),
260            'version' => 'undefined',
261        ];
262    }
263}