router = $router; $this->pathPrefix = $pathPrefix; $this->responseFactory = $responseFactory; $this->basicAuth = $basicAuth; $this->objectFactory = $objectFactory; $this->restValidator = $restValidator; $this->errorReporter = $errorReporter; $this->hookContainer = $hookContainer; $this->stats = StatsFactory::newNull(); } public function getPathPrefix(): string { return $this->pathPrefix; } /** * Return data that can later be used to initialize a new instance of * this module in a fast and efficient way. * * @see initFromCacheData() * * @return array An associative array suitable to be processed by * initFromCacheData. Implementations are free to choose the format. */ abstract public function getCacheData(): array; /** * Initialize from the given cache data if possible. * This allows fast initialization based on data that was cached during * a previous invocation of the module. * * Implementations are responsible for verifying that the cache data * matches the information provided to the constructor, to protect against * a situation where configuration was updated in a way that affects the * operation of the module. * * @param array $cacheData Data generated by getCacheData(), implementations * are free to choose the format. * * @return bool true if the cache data could be used, * false if it was discarded. * @see getCacheData() */ abstract public function initFromCacheData( array $cacheData ): bool; /** * Create a Handler for the given path, taking into account the request * method. * * If $prepExecution is true, the handler's prepareForExecute() method will * be called, which will call postInitSetup(). The $request object will be * updated with any path parameters and parsed body data. * * @unstable * * @param string $path * @param RequestInterface $request The request to handle. If $forExecution * is true, this will be updated with the path parameters and parsed * body data as appropriate. * @param bool $initForExecute Whether the handler and the request should be * prepared for execution. Callers that only need the Handler object * for access to meta-data should set this to false. * * @return Handler * @throws HttpException If no handler was found */ public function getHandlerForPath( string $path, RequestInterface $request, bool $initForExecute = false ): Handler { $requestMethod = strtoupper( $request->getMethod() ); $match = $this->findHandlerMatch( $path, $requestMethod ); if ( !$match['found'] && $requestMethod === 'HEAD' ) { // For a HEAD request, execute the GET handler instead if one exists. $match = $this->findHandlerMatch( $path, 'GET' ); } if ( !$match['found'] ) { $this->throwNoMatch( $path, $request->getMethod(), $match['methods'] ?? [] ); } if ( isset( $match['handler'] ) ) { $handler = $match['handler']; } elseif ( isset( $match['spec'] ) ) { $handler = $this->instantiateHandlerObject( $match['spec'] ); } else { throw new LogicException( 'Match does not specify a handler instance or object spec.' ); } // For backwards compatibility only. Handlers should get the path by // calling getPath(), not from the config array. $config = $match['config'] ?? []; $config['path'] ??= $match['path']; // Provide context about the module $handler->initContext( $this, $match['path'], $config ); // Inject services and state from the router $this->getRouter()->prepareHandler( $handler ); if ( $initForExecute ) { // Use rawurldecode so a "+" in path params is not interpreted as a space character. $pathParams = array_map( 'rawurldecode', $match['params'] ?? [] ); $request->setPathParams( $pathParams ); $handler->initForExecute( $request ); } return $handler; } public function getRouter(): Router { return $this->router; } /** * Determines which handler to use for the given path and returns an array * describing the handler and initialization context. * * @param string $path * @param string $requestMethod * * @return array * - bool "found": Whether a match was found. If true, the `handler` * or `spec` field must be set. * - Handler handler: the Handler object to use. Either "handler" or * "spec" must be given. * - array "spec":" an object spec for use with ObjectFactory * - array "config": the route config, to be passed to Handler::initContext() * - string "path": the path the handler is responsible for, * including placeholders for path parameters. * - string[] "params": path parameters, to be passed the * Request::setPathPrams() * - string[] "methods": supported methods, if the path is known but * the method did not match. Only meaningful if "found" is false. * To be used in the Allow header of a 405 response and included * in CORS pre-flight. */ abstract protected function findHandlerMatch( string $path, string $requestMethod ): array; /** * Implementations of getHandlerForPath() should call this method when they * cannot handle the requested path. * * @param string $path The requested path * @param string $method The HTTP method of the current request * @param string[] $allowed The allowed HTTP methods allowed by the path * * @return never * @throws HttpException */ protected function throwNoMatch( string $path, string $method, array $allowed ): void { // Check for CORS Preflight. This response will *not* allow the request unless // an Access-Control-Allow-Origin header is added to this response. if ( $this->cors && $method === 'OPTIONS' && $allowed ) { // IDEA: Create a CorsHandler, which getHandlerForPath can return in this case. $response = $this->cors->createPreflightResponse( $allowed ); throw new ResponseException( $response ); } if ( $allowed ) { // There are allowed methods for this patch, so reply with Method Not Allowed. $response = $this->responseFactory->createLocalizedHttpError( 405, ( new MessageValue( 'rest-wrong-method' ) ) ->textParams( $method ) ->commaListParams( $allowed ) ->numParams( count( $allowed ) ) ); $response->setHeader( 'Allow', $allowed ); throw new ResponseException( $response ); } else { // There are no allowed methods for this path, so the path was not found at all. $msg = ( new MessageValue( 'rest-no-match' ) ) ->plaintextParams( $path ); throw new LocalizedHttpException( $msg, 404 ); } } private function runRestCheckCanExecuteHook( Handler $handler, string $path, RequestInterface $request ): void { $this->hookRunner ??= new HookRunner( $this->hookContainer ); $error = null; $canExecute = $this->hookRunner->onRestCheckCanExecute( $this, $handler, $path, $request, $error ); if ( $canExecute !== ( $error === null ) ) { throw new LogicException( 'Hook RestCheckCanExecute returned ' . ( $canExecute ? 'true' : 'false' ) . ' but ' . ( $error ? 'did' : 'did not' ) . ' set an error' ); } elseif ( $error instanceof HttpException ) { throw $error; } elseif ( $error ) { throw new LogicException( 'RestCheckCanExecute must set a HttpException when returning false, ' . 'but got ' . get_class( $error ) ); } } /** * Find the handler for a request and execute it */ public function execute( string $path, RequestInterface $request ): ResponseInterface { $handler = null; $startTime = microtime( true ); try { $handler = $this->getHandlerForPath( $path, $request, true ); $this->runRestCheckCanExecuteHook( $handler, $path, $request ); $response = $this->executeHandler( $handler ); } catch ( HttpException $e ) { $extraData = []; if ( $this->router->isRestbaseCompatEnabled( $request ) && $e instanceof LocalizedHttpException ) { $extraData = $this->router->getRestbaseCompatErrorData( $request, $e ); } $response = $this->responseFactory->createFromException( $e, $extraData ); } catch ( Throwable $e ) { // Note that $handler is allowed to be null here. $this->errorReporter->reportError( $e, $handler, $request ); $response = $this->responseFactory->createFromException( $e ); } $this->recordMetrics( $handler, $request, $response, $startTime ); return $response; } private function recordMetrics( ?Handler $handler, RequestInterface $request, ResponseInterface $response, float $startTime ) { $latency = ( microtime( true ) - $startTime ) * 1000; // NOTE: The "/" prefix is for consistency with old logs. It's rather ugly. $pathForMetrics = $this->getPathPrefix(); if ( $pathForMetrics !== '' ) { $pathForMetrics = '/' . $pathForMetrics; } $pathForMetrics .= $handler ? $handler->getPath() : '/UNKNOWN'; // Replace any characters that may have a special meaning in the metrics DB. $pathForMetrics = strtr( $pathForMetrics, '{}:/.', '---__' ); $statusCode = $response->getStatusCode(); $requestMethod = $request->getMethod(); if ( $statusCode >= 400 ) { // count how often we return which error code $this->stats->getCounter( 'rest_api_errors_total' ) ->setLabel( 'path', $pathForMetrics ) ->setLabel( 'method', $requestMethod ) ->setLabel( 'status', "$statusCode" ) ->copyToStatsdAt( [ "rest_api_errors.$pathForMetrics.$requestMethod.$statusCode" ] ) ->increment(); } else { // measure how long it takes to generate a response $this->stats->getTiming( 'rest_api_latency_seconds' ) ->setLabel( 'path', $pathForMetrics ) ->setLabel( 'method', $requestMethod ) ->setLabel( 'status', "$statusCode" ) ->copyToStatsdAt( "rest_api_latency.$pathForMetrics.$requestMethod.$statusCode" ) ->observe( $latency ); } } /** * @internal for testing * * @return array[] An associative array, mapping path patterns to * a list of request methods supported for the path. */ abstract public function getDefinedPaths(): array; /** * Get the allowed methods for a path. * Useful to check for 405 wrong method and for generating OpenAPI specs. * * @param string $relPath A concrete request path. * @return string[] A list of allowed HTTP request methods for the path. * If the path is not supported, the list will be empty. */ abstract public function getAllowedMethods( string $relPath ): array; /** * Creates a handler from the given spec, but does not initialize it. */ protected function instantiateHandlerObject( array $spec ): Handler { /** @var $handler Handler (annotation for PHPStorm) */ $handler = $this->objectFactory->createObject( $spec, [ 'assertClass' => Handler::class ] ); return $handler; } /** * Execute a fully-constructed handler * @throws HttpException */ protected function executeHandler( Handler $handler ): ResponseInterface { ProfilingContext::singleton()->init( MW_ENTRY_POINT, $handler->getPath() ); // Check for basic authorization, to avoid leaking data from private wikis $authResult = $this->basicAuth->authorize( $handler->getRequest(), $handler ); if ( $authResult ) { return $this->responseFactory->createHttpError( 403, [ 'error' => $authResult ] ); } // Check session (and session provider) $handler->checkSession(); // Validate the parameters $handler->validate( $this->restValidator ); // Check conditional request headers $earlyResponse = $handler->checkPreconditions(); if ( $earlyResponse ) { return $earlyResponse; } // Run the main part of the handler $response = $handler->execute(); if ( !( $response instanceof ResponseInterface ) ) { $response = $this->responseFactory->createFromReturnValue( $response ); } // Set Last-Modified and ETag headers in the response if available $handler->applyConditionalResponseHeaders( $response ); $handler->applyCacheControl( $response ); return $response; } public function setCors( CorsUtils $cors ): self { $this->cors = $cors; return $this; } /** * @internal for use by Router * * @param StatsFactory $stats * * @return self */ public function setStats( StatsFactory $stats ): self { $this->stats = $stats; return $this; } /** * Loads a module specification from a file. * * This method does not know or care about the structure of the file * other than that it must be JSON and contain a list or map * (that is, a JSON array or object). * * @param string $fileName * * @internal * * @return array An associative or indexed array describing the module * @throws ModuleConfigurationException */ public static function loadJsonFile( string $fileName ): array { $json = file_get_contents( $fileName ); if ( $json === false ) { throw new ModuleConfigurationException( "Failed to load file `$fileName`" ); } $spec = json_decode( $json, true ); if ( !is_array( $spec ) ) { throw new ModuleConfigurationException( "Failed to parse `$fileName` as a JSON object" ); } return $spec; } /** * Return an array with data to be included in an OpenAPI "info" object * describing this module. * * @see https://spec.openapis.org/oas/v3.0.0#info-object * @return array */ public function getOpenApiInfo() { return []; } /** * Returns fields to be included when describing this module in the * discovery document. * * Supported keys are described in /docs/discovery-1.0.json#/definitions/Module * * @see /docs/discovery-1.0.json * @see /docs/mwapi-1.0.json * @see DiscoveryHandler */ public function getModuleDescription(): array { // TODO: Include the designated audience (T366567). // Note that each module object is designated for only one audience, // even if the spec allows multiple. $moduleId = $this->getPathPrefix(); // Fields from OAS Info to include. // Note that mwapi-1.0 is based on OAS 3.0, so it doesn't support the // "summary" property introduced in 3.1. $infoFields = [ 'version', 'title', 'description' ]; return [ 'moduleId' => $moduleId, 'info' => array_intersect_key( $this->getOpenApiInfo(), array_flip( $infoFields ) ), 'base' => $this->getRouter()->getRouteUrl( '/' . $moduleId ), 'spec' => $this->getRouter()->getRouteUrl( '/specs/v0/module/{module}', // hard-coding this here isn't very pretty [ 'module' => $moduleId == '' ? '-' : $moduleId ] ) ]; } }