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