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