wikimedia/mediawiki-core

View on GitHub
includes/Rest/ResponseFactory.php

Summary

Maintainability
A
4 hrs
Test Coverage
<?php

namespace MediaWiki\Rest;

use HttpStatus;
use InvalidArgumentException;
use MediaWiki\Language\LanguageCode;
use MWExceptionHandler;
use stdClass;
use Throwable;
use Wikimedia\Message\ITextFormatter;
use Wikimedia\Message\MessageValue;

/**
 * Generates standardized response objects.
 */
class ResponseFactory {
    private const CT_HTML = 'text/html; charset=utf-8';
    private const CT_JSON = 'application/json';

    /** @var ITextFormatter[] */
    private $textFormatters;

    /** @var bool Whether to send exception backtraces to the client */
    private $showExceptionDetails = false;

    /**
     * @param ITextFormatter[] $textFormatters
     */
    public function __construct( $textFormatters ) {
        $this->textFormatters = $textFormatters;
    }

    /**
     * Control whether web responses may include a exception messager and backtrace
     *
     * @see $wgShowExceptionDetails
     * @since 1.39
     * @param bool $showExceptionDetails
     */
    public function setShowExceptionDetails( bool $showExceptionDetails ): void {
        $this->showExceptionDetails = $showExceptionDetails;
    }

    /**
     * Encode a stdClass object or array to a JSON string
     *
     * @param array|stdClass|\JsonSerializable $value
     * @return string
     * @throws JsonEncodingException
     */
    public function encodeJson( $value ) {
        $json = json_encode( $value,
            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE );
        if ( $json === false ) {
            throw new JsonEncodingException( json_last_error_msg(), json_last_error() );
        }
        return $json;
    }

    /**
     * Create an unspecified response. It is the caller's responsibility to set specifics
     * like response code, content type etc.
     * @return Response
     */
    public function create() {
        return new Response();
    }

    /**
     * Create a successful JSON response.
     * @param array|stdClass|\JsonSerializable $value JSON value
     * @param string|null $contentType HTTP content type (should be 'application/json+...')
     *   or null for plain 'application/json'
     * @return Response
     */
    public function createJson( $value, $contentType = null ) {
        $contentType ??= self::CT_JSON;
        $response = new Response( $this->encodeJson( $value ) );
        $response->setHeader( 'Content-Type', $contentType );
        return $response;
    }

    /**
     * Create a 204 (No Content) response, used to indicate that an operation which does
     * not return anything (e.g. a PUT request) was successful.
     *
     * Headers are generally interpreted to refer to the target of the operation. E.g. if
     * this was a PUT request, the caller of this method might want to add an ETag header
     * describing the created resource.
     *
     * @return Response
     */
    public function createNoContent() {
        $response = new Response();
        $response->setStatus( 204 );
        return $response;
    }

    /**
     * Creates a permanent (301) redirect.
     * This indicates that the caller of the API should update their indexes and call
     * the new URL in the future. 301 redirects tend to get cached and are hard to undo.
     * Client behavior for methods other than GET/HEAD is not well-defined and this type
     * of response should be avoided in such cases.
     * @param string $target Redirect target (an absolute URL)
     * @return Response
     */
    public function createPermanentRedirect( $target ) {
        $response = $this->createRedirect( $target, 301 );
        return $response;
    }

    /**
     * Creates a temporary (302) redirect.
     * HTTP 302 was underspecified and has been superseded by 303 (when the redirected request
     * should be a GET, regardless of what the current request is) and 307 (when the method should
     * not be changed), but might still be needed for HTTP 1.0 clients or to match legacy behavior.
     * @param string $target Redirect target (an absolute URL)
     * @return Response
     * @see self::createTemporaryRedirect()
     * @see self::createSeeOther()
     */
    public function createLegacyTemporaryRedirect( $target ) {
        $response = $this->createRedirect( $target, 302 );
        return $response;
    }

    /**
     * Creates a redirect specifying the code.
     * This indicates that the operation the client was trying to perform can temporarily
     * be achieved by using a different URL. Clients will preserve the request method when
     * retrying the request with the new URL.
     * @param string $target Redirect target
     * @param int $code Status code
     * @return Response
     */
    public function createRedirect( $target, $code ) {
        $response = $this->createRedirectBase( $target );
        $response->setStatus( $code );
        return $response;
    }

    /**
     * Creates a temporary (307) redirect.
     * This indicates that the operation the client was trying to perform can temporarily
     * be achieved by using a different URL. Clients will preserve the request method when
     * retrying the request with the new URL.
     * @param string $target Redirect target (an absolute URL)
     * @return Response
     */
    public function createTemporaryRedirect( $target ) {
        $response = $this->createRedirect( $target, 307 );
        return $response;
    }

    /**
     * Creates a See Other (303) redirect.
     * This indicates that the target resource might be of interest to the client, without
     * necessarily implying that it is the same resource. The client will always use GET
     * (or HEAD) when following the redirection. Useful for GET-after-POST.
     * @param string $target Redirect target (an absolute URL)
     * @return Response
     */
    public function createSeeOther( $target ) {
        $response = $this->createRedirect( $target, 303 );
        return $response;
    }

    /**
     * Create a 304 (Not Modified) response, used when the client has an up-to-date cached response.
     *
     * Per RFC 7232 the response should contain all Cache-Control, Content-Location, Date,
     * ETag, Expires, and Vary headers that would have been sent with the 200 OK answer
     * if the requesting client did not have a valid cached response. This is the responsibility
     * of the caller of this method.
     *
     * @return Response
     */
    public function createNotModified() {
        $response = new Response();
        $response->setStatus( 304 );
        return $response;
    }

    /**
     * Create a HTTP 4xx or 5xx response.
     * @param int $errorCode HTTP error code
     * @param array $bodyData An array of data to be included in the JSON response
     * @return Response
     */
    public function createHttpError( $errorCode, array $bodyData = [] ) {
        if ( $errorCode < 400 || $errorCode >= 600 ) {
            throw new InvalidArgumentException( 'error code must be 4xx or 5xx' );
        }
        $response = $this->createJson( $bodyData + [
            'httpCode' => $errorCode,
            'httpReason' => HttpStatus::getMessage( $errorCode )
        ] );
        // TODO add link to error code documentation
        $response->setStatus( $errorCode );
        return $response;
    }

    /**
     * Create an HTTP 4xx or 5xx response with error message localisation
     *
     * @param int $errorCode
     * @param MessageValue $messageValue
     * @param array $extraData An array of additional data to be included in the JSON response
     *
     * @return Response
     */
    public function createLocalizedHttpError(
        $errorCode,
        MessageValue $messageValue,
        array $extraData = []
    ) {
        return $this->createHttpError(
            $errorCode,
            array_merge( $extraData, $this->formatMessage( $messageValue ) )
        );
    }

    /**
     * Turn a throwable into a JSON error response.
     *
     * @param Throwable $exception
     * @param array $extraData if present, used to generate a RESTbase-style response
     * @return Response
     */
    public function createFromException( Throwable $exception, array $extraData = [] ) {
        if ( $exception instanceof LocalizedHttpException ) {
            $response = $this->createLocalizedHttpError(
                $exception->getCode(),
                $exception->getMessageValue(),
                $exception->getErrorData() + $extraData + [
                    'errorKey' => $exception->getErrorKey(),
                ]
            );
        } elseif ( $exception instanceof ResponseException ) {
            return $exception->getResponse();
        } elseif ( $exception instanceof RedirectException ) {
            $response = $this->createRedirect( $exception->getTarget(), $exception->getCode() );
        } elseif ( $exception instanceof HttpException ) {
            if ( in_array( $exception->getCode(), [ 204, 304 ], true ) ) {
                $response = $this->create();
                $response->setStatus( $exception->getCode() );
            } else {
                $response = $this->createHttpError(
                    $exception->getCode(),
                    array_merge(
                        [ 'message' => $exception->getMessage() ],
                        $exception->getErrorData()
                    )
                );
            }
        } elseif ( $this->showExceptionDetails ) {
            $response = $this->createHttpError( 500, [
                'message' => 'Error: exception of type ' . get_class( $exception ) . ': '
                    . $exception->getMessage(),
                'exception' => MWExceptionHandler::getStructuredExceptionData(
                    $exception,
                    MWExceptionHandler::CAUGHT_BY_OTHER
                )
            ] );
            // XXX: should we try to do something useful with ILocalizedException?
            // XXX: should we try to do something useful with common MediaWiki errors like ReadOnlyError?
        } else {
            $response = $this->createHttpError( 500, [
                'message' => 'Error: exception of type ' . get_class( $exception ),
            ] );
        }
        return $response;
    }

    /**
     * Create a JSON response from an arbitrary value.
     * This is a fallback; it's preferable to use createJson() instead.
     * @param mixed $value A structure containing only scalars, arrays and stdClass objects
     * @return Response
     * @throws InvalidArgumentException When $value cannot be reasonably represented as JSON
     */
    public function createFromReturnValue( $value ) {
        $originalValue = $value;
        if ( is_scalar( $value ) ) {
            $data = [ 'value' => $value ];
        } elseif ( is_array( $value ) || $value instanceof stdClass ) {
            $data = $value;
        } else {
            $type = get_debug_type( $originalValue );
            throw new InvalidArgumentException( __METHOD__ . ": Invalid return value type $type" );
        }
        $response = $this->createJson( $data );
        return $response;
    }

    /**
     * Create a redirect response with type / response code unspecified.
     * @param string $target Redirect target (an absolute URL)
     * @return Response
     */
    protected function createRedirectBase( $target ) {
        $response = new Response( $this->getHyperLink( $target ) );
        $response->setHeader( 'Content-Type', self::CT_HTML );
        $response->setHeader( 'Location', $target );
        return $response;
    }

    /**
     * Returns a minimal HTML document that links to the given URL, as suggested by
     * RFC 7231 for 3xx responses.
     * @param string $url An absolute URL
     * @return string
     */
    protected function getHyperLink( $url ) {
        $url = htmlspecialchars( $url, ENT_COMPAT );
        return "<!doctype html><title>Redirect</title><a href=\"$url\">$url</a>";
    }

    public function formatMessage( MessageValue $messageValue ) {
        if ( !$this->textFormatters ) {
            // For unit tests
            return [];
        }
        $translations = [];
        foreach ( $this->textFormatters as $formatter ) {
            $lang = LanguageCode::bcp47( $formatter->getLangCode() );
            $messageText = $formatter->format( $messageValue );
            $translations[$lang] = $messageText;
        }
        return [ 'messageTranslations' => $translations ];
    }

    /**
     * Returns OpenAPI schema response components object,
     * providing information about the structure of some standard responses,
     * for use in path specs.
     *
     * @see https://swagger.io/specification/#components-object
     * @see https://swagger.io/specification/#response-object
     *
     * @return array
     */
    public static function getResponseComponents(): array {
        return [
            'responses' => [
                'GenericErrorResponse' => [
                    'description' => 'Generic error response',
                    'content' => [
                        'application/json' => [
                            'schema' => [
                                '$ref' => '#/components/schemas/GenericErrorResponseModel'
                            ]
                        ],
                    ],
                ]
            ],
            'schemas' => [
                'GenericErrorResponseModel' => [
                    'description' => 'Generic error response body',
                    'required' => [ 'httpCode', 'httpMessage' ],
                    'properties' => [
                        'httpCode' => [
                            'type' => 'integer'
                        ],
                        'httpMessage' => [
                            'type' => 'string'
                        ],
                        'message' => [
                            'type' => 'string'
                        ],
                        'messageTranslations' => [
                            'type' => 'object',
                            'additionalProperties' => [
                                'type' => 'string'
                            ]
                        ],
                    ]
                ]
            ],
        ];
    }

}