diff options
Diffstat (limited to 'includes/Rest/Module')
-rw-r--r-- | includes/Rest/Module/Module.php | 384 | ||||
-rw-r--r-- | includes/Rest/Module/ModuleConfigurationException.php | 12 | ||||
-rw-r--r-- | includes/Rest/Module/RouteFileModule.php | 359 |
3 files changed, 755 insertions, 0 deletions
diff --git a/includes/Rest/Module/Module.php b/includes/Rest/Module/Module.php new file mode 100644 index 000000000000..b370b3e4d34a --- /dev/null +++ b/includes/Rest/Module/Module.php @@ -0,0 +1,384 @@ +<?php + +namespace MediaWiki\Rest\Module; + +use LogicException; +use MediaWiki\Profiler\ProfilingContext; +use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface; +use MediaWiki\Rest\CorsUtils; +use MediaWiki\Rest\Handler; +use MediaWiki\Rest\HttpException; +use MediaWiki\Rest\LocalizedHttpException; +use MediaWiki\Rest\PathTemplateMatcher\ModuleConfigurationException; +use MediaWiki\Rest\Reporter\ErrorReporter; +use MediaWiki\Rest\RequestInterface; +use MediaWiki\Rest\ResponseException; +use MediaWiki\Rest\ResponseFactory; +use MediaWiki\Rest\ResponseInterface; +use MediaWiki\Rest\Router; +use MediaWiki\Rest\Validator\Validator; +use Throwable; +use Wikimedia\Message\MessageValue; +use Wikimedia\ObjectFactory\ObjectFactory; + +/** + * A REST module represents a collection of endpoints. + * The module object is responsible for generating a response for a given + * request. This is typically done by routing requests to the appropriate + * request handler. + * + * @since 1.43 + */ +abstract class Module { + + /** + * @internal for use in cached module data + */ + public const CACHE_CONFIG_HASH_KEY = 'CONFIG-HASH'; + + protected string $pathPrefix; + protected ResponseFactory $responseFactory; + private BasicAuthorizerInterface $basicAuth; + private ObjectFactory $objectFactory; + private Validator $restValidator; + private ErrorReporter $errorReporter; + + private Router $router; + private ?CorsUtils $cors = null; + + /** + * @param Router $router + * @param string $pathPrefix + * @param ResponseFactory $responseFactory + * @param BasicAuthorizerInterface $basicAuth + * @param ObjectFactory $objectFactory + * @param Validator $restValidator + * @param ErrorReporter $errorReporter + */ + public function __construct( + Router $router, + string $pathPrefix, + ResponseFactory $responseFactory, + BasicAuthorizerInterface $basicAuth, + ObjectFactory $objectFactory, + Validator $restValidator, + ErrorReporter $errorReporter + ) { + $this->router = $router; + $this->pathPrefix = $pathPrefix; + $this->responseFactory = $responseFactory; + $this->basicAuth = $basicAuth; + $this->objectFactory = $objectFactory; + $this->restValidator = $restValidator; + $this->errorReporter = $errorReporter; + } + + 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.' + ); + } + + // Provide context about the module + $handler->initContext( $this, $match['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<string,mixed> + * - 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 instantiateHandlerObject() + * - 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 returned 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 ); + } + } + + /** + * Find the handler for a request and execute it + */ + public function execute( string $path, RequestInterface $request ): ResponseInterface { + $handler = null; + + try { + $handler = $this->getHandlerForPath( $path, $request, true ); + + $response = $this->executeHandler( $handler ); + } catch ( HttpException $e ) { + $response = $this->responseFactory->createFromException( $e ); + } catch ( Throwable $e ) { + // Note that $handler is allowed to be null here. + $this->errorReporter->reportError( $e, $handler, $request ); + $response = $this->responseFactory->createFromException( $e ); + } + + return $response; + } + + /** + * @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; + } + + /** + * @param CorsUtils $cors + * @return self + */ + public function setCors( CorsUtils $cors ): self { + $this->cors = $cors; + + 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 route file `$fileName`" + ); + } + + $spec = json_decode( + $json, + true + ); + if ( !is_array( $spec ) ) { + throw new ModuleConfigurationException( + "Failed to parse `$fileName` as a JSON object" + ); + } + + return $spec; + } +} diff --git a/includes/Rest/Module/ModuleConfigurationException.php b/includes/Rest/Module/ModuleConfigurationException.php new file mode 100644 index 000000000000..2d64d97ee69d --- /dev/null +++ b/includes/Rest/Module/ModuleConfigurationException.php @@ -0,0 +1,12 @@ +<?php + +namespace MediaWiki\Rest\PathTemplateMatcher; + +use Exception; + +/** + * Exception indicating incorrect REST module configuration. + * @since 1.43 + */ +class ModuleConfigurationException extends Exception { +} diff --git a/includes/Rest/Module/RouteFileModule.php b/includes/Rest/Module/RouteFileModule.php new file mode 100644 index 000000000000..d367764f882c --- /dev/null +++ b/includes/Rest/Module/RouteFileModule.php @@ -0,0 +1,359 @@ +<?php + +namespace MediaWiki\Rest\Module; + +use AppendIterator; +use ArrayIterator; +use Iterator; +use LogicException; +use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface; +use MediaWiki\Rest\Handler\RedirectHandler; +use MediaWiki\Rest\PathTemplateMatcher\ModuleConfigurationException; +use MediaWiki\Rest\PathTemplateMatcher\PathMatcher; +use MediaWiki\Rest\Reporter\ErrorReporter; +use MediaWiki\Rest\ResponseFactory; +use MediaWiki\Rest\RouteDefinitionException; +use MediaWiki\Rest\Router; +use MediaWiki\Rest\Validator\Validator; +use Wikimedia\ObjectFactory\ObjectFactory; + +/** + * A Module that is based on route definition files. This module responds to + * requests by matching the requested path against a list of known routes to + * identify the appropriate handler. The routes are loaded for route definition + * files. + * + * Two versions of route declaration files are currently supported, "flat" + * route files and "annotated" route files. Both use JSON syntax. Flat route + * files are supported for backwards compatibility, and should be avoided. + * + * Flat files just contain a list (a JSON array) or route definitions (see below). + * Annotated route definition files contain a map (a JSON object) with the + * following fields: + * - "module": the module name (string). The router uses this name to find the + * correct module for handling a request by matching it against the prefix + * of the request path. The module name must be unique. + * - "routes": a list (JSON array) or route definitions (see below). + * + * Each route definition maps a path pattern to a handler class. It is given as + * a map (JSON object) with the following fields: + * - "path": the path pattern (string) relative to the module prefix. Required. + * The path may contain placeholders for path parameters. + * - "method": the HTTP method(s) or "verbs" supported by the route. If not given, + * it is assumed that the route supports the "GET" method. The "OPTIONS" method + * for CORS is supported implicitly. + * - "class" or "factory": The handler class (string) or factory function + * (callable) of an "object spec" for use with ObjectFactory::createObject. + * See there for the usage of additional fields like "services". If a shorthand + * is used (see below), no object spec is needed. + * + * The following fields are supported as a shorthand notation: + * - "redirect": the route represents a redirect and will be handled by + * the RedirectHandler class. The redirect is specified as a JSON object + * that specifies the target "path", and optional the redirect "code". + * + * More shorthands may be added in the future. + * + * Route definitions can contain additional fields to configure the handler. + * The handler can access the route definition by calling getConfig(). + * + * @internal + * @since 1.43 + */ +class RouteFileModule extends Module { + + /** @var string[] */ + private array $routeFiles; + + /** + * @var array<int,array> A list of route definitions + */ + private array $extraRoutes; + + /** + * @var array<int,array>|null A list of route definitions loaded from + * the files specified by $routeFiles + */ + private ?array $routesFromFiles = null; + + /** @var int[]|null */ + private ?array $routeFileTimestamps = null; + + /** @var string|null */ + private ?string $configHash = null; + + /** @var PathMatcher[]|null Path matchers by method */ + private ?array $matchers = null; + + /** + * @param string[] $routeFiles List of names of JSON files containing routes + * See the documentation of this class for a description of the file + * format. + * @param array<int,array> $extraRoutes Extension route array. The content of + * this array must be a list of route definitions. See the documentation + * of this class for a description of the expected structure. + */ + public function __construct( + array $routeFiles, + array $extraRoutes, + Router $router, + string $pathPrefix, + ResponseFactory $responseFactory, + BasicAuthorizerInterface $basicAuth, + ObjectFactory $objectFactory, + Validator $restValidator, + ErrorReporter $errorReporter + ) { + parent::__construct( + $router, + $pathPrefix, + $responseFactory, + $basicAuth, + $objectFactory, + $restValidator, + $errorReporter + ); + $this->routeFiles = $routeFiles; + $this->extraRoutes = $extraRoutes; + } + + public function getCacheData(): array { + $cacheData = []; + + foreach ( $this->getMatchers() as $method => $matcher ) { + $cacheData[$method] = $matcher->getCacheData(); + } + + $cacheData[self::CACHE_CONFIG_HASH_KEY] = $this->getConfigHash(); + return $cacheData; + } + + public function initFromCacheData( array $cacheData ): bool { + if ( $cacheData[self::CACHE_CONFIG_HASH_KEY] !== $this->getConfigHash() ) { + return false; + } + + unset( $cacheData[self::CACHE_CONFIG_HASH_KEY] ); + $this->matchers = []; + + foreach ( $cacheData as $method => $data ) { + $this->matchers[$method] = PathMatcher::newFromCache( $data ); + } + + return true; + } + + /** + * Get a config version hash for cache invalidation + * + * @return string + */ + private function getConfigHash(): string { + if ( $this->configHash === null ) { + $this->configHash = md5( json_encode( [ + 'version' => 5, + 'extraRoutes' => $this->extraRoutes, + 'fileTimestamps' => $this->getRouteFileTimestamps() + ] ) ); + } + return $this->configHash; + } + + /** + * Load the defined JSON files and return the merged routes. + * + * @return array<int,array> A list of route definitions. See this class's + * documentation for a description of the format of route definitions. + * @throws ModuleConfigurationException If a route file cannot be loaded or processed. + */ + private function getRoutesFromFiles(): array { + if ( $this->routesFromFiles !== null ) { + return $this->routesFromFiles; + } + + $this->routesFromFiles = []; + $this->routeFileTimestamps = []; + foreach ( $this->routeFiles as $fileName ) { + $this->routeFileTimestamps[$fileName] = filemtime( $fileName ); + + $routes = $this->loadRoutes( $fileName ); + + $this->routesFromFiles = array_merge( $this->routesFromFiles, $routes ); + } + + return $this->routesFromFiles; + } + + /** + * Loads route definitions from the given file + * + * @param string $fileName + * + * @return array<int,array> A list of route definitions. See this class's + * documentation for a description of the format of route definitions. + * @throws ModuleConfigurationException + */ + private function loadRoutes( string $fileName ) { + $spec = $this->loadJsonFile( $fileName ); + + if ( isset( $spec['routes'] ) ) { + if ( !isset( $spec['module'] ) ) { + throw new ModuleConfigurationException( + "Missing module name in $fileName" + ); + } + + if ( $spec['module'] !== $this->pathPrefix ) { + // The Router gave us a route file that doesn't match the module name. + // This is a programming error, the Router should get this right. + throw new LogicException( + "Module name mismatch in $fileName: " . + "expected {$this->pathPrefix} but got {$spec['module']}." + ); + } + + // intermediate format with meta-data + $routes = $spec['routes']; + } else { + // old, flat format + $routes = $spec; + } + + return $routes; + } + + /** + * Get an array of last modification times of the defined route files. + * + * @return int[] Last modification times + */ + private function getRouteFileTimestamps(): array { + if ( $this->routeFileTimestamps === null ) { + $this->routeFileTimestamps = []; + foreach ( $this->routeFiles as $fileName ) { + $this->routeFileTimestamps[$fileName] = filemtime( $fileName ); + } + } + return $this->routeFileTimestamps; + } + + /** + * @internal for testing and for generating OpenAPI specs + * + * @return array[] + */ + public function getDefinedPaths(): array { + $paths = []; + foreach ( $this->getAllRoutes() as $spec ) { + $key = $spec['path']; + + $methods = isset( $spec['method'] ) ? (array)$spec['method'] : [ 'GET' ]; + + $paths[$key] = array_merge( $paths[$key] ?? [], $methods ); + } + + return $paths; + } + + /** + * @return Iterator<array> + */ + private function getAllRoutes() { + $iterator = new AppendIterator; + $iterator->append( new ArrayIterator( $this->getRoutesFromFiles() ) ); + $iterator->append( new ArrayIterator( $this->extraRoutes ) ); + return $iterator; + } + + /** + * Get an array of PathMatcher objects indexed by HTTP method + * + * @return PathMatcher[] + */ + private function getMatchers() { + if ( $this->matchers === null ) { + $routeDefs = $this->getAllRoutes(); + + $matchers = []; + + foreach ( $routeDefs as $spec ) { + $methods = $spec['method'] ?? [ 'GET' ]; + if ( !is_array( $methods ) ) { + $methods = [ $methods ]; + } + foreach ( $methods as $method ) { + if ( !isset( $matchers[$method] ) ) { + $matchers[$method] = new PathMatcher; + } + $matchers[$method]->add( $spec['path'], $spec ); + } + } + $this->matchers = $matchers; + } + + return $this->matchers; + } + + /** + * @inheritDoc + */ + public function findHandlerMatch( + string $path, + string $requestMethod + ): array { + $matchers = $this->getMatchers(); + $matcher = $matchers[$requestMethod] ?? null; + $match = $matcher ? $matcher->match( $path ) : null; + + if ( !$match ) { + // Return allowed methods, to support CORS and 405 responses. + return [ + 'found' => false, + 'methods' => $this->getAllowedMethods( $path ), + ]; + } else { + $spec = $match['userData']; + + if ( !isset( $spec['class'] ) && !isset( $spec['factory'] ) ) { + // Inject well known handler class for shorthand definition + if ( isset( $spec['redirect'] ) ) { + $spec['class'] = RedirectHandler::class; + } else { + throw new RouteDefinitionException( + 'Route handler definition must specify "class" or ' . + '"factory" or "redirect"' + ); + } + } + + return [ + 'found' => true, + 'spec' => $spec, + 'params' => $match['params'] ?? [], + 'config' => $spec, + 'path' => $spec['path'], + ]; + } + } + + /** + * Get the allowed methods for a path. + * Useful to check for 405 wrong method. + * + * @param string $relPath A concrete request path. + * @return string[] + */ + public function getAllowedMethods( string $relPath ): array { + $allowed = []; + foreach ( $this->getMatchers() as $allowedMethod => $allowedMatcher ) { + if ( $allowedMatcher->match( $relPath ) ) { + $allowed[] = $allowedMethod; + } + } + + return array_unique( + in_array( 'GET', $allowed ) ? array_merge( [ 'HEAD' ], $allowed ) : $allowed + ); + } + +} |