diff options
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', ]; /** |