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