From: Tim Starling Date: Thu, 9 May 2019 01:36:18 +0000 (+1000) Subject: REST API initial commit X-Git-Tag: 1.34.0-rc.0~1411^2~2 X-Git-Url: http://git.cyclocoop.org/?a=commitdiff_plain;h=3f0056a252daa4e97396e0eb4b72651f33ce57b3;p=lhc%2Fweb%2Fwiklou.git REST API initial commit 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 --- diff --git a/docs/extension.schema.v2.json b/docs/extension.schema.v2.json index f29f8501b0..e77eca2635 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 fa11bcb1b5..57e434102c 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 ab1afe2109..9bff004809 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -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 index 0000000000..d271db3690 --- /dev/null +++ b/includes/Rest/CopyableStreamInterface.php @@ -0,0 +1,18 @@ +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 0000000000..472e1cc367 --- /dev/null +++ b/includes/Rest/Handler.php @@ -0,0 +1,99 @@ +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 0000000000..6e119dd651 --- /dev/null +++ b/includes/Rest/Handler/HelloHandler.php @@ -0,0 +1,15 @@ + "Hello, $name!" ]; + } +} diff --git a/includes/Rest/HeaderContainer.php b/includes/Rest/HeaderContainer.php new file mode 100644 index 0000000000..50f4355f2b --- /dev/null +++ b/includes/Rest/HeaderContainer.php @@ -0,0 +1,202 @@ +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 0000000000..ae6dde2b3f --- /dev/null +++ b/includes/Rest/HttpException.php @@ -0,0 +1,14 @@ +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 0000000000..69987e0b92 --- /dev/null +++ b/includes/Rest/PathTemplateMatcher/PathMatcher.php @@ -0,0 +1,221 @@ +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 0000000000..cacef62d47 --- /dev/null +++ b/includes/Rest/RequestBase.php @@ -0,0 +1,115 @@ +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 0000000000..1522c6b088 --- /dev/null +++ b/includes/Rest/RequestData.php @@ -0,0 +1,104 @@ +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 0000000000..c73427b1aa --- /dev/null +++ b/includes/Rest/RequestFromGlobals.php @@ -0,0 +1,101 @@ +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 0000000000..65c72f664e --- /dev/null +++ b/includes/Rest/RequestInterface.php @@ -0,0 +1,276 @@ +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 0000000000..3b01028246 --- /dev/null +++ b/includes/Rest/Response.php @@ -0,0 +1,112 @@ +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 0000000000..21307bcc44 --- /dev/null +++ b/includes/Rest/ResponseFactory.php @@ -0,0 +1,52 @@ +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 0000000000..797b96f447 --- /dev/null +++ b/includes/Rest/ResponseInterface.php @@ -0,0 +1,277 @@ +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 0000000000..0c458391ae --- /dev/null +++ b/includes/Rest/Router.php @@ -0,0 +1,231 @@ +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 0000000000..65bc0f59dd --- /dev/null +++ b/includes/Rest/SimpleHandler.php @@ -0,0 +1,19 @@ +getRequest()->getAttributes() ); + return $this->run( ...$params ); + } +} diff --git a/includes/Rest/Stream.php b/includes/Rest/Stream.php new file mode 100644 index 0000000000..1169875923 --- /dev/null +++ b/includes/Rest/Stream.php @@ -0,0 +1,18 @@ +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 0000000000..18fb6b1812 --- /dev/null +++ b/includes/Rest/StringStream.php @@ -0,0 +1,139 @@ +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 0000000000..6b440f7759 --- /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 f367fc2278..54e6795414 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 faaaece456..e71de849c6 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', ]; /**