Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
65.28% covered (warning)
65.28%
94 / 144
71.43% covered (warning)
71.43%
15 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
ResponseFactory
65.28% covered (warning)
65.28%
94 / 144
71.43% covered (warning)
71.43%
15 / 21
98.45
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setShowExceptionDetails
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 encodeJson
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 create
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createJson
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 createNoContent
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 createPermanentRedirect
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 createLegacyTemporaryRedirect
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 createRedirect
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 createTemporaryRedirect
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 createSeeOther
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 createNotModified
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 createHttpError
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 createLocalizedHttpError
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 createFromException
77.78% covered (warning)
77.78%
28 / 36
0.00% covered (danger)
0.00%
0 / 1
7.54
 createFromReturnValue
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 createRedirectBase
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getHyperLink
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 formatMessage
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 getFormattedMessage
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 getResponseComponents
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Rest;
4
5use InvalidArgumentException;
6use MediaWiki\Exception\MWExceptionHandler;
7use MediaWiki\Language\LanguageCode;
8use stdClass;
9use Throwable;
10use Wikimedia\Http\HttpStatus;
11use Wikimedia\Message\ITextFormatter;
12use Wikimedia\Message\MessageValue;
13
14/**
15 * Generates standardized response objects.
16 */
17class ResponseFactory {
18    private const CT_HTML = 'text/html; charset=utf-8';
19    private const CT_JSON = 'application/json';
20
21    /** @var ITextFormatter[] */
22    private $textFormatters;
23
24    /** @var bool Whether to send exception backtraces to the client */
25    private $showExceptionDetails = false;
26
27    /**
28     * @param ITextFormatter[] $textFormatters
29     *
30     * If there is a relative preference among the input text formatters, the formatters should
31     * be ordered from most to least preferred.
32     */
33    public function __construct( $textFormatters ) {
34        $this->textFormatters = $textFormatters;
35    }
36
37    /**
38     * Control whether web responses may include a exception messager and backtrace
39     *
40     * @see $wgShowExceptionDetails
41     * @since 1.39
42     * @param bool $showExceptionDetails
43     */
44    public function setShowExceptionDetails( bool $showExceptionDetails ): void {
45        $this->showExceptionDetails = $showExceptionDetails;
46    }
47
48    /**
49     * Encode a stdClass object or array to a JSON string
50     *
51     * @param array|stdClass|\JsonSerializable $value
52     * @return string
53     * @throws JsonEncodingException
54     */
55    public function encodeJson( $value ) {
56        $json = json_encode( $value,
57            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE );
58        if ( $json === false ) {
59            throw new JsonEncodingException( json_last_error_msg(), json_last_error() );
60        }
61        return $json;
62    }
63
64    /**
65     * Create an unspecified response. It is the caller's responsibility to set specifics
66     * like response code, content type etc.
67     * @return Response
68     */
69    public function create() {
70        return new Response();
71    }
72
73    /**
74     * Create a successful JSON response.
75     * @param array|stdClass|\JsonSerializable $value JSON value
76     * @param string|null $contentType HTTP content type (should be 'application/json+...')
77     *   or null for plain 'application/json'
78     * @return Response
79     */
80    public function createJson( $value, $contentType = null ) {
81        $contentType ??= self::CT_JSON;
82        $response = new Response( $this->encodeJson( $value ) );
83        $response->setHeader( 'Content-Type', $contentType );
84        return $response;
85    }
86
87    /**
88     * Create a 204 (No Content) response, used to indicate that an operation which does
89     * not return anything (e.g. a PUT request) was successful.
90     *
91     * Headers are generally interpreted to refer to the target of the operation. E.g. if
92     * this was a PUT request, the caller of this method might want to add an ETag header
93     * describing the created resource.
94     *
95     * @return Response
96     */
97    public function createNoContent() {
98        $response = new Response();
99        $response->setStatus( 204 );
100        return $response;
101    }
102
103    /**
104     * Creates a permanent (301) redirect.
105     * This indicates that the caller of the API should update their indexes and call
106     * the new URL in the future. 301 redirects tend to get cached and are hard to undo.
107     * Client behavior for methods other than GET/HEAD is not well-defined and this type
108     * of response should be avoided in such cases.
109     * @param string $target Redirect target (an absolute URL)
110     * @return Response
111     */
112    public function createPermanentRedirect( $target ) {
113        $response = $this->createRedirect( $target, 301 );
114        return $response;
115    }
116
117    /**
118     * Creates a temporary (302) redirect.
119     * HTTP 302 was underspecified and has been superseded by 303 (when the redirected request
120     * should be a GET, regardless of what the current request is) and 307 (when the method should
121     * not be changed), but might still be needed for HTTP 1.0 clients or to match legacy behavior.
122     * @param string $target Redirect target (an absolute URL)
123     * @return Response
124     * @see self::createTemporaryRedirect()
125     * @see self::createSeeOther()
126     */
127    public function createLegacyTemporaryRedirect( $target ) {
128        $response = $this->createRedirect( $target, 302 );
129        return $response;
130    }
131
132    /**
133     * Creates a redirect specifying the code.
134     * This indicates that the operation the client was trying to perform can temporarily
135     * be achieved by using a different URL. Clients will preserve the request method when
136     * retrying the request with the new URL.
137     * @param string $target Redirect target
138     * @param int $code Status code
139     * @return Response
140     */
141    public function createRedirect( $target, $code ) {
142        $response = $this->createRedirectBase( $target );
143        $response->setStatus( $code );
144        return $response;
145    }
146
147    /**
148     * Creates a temporary (307) redirect.
149     * This indicates that the operation the client was trying to perform can temporarily
150     * be achieved by using a different URL. Clients will preserve the request method when
151     * retrying the request with the new URL.
152     * @param string $target Redirect target (an absolute URL)
153     * @return Response
154     */
155    public function createTemporaryRedirect( $target ) {
156        $response = $this->createRedirect( $target, 307 );
157        return $response;
158    }
159
160    /**
161     * Creates a See Other (303) redirect.
162     * This indicates that the target resource might be of interest to the client, without
163     * necessarily implying that it is the same resource. The client will always use GET
164     * (or HEAD) when following the redirection. Useful for GET-after-POST.
165     * @param string $target Redirect target (an absolute URL)
166     * @return Response
167     */
168    public function createSeeOther( $target ) {
169        $response = $this->createRedirect( $target, 303 );
170        return $response;
171    }
172
173    /**
174     * Create a 304 (Not Modified) response, used when the client has an up-to-date cached response.
175     *
176     * Per RFC 7232 the response should contain all Cache-Control, Content-Location, Date,
177     * ETag, Expires, and Vary headers that would have been sent with the 200 OK answer
178     * if the requesting client did not have a valid cached response. This is the responsibility
179     * of the caller of this method.
180     *
181     * @return Response
182     */
183    public function createNotModified() {
184        $response = new Response();
185        $response->setStatus( 304 );
186        return $response;
187    }
188
189    /**
190     * Create a HTTP 4xx or 5xx response.
191     * @param int $errorCode HTTP error code
192     * @param array $bodyData An array of data to be included in the JSON response
193     * @return Response
194     */
195    public function createHttpError( $errorCode, array $bodyData = [] ) {
196        if ( $errorCode < 400 || $errorCode >= 600 ) {
197            throw new InvalidArgumentException( 'error code must be 4xx or 5xx' );
198        }
199        $response = $this->createJson( $bodyData + [
200            'httpCode' => $errorCode,
201            'httpReason' => HttpStatus::getMessage( $errorCode )
202        ] );
203        // TODO add link to error code documentation
204        $response->setStatus( $errorCode );
205        return $response;
206    }
207
208    /**
209     * Create an HTTP 4xx or 5xx response with error message localisation
210     *
211     * @param int $errorCode
212     * @param MessageValue $messageValue
213     * @param array $extraData An array of additional data to be included in the JSON response
214     *
215     * @return Response
216     */
217    public function createLocalizedHttpError(
218        $errorCode,
219        MessageValue $messageValue,
220        array $extraData = []
221    ) {
222        return $this->createHttpError(
223            $errorCode,
224            array_merge( $extraData, $this->formatMessage( $messageValue ) )
225        );
226    }
227
228    /**
229     * Turn a throwable into a JSON error response.
230     *
231     * @param Throwable $exception
232     * @param array $extraData if present, used to generate a RESTbase-style response
233     * @return Response
234     */
235    public function createFromException( Throwable $exception, array $extraData = [] ) {
236        if ( $exception instanceof LocalizedHttpException ) {
237            $response = $this->createLocalizedHttpError(
238                $exception->getCode(),
239                $exception->getMessageValue(),
240                $exception->getErrorData() + $extraData + [
241                    'errorKey' => $exception->getErrorKey(),
242                ]
243            );
244        } elseif ( $exception instanceof ResponseException ) {
245            return $exception->getResponse();
246        } elseif ( $exception instanceof RedirectException ) {
247            $response = $this->createRedirect( $exception->getTarget(), $exception->getCode() );
248        } elseif ( $exception instanceof HttpException ) {
249            if ( in_array( $exception->getCode(), [ 204, 304 ], true ) ) {
250                $response = $this->create();
251                $response->setStatus( $exception->getCode() );
252            } else {
253                $response = $this->createHttpError(
254                    $exception->getCode(),
255                    array_merge(
256                        [ 'message' => $exception->getMessage() ],
257                        $exception->getErrorData()
258                    )
259                );
260            }
261        } elseif ( $this->showExceptionDetails ) {
262            $response = $this->createHttpError( 500, [
263                'message' => 'Error: exception of type ' . get_class( $exception ) . ': '
264                    . $exception->getMessage(),
265                'exception' => MWExceptionHandler::getStructuredExceptionData(
266                    $exception,
267                    MWExceptionHandler::CAUGHT_BY_OTHER
268                )
269            ] );
270            // XXX: should we try to do something useful with ILocalizedException?
271            // XXX: should we try to do something useful with common MediaWiki errors like ReadOnlyError?
272        } else {
273            $response = $this->createHttpError( 500, [
274                'message' => 'Error: exception of type ' . get_class( $exception ),
275            ] );
276        }
277        return $response;
278    }
279
280    /**
281     * Create a JSON response from an arbitrary value.
282     * This is a fallback; it's preferable to use createJson() instead.
283     * @param mixed $value A structure containing only scalars, arrays and stdClass objects
284     * @return Response
285     * @throws InvalidArgumentException When $value cannot be reasonably represented as JSON
286     */
287    public function createFromReturnValue( $value ) {
288        $originalValue = $value;
289        if ( is_scalar( $value ) ) {
290            $data = [ 'value' => $value ];
291        } elseif ( is_array( $value ) || $value instanceof stdClass ) {
292            $data = $value;
293        } else {
294            $type = get_debug_type( $originalValue );
295            throw new InvalidArgumentException( __METHOD__ . ": Invalid return value type $type" );
296        }
297        $response = $this->createJson( $data );
298        return $response;
299    }
300
301    /**
302     * Create a redirect response with type / response code unspecified.
303     * @param string $target Redirect target (an absolute URL)
304     * @return Response
305     */
306    protected function createRedirectBase( $target ) {
307        $response = new Response( $this->getHyperLink( $target ) );
308        $response->setHeader( 'Content-Type', self::CT_HTML );
309        $response->setHeader( 'Location', $target );
310        return $response;
311    }
312
313    /**
314     * Returns a minimal HTML document that links to the given URL, as suggested by
315     * RFC 7231 for 3xx responses.
316     * @param string $url An absolute URL
317     * @return string
318     */
319    protected function getHyperLink( $url ) {
320        $url = htmlspecialchars( $url, ENT_COMPAT );
321        return "<!doctype html><title>Redirect</title><a href=\"$url\">$url</a>";
322    }
323
324    /**
325     * Tries to return the formatted string(s) for a message value object using the
326     * response factory's text formatters. The returned array will either be empty (if there are
327     * no text formatters), or have exactly one key, "messageTranslations", whose value
328     * is an array of formatted strings, keyed by the associated language code.
329     *
330     * @param MessageValue $messageValue the message value object to format
331     *
332     * @return array
333     */
334    public function formatMessage( MessageValue $messageValue ): array {
335        if ( !$this->textFormatters ) {
336            // For unit tests
337            return [];
338        }
339        $translations = [];
340        foreach ( $this->textFormatters as $formatter ) {
341            $lang = LanguageCode::bcp47( $formatter->getLangCode() );
342            $messageText = $formatter->format( $messageValue );
343            $translations[$lang] = $messageText;
344        }
345        return [ 'messageTranslations' => $translations ];
346    }
347
348    /**
349     * Tries to return one formatted string for a message value object. Return value will be:
350     *   1) the formatted string for $preferredLang, if $preferredLang is supplied and the
351     *      formatted string for that language is available.
352     *   2) the first available formatted string, if any are available.
353     *   3) the message key string, if no formatted strings are available.
354     * Callers who need more specific control should call formatMessage() instead.
355     *
356     * @param MessageValue $messageValue the message value object to format
357     * @param string $preferredlang preferred language for the formatted string, if available
358     *
359     * @return string
360     */
361    public function getFormattedMessage(
362        MessageValue $messageValue, string $preferredlang = ''
363    ): string {
364        $strings = $this->formatMessage( $messageValue );
365        if ( !$strings ) {
366            return $messageValue->getKey();
367        }
368
369        $strings = $strings['messageTranslations'];
370        if ( $preferredlang && array_key_exists( $preferredlang, $strings ) ) {
371            return $strings[ $preferredlang ];
372        } else {
373            return reset( $strings );
374        }
375    }
376
377    /**
378     * Returns OpenAPI schema response components object,
379     * providing information about the structure of some standard responses,
380     * for use in path specs.
381     *
382     * @see https://swagger.io/specification/#components-object
383     * @see https://swagger.io/specification/#response-object
384     *
385     * @return array
386     */
387    public static function getResponseComponents(): array {
388        return [
389            'responses' => [
390                'GenericErrorResponse' => [
391                    'description' => 'Generic error response',
392                    'content' => [
393                        'application/json' => [
394                            'schema' => [
395                                '$ref' => '#/components/schemas/GenericErrorResponseModel'
396                            ]
397                        ],
398                    ],
399                ]
400            ],
401            'schemas' => [
402                'GenericErrorResponseModel' => [
403                    'description' => 'Generic error response body',
404                    'required' => [ 'httpCode' ],
405                    'properties' => [
406                        'httpCode' => [
407                            'type' => 'integer'
408                        ],
409                        'httpMessage' => [
410                            'type' => 'string'
411                        ],
412                        'message' => [
413                            'type' => 'string'
414                        ],
415                        'messageTranslations' => [
416                            'type' => 'object',
417                            'additionalProperties' => [
418                                'type' => 'string'
419                            ]
420                        ],
421                    ]
422                ]
423            ],
424        ];
425    }
426
427}