Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 118
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Module
0.00% covered (danger)
0.00%
0 / 118
0.00% covered (danger)
0.00%
0 / 12
1056
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getPathPrefix
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCacheData
n/a
0 / 0
n/a
0 / 0
0
 initFromCacheData
n/a
0 / 0
n/a
0 / 0
0
 getHandlerForPath
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
56
 getRouter
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 findHandlerMatch
n/a
0 / 0
n/a
0 / 0
0
 throwNoMatch
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 execute
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 recordMetrics
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 getDefinedPaths
n/a
0 / 0
n/a
0 / 0
0
 getAllowedMethods
n/a
0 / 0
n/a
0 / 0
0
 instantiateHandlerObject
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 executeHandler
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 setCors
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setStats
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 loadJsonFile
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace MediaWiki\Rest\Module;
4
5use LogicException;
6use MediaWiki\Profiler\ProfilingContext;
7use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface;
8use MediaWiki\Rest\CorsUtils;
9use MediaWiki\Rest\Handler;
10use MediaWiki\Rest\HttpException;
11use MediaWiki\Rest\LocalizedHttpException;
12use MediaWiki\Rest\PathTemplateMatcher\ModuleConfigurationException;
13use MediaWiki\Rest\Reporter\ErrorReporter;
14use MediaWiki\Rest\RequestInterface;
15use MediaWiki\Rest\ResponseException;
16use MediaWiki\Rest\ResponseFactory;
17use MediaWiki\Rest\ResponseInterface;
18use MediaWiki\Rest\Router;
19use MediaWiki\Rest\Validator\Validator;
20use Throwable;
21use Wikimedia\Message\MessageValue;
22use Wikimedia\ObjectFactory\ObjectFactory;
23use Wikimedia\Stats\StatsFactory;
24
25/**
26 * A REST module represents a collection of endpoints.
27 * The module object is responsible for generating a response for a given
28 * request. This is typically done by routing requests to the appropriate
29 * request handler.
30 *
31 * @since 1.43
32 */
33abstract class Module {
34
35    /**
36     * @internal for use in cached module data
37     */
38    public const CACHE_CONFIG_HASH_KEY = 'CONFIG-HASH';
39
40    protected string $pathPrefix;
41    protected ResponseFactory $responseFactory;
42    private BasicAuthorizerInterface $basicAuth;
43    private ObjectFactory $objectFactory;
44    private Validator $restValidator;
45    private ErrorReporter $errorReporter;
46    private Router $router;
47
48    private StatsFactory $stats;
49    private ?CorsUtils $cors = null;
50
51    /**
52     * @param Router $router
53     * @param string $pathPrefix
54     * @param ResponseFactory $responseFactory
55     * @param BasicAuthorizerInterface $basicAuth
56     * @param ObjectFactory $objectFactory
57     * @param Validator $restValidator
58     * @param ErrorReporter $errorReporter
59     */
60    public function __construct(
61        Router $router,
62        string $pathPrefix,
63        ResponseFactory $responseFactory,
64        BasicAuthorizerInterface $basicAuth,
65        ObjectFactory $objectFactory,
66        Validator $restValidator,
67        ErrorReporter $errorReporter
68    ) {
69        $this->router = $router;
70        $this->pathPrefix = $pathPrefix;
71        $this->responseFactory = $responseFactory;
72        $this->basicAuth = $basicAuth;
73        $this->objectFactory = $objectFactory;
74        $this->restValidator = $restValidator;
75        $this->errorReporter = $errorReporter;
76
77        $this->stats = StatsFactory::newNull();
78    }
79
80    public function getPathPrefix(): string {
81        return $this->pathPrefix;
82    }
83
84    /**
85     * Return data that can later be used to initialize a new instance of
86     * this module in a fast and efficient way.
87     *
88     * @see initFromCacheData()
89     *
90     * @return array An associative array suitable to be processed by
91     *         initFromCacheData. Implementations are free to choose the format.
92     */
93    abstract public function getCacheData(): array;
94
95    /**
96     * Initialize from the given cache data if possible.
97     * This allows fast initialization based on data that was cached during
98     * a previous invocation of the module.
99     *
100     * Implementations are responsible for verifying that the cache data
101     * matches the information provided to the constructor, to protect against
102     * a situation where configuration was updated in a way that affects the
103     * operation of the module.
104     *
105     * @param array $cacheData Data generated by getCacheData(), implementations
106     *        are free to choose the format.
107     *
108     * @return bool true if the cache data could be used,
109     *         false if it was discarded.
110     * @see getCacheData()
111     */
112    abstract public function initFromCacheData( array $cacheData ): bool;
113
114    /**
115     * Create a Handler for the given path, taking into account the request
116     * method.
117     *
118     * If $prepExecution is true, the handler's prepareForExecute() method will
119     * be called, which will call postInitSetup(). The $request object will be
120     * updated with any path parameters and parsed body data.
121     *
122     * @unstable
123     *
124     * @param string $path
125     * @param RequestInterface $request The request to handle. If $forExecution
126     *        is true, this will be updated with the path parameters and parsed
127     *        body data as appropriate.
128     * @param bool $initForExecute Whether the handler and the request should be
129     *        prepared for execution. Callers that only need the Handler object
130     *        for access to meta-data should set this to false.
131     *
132     * @return Handler
133     * @throws HttpException If no handler was found
134     */
135    public function getHandlerForPath(
136        string $path,
137        RequestInterface $request,
138        bool $initForExecute = false
139    ): Handler {
140        $requestMethod = strtoupper( $request->getMethod() );
141
142        $match = $this->findHandlerMatch( $path, $requestMethod );
143
144        if ( !$match['found'] && $requestMethod === 'HEAD' ) {
145            // For a HEAD request, execute the GET handler instead if one exists.
146            $match = $this->findHandlerMatch( $path, 'GET' );
147        }
148
149        if ( !$match['found'] ) {
150            $this->throwNoMatch(
151                $path,
152                $request->getMethod(),
153                $match['methods'] ?? []
154            );
155        }
156
157        if ( isset( $match['handler'] ) ) {
158            $handler = $match['handler'];
159        } elseif ( isset( $match['spec'] ) ) {
160            $handler = $this->instantiateHandlerObject( $match['spec'] );
161        } else {
162            throw new LogicException(
163                'Match does not specify a handler instance or object spec.'
164            );
165        }
166
167        // For backwards compatibility only. Handlers should get the path by
168        // calling getPath(), not from the config array.
169        $config = $match['config'] ?? [];
170        $config['path'] ??= $match['path'];
171
172        // Provide context about the module
173        $handler->initContext( $this, $match['path'], $config );
174
175        // Inject services and state from the router
176        $this->getRouter()->prepareHandler( $handler );
177
178        if ( $initForExecute ) {
179            // Use rawurldecode so a "+" in path params is not interpreted as a space character.
180            $pathParams = array_map( 'rawurldecode', $match['params'] ?? [] );
181            $request->setPathParams( $pathParams );
182
183            $handler->initForExecute( $request );
184        }
185
186        return $handler;
187    }
188
189    public function getRouter(): Router {
190        return $this->router;
191    }
192
193    /**
194     * Determines which handler to use for the given path and returns an array
195     * describing the handler and initialization context.
196     *
197     * @param string $path
198     * @param string $requestMethod
199     *
200     * @return array<string,mixed>
201     *         - bool "found": Whether a match was found. If true, the `handler`
202     *           or `spec` field must be set.
203     *         - Handler handler: the Handler object to use. Either "handler" or
204     *           "spec" must be given.
205     *         - array "spec":" an object spec for use with ObjectFactory
206     *         - array "config": the route config, to be passed to Handler::initContext()
207     *         - string "path": the path the handler is responsible for,
208     *           including placeholders for path parameters.
209     *         - string[] "params": path parameters, to be passed the
210     *           Request::setPathPrams()
211     *         - string[] "methods": supported methods, if the path is known but
212     *           the method did not match. Only meaningful if "found" is false.
213     *           To be used in the Allow header of a 405 response and included
214     *           in CORS pre-flight.
215     */
216    abstract protected function findHandlerMatch(
217        string $path,
218        string $requestMethod
219    ): array;
220
221    /**
222     * Implementations of getHandlerForPath() should call this method when they
223     * cannot handle the requested path.
224     *
225     * @param string $path The requested path
226     * @param string $method The HTTP method of the current request
227     * @param string[] $allowed The allowed HTTP methods allowed by the path
228     *
229     * @return never
230     * @throws HttpException
231     */
232    protected function throwNoMatch( string $path, string $method, array $allowed ): void {
233        // Check for CORS Preflight. This response will *not* allow the request unless
234        // an Access-Control-Allow-Origin header is added to this response.
235        if ( $this->cors && $method === 'OPTIONS' && $allowed ) {
236            // IDEA: Create a CorsHandler, which getHandlerForPath can return in this case.
237            $response = $this->cors->createPreflightResponse( $allowed );
238            throw new ResponseException( $response );
239        }
240
241        if ( $allowed ) {
242            // There are allowed methods for this patch, so reply with Method Not Allowed.
243            $response = $this->responseFactory->createLocalizedHttpError( 405,
244                ( new MessageValue( 'rest-wrong-method' ) )
245                    ->textParams( $method )
246                    ->commaListParams( $allowed )
247                    ->numParams( count( $allowed ) )
248            );
249            $response->setHeader( 'Allow', $allowed );
250            throw new ResponseException( $response );
251        } else {
252            // There are no allowed methods for this path, so the path was not found at all.
253            $msg = ( new MessageValue( 'rest-no-match' ) )
254                ->plaintextParams( $path );
255            throw new LocalizedHttpException( $msg, 404 );
256        }
257    }
258
259    /**
260     * Find the handler for a request and execute it
261     */
262    public function execute( string $path, RequestInterface $request ): ResponseInterface {
263        $handler = null;
264        $startTime = microtime( true );
265
266        try {
267            $handler = $this->getHandlerForPath( $path, $request, true );
268
269            $response = $this->executeHandler( $handler );
270        } catch ( HttpException $e ) {
271            $response = $this->responseFactory->createFromException( $e );
272        } catch ( Throwable $e ) {
273            // Note that $handler is allowed to be null here.
274            $this->errorReporter->reportError( $e, $handler, $request );
275            $response = $this->responseFactory->createFromException( $e );
276        }
277
278        $this->recordMetrics( $handler, $request, $response, $startTime );
279
280        return $response;
281    }
282
283    private function recordMetrics(
284        ?Handler $handler,
285        RequestInterface $request,
286        ResponseInterface $response,
287        float $startTime
288    ) {
289        $latency = ( microtime( true ) - $startTime ) * 1000;
290
291        // NOTE: The "/" prefix is for consistency with old logs. It's rather ugly.
292        $pathForMetrics = $this->getPathPrefix();
293
294        if ( $pathForMetrics !== '' ) {
295            $pathForMetrics = '/' . $pathForMetrics;
296        }
297
298        $pathForMetrics .= $handler ? $handler->getPath() : '/UNKNOWN';
299
300        // Replace any characters that may have a special meaning in the metrics DB.
301        $pathForMetrics = strtr( $pathForMetrics, '{}:/.', '---__' );
302
303        $statusCode = $response->getStatusCode();
304        $requestMethod = $request->getMethod();
305        if ( $statusCode >= 400 ) {
306            // count how often we return which error code
307            $this->stats->getCounter( 'rest_api_errors_total' )
308                ->setLabel( 'path', $pathForMetrics )
309                ->setLabel( 'method', $requestMethod )
310                ->setLabel( 'status', "$statusCode" )
311                ->copyToStatsdAt( [ "rest_api_errors.$pathForMetrics.$requestMethod.$statusCode" ] )
312                ->increment();
313        } else {
314            // measure how long it takes to generate a response
315            $this->stats->getTiming( 'rest_api_latency_seconds' )
316                ->setLabel( 'path', $pathForMetrics )
317                ->setLabel( 'method', $requestMethod )
318                ->setLabel( 'status', "$statusCode" )
319                ->copyToStatsdAt( "rest_api_latency.$pathForMetrics.$requestMethod.$statusCode" )
320                ->observe( $latency );
321        }
322    }
323
324    /**
325     * @internal for testing
326     *
327     * @return array[] An associative array, mapping path patterns to
328     *         a list of request methods supported for the path.
329     */
330    abstract public function getDefinedPaths(): array;
331
332    /**
333     * Get the allowed methods for a path.
334     * Useful to check for 405 wrong method and for generating OpenAPI specs.
335     *
336     * @param string $relPath A concrete request path.
337     * @return string[] A list of allowed HTTP request methods for the path.
338     *         If the path is not supported, the list will be empty.
339     */
340    abstract public function getAllowedMethods( string $relPath ): array;
341
342    /**
343     * Creates a handler from the given spec, but does not initialize it.
344     */
345    protected function instantiateHandlerObject( array $spec ): Handler {
346        /** @var $handler Handler (annotation for PHPStorm) */
347        $handler = $this->objectFactory->createObject(
348            $spec,
349            [ 'assertClass' => Handler::class ]
350        );
351
352        return $handler;
353    }
354
355    /**
356     * Execute a fully-constructed handler
357     * @throws HttpException
358     */
359    protected function executeHandler( Handler $handler ): ResponseInterface {
360        ProfilingContext::singleton()->init( MW_ENTRY_POINT, $handler->getPath() );
361        // Check for basic authorization, to avoid leaking data from private wikis
362        $authResult = $this->basicAuth->authorize( $handler->getRequest(), $handler );
363        if ( $authResult ) {
364            return $this->responseFactory->createHttpError( 403, [ 'error' => $authResult ] );
365        }
366
367        // Check session (and session provider)
368        $handler->checkSession();
369
370        // Validate the parameters
371        $handler->validate( $this->restValidator );
372
373        // Check conditional request headers
374        $earlyResponse = $handler->checkPreconditions();
375        if ( $earlyResponse ) {
376            return $earlyResponse;
377        }
378
379        // Run the main part of the handler
380        $response = $handler->execute();
381        if ( !( $response instanceof ResponseInterface ) ) {
382            $response = $this->responseFactory->createFromReturnValue( $response );
383        }
384
385        // Set Last-Modified and ETag headers in the response if available
386        $handler->applyConditionalResponseHeaders( $response );
387
388        $handler->applyCacheControl( $response );
389
390        return $response;
391    }
392
393    /**
394     * @param CorsUtils $cors
395     * @return self
396     */
397    public function setCors( CorsUtils $cors ): self {
398        $this->cors = $cors;
399
400        return $this;
401    }
402
403    /**
404     * @internal for use by Router
405     *
406     * @param StatsFactory $stats
407     *
408     * @return self
409     */
410    public function setStats( StatsFactory $stats ): self {
411        $this->stats = $stats;
412
413        return $this;
414    }
415
416    /**
417     * Loads a module specification from a file.
418     *
419     * This method does not know or care about the structure of the file
420     * other than that it must be JSON and contain a list or map
421     * (that is, a JSON array or object).
422     *
423     * @param string $fileName
424     *
425     * @internal
426     *
427     * @return array An associative or indexed array describing the module
428     * @throws ModuleConfigurationException
429     */
430    public static function loadJsonFile( string $fileName ): array {
431        $json = file_get_contents( $fileName );
432        if ( $json === false ) {
433            throw new ModuleConfigurationException(
434                "Failed to load file `$fileName`"
435            );
436        }
437
438        $spec = json_decode( $json, true );
439
440        if ( !is_array( $spec ) ) {
441            throw new ModuleConfigurationException(
442                "Failed to parse `$fileName` as a JSON object"
443            );
444        }
445
446        return $spec;
447    }
448}