aboutsummaryrefslogtreecommitdiffstats
path: root/includes
diff options
context:
space:
mode:
Diffstat (limited to 'includes')
-rw-r--r--includes/Rest/EntryPoint.php9
-rw-r--r--includes/Rest/Handler.php73
-rw-r--r--includes/Rest/Handler/HelloHandler.php11
-rw-r--r--includes/Rest/HttpException.php14
-rw-r--r--includes/Rest/ResponseFactory.php9
-rw-r--r--includes/Rest/Router.php20
-rw-r--r--includes/Rest/SimpleHandler.php21
-rw-r--r--includes/Rest/Validator/BodyValidator.php26
-rw-r--r--includes/Rest/Validator/NullBodyValidator.php16
-rw-r--r--includes/Rest/Validator/ParamValidatorCallbacks.php82
-rw-r--r--includes/Rest/Validator/Validator.php163
11 files changed, 437 insertions, 7 deletions
diff --git a/includes/Rest/EntryPoint.php b/includes/Rest/EntryPoint.php
index f28b4ea80cb4..ee3441e59587 100644
--- a/includes/Rest/EntryPoint.php
+++ b/includes/Rest/EntryPoint.php
@@ -6,6 +6,7 @@ use ExtensionRegistry;
use MediaWiki;
use MediaWiki\MediaWikiServices;
use MediaWiki\Rest\BasicAccess\MWBasicAuthorizer;
+use MediaWiki\Rest\Validator\Validator;
use RequestContext;
use Title;
use WebResponse;
@@ -36,6 +37,7 @@ class EntryPoint {
$services = MediaWikiServices::getInstance();
$conf = $services->getMainConfig();
+ $objectFactory = $services->getObjectFactory();
if ( !$conf->get( 'EnableRestAPI' ) ) {
wfHttpError( 403, 'Access Denied',
@@ -51,6 +53,9 @@ class EntryPoint {
$authorizer = new MWBasicAuthorizer( $context->getUser(),
$services->getPermissionManager() );
+ // @phan-suppress-next-line PhanAccessMethodInternal
+ $restValidator = new Validator( $objectFactory, $request, RequestContext::getMain()->getUser() );
+
global $IP;
$router = new Router(
[ "$IP/includes/Rest/coreRoutes.json" ],
@@ -58,7 +63,9 @@ class EntryPoint {
$conf->get( 'RestPath' ),
$services->getLocalServerObjectCache(),
new ResponseFactory,
- $authorizer
+ $authorizer,
+ $objectFactory,
+ $restValidator
);
$entryPoint = new self(
diff --git a/includes/Rest/Handler.php b/includes/Rest/Handler.php
index c05d8e774a0c..efe2b7e9e25b 100644
--- a/includes/Rest/Handler.php
+++ b/includes/Rest/Handler.php
@@ -2,7 +2,18 @@
namespace MediaWiki\Rest;
+use MediaWiki\Rest\Validator\BodyValidator;
+use MediaWiki\Rest\Validator\NullBodyValidator;
+use MediaWiki\Rest\Validator\Validator;
+
abstract class Handler {
+
+ /**
+ * (string) ParamValidator constant to specify the source of the parameter.
+ * Value must be 'path', 'query', or 'post'.
+ */
+ const PARAM_SOURCE = 'rest-param-source';
+
/** @var Router */
private $router;
@@ -15,6 +26,12 @@ abstract class Handler {
/** @var ResponseFactory */
private $responseFactory;
+ /** @var array|null */
+ private $validatedParams;
+
+ /** @var mixed */
+ private $validatedBody;
+
/**
* Initialise with dependencies from the Router. This is called after construction.
* @internal
@@ -69,6 +86,62 @@ abstract class Handler {
}
/**
+ * Validate the request parameters/attributes and body. If there is a validation
+ * failure, a response with an error message should be returned or an
+ * HttpException should be thrown.
+ *
+ * @param Validator $restValidator
+ * @throws HttpException On validation failure.
+ */
+ public function validate( Validator $restValidator ) {
+ $validatedParams = $restValidator->validateParams( $this->getParamSettings() );
+ $validatedBody = $restValidator->validateBody( $this->request, $this );
+ $this->validatedParams = $validatedParams;
+ $this->validatedBody = $validatedBody;
+ }
+
+ /**
+ * Fetch ParamValidator settings for parameters
+ *
+ * Every setting must include self::PARAM_SOURCE to specify which part of
+ * the request is to contain the parameter.
+ *
+ * @return array[] Associative array mapping parameter names to
+ * ParamValidator settings arrays
+ */
+ public function getParamSettings() {
+ return [];
+ }
+
+ /**
+ * Fetch the BodyValidator
+ * @param string $contentType Content type of the request.
+ * @return BodyValidator
+ */
+ public function getBodyValidator( $contentType ) {
+ return new NullBodyValidator();
+ }
+
+ /**
+ * Fetch the validated parameters
+ *
+ * @return array|null Array mapping parameter names to validated values,
+ * or null if validateParams() was not called yet or validation failed.
+ */
+ public function getValidatedParams() {
+ return $this->validatedParams;
+ }
+
+ /**
+ * Fetch the validated body
+ * @return mixed Value returned by the body validator, or null if validateParams() was
+ * not called yet, validation failed, there was no body, or the body was form data.
+ */
+ public function getValidatedBody() {
+ return $this->validatedBody;
+ }
+
+ /**
* The subclass should override this to provide the maximum last modified
* timestamp for the current request. This is called before execute() in
* order to decide whether to send a 304.
diff --git a/includes/Rest/Handler/HelloHandler.php b/includes/Rest/Handler/HelloHandler.php
index 34faee26d35b..495b10139a93 100644
--- a/includes/Rest/Handler/HelloHandler.php
+++ b/includes/Rest/Handler/HelloHandler.php
@@ -2,6 +2,7 @@
namespace MediaWiki\Rest\Handler;
+use Wikimedia\ParamValidator\ParamValidator;
use MediaWiki\Rest\SimpleHandler;
/**
@@ -16,4 +17,14 @@ class HelloHandler extends SimpleHandler {
public function needsWriteAccess() {
return false;
}
+
+ public function getParamSettings() {
+ return [
+ 'name' => [
+ self::PARAM_SOURCE => 'path',
+ ParamValidator::PARAM_TYPE => 'string',
+ ParamValidator::PARAM_REQUIRED => true,
+ ],
+ ];
+ }
}
diff --git a/includes/Rest/HttpException.php b/includes/Rest/HttpException.php
index ae6dde2b3f4f..bcc414fdf129 100644
--- a/includes/Rest/HttpException.php
+++ b/includes/Rest/HttpException.php
@@ -8,7 +8,19 @@ namespace MediaWiki\Rest;
* error response.
*/
class HttpException extends \Exception {
- public function __construct( $message, $code = 500 ) {
+
+ /** @var array|null */
+ private $errorData = null;
+
+ public function __construct( $message, $code = 500, $errorData = null ) {
parent::__construct( $message, $code );
+ $this->errorData = $errorData;
+ }
+
+ /**
+ * @return array|null
+ */
+ public function getErrorData() {
+ return $this->errorData;
}
}
diff --git a/includes/Rest/ResponseFactory.php b/includes/Rest/ResponseFactory.php
index d18cdb5d6bbe..5e5a19852d8a 100644
--- a/includes/Rest/ResponseFactory.php
+++ b/includes/Rest/ResponseFactory.php
@@ -175,8 +175,13 @@ class ResponseFactory {
public function createFromException( $exception ) {
if ( $exception instanceof HttpException ) {
// FIXME can HttpException represent 2xx or 3xx responses?
- $response = $this->createHttpError( $exception->getCode(),
- [ 'message' => $exception->getMessage() ] );
+ $response = $this->createHttpError(
+ $exception->getCode(),
+ array_merge(
+ [ 'message' => $exception->getMessage() ],
+ (array)$exception->getErrorData()
+ )
+ );
} else {
$response = $this->createHttpError( 500, [
'message' => 'Error: exception of type ' . get_class( $exception ),
diff --git a/includes/Rest/Router.php b/includes/Rest/Router.php
index 961da0147104..a520130d0616 100644
--- a/includes/Rest/Router.php
+++ b/includes/Rest/Router.php
@@ -6,6 +6,7 @@ use AppendIterator;
use BagOStuff;
use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface;
use MediaWiki\Rest\PathTemplateMatcher\PathMatcher;
+use MediaWiki\Rest\Validator\Validator;
use Wikimedia\ObjectFactory;
/**
@@ -44,6 +45,12 @@ class Router {
/** @var BasicAuthorizerInterface */
private $basicAuth;
+ /** @var ObjectFactory */
+ private $objectFactory;
+
+ /** @var Validator */
+ private $restValidator;
+
/**
* @param string[] $routeFiles List of names of JSON files containing routes
* @param array $extraRoutes Extension route array
@@ -51,10 +58,13 @@ class Router {
* @param BagOStuff $cacheBag A cache in which to store the matcher trees
* @param ResponseFactory $responseFactory
* @param BasicAuthorizerInterface $basicAuth
+ * @param ObjectFactory $objectFactory
+ * @param Validator $restValidator
*/
public function __construct( $routeFiles, $extraRoutes, $rootPath,
BagOStuff $cacheBag, ResponseFactory $responseFactory,
- BasicAuthorizerInterface $basicAuth
+ BasicAuthorizerInterface $basicAuth, ObjectFactory $objectFactory,
+ Validator $restValidator
) {
$this->routeFiles = $routeFiles;
$this->extraRoutes = $extraRoutes;
@@ -62,6 +72,8 @@ class Router {
$this->cacheBag = $cacheBag;
$this->responseFactory = $responseFactory;
$this->basicAuth = $basicAuth;
+ $this->objectFactory = $objectFactory;
+ $this->restValidator = $restValidator;
}
/**
@@ -245,9 +257,10 @@ class Router {
$request->setPathParams( array_map( 'rawurldecode', $match['params'] ) );
$spec = $match['userData'];
$objectFactorySpec = array_intersect_key( $spec,
+ // @todo ObjectFactory supports more keys than this.
[ 'factory' => true, 'class' => true, 'args' => true ] );
/** @var $handler Handler (annotation for PHPStorm) */
- $handler = ObjectFactory::getObjectFromSpec( $objectFactorySpec );
+ $handler = $this->objectFactory->createObject( $objectFactorySpec );
$handler->init( $this, $request, $spec, $this->responseFactory );
try {
@@ -268,6 +281,9 @@ class Router {
if ( $authResult ) {
return $this->responseFactory->createHttpError( 403, [ 'error' => $authResult ] );
}
+
+ $handler->validate( $this->restValidator );
+
$response = $handler->execute();
if ( !( $response instanceof ResponseInterface ) ) {
$response = $this->responseFactory->createFromReturnValue( $response );
diff --git a/includes/Rest/SimpleHandler.php b/includes/Rest/SimpleHandler.php
index 3718d66b9350..3c19e48e876e 100644
--- a/includes/Rest/SimpleHandler.php
+++ b/includes/Rest/SimpleHandler.php
@@ -14,7 +14,26 @@ namespace MediaWiki\Rest;
*/
class SimpleHandler extends Handler {
public function execute() {
- $params = array_values( $this->getRequest()->getPathParams() );
+ $paramSettings = $this->getParamSettings();
+ $validatedParams = $this->getValidatedParams();
+ $unvalidatedParams = [];
+ $params = [];
+ foreach ( $this->getRequest()->getPathParams() as $name => $value ) {
+ $source = $paramSettings[$name][self::PARAM_SOURCE] ?? 'unknown';
+ if ( $source !== 'path' ) {
+ $unvalidatedParams[] = $name;
+ $params[] = $value;
+ } else {
+ $params[] = $validatedParams[$name];
+ }
+ }
+
+ if ( $unvalidatedParams ) {
+ throw new \LogicException(
+ 'Path parameters were not validated: ' . implode( ', ', $unvalidatedParams )
+ );
+ }
+
// @phan-suppress-next-line PhanUndeclaredMethod
return $this->run( ...$params );
}
diff --git a/includes/Rest/Validator/BodyValidator.php b/includes/Rest/Validator/BodyValidator.php
new file mode 100644
index 000000000000..0147fa880cf3
--- /dev/null
+++ b/includes/Rest/Validator/BodyValidator.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace MediaWiki\Rest\Validator;
+
+use MediaWiki\Rest\HttpException;
+use MediaWiki\Rest\RequestInterface;
+
+/**
+ * Interface for validating a request body
+ */
+interface BodyValidator {
+
+ /**
+ * Validate the body of a request.
+ *
+ * This may return a data structure representing the parsed body. When used
+ * in the context of Handler::validateParams(), the returned value will be
+ * available to the handler via Handler::getValidatedBody().
+ *
+ * @param RequestInterface $request
+ * @return mixed
+ * @throws HttpException on validation failure
+ */
+ public function validateBody( RequestInterface $request );
+
+}
diff --git a/includes/Rest/Validator/NullBodyValidator.php b/includes/Rest/Validator/NullBodyValidator.php
new file mode 100644
index 000000000000..4fba5fb1e3ac
--- /dev/null
+++ b/includes/Rest/Validator/NullBodyValidator.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace MediaWiki\Rest\Validator;
+
+use MediaWiki\Rest\RequestInterface;
+
+/**
+ * Do-nothing body validator
+ */
+class NullBodyValidator implements BodyValidator {
+
+ public function validateBody( RequestInterface $request ) {
+ return null;
+ }
+
+}
diff --git a/includes/Rest/Validator/ParamValidatorCallbacks.php b/includes/Rest/Validator/ParamValidatorCallbacks.php
new file mode 100644
index 000000000000..6c54a504d796
--- /dev/null
+++ b/includes/Rest/Validator/ParamValidatorCallbacks.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace MediaWiki\Rest\Validator;
+
+use InvalidArgumentException;
+use MediaWiki\Rest\RequestInterface;
+use Psr\Http\Message\UploadedFileInterface;
+use User;
+use Wikimedia\ParamValidator\Callbacks;
+use Wikimedia\ParamValidator\ValidationException;
+
+class ParamValidatorCallbacks implements Callbacks {
+
+ /** @var RequestInterface */
+ private $request;
+
+ /** @var User */
+ private $user;
+
+ public function __construct( RequestInterface $request, User $user ) {
+ $this->request = $request;
+ $this->user = $user;
+ }
+
+ /**
+ * Get the raw parameters from a source in the request
+ * @param string $source 'path', 'query', or 'post'
+ * @return array
+ */
+ private function getParamsFromSource( $source ) {
+ switch ( $source ) {
+ case 'path':
+ return $this->request->getPathParams();
+
+ case 'query':
+ return $this->request->getQueryParams();
+
+ case 'post':
+ return $this->request->getPostParams();
+
+ default:
+ throw new InvalidArgumentException( __METHOD__ . ": Invalid source '$source'" );
+ }
+ }
+
+ public function hasParam( $name, array $options ) {
+ $params = $this->getParamsFromSource( $options['source'] );
+ return isset( $params[$name] );
+ }
+
+ public function getValue( $name, $default, array $options ) {
+ $params = $this->getParamsFromSource( $options['source'] );
+ return $params[$name] ?? $default;
+ // @todo Should normalization to NFC UTF-8 be done here (much like in the
+ // action API and the rest of MW), or should it be left to handlers to
+ // do whatever normalization they need?
+ }
+
+ public function hasUpload( $name, array $options ) {
+ if ( $options['source'] !== 'post' ) {
+ return false;
+ }
+ return $this->getUploadedFile( $name, $options ) !== null;
+ }
+
+ public function getUploadedFile( $name, array $options ) {
+ if ( $options['source'] !== 'post' ) {
+ return null;
+ }
+ $upload = $this->request->getUploadedFiles()[$name] ?? null;
+ return $upload instanceof UploadedFileInterface ? $upload : null;
+ }
+
+ public function recordCondition( ValidationException $condition, array $options ) {
+ // @todo Figure out how to handle warnings
+ }
+
+ public function useHighLimits( array $options ) {
+ return $this->user->isAllowed( 'apihighlimits' );
+ }
+
+}
diff --git a/includes/Rest/Validator/Validator.php b/includes/Rest/Validator/Validator.php
new file mode 100644
index 000000000000..cee1cdb35973
--- /dev/null
+++ b/includes/Rest/Validator/Validator.php
@@ -0,0 +1,163 @@
+<?php
+
+namespace MediaWiki\Rest\Validator;
+
+use MediaWiki\Rest\Handler;
+use MediaWiki\Rest\HttpException;
+use MediaWiki\Rest\RequestInterface;
+use User;
+use Wikimedia\ObjectFactory;
+use Wikimedia\ParamValidator\ParamValidator;
+use Wikimedia\ParamValidator\TypeDef\BooleanDef;
+use Wikimedia\ParamValidator\TypeDef\EnumDef;
+use Wikimedia\ParamValidator\TypeDef\FloatDef;
+use Wikimedia\ParamValidator\TypeDef\IntegerDef;
+use Wikimedia\ParamValidator\TypeDef\PasswordDef;
+use Wikimedia\ParamValidator\TypeDef\StringDef;
+use Wikimedia\ParamValidator\TypeDef\TimestampDef;
+use Wikimedia\ParamValidator\TypeDef\UploadDef;
+use Wikimedia\ParamValidator\ValidationException;
+
+/**
+ * Wrapper for ParamValidator
+ *
+ * It's intended to be used in the REST API classes by composition.
+ *
+ * @since 1.34
+ */
+class Validator {
+
+ /** @var array Type defs for ParamValidator */
+ private static $typeDefs = [
+ 'boolean' => [ 'class' => BooleanDef::class ],
+ 'enum' => [ 'class' => EnumDef::class ],
+ 'integer' => [ 'class' => IntegerDef::class ],
+ 'float' => [ 'class' => FloatDef::class ],
+ 'double' => [ 'class' => FloatDef::class ],
+ 'NULL' => [
+ 'class' => StringDef::class,
+ 'args' => [ [
+ 'allowEmptyWhenRequired' => true,
+ ] ],
+ ],
+ 'password' => [ 'class' => PasswordDef::class ],
+ 'string' => [ 'class' => StringDef::class ],
+ 'timestamp' => [ 'class' => TimestampDef::class ],
+ 'upload' => [ 'class' => UploadDef::class ],
+ ];
+
+ /** @var string[] HTTP request methods that we expect never to have a payload */
+ private static $noBodyMethods = [ 'GET', 'HEAD', 'DELETE' ];
+
+ /** @var string[] HTTP request methods that we expect always to have a payload */
+ private static $bodyMethods = [ 'POST', 'PUT' ];
+
+ /** @var string[] Content types handled via $_POST */
+ private static $formDataContentTypes = [
+ 'application/x-www-form-urlencoded',
+ 'multipart/form-data',
+ ];
+
+ /** @var ParamValidator */
+ private $paramValidator;
+
+ /**
+ * @internal
+ * @param ObjectFactory $objectFactory
+ * @param RequestInterface $request
+ * @param User $user
+ */
+ public function __construct(
+ ObjectFactory $objectFactory, RequestInterface $request, User $user
+ ) {
+ $this->paramValidator = new ParamValidator(
+ new ParamValidatorCallbacks( $request, $user ),
+ $objectFactory,
+ [
+ 'typeDefs' => self::$typeDefs,
+ ]
+ );
+ }
+
+ /**
+ * Validate parameters
+ * @param array[] $paramSettings Parameter settings
+ * @return array Validated parameters
+ * @throws HttpException on validaton failure
+ */
+ public function validateParams( array $paramSettings ) {
+ $validatedParams = [];
+ foreach ( $paramSettings as $name => $settings ) {
+ try {
+ $validatedParams[$name] = $this->paramValidator->getValue( $name, $settings, [
+ 'source' => $settings[Handler::PARAM_SOURCE] ?? 'unspecified',
+ ] );
+ } catch ( ValidationException $e ) {
+ throw new HttpException( 'Parameter validation failed', 400, [
+ 'error' => 'parameter-validation-failed',
+ 'name' => $e->getParamName(),
+ 'value' => $e->getParamValue(),
+ 'failureCode' => $e->getFailureCode(),
+ 'failureData' => $e->getFailureData(),
+ ] );
+ }
+ }
+ return $validatedParams;
+ }
+
+ /**
+ * Validate the body of a request.
+ *
+ * This may return a data structure representing the parsed body. When used
+ * in the context of Handler::validateParams(), the returned value will be
+ * available to the handler via Handler::getValidatedBody().
+ *
+ * @param RequestInterface $request
+ * @param Handler $handler Used to call getBodyValidator()
+ * @return mixed May be null
+ * @throws HttpException on validation failure
+ */
+ public function validateBody( RequestInterface $request, Handler $handler ) {
+ $method = strtoupper( trim( $request->getMethod() ) );
+
+ // If the method should never have a body, don't bother validating.
+ if ( in_array( $method, self::$noBodyMethods, true ) ) {
+ return null;
+ }
+
+ // Get the content type
+ list( $ct ) = explode( ';', $request->getHeaderLine( 'Content-Type' ), 2 );
+ $ct = strtolower( trim( $ct ) );
+ if ( $ct === '' ) {
+ // No Content-Type was supplied. RFC 7231 ยง 3.1.1.5 allows this, but since it's probably a
+ // client error let's return a 415. But don't 415 for unknown methods and an empty body.
+ if ( !in_array( $method, self::$bodyMethods, true ) ) {
+ $body = $request->getBody();
+ $size = $body->getSize();
+ if ( $size === null ) {
+ // No size available. Try reading 1 byte.
+ if ( $body->isSeekable() ) {
+ $body->rewind();
+ }
+ $size = $body->read( 1 ) === '' ? 0 : 1;
+ }
+ if ( $size === 0 ) {
+ return null;
+ }
+ }
+ throw new HttpException( "A Content-Type header must be supplied with a request payload.", 415, [
+ 'error' => 'no-content-type',
+ ] );
+ }
+
+ // Form data is parsed into $_POST and $_FILES by PHP and from there is accessed as parameters,
+ // don't bother trying to handle these via BodyValidator too.
+ if ( in_array( $ct, self::$formDataContentTypes, true ) ) {
+ return null;
+ }
+
+ // Validate the body. BodyValidator throws an HttpException on failure.
+ return $handler->getBodyValidator( $ct )->validateBody( $request );
+ }
+
+}