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