Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 139
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Module
0.00% covered (danger)
0.00%
0 / 139
0.00% covered (danger)
0.00%
0 / 14
1406
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 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 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
 getOpenApiInfo
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getModuleDescription
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
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            $extraData = [];
272            if ( $this->router->isRestbaseCompatEnabled( $request )
273                && $e instanceof LocalizedHttpException
274            ) {
275                $extraData = $this->router->getRestbaseCompatErrorData( $request, $e );
276            }
277            $response = $this->responseFactory->createFromException( $e, $extraData );
278        } catch ( Throwable $e ) {
279            // Note that $handler is allowed to be null here.
280            $this->errorReporter->reportError( $e, $handler, $request );
281            $response = $this->responseFactory->createFromException( $e );
282        }
283
284        $this->recordMetrics( $handler, $request, $response, $startTime );
285
286        return $response;
287    }
288
289    private function recordMetrics(
290        ?Handler $handler,
291        RequestInterface $request,
292        ResponseInterface $response,
293        float $startTime
294    ) {
295        $latency = ( microtime( true ) - $startTime ) * 1000;
296
297        // NOTE: The "/" prefix is for consistency with old logs. It's rather ugly.
298        $pathForMetrics = $this->getPathPrefix();
299
300        if ( $pathForMetrics !== '' ) {
301            $pathForMetrics = '/' . $pathForMetrics;
302        }
303
304        $pathForMetrics .= $handler ? $handler->getPath() : '/UNKNOWN';
305
306        // Replace any characters that may have a special meaning in the metrics DB.
307        $pathForMetrics = strtr( $pathForMetrics, '{}:/.', '---__' );
308
309        $statusCode = $response->getStatusCode();
310        $requestMethod = $request->getMethod();
311        if ( $statusCode >= 400 ) {
312            // count how often we return which error code
313            $this->stats->getCounter( 'rest_api_errors_total' )
314                ->setLabel( 'path', $pathForMetrics )
315                ->setLabel( 'method', $requestMethod )
316                ->setLabel( 'status', "$statusCode" )
317                ->copyToStatsdAt( [ "rest_api_errors.$pathForMetrics.$requestMethod.$statusCode" ] )
318                ->increment();
319        } else {
320            // measure how long it takes to generate a response
321            $this->stats->getTiming( 'rest_api_latency_seconds' )
322                ->setLabel( 'path', $pathForMetrics )
323                ->setLabel( 'method', $requestMethod )
324                ->setLabel( 'status', "$statusCode" )
325                ->copyToStatsdAt( "rest_api_latency.$pathForMetrics.$requestMethod.$statusCode" )
326                ->observe( $latency );
327        }
328    }
329
330    /**
331     * @internal for testing
332     *
333     * @return array[] An associative array, mapping path patterns to
334     *         a list of request methods supported for the path.
335     */
336    abstract public function getDefinedPaths(): array;
337
338    /**
339     * Get the allowed methods for a path.
340     * Useful to check for 405 wrong method and for generating OpenAPI specs.
341     *
342     * @param string $relPath A concrete request path.
343     * @return string[] A list of allowed HTTP request methods for the path.
344     *         If the path is not supported, the list will be empty.
345     */
346    abstract public function getAllowedMethods( string $relPath ): array;
347
348    /**
349     * Creates a handler from the given spec, but does not initialize it.
350     */
351    protected function instantiateHandlerObject( array $spec ): Handler {
352        /** @var $handler Handler (annotation for PHPStorm) */
353        $handler = $this->objectFactory->createObject(
354            $spec,
355            [ 'assertClass' => Handler::class ]
356        );
357
358        return $handler;
359    }
360
361    /**
362     * Execute a fully-constructed handler
363     * @throws HttpException
364     */
365    protected function executeHandler( Handler $handler ): ResponseInterface {
366        ProfilingContext::singleton()->init( MW_ENTRY_POINT, $handler->getPath() );
367        // Check for basic authorization, to avoid leaking data from private wikis
368        $authResult = $this->basicAuth->authorize( $handler->getRequest(), $handler );
369        if ( $authResult ) {
370            return $this->responseFactory->createHttpError( 403, [ 'error' => $authResult ] );
371        }
372
373        // Check session (and session provider)
374        $handler->checkSession();
375
376        // Validate the parameters
377        $handler->validate( $this->restValidator );
378
379        // Check conditional request headers
380        $earlyResponse = $handler->checkPreconditions();
381        if ( $earlyResponse ) {
382            return $earlyResponse;
383        }
384
385        // Run the main part of the handler
386        $response = $handler->execute();
387        if ( !( $response instanceof ResponseInterface ) ) {
388            $response = $this->responseFactory->createFromReturnValue( $response );
389        }
390
391        // Set Last-Modified and ETag headers in the response if available
392        $handler->applyConditionalResponseHeaders( $response );
393
394        $handler->applyCacheControl( $response );
395
396        return $response;
397    }
398
399    /**
400     * @param CorsUtils $cors
401     * @return self
402     */
403    public function setCors( CorsUtils $cors ): self {
404        $this->cors = $cors;
405
406        return $this;
407    }
408
409    /**
410     * @internal for use by Router
411     *
412     * @param StatsFactory $stats
413     *
414     * @return self
415     */
416    public function setStats( StatsFactory $stats ): self {
417        $this->stats = $stats;
418
419        return $this;
420    }
421
422    /**
423     * Loads a module specification from a file.
424     *
425     * This method does not know or care about the structure of the file
426     * other than that it must be JSON and contain a list or map
427     * (that is, a JSON array or object).
428     *
429     * @param string $fileName
430     *
431     * @internal
432     *
433     * @return array An associative or indexed array describing the module
434     * @throws ModuleConfigurationException
435     */
436    public static function loadJsonFile( string $fileName ): array {
437        $json = file_get_contents( $fileName );
438        if ( $json === false ) {
439            throw new ModuleConfigurationException(
440                "Failed to load file `$fileName`"
441            );
442        }
443
444        $spec = json_decode( $json, true );
445
446        if ( !is_array( $spec ) ) {
447            throw new ModuleConfigurationException(
448                "Failed to parse `$fileName` as a JSON object"
449            );
450        }
451
452        return $spec;
453    }
454
455    /**
456     * Return an array with data to be included in an OpenAPI "info" object
457     * describing this module.
458     *
459     * @see https://spec.openapis.org/oas/v3.0.0#info-object
460     * @return array
461     */
462    public function getOpenApiInfo() {
463        return [];
464    }
465
466    /**
467     * Returns fields to be included when describing this module in the
468     * discovery document.
469     *
470     * Supported keys are described in /docs/discovery-1.0.json#/definitions/Module
471     *
472     * @see /docs/discovery-1.0.json
473     * @see /docs/mwapi-1.0.json
474     * @see DiscoveryHandler
475     */
476    public function getModuleDescription(): array {
477        // TODO: Include the designated audience (T366567).
478        // Note that each module object is designated for only one audience,
479        // even if the spec allows multiple.
480        $moduleId = $this->getPathPrefix();
481
482        // Fields from OAS Info to include.
483        // Note that mwapi-1.0 is based on OAS 3.0, so it doesn't support the
484        // "summary" property introduced in 3.1.
485        $infoFields = [ 'version', 'title', 'description' ];
486
487        return [
488            'moduleId' => $moduleId,
489            'info' => array_intersect_key(
490                $this->getOpenApiInfo(),
491                array_flip( $infoFields )
492            ),
493            'base' => $this->getRouter()->getRouteUrl(
494                '/' . $moduleId
495            ),
496            'spec' => $this->getRouter()->getRouteUrl(
497                '/specs/v0/module/{module}', // hard-coding this here isn't very pretty
498                [ 'module' => $moduleId == '' ? '-' : $moduleId ]
499            )
500        ];
501    }
502}