diff options
author | daniel <dkinzler@wikimedia.org> | 2023-10-30 17:04:41 +0100 |
---|---|---|
committer | daniel <dkinzler@wikimedia.org> | 2024-05-08 16:12:30 +0200 |
commit | 91a1741787aed472abe9cb24eaacd40a76f9f894 (patch) | |
tree | ae8ed939c55fbb0f284e2eba1760fe8b3f72ad05 /includes/Rest/Module/Module.php | |
parent | 4d4d2a15a9d3901fcad7db840026feafedfd9ba9 (diff) | |
download | mediawikicore-91a1741787aed472abe9cb24eaacd40a76f9f894.tar.gz mediawikicore-91a1741787aed472abe9cb24eaacd40a76f9f894.zip |
Introduce Modules into the REST framework
Modules group together endpoints by a shared prefix. The idea is that each module has its own version and can generated self-contained self-documentation. This allows clients to have clear expectations about the endpoints of each module, no matter what wiki they are accessing. So far, each wiki may be exposing a different set of endpoints, with no way to provide a spec that describes that set of endpoints in a way that would be consistent across wikis and stable over time.
Bug: T362480
Change-Id: Iebcde4645d472d27eee5a30adb6eee12cc7d046b
Diffstat (limited to 'includes/Rest/Module/Module.php')
-rw-r--r-- | includes/Rest/Module/Module.php | 384 |
1 files changed, 384 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; + } +} |