The discovery endpoint provides basic information about accessing the wiki's APIs, as well as a directory of available modules. Bug: T365753 Change-Id: I161aa68566da91867b650e13c8aadc87cd0c428c
502 lines
15 KiB
PHP
502 lines
15 KiB
PHP
<?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;
|
|
use Wikimedia\Stats\StatsFactory;
|
|
|
|
/**
|
|
* 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 StatsFactory $stats;
|
|
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;
|
|
|
|
$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<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 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 );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 );
|
|
|
|
$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;
|
|
}
|
|
|
|
/**
|
|
* @param CorsUtils $cors
|
|
* @return self
|
|
*/
|
|
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 ]
|
|
)
|
|
];
|
|
}
|
|
}
|