aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/extension.schema.v2.json40
-rw-r--r--includes/AutoLoader.php1
-rw-r--r--includes/DefaultSettings.php13
-rw-r--r--includes/Rest/CopyableStreamInterface.php18
-rw-r--r--includes/Rest/EntryPoint.php73
-rw-r--r--includes/Rest/Handler.php99
-rw-r--r--includes/Rest/Handler/HelloHandler.php15
-rw-r--r--includes/Rest/HeaderContainer.php202
-rw-r--r--includes/Rest/HttpException.php14
-rw-r--r--includes/Rest/JsonEncodingException.php9
-rw-r--r--includes/Rest/PathTemplateMatcher/PathConflict.php21
-rw-r--r--includes/Rest/PathTemplateMatcher/PathMatcher.php221
-rw-r--r--includes/Rest/RequestBase.php115
-rw-r--r--includes/Rest/RequestData.php104
-rw-r--r--includes/Rest/RequestFromGlobals.php101
-rw-r--r--includes/Rest/RequestInterface.php276
-rw-r--r--includes/Rest/Response.php112
-rw-r--r--includes/Rest/ResponseFactory.php52
-rw-r--r--includes/Rest/ResponseInterface.php277
-rw-r--r--includes/Rest/Router.php231
-rw-r--r--includes/Rest/SimpleHandler.php19
-rw-r--r--includes/Rest/Stream.php18
-rw-r--r--includes/Rest/StringStream.php139
-rw-r--r--includes/Rest/coreRoutes.json6
-rw-r--r--includes/Setup.php3
-rw-r--r--includes/registration/ExtensionProcessor.php1
26 files changed, 2177 insertions, 3 deletions
diff --git a/docs/extension.schema.v2.json b/docs/extension.schema.v2.json
index f29f8501b021..e77eca2635e8 100644
--- a/docs/extension.schema.v2.json
+++ b/docs/extension.schema.v2.json
@@ -890,6 +890,46 @@
"type": "array",
"description": "List of service wiring files to be loaded by the default instance of MediaWikiServices"
},
+ "RestRoutes": {
+ "type": "array",
+ "description": "List of route specifications to be added to the REST API",
+ "items": {
+ "type": "object",
+ "properties": {
+ "method": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "The HTTP method name"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "description": "An acceptable HTTP method name"
+ }
+ }
+ ]
+ },
+ "path": {
+ "type": "string",
+ "description": "The path template. This should start with an initial slash, designating the root of the REST API. Path parameters are enclosed in braces, for example /endpoint/{param}."
+ },
+ "factory": {
+ "type": ["string", "array"],
+ "description": "A factory function to be called to create the handler for this route"
+ },
+ "class": {
+ "type": "string",
+ "description": "The fully-qualified class name of the handler. This should be omitted if a factory is specified."
+ },
+ "args": {
+ "type": "array",
+ "description": "The arguments passed to the handler constructor or factory"
+ }
+ }
+ }
+ },
"attributes": {
"description":"Registration information for other extensions",
"type": "object",
diff --git a/includes/AutoLoader.php b/includes/AutoLoader.php
index fa11bcb1b5b0..57e434102cdc 100644
--- a/includes/AutoLoader.php
+++ b/includes/AutoLoader.php
@@ -136,6 +136,7 @@ class AutoLoader {
'MediaWiki\\Linker\\' => __DIR__ . '/linker/',
'MediaWiki\\Permissions\\' => __DIR__ . '/Permissions/',
'MediaWiki\\Preferences\\' => __DIR__ . '/preferences/',
+ 'MediaWiki\\Rest\\' => __DIR__ . '/Rest/',
'MediaWiki\\Revision\\' => __DIR__ . '/Revision/',
'MediaWiki\\Session\\' => __DIR__ . '/session/',
'MediaWiki\\Shell\\' => __DIR__ . '/shell/',
diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php
index ab1afe2109f8..9bff00480982 100644
--- a/includes/DefaultSettings.php
+++ b/includes/DefaultSettings.php
@@ -194,6 +194,13 @@ $wgScript = false;
$wgLoadScript = false;
/**
+ * The URL path to the REST API
+ * Defaults to "{$wgScriptPath}/rest.php"
+ * @since 1.34
+ */
+$wgRestPath = false;
+
+/**
* The URL path of the skins directory.
* Defaults to "{$wgResourceBasePath}/skins".
* @since 1.3
@@ -8081,10 +8088,10 @@ $wgExemptFromUserRobotsControl = null;
/** @} */ # End robot policy }
/************************************************************************//**
- * @name AJAX and API
+ * @name AJAX, Action API and REST API
* Note: The AJAX entry point which this section refers to is gradually being
- * replaced by the API entry point, api.php. They are essentially equivalent.
- * Both of them are used for dynamic client-side features, via XHR.
+ * replaced by the Action API entry point, api.php. They are essentially
+ * equivalent. Both of them are used for dynamic client-side features, via XHR.
* @{
*/
diff --git a/includes/Rest/CopyableStreamInterface.php b/includes/Rest/CopyableStreamInterface.php
new file mode 100644
index 000000000000..d271db36906e
--- /dev/null
+++ b/includes/Rest/CopyableStreamInterface.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+/**
+ * An interface for a stream with a copyToStream() function.
+ */
+interface CopyableStreamInterface extends \Psr\Http\Message\StreamInterface {
+ /**
+ * Copy this stream to a specified stream resource. For some streams,
+ * this can be implemented without a tight loop in PHP code.
+ *
+ * Note that $stream is not a StreamInterface object.
+ *
+ * @param resource $stream Destination
+ */
+ function copyToStream( $stream );
+}
diff --git a/includes/Rest/EntryPoint.php b/includes/Rest/EntryPoint.php
new file mode 100644
index 000000000000..d5924f06c10a
--- /dev/null
+++ b/includes/Rest/EntryPoint.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+use ExtensionRegistry;
+use MediaWiki\MediaWikiServices;
+use RequestContext;
+use Title;
+
+class EntryPoint {
+ public static function main() {
+ // URL safety checks
+ global $wgRequest;
+ if ( !$wgRequest->checkUrlExtension() ) {
+ return;
+ }
+
+ // Set $wgTitle and the title in RequestContext, as in api.php
+ global $wgTitle;
+ $wgTitle = Title::makeTitle( NS_SPECIAL, 'Badtitle/rest.php' );
+ RequestContext::getMain()->setTitle( $wgTitle );
+
+ $services = MediaWikiServices::getInstance();
+
+ $conf = $services->getMainConfig();
+ $request = new RequestFromGlobals( [
+ 'cookiePrefix' => $conf->get( 'CookiePrefix' )
+ ] );
+
+ global $IP;
+ $router = new Router(
+ [ "$IP/includes/Rest/coreRoutes.json" ],
+ ExtensionRegistry::getInstance()->getAttribute( 'RestRoutes' ),
+ $conf->get( 'RestPath' ),
+ $services->getLocalServerObjectCache(),
+ new ResponseFactory
+ );
+
+ $response = $router->execute( $request );
+
+ $webResponse = $wgRequest->response();
+ $webResponse->header(
+ 'HTTP/' . $response->getProtocolVersion() . ' ' .
+ $response->getStatusCode() . ' ' .
+ $response->getReasonPhrase() );
+
+ foreach ( $response->getRawHeaderLines() as $line ) {
+ $webResponse->header( $line );
+ }
+
+ foreach ( $response->getCookies() as $cookie ) {
+ $webResponse->setCookie(
+ $cookie['name'],
+ $cookie['value'],
+ $cookie['expiry'],
+ $cookie['options'] );
+ }
+
+ $stream = $response->getBody();
+ $stream->rewind();
+ if ( $stream instanceof CopyableStreamInterface ) {
+ $stream->copyToStream( fopen( 'php://output', 'w' ) );
+ } else {
+ while ( true ) {
+ $buffer = $stream->read( 65536 );
+ if ( $buffer === '' ) {
+ break;
+ }
+ echo $buffer;
+ }
+ }
+ }
+}
diff --git a/includes/Rest/Handler.php b/includes/Rest/Handler.php
new file mode 100644
index 000000000000..472e1cc367c6
--- /dev/null
+++ b/includes/Rest/Handler.php
@@ -0,0 +1,99 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+abstract class Handler {
+ /** @var RequestInterface */
+ private $request;
+
+ /** @var array */
+ private $config;
+
+ /** @var ResponseFactory */
+ private $responseFactory;
+
+ /**
+ * Initialise with dependencies from the Router. This is called after construction.
+ */
+ public function init( RequestInterface $request, array $config,
+ ResponseFactory $responseFactory
+ ) {
+ $this->request = $request;
+ $this->config = $config;
+ $this->responseFactory = $responseFactory;
+ }
+
+ /**
+ * Get the current request. The return type declaration causes it to raise
+ * a fatal error if init() has not yet been called.
+ *
+ * @return RequestInterface
+ */
+ public function getRequest(): RequestInterface {
+ return $this->request;
+ }
+
+ /**
+ * Get the configuration array for the current route. The return type
+ * declaration causes it to raise a fatal error if init() has not
+ * been called.
+ *
+ * @return array
+ */
+ public function getConfig(): array {
+ return $this->config;
+ }
+
+ /**
+ * Get the ResponseFactory which can be used to generate Response objects.
+ * This will raise a fatal error if init() has not been
+ * called.
+ *
+ * @return ResponseFactory
+ */
+ public function getResponseFactory(): ResponseFactory {
+ return $this->responseFactory;
+ }
+
+ /**
+ * 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.
+ *
+ * The timestamp can be in any format accepted by ConvertibleTimestamp, or
+ * null to indicate that the timestamp is unknown.
+ *
+ * @return bool|string|int|float|\DateTime|null
+ */
+ protected function getLastModified() {
+ return null;
+ }
+
+ /**
+ * The subclass should override this to provide an ETag for the current
+ * request. This is called before execute() in order to decide whether to
+ * send a 304.
+ *
+ * See RFC 7232 ยง 2.3 for semantics.
+ *
+ * @return string|null
+ */
+ protected function getETag() {
+ return null;
+ }
+
+ /**
+ * Execute the handler. This is called after parameter validation. The
+ * return value can either be a Response or any type accepted by
+ * ResponseFactory::createFromReturnValue().
+ *
+ * To automatically construct an error response, execute() should throw a
+ * RestException. Such exceptions will not be logged like a normal exception.
+ *
+ * If execute() throws any other kind of exception, the exception will be
+ * logged and a generic 500 error page will be shown.
+ *
+ * @return mixed
+ */
+ abstract public function execute();
+}
diff --git a/includes/Rest/Handler/HelloHandler.php b/includes/Rest/Handler/HelloHandler.php
new file mode 100644
index 000000000000..6e119dd651b4
--- /dev/null
+++ b/includes/Rest/Handler/HelloHandler.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace MediaWiki\Rest\Handler;
+
+use MediaWiki\Rest\SimpleHandler;
+
+/**
+ * Example handler
+ * @unstable
+ */
+class HelloHandler extends SimpleHandler {
+ public function run( $name ) {
+ return [ 'message' => "Hello, $name!" ];
+ }
+}
diff --git a/includes/Rest/HeaderContainer.php b/includes/Rest/HeaderContainer.php
new file mode 100644
index 000000000000..50f4355f2b7b
--- /dev/null
+++ b/includes/Rest/HeaderContainer.php
@@ -0,0 +1,202 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+/**
+ * This is a container for storing headers. The header names are case-insensitive,
+ * but the case is preserved for methods that return headers in bulk. The
+ * header values are a comma-separated list, or equivalently, an array of strings.
+ *
+ * Unlike PSR-7, the container is mutable.
+ */
+class HeaderContainer {
+ private $headerLists;
+ private $headerLines;
+ private $headerNames;
+
+ /**
+ * Erase any existing headers and replace them with the specified
+ * header arrays or values.
+ *
+ * @param array $headers
+ */
+ public function resetHeaders( $headers = [] ) {
+ $this->headerLines = [];
+ $this->headerLists = [];
+ $this->headerNames = [];
+ foreach ( $headers as $name => $value ) {
+ $this->headerNames[ strtolower( $name ) ] = $name;
+ list( $valueParts, $valueLine ) = $this->convertToListAndString( $value );
+ $this->headerLines[$name] = $valueLine;
+ $this->headerLists[$name] = $valueParts;
+ }
+ }
+
+ /**
+ * Take an input header value, which may either be a string or an array,
+ * and convert it to an array of header values and a header line.
+ *
+ * The return value is an array where element 0 has the array of header
+ * values, and element 1 has the header line.
+ *
+ * Theoretically, if the input is a string, this could parse the string
+ * and split it on commas. Doing this is complicated, because some headers
+ * can contain double-quoted strings containing commas. The User-Agent
+ * header allows commas in comments delimited by parentheses. So it is not
+ * just explode(",", $value), we would need to parse a grammar defined by
+ * RFC 7231 appendix D which depends on header name.
+ *
+ * It's unclear how much it would help handlers to have fully spec-aware
+ * HTTP header handling just to split on commas. They would probably be
+ * better served by an HTTP header parsing library which provides the full
+ * parse tree.
+ *
+ * @param string $name The header name
+ * @param string|string[] $value The input header value
+ * @return array
+ */
+ private function convertToListAndString( $value ) {
+ if ( is_array( $value ) ) {
+ return [ array_values( $value ), implode( ', ', $value ) ];
+ } else {
+ return [ [ $value ], $value ];
+ }
+ }
+
+ /**
+ * Set or replace a header
+ *
+ * @param string $name
+ * @param string|string[] $value
+ */
+ public function setHeader( $name, $value ) {
+ list( $valueParts, $valueLine ) = $this->convertToListAndString( $value );
+ $lowerName = strtolower( $name );
+ $origName = $this->headerNames[$lowerName] ?? null;
+ if ( $origName !== null ) {
+ unset( $this->headerLines[$origName] );
+ unset( $this->headerLists[$origName] );
+ }
+ $this->headerNames[$lowerName] = $name;
+ $this->headerLines[$name] = $valueLine;
+ $this->headerLists[$name] = $valueParts;
+ }
+
+ /**
+ * Set a header or append to an existing header
+ *
+ * @param string $name
+ * @param string|string[] $value
+ */
+ public function addHeader( $name, $value ) {
+ list( $valueParts, $valueLine ) = $this->convertToListAndString( $value );
+ $lowerName = strtolower( $name );
+ $origName = $this->headerNames[$lowerName] ?? null;
+ if ( $origName === null ) {
+ $origName = $name;
+ $this->headerNames[$lowerName] = $origName;
+ $this->headerLines[$origName] = $valueLine;
+ $this->headerLists[$origName] = $valueParts;
+ } else {
+ $this->headerLines[$origName] .= ', ' . $valueLine;
+ $this->headerLists[$origName] = array_merge( $this->headerLists[$origName],
+ $valueParts );
+ }
+ }
+
+ /**
+ * Remove a header
+ *
+ * @param string $name
+ */
+ public function removeHeader( $name ) {
+ $lowerName = strtolower( $name );
+ $origName = $this->headerNames[$lowerName] ?? null;
+ if ( $origName !== null ) {
+ unset( $this->headerNames[$lowerName] );
+ unset( $this->headerLines[$origName] );
+ unset( $this->headerLists[$origName] );
+ }
+ }
+
+ /**
+ * Get header arrays indexed by original name
+ *
+ * @return string[][]
+ */
+ public function getHeaders() {
+ return $this->headerLists;
+ }
+
+ /**
+ * Get the header with a particular name, or an empty array if there is no
+ * such header.
+ *
+ * @param string $name
+ * @return string[]
+ */
+ public function getHeader( $name ) {
+ $headerName = $this->headerNames[ strtolower( $name ) ] ?? null;
+ if ( $headerName === null ) {
+ return [];
+ }
+ return $this->headerLists[$headerName];
+ }
+
+ /**
+ * Return true if the header exists, false otherwise
+ * @param string $name
+ * @return bool
+ */
+ public function hasHeader( $name ) {
+ return isset( $this->headerNames[ strtolower( $name ) ] );
+ }
+
+ /**
+ * Get the specified header concatenated into a comma-separated string.
+ * If the header does not exist, an empty string is returned.
+ *
+ * @param string $name
+ * @return string
+ */
+ public function getHeaderLine( $name ) {
+ $headerName = $this->headerNames[ strtolower( $name ) ] ?? null;
+ if ( $headerName === null ) {
+ return '';
+ }
+ return $this->headerLines[$headerName];
+ }
+
+ /**
+ * Get all header lines
+ *
+ * @return string[]
+ */
+ public function getHeaderLines() {
+ return $this->headerLines;
+ }
+
+ /**
+ * Get an array of strings of the form "Name: Value", suitable for passing
+ * directly to header() to set response headers. The PHP manual describes
+ * these strings as "raw HTTP headers", so we adopt that terminology.
+ *
+ * @return string[] Header list (integer indexed)
+ */
+ public function getRawHeaderLines() {
+ $lines = [];
+ foreach ( $this->headerNames as $lowerName => $name ) {
+ if ( $lowerName === 'set-cookie' ) {
+ // As noted by RFC 7230 section 3.2.2, Set-Cookie is the only
+ // header for which multiple values cannot be concatenated into
+ // a single comma-separated line.
+ foreach ( $this->headerLists[$name] as $value ) {
+ $lines[] = "$name: $value";
+ }
+ } else {
+ $lines[] = "$name: " . $this->headerLines[$name];
+ }
+ }
+ return $lines;
+ }
+}
diff --git a/includes/Rest/HttpException.php b/includes/Rest/HttpException.php
new file mode 100644
index 000000000000..ae6dde2b3f4f
--- /dev/null
+++ b/includes/Rest/HttpException.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+/**
+ * This is the base exception class for non-fatal exceptions thrown from REST
+ * handlers. The exception is not logged, it is merely converted to an
+ * error response.
+ */
+class HttpException extends \Exception {
+ public function __construct( $message, $code = 500 ) {
+ parent::__construct( $message, $code );
+ }
+}
diff --git a/includes/Rest/JsonEncodingException.php b/includes/Rest/JsonEncodingException.php
new file mode 100644
index 000000000000..e731ac352012
--- /dev/null
+++ b/includes/Rest/JsonEncodingException.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+class JsonEncodingException extends \RuntimeException {
+ public function __construct( $message, $code ) {
+ parent::__construct( "JSON encoding error: $message", $code );
+ }
+}
diff --git a/includes/Rest/PathTemplateMatcher/PathConflict.php b/includes/Rest/PathTemplateMatcher/PathConflict.php
new file mode 100644
index 000000000000..dd9f34a7cf07
--- /dev/null
+++ b/includes/Rest/PathTemplateMatcher/PathConflict.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace MediaWiki\Rest\PathTemplateMatcher;
+
+use Exception;
+
+class PathConflict extends Exception {
+ public $newTemplate;
+ public $newUserData;
+ public $existingTemplate;
+ public $existingUserData;
+
+ public function __construct( $template, $userData, $existingNode ) {
+ $this->newTemplate = $template;
+ $this->newUserData = $userData;
+ $this->existingTemplate = $existingNode['template'];
+ $this->existingUserData = $existingNode['userData'];
+ parent::__construct( "Unable to add path template \"$template\" since it conflicts " .
+ "with the existing template \"{$this->existingTemplate}\"" );
+ }
+}
diff --git a/includes/Rest/PathTemplateMatcher/PathMatcher.php b/includes/Rest/PathTemplateMatcher/PathMatcher.php
new file mode 100644
index 000000000000..69987e0b9270
--- /dev/null
+++ b/includes/Rest/PathTemplateMatcher/PathMatcher.php
@@ -0,0 +1,221 @@
+<?php
+
+namespace MediaWiki\Rest\PathTemplateMatcher;
+
+/**
+ * A tree-based path routing algorithm.
+ *
+ * This container builds defined routing templates into a tree, allowing
+ * paths to be efficiently matched against all templates. The match time is
+ * independent of the number of registered path templates.
+ *
+ * Efficient matching comes at the cost of a potentially significant setup time.
+ * We measured ~10ms for 1000 templates. Using getCacheData() and
+ * newFromCache(), this setup time may be amortized over multiple requests.
+ */
+class PathMatcher {
+ /**
+ * An array of trees indexed by the number of path components in the input.
+ *
+ * A tree node consists of an associative array in which the key is a match
+ * specifier string, and the value is another node. A leaf node, which is
+ * identifiable by its fixed depth in the tree, consists of an associative
+ * array with the following keys:
+ * - template: The path template string
+ * - paramNames: A list of parameter names extracted from the template
+ * - userData: The user data supplied to add()
+ *
+ * A match specifier string may be either "*", which matches any path
+ * component, or a literal string prefixed with "=", which matches the
+ * specified deprefixed string literal.
+ *
+ * @var array
+ */
+ private $treesByLength = [];
+
+ /**
+ * Create a PathMatcher from cache data
+ *
+ * @param array $data The data array previously returned by getCacheData()
+ * @return PathMatcher
+ */
+ public static function newFromCache( $data ) {
+ $matcher = new self;
+ $matcher->treesByLength = $data;
+ return $matcher;
+ }
+
+ /**
+ * Get a data array for later use by newFromCache().
+ *
+ * The internal format is private to PathMatcher, but note that it includes
+ * any data passed as $userData to add(). The array returned will be
+ * serializable as long as all $userData values are serializable.
+ *
+ * @return array
+ */
+ public function getCacheData() {
+ return $this->treesByLength;
+ }
+
+ /**
+ * Determine whether a path template component is a parameter
+ *
+ * @param string $part
+ * @return bool
+ */
+ private function isParam( $part ) {
+ $partLength = strlen( $part );
+ return $partLength > 2 && $part[0] === '{' && $part[$partLength - 1] === '}';
+ }
+
+ /**
+ * If a path template component is a parameter, return the parameter name.
+ * Otherwise, return false.
+ *
+ * @param string $part
+ * @return string|false
+ */
+ private function getParamName( $part ) {
+ if ( $this->isParam( $part ) ) {
+ return substr( $part, 1, -1 );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Recursively search the match tree, checking whether the proposed path
+ * template, passed as an array of component parts, can be added to the
+ * matcher without ambiguity.
+ *
+ * Ambiguity means that a path exists which matches multiple templates.
+ *
+ * The function calls itself recursively, incrementing $index so as to
+ * ignore a prefix of the input, in order to check deeper parts of the
+ * match tree.
+ *
+ * If a conflict is discovered, the conflicting leaf node is returned.
+ * Otherwise, false is returned.
+ *
+ * @param array $node The tree node to check against
+ * @param string[] $parts The array of path template parts
+ * @param int $index The current index into $parts
+ * @return array|false
+ */
+ private function findConflict( $node, $parts, $index = 0 ) {
+ if ( $index >= count( $parts ) ) {
+ // If we reached the leaf node then a conflict is detected
+ return $node;
+ }
+ $part = $parts[$index];
+ $result = false;
+ if ( $this->isParam( $part ) ) {
+ foreach ( $node as $key => $childNode ) {
+ $result = $this->findConflict( $childNode, $parts, $index + 1 );
+ if ( $result !== false ) {
+ break;
+ }
+ }
+ } else {
+ if ( isset( $node["=$part"] ) ) {
+ $result = $this->findConflict( $node["=$part"], $parts, $index + 1 );
+ }
+ if ( $result === false && isset( $node['*'] ) ) {
+ $result = $this->findConflict( $node['*'], $parts, $index + 1 );
+ }
+ }
+ return $result;
+ }
+
+ /**
+ * Add a template to the matcher.
+ *
+ * The path template consists of components separated by "/". Each component
+ * may be either a parameter of the form {paramName}, or a literal string.
+ * A parameter matches any input path component, whereas a literal string
+ * matches itself.
+ *
+ * Path templates must not conflict with each other, that is, any input
+ * path must match at most one path template. If a path template conflicts
+ * with another already registered, this function throws a PathConflict
+ * exception.
+ *
+ * @param string $template The path template
+ * @param mixed $userData User data used to identify the matched route to
+ * the caller of match()
+ * @throws PathConflict
+ */
+ public function add( $template, $userData ) {
+ $parts = explode( '/', $template );
+ $length = count( $parts );
+ if ( !isset( $this->treesByLength[$length] ) ) {
+ $this->treesByLength[$length] = [];
+ }
+ $tree =& $this->treesByLength[$length];
+ $conflict = $this->findConflict( $tree, $parts );
+ if ( $conflict !== false ) {
+ throw new PathConflict( $template, $userData, $conflict );
+ }
+
+ $params = [];
+ foreach ( $parts as $index => $part ) {
+ $paramName = $this->getParamName( $part );
+ if ( $paramName !== false ) {
+ $params[] = $paramName;
+ $key = '*';
+ } else {
+ $key = "=$part";
+ }
+ if ( $index === $length - 1 ) {
+ $tree[$key] = [
+ 'template' => $template,
+ 'paramNames' => $params,
+ 'userData' => $userData
+ ];
+ } elseif ( !isset( $tree[$key] ) ) {
+ $tree[$key] = [];
+ }
+ $tree =& $tree[$key];
+ }
+ }
+
+ /**
+ * Match a path against the current match trees.
+ *
+ * If the path matches a previously added path template, an array will be
+ * returned with the following keys:
+ * - params: An array mapping parameter names to their detected values
+ * - userData: The user data passed to add(), which identifies the route
+ *
+ * If the path does not match any template, false is returned.
+ *
+ * @param string $path
+ * @return array|false
+ */
+ public function match( $path ) {
+ $parts = explode( '/', $path );
+ $length = count( $parts );
+ if ( !isset( $this->treesByLength[$length] ) ) {
+ return false;
+ }
+ $node = $this->treesByLength[$length];
+
+ $paramValues = [];
+ foreach ( $parts as $part ) {
+ if ( isset( $node["=$part"] ) ) {
+ $node = $node["=$part"];
+ } elseif ( isset( $node['*'] ) ) {
+ $node = $node['*'];
+ $paramValues[] = $part;
+ } else {
+ return false;
+ }
+ }
+
+ return [
+ 'params' => array_combine( $node['paramNames'], $paramValues ),
+ 'userData' => $node['userData']
+ ];
+ }
+}
diff --git a/includes/Rest/RequestBase.php b/includes/Rest/RequestBase.php
new file mode 100644
index 000000000000..cacef62d473d
--- /dev/null
+++ b/includes/Rest/RequestBase.php
@@ -0,0 +1,115 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+/**
+ * Shared code between RequestData and RequestFromGlobals
+ */
+abstract class RequestBase implements RequestInterface {
+ /**
+ * @var HeaderContainer|null
+ */
+ private $headerCollection;
+
+ /** @var array */
+ private $attributes = [];
+
+ /** @var string */
+ private $cookiePrefix;
+
+ /**
+ * @internal
+ * @param string $cookiePrefix
+ */
+ protected function __construct( $cookiePrefix ) {
+ $this->cookiePrefix = $cookiePrefix;
+ }
+
+ /**
+ * Override this in the implementation class if lazy initialisation of
+ * header values is desired. It should call setHeaders().
+ *
+ * @internal
+ */
+ protected function initHeaders() {
+ }
+
+ public function __clone() {
+ if ( $this->headerCollection !== null ) {
+ $this->headerCollection = clone $this->headerCollection;
+ }
+ }
+
+ /**
+ * Erase any existing headers and replace them with the specified header
+ * lines.
+ *
+ * Call this either from the constructor or from initHeaders() of the
+ * implementing class.
+ *
+ * @internal
+ * @param string[] $headers The header lines
+ */
+ protected function setHeaders( $headers ) {
+ $this->headerCollection = new HeaderContainer;
+ $this->headerCollection->resetHeaders( $headers );
+ }
+
+ public function getHeaders() {
+ if ( $this->headerCollection === null ) {
+ $this->initHeaders();
+ }
+ return $this->headerCollection->getHeaders();
+ }
+
+ public function getHeader( $name ) {
+ if ( $this->headerCollection === null ) {
+ $this->initHeaders();
+ }
+ return $this->headerCollection->getHeader( $name );
+ }
+
+ public function hasHeader( $name ) {
+ if ( $this->headerCollection === null ) {
+ $this->initHeaders();
+ }
+ return $this->headerCollection->hasHeader( $name );
+ }
+
+ public function getHeaderLine( $name ) {
+ if ( $this->headerCollection === null ) {
+ $this->initHeaders();
+ }
+ return $this->headerCollection->getHeaderLine( $name );
+ }
+
+ public function setAttributes( $attributes ) {
+ $this->attributes = $attributes;
+ }
+
+ public function getAttributes() {
+ return $this->attributes;
+ }
+
+ public function getAttribute( $name, $default = null ) {
+ if ( array_key_exists( $name, $this->attributes ) ) {
+ return $this->attributes[$name];
+ } else {
+ return $default;
+ }
+ }
+
+ public function getCookiePrefix() {
+ return $this->cookiePrefix;
+ }
+
+ public function getCookie( $name, $default = null ) {
+ $cookies = $this->getCookieParams();
+ $prefixedName = $this->getCookiePrefix() . $name;
+ if ( array_key_exists( $prefixedName, $cookies ) ) {
+ return $cookies[$prefixedName];
+ } else {
+ return $default;
+ }
+ }
+}
diff --git a/includes/Rest/RequestData.php b/includes/Rest/RequestData.php
new file mode 100644
index 000000000000..1522c6b0885c
--- /dev/null
+++ b/includes/Rest/RequestData.php
@@ -0,0 +1,104 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+use GuzzleHttp\Psr7\Uri;
+use Psr\Http\Message\StreamInterface;
+use Psr\Http\Message\UploadedFileInterface;
+use Psr\Http\Message\UriInterface;
+
+/**
+ * This is a Request class that allows data to be injected, for the purposes
+ * of testing or internal requests.
+ */
+class RequestData extends RequestBase {
+ private $method;
+
+ /** @var UriInterface */
+ private $uri;
+
+ private $protocolVersion;
+
+ /** @var StreamInterface */
+ private $body;
+
+ private $serverParams;
+
+ private $cookieParams;
+
+ private $queryParams;
+
+ /** @var UploadedFileInterface[] */
+ private $uploadedFiles;
+
+ private $postParams;
+
+ /**
+ * Construct a RequestData from an array of parameters.
+ *
+ * @param array $params An associative array of parameters. All parameters
+ * have defaults. Parameters are:
+ * - method: The HTTP method
+ * - uri: The URI
+ * - protocolVersion: The HTTP protocol version number
+ * - bodyContents: A string giving the request body
+ * - serverParams: Equivalent to $_SERVER
+ * - cookieParams: Equivalent to $_COOKIE
+ * - queryParams: Equivalent to $_GET
+ * - uploadedFiles: An array of objects implementing UploadedFileInterface
+ * - postParams: Equivalent to $_POST
+ * - attributes: The attributes, usually from path template parameters
+ * - headers: An array with the the key being the header name
+ * - cookiePrefix: A prefix to add to cookie names in getCookie()
+ */
+ public function __construct( $params = [] ) {
+ $this->method = $params['method'] ?? 'GET';
+ $this->uri = $params['uri'] ?? new Uri;
+ $this->protocolVersion = $params['protocolVersion'] ?? '1.1';
+ $this->body = new StringStream( $params['bodyContents'] ?? '' );
+ $this->serverParams = $params['serverParams'] ?? [];
+ $this->cookieParams = $params['cookieParams'] ?? [];
+ $this->queryParams = $params['queryParams'] ?? [];
+ $this->uploadedFiles = $params['uploadedFiles'] ?? [];
+ $this->postParams = $params['postParams'] ?? [];
+ $this->setAttributes( $params['attributes'] ?? [] );
+ $this->setHeaders( $params['headers'] ?? [] );
+ parent::__construct( $params['cookiePrefix'] ?? '' );
+ }
+
+ public function getMethod() {
+ return $this->method;
+ }
+
+ public function getUri() {
+ return $this->uri;
+ }
+
+ public function getProtocolVersion() {
+ return $this->protocolVersion;
+ }
+
+ public function getBody() {
+ return $this->body;
+ }
+
+ public function getServerParams() {
+ return $this->serverParams;
+ }
+
+ public function getCookieParams() {
+ return $this->cookieParams;
+ }
+
+ public function getQueryParams() {
+ return $this->queryParams;
+ }
+
+ public function getUploadedFiles() {
+ return $this->uploadedFiles;
+ }
+
+ public function getPostParams() {
+ return $this->postParams;
+ }
+}
diff --git a/includes/Rest/RequestFromGlobals.php b/includes/Rest/RequestFromGlobals.php
new file mode 100644
index 000000000000..c73427b1aadc
--- /dev/null
+++ b/includes/Rest/RequestFromGlobals.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+use GuzzleHttp\Psr7\LazyOpenStream;
+use GuzzleHttp\Psr7\ServerRequest;
+use GuzzleHttp\Psr7\Uri;
+
+// phpcs:disable MediaWiki.Usage.SuperGlobalsUsage.SuperGlobals
+
+/**
+ * This is a request class that gets data directly from the superglobals and
+ * other global PHP state, notably php://input.
+ */
+class RequestFromGlobals extends RequestBase {
+ private $uri;
+ private $protocol;
+ private $uploadedFiles;
+
+ /**
+ * @param array $params Associative array of parameters:
+ * - cookiePrefix: The prefix for cookie names used by getCookie()
+ */
+ public function __construct( $params = [] ) {
+ parent::__construct( $params['cookiePrefix'] ?? '' );
+ }
+
+ // RequestInterface
+
+ public function getMethod() {
+ return $_SERVER['REQUEST_METHOD'] ?? 'GET';
+ }
+
+ public function getUri() {
+ if ( $this->uri === null ) {
+ $this->uri = new Uri( \WebRequest::getGlobalRequestURL() );
+ }
+ return $this->uri;
+ }
+
+ // MessageInterface
+
+ public function getProtocolVersion() {
+ if ( $this->protocol === null ) {
+ $serverProtocol = $_SERVER['SERVER_PROTOCOL'] ?? '';
+ $prefixLength = strlen( 'HTTP/' );
+ if ( strncmp( $serverProtocol, 'HTTP/', $prefixLength ) === 0 ) {
+ $this->protocol = substr( $serverProtocol, $prefixLength );
+ } else {
+ $this->protocol = '1.1';
+ }
+ }
+ return $this->protocol;
+ }
+
+ protected function initHeaders() {
+ if ( function_exists( 'apache_request_headers' ) ) {
+ $this->setHeaders( apache_request_headers() );
+ } else {
+ $headers = [];
+ foreach ( $_SERVER as $name => $value ) {
+ if ( substr( $name, 0, 5 ) === 'HTTP_' ) {
+ $name = strtolower( str_replace( '_', '-', substr( $name, 5 ) ) );
+ $headers[$name] = $value;
+ } elseif ( $name === 'CONTENT_LENGTH' ) {
+ $headers['content-length'] = $value;
+ }
+ }
+ $this->setHeaders( $headers );
+ }
+ }
+
+ public function getBody() {
+ return new LazyOpenStream( 'php://input', 'r' );
+ }
+
+ // ServerRequestInterface
+
+ public function getServerParams() {
+ return $_SERVER;
+ }
+
+ public function getCookieParams() {
+ return $_COOKIE;
+ }
+
+ public function getQueryParams() {
+ return $_GET;
+ }
+
+ public function getUploadedFiles() {
+ if ( $this->uploadedFiles === null ) {
+ $this->uploadedFiles = ServerRequest::normalizeFiles( $_FILES );
+ }
+ return $this->uploadedFiles;
+ }
+
+ public function getPostParams() {
+ return $_POST;
+ }
+}
diff --git a/includes/Rest/RequestInterface.php b/includes/Rest/RequestInterface.php
new file mode 100644
index 000000000000..65c72f664e24
--- /dev/null
+++ b/includes/Rest/RequestInterface.php
@@ -0,0 +1,276 @@
+<?php
+
+/**
+ * Copyright (c) 2019 Wikimedia Foundation.
+ *
+ * This file is partly derived from PSR-7, which requires the following copyright notice:
+ *
+ * Copyright (c) 2014 PHP Framework Interoperability Group
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @file
+ */
+
+namespace MediaWiki\Rest;
+
+use Psr\Http\Message\StreamInterface;
+use Psr\Http\Message\UriInterface;
+
+/**
+ * A request interface similar to PSR-7's ServerRequestInterface
+ */
+interface RequestInterface {
+ // RequestInterface
+
+ /**
+ * Retrieves the HTTP method of the request.
+ *
+ * @return string Returns the request method.
+ */
+ function getMethod();
+
+ /**
+ * Retrieves the URI instance.
+ *
+ * This method MUST return a UriInterface instance.
+ *
+ * @link http://tools.ietf.org/html/rfc3986#section-4.3
+ * @return UriInterface Returns a UriInterface instance
+ * representing the URI of the request.
+ */
+ function getUri();
+
+ // MessageInterface
+
+ /**
+ * Retrieves the HTTP protocol version as a string.
+ *
+ * The string MUST contain only the HTTP version number (e.g., "1.1", "1.0").
+ *
+ * @return string HTTP protocol version.
+ */
+ function getProtocolVersion();
+
+ /**
+ * Retrieves all message header values.
+ *
+ * The keys represent the header name as it will be sent over the wire, and
+ * each value is an array of strings associated with the header.
+ *
+ * // Represent the headers as a string
+ * foreach ($message->getHeaders() as $name => $values) {
+ * echo $name . ": " . implode(", ", $values);
+ * }
+ *
+ * // Emit headers iteratively:
+ * foreach ($message->getHeaders() as $name => $values) {
+ * foreach ($values as $value) {
+ * header(sprintf('%s: %s', $name, $value), false);
+ * }
+ * }
+ *
+ * While header names are not case-sensitive, getHeaders() will preserve the
+ * exact case in which headers were originally specified.
+ *
+ * A single header value may be a string containing a comma-separated list.
+ * Lists will not necessarily be split into arrays. See the comment on
+ * HeaderContainer::convertToListAndString().
+ *
+ * @return string[][] Returns an associative array of the message's headers. Each
+ * key MUST be a header name, and each value MUST be an array of strings
+ * for that header.
+ */
+ function getHeaders();
+
+ /**
+ * Retrieves a message header value by the given case-insensitive name.
+ *
+ * This method returns an array of all the header values of the given
+ * case-insensitive header name.
+ *
+ * If the header does not appear in the message, this method MUST return an
+ * empty array.
+ *
+ * A single header value may be a string containing a comma-separated list.
+ * Lists will not necessarily be split into arrays. See the comment on
+ * HeaderContainer::convertToListAndString().
+ *
+ * @param string $name Case-insensitive header field name.
+ * @return string[] An array of string values as provided for the given
+ * header. If the header does not appear in the message, this method MUST
+ * return an empty array.
+ */
+ function getHeader( $name );
+
+ /**
+ * Checks if a header exists by the given case-insensitive name.
+ *
+ * @param string $name Case-insensitive header field name.
+ * @return bool Returns true if any header names match the given header
+ * name using a case-insensitive string comparison. Returns false if
+ * no matching header name is found in the message.
+ */
+ function hasHeader( $name );
+
+ /**
+ * Retrieves a comma-separated string of the values for a single header.
+ *
+ * This method returns all of the header values of the given
+ * case-insensitive header name as a string concatenated together using
+ * a comma.
+ *
+ * NOTE: Not all header values may be appropriately represented using
+ * comma concatenation. For such headers, use getHeader() instead
+ * and supply your own delimiter when concatenating.
+ *
+ * If the header does not appear in the message, this method MUST return
+ * an empty string.
+ *
+ * @param string $name Case-insensitive header field name.
+ * @return string A string of values as provided for the given header
+ * concatenated together using a comma. If the header does not appear in
+ * the message, this method MUST return an empty string.
+ */
+ function getHeaderLine( $name );
+
+ /**
+ * Gets the body of the message.
+ *
+ * @return StreamInterface Returns the body as a stream.
+ */
+ function getBody();
+
+ // ServerRequestInterface
+
+ /**
+ * Retrieve server parameters.
+ *
+ * Retrieves data related to the incoming request environment,
+ * typically derived from PHP's $_SERVER superglobal. The data IS NOT
+ * REQUIRED to originate from $_SERVER.
+ *
+ * @return array
+ */
+ function getServerParams();
+
+ /**
+ * Retrieve cookies.
+ *
+ * Retrieves cookies sent by the client to the server.
+ *
+ * The data MUST be compatible with the structure of the $_COOKIE
+ * superglobal.
+ *
+ * @return array
+ */
+ function getCookieParams();
+
+ /**
+ * Retrieve query string arguments.
+ *
+ * Retrieves the deserialized query string arguments, if any.
+ *
+ * Note: the query params might not be in sync with the URI or server
+ * params. If you need to ensure you are only getting the original
+ * values, you may need to parse the query string from `getUri()->getQuery()`
+ * or from the `QUERY_STRING` server param.
+ *
+ * @return array
+ */
+ function getQueryParams();
+
+ /**
+ * Retrieve normalized file upload data.
+ *
+ * This method returns upload metadata in a normalized tree, with each leaf
+ * an instance of Psr\Http\Message\UploadedFileInterface.
+ *
+ * @return array An array tree of UploadedFileInterface instances; an empty
+ * array MUST be returned if no data is present.
+ */
+ function getUploadedFiles();
+
+ /**
+ * Retrieve attributes derived from the request.
+ *
+ * The request "attributes" may be used to allow injection of any
+ * parameters derived from the request: e.g., the results of path
+ * match operations; the results of decrypting cookies; the results of
+ * deserializing non-form-encoded message bodies; etc. Attributes
+ * will be application and request specific, and CAN be mutable.
+ *
+ * @return array Attributes derived from the request.
+ */
+ function getAttributes();
+
+ /**
+ * Retrieve a single derived request attribute.
+ *
+ * Retrieves a single derived request attribute as described in
+ * getAttributes(). If the attribute has not been previously set, returns
+ * the default value as provided.
+ *
+ * This method obviates the need for a hasAttribute() method, as it allows
+ * specifying a default value to return if the attribute is not found.
+ *
+ * @see getAttributes()
+ * @param string $name The attribute name.
+ * @param mixed|null $default Default value to return if the attribute does not exist.
+ * @return mixed
+ */
+ function getAttribute( $name, $default = null );
+
+ // MediaWiki extensions to PSR-7
+
+ /**
+ * Erase all attributes from the object and set the attribute array to the
+ * specified value
+ *
+ * @param mixed[] $attributes
+ */
+ function setAttributes( $attributes );
+
+ /**
+ * Get the current cookie prefix
+ *
+ * @return string
+ */
+ function getCookiePrefix();
+
+ /**
+ * Add the cookie prefix to a specified cookie name and get the value of
+ * the resulting prefixed cookie. If the cookie does not exist, $default
+ * is returned.
+ *
+ * @param string $name
+ * @param mixed|null $default
+ * @return mixed The cookie value as a string, or $default
+ */
+ function getCookie( $name, $default = null );
+
+ /**
+ * Retrieve POST form parameters.
+ *
+ * This will return an array of parameters in the format of $_POST.
+ *
+ * @return array The deserialized POST parameters
+ */
+ function getPostParams();
+}
diff --git a/includes/Rest/Response.php b/includes/Rest/Response.php
new file mode 100644
index 000000000000..3b0102824609
--- /dev/null
+++ b/includes/Rest/Response.php
@@ -0,0 +1,112 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+use HttpStatus;
+use Psr\Http\Message\StreamInterface;
+
+class Response implements ResponseInterface {
+ /** @var int */
+ private $statusCode = 200;
+
+ /** @var string */
+ private $reasonPhrase = 'OK';
+
+ /** @var string */
+ private $protocolVersion = '1.1';
+
+ /** @var StreamInterface */
+ private $body;
+
+ /** @var HeaderContainer */
+ private $headerContainer;
+
+ /** @var array */
+ private $cookies = [];
+
+ /**
+ * @internal Use ResponseFactory
+ * @param string $bodyContents
+ */
+ public function __construct( $bodyContents = '' ) {
+ $this->body = new StringStream( $bodyContents );
+ $this->headerContainer = new HeaderContainer;
+ }
+
+ public function getStatusCode() {
+ return $this->statusCode;
+ }
+
+ public function getReasonPhrase() {
+ return $this->reasonPhrase;
+ }
+
+ public function setStatus( $code, $reasonPhrase = '' ) {
+ $this->statusCode = $code;
+ if ( $reasonPhrase === '' ) {
+ $reasonPhrase = HttpStatus::getMessage( $code ) ?? '';
+ }
+ $this->reasonPhrase = $reasonPhrase;
+ }
+
+ public function getProtocolVersion() {
+ return $this->protocolVersion;
+ }
+
+ public function getHeaders() {
+ return $this->headerContainer->getHeaders();
+ }
+
+ public function hasHeader( $name ) {
+ return $this->headerContainer->hasHeader( $name );
+ }
+
+ public function getHeader( $name ) {
+ return $this->headerContainer->getHeader( $name );
+ }
+
+ public function getHeaderLine( $name ) {
+ return $this->headerContainer->getHeaderLine( $name );
+ }
+
+ public function getBody() {
+ return $this->body;
+ }
+
+ public function setProtocolVersion( $version ) {
+ $this->protocolVersion = $version;
+ }
+
+ public function setHeader( $name, $value ) {
+ $this->headerContainer->setHeader( $name, $value );
+ }
+
+ public function addHeader( $name, $value ) {
+ $this->headerContainer->addHeader( $name, $value );
+ }
+
+ public function removeHeader( $name ) {
+ $this->headerContainer->removeHeader( $name );
+ }
+
+ public function setBody( StreamInterface $body ) {
+ $this->body = $body;
+ }
+
+ public function getRawHeaderLines() {
+ return $this->headerContainer->getRawHeaderLines();
+ }
+
+ public function setCookie( $name, $value, $expire = 0, $options = [] ) {
+ $this->cookies[] = [
+ 'name' => $name,
+ 'value' => $value,
+ 'expire' => $expire,
+ 'options' => $options
+ ];
+ }
+
+ public function getCookies() {
+ return $this->cookies;
+ }
+}
diff --git a/includes/Rest/ResponseFactory.php b/includes/Rest/ResponseFactory.php
new file mode 100644
index 000000000000..21307bcc44a9
--- /dev/null
+++ b/includes/Rest/ResponseFactory.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+/**
+ * MOCK UP ONLY
+ * @unstable
+ */
+class ResponseFactory {
+ const CT_PLAIN = 'text/plain; charset=utf-8';
+ const CT_JSON = 'application/json';
+
+ public function create404() {
+ $response = new Response( 'Path not found' );
+ $response->setStatus( 404 );
+ $response->setHeader( 'Content-Type', self::CT_PLAIN );
+ return $response;
+ }
+
+ public function create500( $message ) {
+ $response = new Response( $message );
+ $response->setStatus( 500 );
+ $response->setHeader( 'Content-Type', self::CT_PLAIN );
+ return $response;
+ }
+
+ public function createFromException( \Exception $exception ) {
+ if ( $exception instanceof HttpException ) {
+ $response = new Response( $exception->getMessage() );
+ $response->setStatus( $exception->getCode() );
+ $response->setHeader( 'Content-Type', self::CT_PLAIN );
+ return $response;
+ } else {
+ return $this->create500( "Error: exception of type " . gettype( $exception ) );
+ }
+ }
+
+ public function createFromReturnValue( $value ) {
+ if ( is_scalar( $value )
+ || ( is_object( $value ) && method_exists( $value, '__toString' ) )
+ ) {
+ $value = [ 'value' => (string)$value ];
+ }
+ $json = json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
+ if ( $json === false ) {
+ throw new JsonEncodingException( json_last_error_msg(), json_last_error() );
+ }
+ $response = new Response( $json );
+ $response->setHeader( 'Content-Type', self::CT_JSON );
+ return $response;
+ }
+}
diff --git a/includes/Rest/ResponseInterface.php b/includes/Rest/ResponseInterface.php
new file mode 100644
index 000000000000..797b96f4472c
--- /dev/null
+++ b/includes/Rest/ResponseInterface.php
@@ -0,0 +1,277 @@
+<?php
+
+/**
+ * Copyright (c) 2019 Wikimedia Foundation.
+ *
+ * This file is partly derived from PSR-7, which requires the following copyright notice:
+ *
+ * Copyright (c) 2014 PHP Framework Interoperability Group
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @file
+ */
+
+namespace MediaWiki\Rest;
+
+use Psr\Http\Message\StreamInterface;
+
+/**
+ * An interface similar to PSR-7's ResponseInterface, the primary difference
+ * being that it is mutable.
+ */
+interface ResponseInterface {
+ // ResponseInterface
+
+ /**
+ * Gets the response status code.
+ *
+ * The status code is a 3-digit integer result code of the server's attempt
+ * to understand and satisfy the request.
+ *
+ * @return int Status code.
+ */
+ function getStatusCode();
+
+ /**
+ * Gets the response reason phrase associated with the status code.
+ *
+ * Because a reason phrase is not a required element in a response
+ * status line, the reason phrase value MAY be empty. Implementations MAY
+ * choose to return the default RFC 7231 recommended reason phrase (or those
+ * listed in the IANA HTTP Status Code Registry) for the response's
+ * status code.
+ *
+ * @see http://tools.ietf.org/html/rfc7231#section-6
+ * @see http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
+ * @return string Reason phrase; must return an empty string if none present.
+ */
+ function getReasonPhrase();
+
+ // ResponseInterface mutation
+
+ /**
+ * Set the status code and, optionally, reason phrase.
+ *
+ * If no reason phrase is specified, implementations MAY choose to default
+ * to the RFC 7231 or IANA recommended reason phrase for the response's
+ * status code.
+ *
+ * @see http://tools.ietf.org/html/rfc7231#section-6
+ * @see http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
+ * @param int $code The 3-digit integer result code to set.
+ * @param string $reasonPhrase The reason phrase to use with the
+ * provided status code; if none is provided, implementations MAY
+ * use the defaults as suggested in the HTTP specification.
+ * @throws \InvalidArgumentException For invalid status code arguments.
+ */
+ function setStatus( $code, $reasonPhrase = '' );
+
+ // MessageInterface
+
+ /**
+ * Retrieves the HTTP protocol version as a string.
+ *
+ * The string MUST contain only the HTTP version number (e.g., "1.1", "1.0").
+ *
+ * @return string HTTP protocol version.
+ */
+ function getProtocolVersion();
+
+ /**
+ * Retrieves all message header values.
+ *
+ * The keys represent the header name as it will be sent over the wire, and
+ * each value is an array of strings associated with the header.
+ *
+ * // Represent the headers as a string
+ * foreach ($message->getHeaders() as $name => $values) {
+ * echo $name . ': ' . implode(', ', $values);
+ * }
+ *
+ * // Emit headers iteratively:
+ * foreach ($message->getHeaders() as $name => $values) {
+ * foreach ($values as $value) {
+ * header(sprintf('%s: %s', $name, $value), false);
+ * }
+ * }
+ *
+ * While header names are not case-sensitive, getHeaders() will preserve the
+ * exact case in which headers were originally specified.
+ *
+ * @return string[][] Returns an associative array of the message's headers.
+ * Each key MUST be a header name, and each value MUST be an array of
+ * strings for that header.
+ */
+ function getHeaders();
+
+ /**
+ * Checks if a header exists by the given case-insensitive name.
+ *
+ * @param string $name Case-insensitive header field name.
+ * @return bool Returns true if any header names match the given header
+ * name using a case-insensitive string comparison. Returns false if
+ * no matching header name is found in the message.
+ */
+ function hasHeader( $name );
+
+ /**
+ * Retrieves a message header value by the given case-insensitive name.
+ *
+ * This method returns an array of all the header values of the given
+ * case-insensitive header name.
+ *
+ * If the header does not appear in the message, this method MUST return an
+ * empty array.
+ *
+ * @param string $name Case-insensitive header field name.
+ * @return string[] An array of string values as provided for the given
+ * header. If the header does not appear in the message, this method MUST
+ * return an empty array.
+ */
+ function getHeader( $name );
+
+ /**
+ * Retrieves a comma-separated string of the values for a single header.
+ *
+ * This method returns all of the header values of the given
+ * case-insensitive header name as a string concatenated together using
+ * a comma.
+ *
+ * NOTE: Not all header values may be appropriately represented using
+ * comma concatenation. For such headers, use getHeader() instead
+ * and supply your own delimiter when concatenating.
+ *
+ * If the header does not appear in the message, this method MUST return
+ * an empty string.
+ *
+ * @param string $name Case-insensitive header field name.
+ * @return string A string of values as provided for the given header
+ * concatenated together using a comma. If the header does not appear in
+ * the message, this method MUST return an empty string.
+ */
+ function getHeaderLine( $name );
+
+ /**
+ * Gets the body of the message.
+ *
+ * @return StreamInterface Returns the body as a stream.
+ */
+ function getBody();
+
+ // MessageInterface mutation
+
+ /**
+ * Set the HTTP protocol version.
+ *
+ * The version string MUST contain only the HTTP version number (e.g.,
+ * "1.1", "1.0").
+ *
+ * @param string $version HTTP protocol version
+ */
+ function setProtocolVersion( $version );
+
+ /**
+ * Set or replace the specified header.
+ *
+ * While header names are case-insensitive, the casing of the header will
+ * be preserved by this function, and returned from getHeaders().
+ *
+ * @param string $name Case-insensitive header field name.
+ * @param string|string[] $value Header value(s).
+ * @throws \InvalidArgumentException for invalid header names or values.
+ */
+ function setHeader( $name, $value );
+
+ /**
+ * Append the given value to the specified header.
+ *
+ * Existing values for the specified header will be maintained. The new
+ * value(s) will be appended to the existing list. If the header did not
+ * exist previously, it will be added.
+ *
+ * @param string $name Case-insensitive header field name to add.
+ * @param string|string[] $value Header value(s).
+ * @throws \InvalidArgumentException for invalid header names.
+ * @throws \InvalidArgumentException for invalid header values.
+ */
+ function addHeader( $name, $value );
+
+ /**
+ * Remove the specified header.
+ *
+ * Header resolution MUST be done without case-sensitivity.
+ *
+ * @param string $name Case-insensitive header field name to remove.
+ */
+ function removeHeader( $name );
+
+ /**
+ * Set the message body
+ *
+ * The body MUST be a StreamInterface object.
+ *
+ * @param StreamInterface $body Body.
+ * @throws \InvalidArgumentException When the body is not valid.
+ */
+ function setBody( StreamInterface $body );
+
+ // MediaWiki extensions to PSR-7
+
+ /**
+ * Get the full header lines including colon-separated name and value, for
+ * passing directly to header(). Not including the status line.
+ *
+ * @return string[]
+ */
+ function getRawHeaderLines();
+
+ /**
+ * Set a cookie
+ *
+ * The name will have the cookie prefix added to it before it is sent over
+ * the network.
+ *
+ * @param string $name The name of the cookie, not including prefix.
+ * @param string $value The value to be stored in the cookie.
+ * @param int|null $expire Unix timestamp (in seconds) when the cookie should expire.
+ * 0 (the default) causes it to expire $wgCookieExpiration seconds from now.
+ * null causes it to be a session cookie.
+ * @param array $options Assoc of additional cookie options:
+ * prefix: string, name prefix ($wgCookiePrefix)
+ * domain: string, cookie domain ($wgCookieDomain)
+ * path: string, cookie path ($wgCookiePath)
+ * secure: bool, secure attribute ($wgCookieSecure)
+ * httpOnly: bool, httpOnly attribute ($wgCookieHttpOnly)
+ */
+ public function setCookie( $name, $value, $expire = 0, $options = [] );
+
+ /**
+ * Get all previously set cookies as a list of associative arrays with
+ * the following keys:
+ *
+ * - name: The cookie name
+ * - value: The cookie value
+ * - expire: The requested expiry time
+ * - options: An associative array of further options
+ *
+ * @return array
+ */
+ public function getCookies();
+}
diff --git a/includes/Rest/Router.php b/includes/Rest/Router.php
new file mode 100644
index 000000000000..0c458391aede
--- /dev/null
+++ b/includes/Rest/Router.php
@@ -0,0 +1,231 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+use AppendIterator;
+use BagOStuff;
+use MediaWiki\Rest\PathTemplateMatcher\PathMatcher;
+use Wikimedia\ObjectFactory;
+
+/**
+ * The REST router is responsible for gathering handler configuration, matching
+ * an input path and HTTP method against the defined routes, and constructing
+ * and executing the relevant handler for a request.
+ */
+class Router {
+ /** @var string[] */
+ private $routeFiles;
+
+ /** @var array */
+ private $extraRoutes;
+
+ /** @var array|null */
+ private $routesFromFiles;
+
+ /** @var int[]|null */
+ private $routeFileTimestamps;
+
+ /** @var string */
+ private $rootPath;
+
+ /** @var \BagOStuff */
+ private $cacheBag;
+
+ /** @var PathMatcher[]|null Path matchers by method */
+ private $matchers;
+
+ /** @var string|null */
+ private $configHash;
+
+ /** @var ResponseFactory */
+ private $responseFactory;
+
+ /**
+ * @param string[] $routeFiles List of names of JSON files containing routes
+ * @param array $extraRoutes Extension route array
+ * @param string $rootPath The base URL path
+ * @param BagOStuff $cacheBag A cache in which to store the matcher trees
+ * @param ResponseFactory $responseFactory
+ */
+ public function __construct( $routeFiles, $extraRoutes, $rootPath,
+ BagOStuff $cacheBag, ResponseFactory $responseFactory
+ ) {
+ $this->routeFiles = $routeFiles;
+ $this->extraRoutes = $extraRoutes;
+ $this->rootPath = $rootPath;
+ $this->cacheBag = $cacheBag;
+ $this->responseFactory = $responseFactory;
+ }
+
+ /**
+ * Get the cache data, or false if it is missing or invalid
+ *
+ * @return bool|array
+ */
+ private function fetchCacheData() {
+ $cacheData = $this->cacheBag->get( $this->getCacheKey() );
+ if ( $cacheData && $cacheData['CONFIG-HASH'] === $this->getConfigHash() ) {
+ unset( $cacheData['CONFIG-HASH'] );
+ return $cacheData;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * @return string The cache key
+ */
+ private function getCacheKey() {
+ return $this->cacheBag->makeKey( __CLASS__, '1' );
+ }
+
+ /**
+ * Get a config version hash for cache invalidation
+ *
+ * @return string
+ */
+ private function getConfigHash() {
+ if ( $this->configHash === null ) {
+ $this->configHash = md5( json_encode( [
+ $this->extraRoutes,
+ $this->getRouteFileTimestamps()
+ ] ) );
+ }
+ return $this->configHash;
+ }
+
+ /**
+ * Load the defined JSON files and return the merged routes
+ *
+ * @return array
+ */
+ private function getRoutesFromFiles() {
+ if ( $this->routesFromFiles === null ) {
+ $this->routeFileTimestamps = [];
+ foreach ( $this->routeFiles as $fileName ) {
+ $this->routeFileTimestamps[$fileName] = filemtime( $fileName );
+ $routes = json_decode( file_get_contents( $fileName ), true );
+ if ( $this->routesFromFiles === null ) {
+ $this->routesFromFiles = $routes;
+ } else {
+ $this->routesFromFiles = array_merge( $this->routesFromFiles, $routes );
+ }
+ }
+ }
+ return $this->routesFromFiles;
+ }
+
+ /**
+ * Get an array of last modification times of the defined route files.
+ *
+ * @return int[] Last modification times
+ */
+ private function getRouteFileTimestamps() {
+ if ( $this->routeFileTimestamps === null ) {
+ $this->routeFileTimestamps = [];
+ foreach ( $this->routeFiles as $fileName ) {
+ $this->routeFileTimestamps[$fileName] = filemtime( $fileName );
+ }
+ }
+ return $this->routeFileTimestamps;
+ }
+
+ /**
+ * Get an iterator for all defined routes, including loading the routes from
+ * the JSON files.
+ *
+ * @return AppendIterator
+ */
+ 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 ) {
+ $cacheData = $this->fetchCacheData();
+ $matchers = [];
+ if ( $cacheData ) {
+ foreach ( $cacheData as $method => $data ) {
+ $matchers[$method] = PathMatcher::newFromCache( $data );
+ }
+ } else {
+ foreach ( $this->getAllRoutes() 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 );
+ }
+ }
+
+ $cacheData = [ 'CONFIG-HASH' => $this->getConfigHash() ];
+ foreach ( $matchers as $method => $matcher ) {
+ $cacheData[$method] = $matcher->getCacheData();
+ }
+ $this->cacheBag->set( $this->getCacheKey(), $cacheData );
+ }
+ $this->matchers = $matchers;
+ }
+ return $this->matchers;
+ }
+
+ /**
+ * Find the handler for a request and execute it
+ *
+ * @param RequestInterface $request
+ * @return ResponseInterface
+ */
+ public function execute( RequestInterface $request ) {
+ $matchers = $this->getMatchers();
+ $matcher = $matchers[$request->getMethod()] ?? null;
+ if ( $matcher === null ) {
+ return $this->responseFactory->create404();
+ }
+ $path = $request->getUri()->getPath();
+ if ( substr_compare( $path, $this->rootPath, 0, strlen( $this->rootPath ) ) !== 0 ) {
+ return $this->responseFactory->create404();
+ }
+ $relPath = substr( $path, strlen( $this->rootPath ) );
+ $match = $matcher->match( $relPath );
+ if ( !$match ) {
+ return $this->responseFactory->create404();
+ }
+ $request->setAttributes( $match['params'] );
+ $spec = $match['userData'];
+ $objectFactorySpec = array_intersect_key( $spec,
+ [ 'factory' => true, 'class' => true, 'args' => true ] );
+ $handler = ObjectFactory::getObjectFromSpec( $objectFactorySpec );
+ $handler->init( $request, $spec, $this->responseFactory );
+
+ try {
+ return $this->executeHandler( $handler );
+ } catch ( HttpException $e ) {
+ return $this->responseFactory->createFromException( $e );
+ }
+ }
+
+ /**
+ * Execute a fully-constructed handler
+ * @param Handler $handler
+ * @return ResponseInterface
+ */
+ private function executeHandler( $handler ): ResponseInterface {
+ $response = $handler->execute();
+ if ( !( $response instanceof ResponseInterface ) ) {
+ $response = $this->responseFactory->createFromReturnValue( $response );
+ }
+ return $response;
+ }
+}
diff --git a/includes/Rest/SimpleHandler.php b/includes/Rest/SimpleHandler.php
new file mode 100644
index 000000000000..65bc0f59dd26
--- /dev/null
+++ b/includes/Rest/SimpleHandler.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+/**
+ * A handler base class which unpacks attributes from the path template and
+ * passes them as formal parameters to run().
+ *
+ * run() must be declared in the subclass. It cannot be declared as abstract
+ * here because it has a variable parameter list.
+ *
+ * @package MediaWiki\Rest
+ */
+class SimpleHandler extends Handler {
+ public function execute() {
+ $params = array_values( $this->getRequest()->getAttributes() );
+ return $this->run( ...$params );
+ }
+}
diff --git a/includes/Rest/Stream.php b/includes/Rest/Stream.php
new file mode 100644
index 000000000000..1169875923a2
--- /dev/null
+++ b/includes/Rest/Stream.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+use GuzzleHttp\Psr7;
+
+class Stream extends Psr7\Stream implements CopyableStreamInterface {
+ private $stream;
+
+ public function __construct( $stream, $options = [] ) {
+ $this->stream = $stream;
+ parent::__construct( $stream, $options );
+ }
+
+ public function copyToStream( $target ) {
+ stream_copy_to_stream( $this->stream, $target );
+ }
+}
diff --git a/includes/Rest/StringStream.php b/includes/Rest/StringStream.php
new file mode 100644
index 000000000000..18fb6b1812d5
--- /dev/null
+++ b/includes/Rest/StringStream.php
@@ -0,0 +1,139 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+/**
+ * A stream class which uses a string as the underlying storage. Surprisingly,
+ * Guzzle does not appear to have one of these. BufferStream does not do what
+ * we want.
+ *
+ * The normal use of this class should be to first write to the stream, then
+ * rewind, then read back the whole buffer with getContents().
+ *
+ * Seeking is supported, however seeking past the end of the string does not
+ * fill with null bytes as in a real file, it throws an exception instead.
+ */
+class StringStream implements CopyableStreamInterface {
+ private $contents = '';
+ private $offset = 0;
+
+ /**
+ * Construct a StringStream with the given contents.
+ *
+ * The offset will start at 0, ready for reading. If appending to the
+ * given string is desired, you should first seek to the end.
+ *
+ * @param string $contents
+ */
+ public function __construct( $contents = '' ) {
+ $this->contents = $contents;
+ }
+
+ public function copyToStream( $stream ) {
+ if ( $this->offset !== 0 ) {
+ $block = substr( $this->contents, $this->offset );
+ } else {
+ $block = $this->contents;
+ }
+ fwrite( $stream, $block );
+ }
+
+ public function __toString() {
+ return $this->contents;
+ }
+
+ public function close() {
+ }
+
+ public function detach() {
+ return null;
+ }
+
+ public function getSize() {
+ return strlen( $this->contents );
+ }
+
+ public function tell() {
+ return $this->offset;
+ }
+
+ public function eof() {
+ return $this->offset >= strlen( $this->contents );
+ }
+
+ public function isSeekable() {
+ return true;
+ }
+
+ public function seek( $offset, $whence = SEEK_SET ) {
+ switch ( $whence ) {
+ case SEEK_SET:
+ $this->offset = $offset;
+ break;
+
+ case SEEK_CUR:
+ $this->offset += $offset;
+ break;
+
+ case SEEK_END:
+ $this->offset = strlen( $this->contents ) + $offset;
+ break;
+
+ default:
+ throw new \InvalidArgumentException( "Invalid value for \$whence" );
+ }
+ if ( $this->offset > strlen( $this->contents ) ) {
+ throw new \InvalidArgumentException( "Cannot seek beyond the end of a StringStream" );
+ }
+ if ( $this->offset < 0 ) {
+ throw new \InvalidArgumentException( "Cannot seek before the start of a StringStream" );
+ }
+ }
+
+ public function rewind() {
+ $this->offset = 0;
+ }
+
+ public function isWritable() {
+ return true;
+ }
+
+ public function write( $string ) {
+ if ( $this->offset === strlen( $this->contents ) ) {
+ $this->contents .= $string;
+ } else {
+ $this->contents = substr_replace( $this->contents, $string,
+ $this->offset, strlen( $string ) );
+ }
+ $this->offset += strlen( $string );
+ return strlen( $string );
+ }
+
+ public function isReadable() {
+ return true;
+ }
+
+ public function read( $length ) {
+ if ( $this->offset === 0 && $length >= strlen( $this->contents ) ) {
+ $ret = $this->contents;
+ } else {
+ $ret = substr( $this->contents, $this->offset, $length );
+ }
+ $this->offset += strlen( $ret );
+ return $ret;
+ }
+
+ public function getContents() {
+ if ( $this->offset === 0 ) {
+ $ret = $this->contents;
+ } else {
+ $ret = substr( $this->contents, $this->offset );
+ }
+ $this->offset = strlen( $this->contents );
+ return $ret;
+ }
+
+ public function getMetadata( $key = null ) {
+ return null;
+ }
+}
diff --git a/includes/Rest/coreRoutes.json b/includes/Rest/coreRoutes.json
new file mode 100644
index 000000000000..6b440f77593f
--- /dev/null
+++ b/includes/Rest/coreRoutes.json
@@ -0,0 +1,6 @@
+[
+ {
+ "path": "/user/{name}/hello",
+ "class": "MediaWiki\\Rest\\Handler\\HelloHandler"
+ }
+]
diff --git a/includes/Setup.php b/includes/Setup.php
index f367fc2278cf..54e679541486 100644
--- a/includes/Setup.php
+++ b/includes/Setup.php
@@ -143,6 +143,9 @@ if ( $wgScript === false ) {
if ( $wgLoadScript === false ) {
$wgLoadScript = "$wgScriptPath/load.php";
}
+if ( $wgRestPath === false ) {
+ $wgRestPath = "$wgScriptPath/rest.php";
+}
if ( $wgArticlePath === false ) {
if ( $wgUsePathInfo ) {
diff --git a/includes/registration/ExtensionProcessor.php b/includes/registration/ExtensionProcessor.php
index faaaece456d6..e71de849c6fe 100644
--- a/includes/registration/ExtensionProcessor.php
+++ b/includes/registration/ExtensionProcessor.php
@@ -65,6 +65,7 @@ class ExtensionProcessor implements Processor {
protected static $coreAttributes = [
'SkinOOUIThemes',
'TrackingCategories',
+ 'RestRoutes',
];
/**