Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 193
0.00% covered (danger)
0.00%
0 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Module
0.00% covered (danger)
0.00%
0 / 193
0.00% covered (danger)
0.00%
0 / 15
2756
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
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 / 27
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
 runRestCheckCanExecuteHook
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
42
 execute
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 recordMetrics
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
182
 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 / 16
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\HookContainer\HookContainer;
7use MediaWiki\Profiler\ProfilingContext;
8use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface;
9use MediaWiki\Rest\CorsUtils;
10use MediaWiki\Rest\Handler;
11use MediaWiki\Rest\Hook\HookRunner;
12use MediaWiki\Rest\HttpException;
13use MediaWiki\Rest\LocalizedHttpException;
14use MediaWiki\Rest\PathTemplateMatcher\ModuleConfigurationException;
15use MediaWiki\Rest\Reporter\ErrorReporter;
16use MediaWiki\Rest\RequestInterface;
17use MediaWiki\Rest\ResponseException;
18use MediaWiki\Rest\ResponseFactory;
19use MediaWiki\Rest\ResponseInterface;
20use MediaWiki\Rest\Router;
21use MediaWiki\Rest\Validator\Validator;
22use Throwable;
23use Wikimedia\Message\MessageValue;
24use Wikimedia\ObjectFactory\ObjectFactory;
25use Wikimedia\Stats\StatsFactory;
26use Wikimedia\Timestamp\ConvertibleTimestamp;
27
28/**
29 * A REST module represents a collection of endpoints.
30 * The module object is responsible for generating a response for a given
31 * request. This is typically done by routing requests to the appropriate
32 * request handler.
33 *
34 * @since 1.43
35 */
36abstract class Module {
37
38    /**
39     * @internal for use in cached module data
40     */
41    public const CACHE_CONFIG_HASH_KEY = 'CONFIG-HASH';
42
43    private Router $router;
44    protected string $pathPrefix;
45    protected ResponseFactory $responseFactory;
46    private BasicAuthorizerInterface $basicAuth;
47    private ObjectFactory $objectFactory;
48    private Validator $restValidator;
49    private ErrorReporter $errorReporter;
50    private HookContainer $hookContainer;
51
52    private StatsFactory $stats;
53    private ?CorsUtils $cors = null;
54    private ?HookRunner $hookRunner = null;
55
56    /**
57     * @param Router $router
58     * @param string $pathPrefix
59     * @param ResponseFactory $responseFactory
60     * @param BasicAuthorizerInterface $basicAuth
61     * @param ObjectFactory $objectFactory
62     * @param Validator $restValidator
63     * @param ErrorReporter $errorReporter
64     */
65    public function __construct(
66        Router $router,
67        string $pathPrefix,
68        ResponseFactory $responseFactory,
69        BasicAuthorizerInterface $basicAuth,
70        ObjectFactory $objectFactory,
71        Validator $restValidator,
72        ErrorReporter $errorReporter,
73        HookContainer $hookContainer
74    ) {
75        $this->router = $router;
76        $this->pathPrefix = $pathPrefix;
77        $this->responseFactory = $responseFactory;
78        $this->basicAuth = $basicAuth;
79        $this->objectFactory = $objectFactory;
80        $this->restValidator = $restValidator;
81        $this->errorReporter = $errorReporter;
82        $this->hookContainer = $hookContainer;
83
84        $this->stats = StatsFactory::newNull();
85    }
86
87    public function getPathPrefix(): string {
88        return $this->pathPrefix;
89    }
90
91    /**
92     * Return data that can later be used to initialize a new instance of
93     * this module in a fast and efficient way.
94     *
95     * @see initFromCacheData()
96     *
97     * @return array An associative array suitable to be processed by
98     *         initFromCacheData. Implementations are free to choose the format.
99     */
100    abstract public function getCacheData(): array;
101
102    /**
103     * Initialize from the given cache data if possible.
104     * This allows fast initialization based on data that was cached during
105     * a previous invocation of the module.
106     *
107     * Implementations are responsible for verifying that the cache data
108     * matches the information provided to the constructor, to protect against
109     * a situation where configuration was updated in a way that affects the
110     * operation of the module.
111     *
112     * @param array $cacheData Data generated by getCacheData(), implementations
113     *        are free to choose the format.
114     *
115     * @return bool true if the cache data could be used,
116     *         false if it was discarded.
117     * @see getCacheData()
118     */
119    abstract public function initFromCacheData( array $cacheData ): bool;
120
121    /**
122     * Create a Handler for the given path, taking into account the request
123     * method.
124     *
125     * If $prepExecution is true, the handler's prepareForExecute() method will
126     * be called, which will call postInitSetup(). The $request object will be
127     * updated with any path parameters and parsed body data.
128     *
129     * @unstable
130     *
131     * @param string $path
132     * @param RequestInterface $request The request to handle. If $forExecution
133     *        is true, this will be updated with the path parameters and parsed
134     *        body data as appropriate.
135     * @param bool $initForExecute Whether the handler and the request should be
136     *        prepared for execution. Callers that only need the Handler object
137     *        for access to meta-data should set this to false.
138     *
139     * @return Handler
140     * @throws HttpException If no handler was found
141     */
142    public function getHandlerForPath(
143        string $path,
144        RequestInterface $request,
145        bool $initForExecute = false
146    ): Handler {
147        $requestMethod = strtoupper( $request->getMethod() );
148
149        $match = $this->findHandlerMatch( $path, $requestMethod );
150
151        if ( !$match['found'] && $requestMethod === 'HEAD' ) {
152            // For a HEAD request, execute the GET handler instead if one exists.
153            $match = $this->findHandlerMatch( $path, 'GET' );
154        }
155
156        if ( !$match['found'] ) {
157            $this->throwNoMatch(
158                $path,
159                $request->getMethod(),
160                $match['methods'] ?? []
161            );
162        }
163
164        if ( isset( $match['handler'] ) ) {
165            $handler = $match['handler'];
166        } elseif ( isset( $match['spec'] ) ) {
167            $handler = $this->instantiateHandlerObject( $match['spec'] );
168        } else {
169            throw new LogicException(
170                'Match does not specify a handler instance or object spec.'
171            );
172        }
173
174        // For backwards compatibility only. Handlers should get the path by
175        // calling getPath(), not from the config array.
176        $config = $match['config'] ?? [];
177        $config['path'] ??= $match['path'];
178
179        $openApiSpec = $match['openApiSpec'] ?? [];
180
181        // Provide context about the module
182        $handler->initContext( $this, $match['path'], $config, $openApiSpec );
183
184        // Inject services and state from the router
185        $this->getRouter()->prepareHandler( $handler );
186
187        if ( $initForExecute ) {
188            // Use rawurldecode so a "+" in path params is not interpreted as a space character.
189            $pathParams = array_map( 'rawurldecode', $match['params'] ?? [] );
190            $request->setPathParams( $pathParams );
191
192            $handler->initForExecute( $request );
193        }
194
195        return $handler;
196    }
197
198    public function getRouter(): Router {
199        return $this->router;
200    }
201
202    /**
203     * Determines which handler to use for the given path and returns an array
204     * describing the handler and initialization context.
205     *
206     * @param string $path
207     * @param string $requestMethod
208     *
209     * @return array<string,mixed>
210     *   - bool "found": Whether a match was found. If true, the `handler`
211     *     or `spec` field must be set.
212     *   - Handler handler: the Handler object to use. Either "handler" or
213     *     "spec" must be given.
214     *   - array "spec":" an object spec for use with ObjectFactory
215     *   - array "config": the route config, to be passed to Handler::initContext()
216     *   - string "path": the path the handler is responsible for,
217     *     including placeholders for path parameters.
218     *   - string[] "params": path parameters, to be passed the
219     *     Request::setPathPrams()
220     *   - string[] "methods": supported methods, if the path is known but
221     *     the method did not match. Only meaningful if "found" is false.
222     *     To be used in the Allow header of a 405 response and included
223     *     in CORS pre-flight.
224     */
225    abstract protected function findHandlerMatch(
226        string $path,
227        string $requestMethod
228    ): array;
229
230    /**
231     * Implementations of getHandlerForPath() should call this method when they
232     * cannot handle the requested path.
233     *
234     * @param string $path The requested path
235     * @param string $method The HTTP method of the current request
236     * @param string[] $allowed The allowed HTTP methods allowed by the path
237     *
238     * @return never
239     * @throws HttpException
240     */
241    protected function throwNoMatch( string $path, string $method, array $allowed ): never {
242        // Check for CORS Preflight. This response will *not* allow the request unless
243        // an Access-Control-Allow-Origin header is added to this response.
244        if ( $this->cors && $method === 'OPTIONS' && $allowed ) {
245            // IDEA: Create a CorsHandler, which getHandlerForPath can return in this case.
246            $response = $this->cors->createPreflightResponse( $allowed );
247            throw new ResponseException( $response );
248        }
249
250        if ( $allowed ) {
251            // There are allowed methods for this patch, so reply with Method Not Allowed.
252            $response = $this->responseFactory->createLocalizedHttpError( 405,
253                ( new MessageValue( 'rest-wrong-method' ) )
254                    ->textParams( $method )
255                    ->commaListParams( $allowed )
256                    ->numParams( count( $allowed ) )
257            );
258            $response->setHeader( 'Allow', $allowed );
259            throw new ResponseException( $response );
260        } else {
261            // There are no allowed methods for this path, so the path was not found at all.
262            $msg = ( new MessageValue( 'rest-no-match' ) )
263                ->plaintextParams( $path );
264            throw new LocalizedHttpException( $msg, 404 );
265        }
266    }
267
268    private function runRestCheckCanExecuteHook(
269        Handler $handler,
270        string $path,
271        RequestInterface $request
272    ): void {
273        $this->hookRunner ??= new HookRunner( $this->hookContainer );
274        $error = null;
275        $canExecute = $this->hookRunner->onRestCheckCanExecute( $this, $handler, $path, $request, $error );
276        if ( $canExecute !== ( $error === null ) ) {
277            throw new LogicException(
278                'Hook RestCheckCanExecute returned ' . ( $canExecute ? 'true' : 'false' )
279                    . ' but ' . ( $error ? 'did' : 'did not' ) . ' set an error'
280            );
281        } elseif ( $error instanceof HttpException ) {
282            throw $error;
283        } elseif ( $error ) {
284            throw new LogicException(
285                'RestCheckCanExecute must set a HttpException when returning false, '
286                    . 'but got ' . get_class( $error )
287            );
288        }
289    }
290
291    /**
292     * Find the handler for a request and execute it
293     */
294    public function execute( string $path, RequestInterface $request ): ResponseInterface {
295        $handler = null;
296        $startTime = ConvertibleTimestamp::hrtime();
297
298        try {
299            $handler = $this->getHandlerForPath( $path, $request, true );
300            $this->runRestCheckCanExecuteHook( $handler, $path, $request );
301            $response = $this->executeHandler( $handler );
302        } catch ( HttpException $e ) {
303            $extraData = [];
304            if ( $this->router->isRestbaseCompatEnabled( $request )
305                && $e instanceof LocalizedHttpException
306            ) {
307                $extraData = $this->router->getRestbaseCompatErrorData( $request, $e );
308            }
309            $response = $this->responseFactory->createFromException( $e, $extraData );
310        } catch ( Throwable $e ) {
311            // Note that $handler is allowed to be null here.
312            $this->errorReporter->reportError( $e, $handler, $request );
313            $response = $this->responseFactory->createFromException( $e );
314        }
315
316        $this->recordMetrics( $handler, $request, $response, $startTime );
317
318        return $response;
319    }
320
321    private function recordMetrics(
322        ?Handler $handler,
323        RequestInterface $request,
324        ResponseInterface $response,
325        float $startTime
326    ) {
327        $latency = ConvertibleTimestamp::hrtime() - $startTime;
328
329        // NOTE: The "/" prefix is for consistency with old logs. It's rather ugly.
330        $pathForMetrics = $this->getPathPrefix();
331
332        if ( $pathForMetrics !== '' ) {
333            $pathForMetrics = '/' . $pathForMetrics;
334        }
335
336        $pathForMetrics .= $handler ? $handler->getPath() : '/UNKNOWN';
337
338        // Replace any characters that may have a special meaning in the metrics DB.
339        $pathForMetrics = strtr( $pathForMetrics, '{}:/.', '---__' );
340
341        $statusCode = $response->getStatusCode();
342        $requestMethod = $request->getMethod();
343        if ( $statusCode >= 400 ) {
344            // count how often we return which error code
345            $this->stats->getCounter( 'rest_api_errors_total' )
346                ->setLabel( 'path', $pathForMetrics )
347                ->setLabel( 'method', $requestMethod )
348                ->setLabel( 'status', "$statusCode" )
349                ->increment();
350        } else {
351            // measure how long it takes to generate a response
352            $this->stats->getTiming( 'rest_api_latency_seconds' )
353                ->setLabel( 'path', $pathForMetrics )
354                ->setLabel( 'method', $requestMethod )
355                ->setLabel( 'status', "$statusCode" )
356                ->observeNanoseconds( $latency );
357        }
358
359        // New unified metrics for the API
360        // Only record those if there's a handler
361        if ( !$handler ) {
362            return;
363        }
364        $moduleDescription = $this->getModuleDescription();
365
366        $metricsLabels = [
367            'api_module' => $moduleDescription['moduleId'],
368            // as a starting point, we'll use the Handler class name for the endpoint
369            'api_endpoint' => $handler::class,
370            'path' => $pathForMetrics,
371            'method' => $requestMethod,
372            'status' => "$statusCode",
373        ];
374
375        $approvedLabels = [
376            'api_module',
377            'api_endpoint',
378            'path',
379            'method',
380            'status',
381        ];
382
383        // Hit metrics
384        $metricHitStats = $this->stats->getCounter( 'rest_api_modules_hit_total' )
385            ->setLabel( 'api_type', 'REST_API' );
386        // Iterate over the approved labels and set the labels for the metric
387        foreach ( $approvedLabels as $label ) {
388            // Set a fallback value for empty strings
389            $value = (
390                array_key_exists( $label, $metricsLabels ) &&
391                is_string( $metricsLabels[$label] ) &&
392                trim( $metricsLabels[$label] ) !== ''
393            ) ? $metricsLabels[$label] : 'EMPTY_VALUE';
394
395            $metricHitStats->setLabel( $label, $value );
396        }
397        $metricHitStats->increment();
398
399        // Latency metrics
400        $metricLatencyStats = $this->stats->getTiming( 'rest_api_modules_latency' )
401            ->setLabel( 'api_type', 'REST_API' );
402        // Iterate over the approved labels and set the labels for the metric
403        foreach ( $approvedLabels as $label ) {
404            // Set a fallback value for empty strings
405            $value = (
406                array_key_exists( $label, $metricsLabels ) &&
407                is_string( $metricsLabels[$label] ) &&
408                trim( $metricsLabels[$label] ) !== ''
409            ) ? $metricsLabels[$label] : 'EMPTY_VALUE';
410
411            $metricLatencyStats->setLabel( $label, $value );
412        }
413        $metricLatencyStats->observeNanoseconds( $latency );
414    }
415
416    /**
417     * @internal for testing
418     *
419     * @return array[] An associative array, mapping path patterns to
420     *         a list of request methods supported for the path.
421     */
422    abstract public function getDefinedPaths(): array;
423
424    /**
425     * Get the allowed methods for a path.
426     * Useful to check for 405 wrong method and for generating OpenAPI specs.
427     *
428     * @param string $relPath A concrete request path.
429     * @return string[] A list of allowed HTTP request methods for the path.
430     *         If the path is not supported, the list will be empty.
431     */
432    abstract public function getAllowedMethods( string $relPath ): array;
433
434    /**
435     * Creates a handler from the given spec, but does not initialize it.
436     */
437    protected function instantiateHandlerObject( array $spec ): Handler {
438        /** @var $handler Handler (annotation for PHPStorm) */
439        $handler = $this->objectFactory->createObject(
440            $spec,
441            [ 'assertClass' => Handler::class ]
442        );
443
444        return $handler;
445    }
446
447    /**
448     * Execute a fully-constructed handler
449     * @throws HttpException
450     */
451    protected function executeHandler( Handler $handler ): ResponseInterface {
452        ProfilingContext::singleton()->init( MW_ENTRY_POINT, $handler->getPath() );
453        // Check for basic authorization, to avoid leaking data from private wikis
454        $authResult = $this->basicAuth->authorize( $handler->getRequest(), $handler );
455        if ( $authResult ) {
456            return $this->responseFactory->createHttpError( 403, [ 'error' => $authResult ] );
457        }
458
459        // Check session (and session provider)
460        $handler->checkSession();
461
462        // Validate the parameters
463        $handler->validate( $this->restValidator );
464
465        // Check conditional request headers
466        $earlyResponse = $handler->checkPreconditions();
467        if ( $earlyResponse ) {
468            return $earlyResponse;
469        }
470
471        // Run the main part of the handler
472        $response = $handler->execute();
473        if ( !( $response instanceof ResponseInterface ) ) {
474            $response = $this->responseFactory->createFromReturnValue( $response );
475        }
476
477        // Deprecation header per RFC 9745
478        $handler->applyDeprecationHeader( $response );
479
480        // Set Last-Modified and ETag headers in the response if available
481        $handler->applyConditionalResponseHeaders( $response );
482
483        $handler->applyCacheControl( $response );
484
485        return $response;
486    }
487
488    public function setCors( CorsUtils $cors ): self {
489        $this->cors = $cors;
490
491        return $this;
492    }
493
494    /**
495     * @internal for use by Router
496     *
497     * @param StatsFactory $stats
498     *
499     * @return self
500     */
501    public function setStats( StatsFactory $stats ): self {
502        $this->stats = $stats;
503
504        return $this;
505    }
506
507    /**
508     * Loads a module specification from a file.
509     *
510     * This method does not know or care about the structure of the file
511     * other than that it must be JSON and contain a list or map
512     * (that is, a JSON array or object).
513     *
514     * @param string $fileName
515     *
516     * @internal
517     *
518     * @return array An associative or indexed array describing the module
519     * @throws ModuleConfigurationException
520     */
521    public static function loadJsonFile( string $fileName ): array {
522        $json = file_get_contents( $fileName );
523        if ( $json === false ) {
524            throw new ModuleConfigurationException(
525                "Failed to load file `$fileName`"
526            );
527        }
528
529        $spec = json_decode( $json, true );
530
531        if ( !is_array( $spec ) ) {
532            throw new ModuleConfigurationException(
533                "Failed to parse `$fileName` as a JSON object"
534            );
535        }
536
537        return $spec;
538    }
539
540    /**
541     * Return an array with data to be included in an OpenAPI "info" object
542     * describing this module.
543     *
544     * @see https://spec.openapis.org/oas/v3.0.0#info-object
545     * @return array
546     */
547    public function getOpenApiInfo() {
548        return [];
549    }
550
551    /**
552     * Returns fields to be included when describing this module in the
553     * discovery document.
554     *
555     * Supported keys are described in /docs/discovery-1.0.json#/definitions/Module
556     *
557     * @see /docs/discovery-1.0.json
558     * @see /docs/mwapi-1.1.json
559     * @see DiscoveryHandler
560     */
561    public function getModuleDescription(): array {
562        // TODO: Include the designated audience (T366567).
563        // Note that each module object is designated for only one audience,
564        // even if the spec allows multiple.
565        $moduleId = $this->getPathPrefix();
566
567        // Fields from openApiSpec info to include.
568        // Note that mwapi-1.1 and earlier are based on OAS 3.0, so they don't support the
569        // "summary" property introduced in 3.1.
570        $infoFields = [ 'version', 'title', 'description', 'deprecationSettings' ];
571
572        return [
573            'moduleId' => $moduleId,
574            'info' => array_intersect_key(
575                $this->getOpenApiInfo(),
576                array_flip( $infoFields )
577            ),
578            'base' => $this->getRouter()->getRouteUrl(
579                '/' . $moduleId
580            ),
581            'spec' => $this->getRouter()->getRouteUrl(
582                '/specs/v0/module/{module}', // hard-coding this here isn't very pretty
583                [ 'module' => $moduleId == '' ? '-' : $moduleId ]
584            )
585        ];
586    }
587}