REST API initial commit
authorTim Starling <tstarling@wikimedia.org>
Thu, 9 May 2019 01:36:18 +0000 (11:36 +1000)
committerTim Starling <tstarling@wikimedia.org>
Wed, 12 Jun 2019 00:22:28 +0000 (10:22 +1000)
Add some of the basic REST API class hierarchies:

* EntryPoint
* Router
* Request
* Response
* Handler

The actual entry point file rest.php has been moved to a separate
commit, so this is just an unused library and service.

Bug: T221177
Change-Id: Ifca6bcb8a304e8e8b7f52b79c607bdcebf805cd1

26 files changed:
docs/extension.schema.v2.json
includes/AutoLoader.php
includes/DefaultSettings.php
includes/Rest/CopyableStreamInterface.php [new file with mode: 0644]
includes/Rest/EntryPoint.php [new file with mode: 0644]
includes/Rest/Handler.php [new file with mode: 0644]
includes/Rest/Handler/HelloHandler.php [new file with mode: 0644]
includes/Rest/HeaderContainer.php [new file with mode: 0644]
includes/Rest/HttpException.php [new file with mode: 0644]
includes/Rest/JsonEncodingException.php [new file with mode: 0644]
includes/Rest/PathTemplateMatcher/PathConflict.php [new file with mode: 0644]
includes/Rest/PathTemplateMatcher/PathMatcher.php [new file with mode: 0644]
includes/Rest/RequestBase.php [new file with mode: 0644]
includes/Rest/RequestData.php [new file with mode: 0644]
includes/Rest/RequestFromGlobals.php [new file with mode: 0644]
includes/Rest/RequestInterface.php [new file with mode: 0644]
includes/Rest/Response.php [new file with mode: 0644]
includes/Rest/ResponseFactory.php [new file with mode: 0644]
includes/Rest/ResponseInterface.php [new file with mode: 0644]
includes/Rest/Router.php [new file with mode: 0644]
includes/Rest/SimpleHandler.php [new file with mode: 0644]
includes/Rest/Stream.php [new file with mode: 0644]
includes/Rest/StringStream.php [new file with mode: 0644]
includes/Rest/coreRoutes.json [new file with mode: 0644]
includes/Setup.php
includes/registration/ExtensionProcessor.php

index f29f850..e77eca2 100644 (file)
                        "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",
index fa11bcb..57e4341 100644 (file)
@@ -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/',
index ab1afe2..9bff004 100644 (file)
@@ -193,6 +193,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".
@@ -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 (file)
index 0000000..d271db3
--- /dev/null
@@ -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 (file)
index 0000000..d5924f0
--- /dev/null
@@ -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 (file)
index 0000000..472e1cc
--- /dev/null
@@ -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 (file)
index 0000000..6e119dd
--- /dev/null
@@ -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 (file)
index 0000000..50f4355
--- /dev/null
@@ -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 (file)
index 0000000..ae6dde2
--- /dev/null
@@ -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 (file)
index 0000000..e731ac3
--- /dev/null
@@ -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 (file)
index 0000000..dd9f34a
--- /dev/null
@@ -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 (file)
index 0000000..69987e0
--- /dev/null
@@ -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 (file)
index 0000000..cacef62
--- /dev/null
@@ -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 (file)
index 0000000..1522c6b
--- /dev/null
@@ -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 (file)
index 0000000..c73427b
--- /dev/null
@@ -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 (file)
index 0000000..65c72f6
--- /dev/null
@@ -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 (file)
index 0000000..3b01028
--- /dev/null
@@ -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 (file)
index 0000000..21307bc
--- /dev/null
@@ -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 (file)
index 0000000..797b96f
--- /dev/null
@@ -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 (file)
index 0000000..0c45839
--- /dev/null
@@ -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 (file)
index 0000000..65bc0f5
--- /dev/null
@@ -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 (file)
index 0000000..1169875
--- /dev/null
@@ -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 (file)
index 0000000..18fb6b1
--- /dev/null
@@ -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 (file)
index 0000000..6b440f7
--- /dev/null
@@ -0,0 +1,6 @@
+[
+       {
+               "path": "/user/{name}/hello",
+               "class": "MediaWiki\\Rest\\Handler\\HelloHandler"
+       }
+]
index f367fc2..54e6795 100644 (file)
@@ -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 ) {
index faaaece..e71de84 100644 (file)
@@ -65,6 +65,7 @@ class ExtensionProcessor implements Processor {
        protected static $coreAttributes = [
                'SkinOOUIThemes',
                'TrackingCategories',
+               'RestRoutes',
        ];
 
        /**