Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 139 |
|
0.00% |
0 / 14 |
CRAP | |
0.00% |
0 / 1 |
Module | |
0.00% |
0 / 139 |
|
0.00% |
0 / 14 |
1406 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
getPathPrefix | |
0.00% |
0 / 1 |
|
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% |
0 / 26 |
|
0.00% |
0 / 1 |
56 | |||
getRouter | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
findHandlerMatch | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
throwNoMatch | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
30 | |||
execute | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
30 | |||
recordMetrics | |
0.00% |
0 / 21 |
|
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% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
executeHandler | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
20 | |||
setCors | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
setStats | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
loadJsonFile | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
12 | |||
getOpenApiInfo | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getModuleDescription | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Rest\Module; |
4 | |
5 | use LogicException; |
6 | use MediaWiki\Profiler\ProfilingContext; |
7 | use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface; |
8 | use MediaWiki\Rest\CorsUtils; |
9 | use MediaWiki\Rest\Handler; |
10 | use MediaWiki\Rest\HttpException; |
11 | use MediaWiki\Rest\LocalizedHttpException; |
12 | use MediaWiki\Rest\PathTemplateMatcher\ModuleConfigurationException; |
13 | use MediaWiki\Rest\Reporter\ErrorReporter; |
14 | use MediaWiki\Rest\RequestInterface; |
15 | use MediaWiki\Rest\ResponseException; |
16 | use MediaWiki\Rest\ResponseFactory; |
17 | use MediaWiki\Rest\ResponseInterface; |
18 | use MediaWiki\Rest\Router; |
19 | use MediaWiki\Rest\Validator\Validator; |
20 | use Throwable; |
21 | use Wikimedia\Message\MessageValue; |
22 | use Wikimedia\ObjectFactory\ObjectFactory; |
23 | use 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 | */ |
33 | abstract 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 | } |