Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
65.28% |
94 / 144 |
|
71.43% |
15 / 21 |
CRAP | |
0.00% |
0 / 1 |
ResponseFactory | |
65.28% |
94 / 144 |
|
71.43% |
15 / 21 |
98.45 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setShowExceptionDetails | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
encodeJson | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
2.03 | |||
create | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
createJson | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
createNoContent | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
createPermanentRedirect | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
createLegacyTemporaryRedirect | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
createRedirect | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
createTemporaryRedirect | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
createSeeOther | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
createNotModified | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
createHttpError | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
createLocalizedHttpError | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
createFromException | |
77.78% |
28 / 36 |
|
0.00% |
0 / 1 |
7.54 | |||
createFromReturnValue | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
createRedirectBase | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
getHyperLink | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
formatMessage | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
3.02 | |||
getFormattedMessage | |
71.43% |
5 / 7 |
|
0.00% |
0 / 1 |
4.37 | |||
getResponseComponents | |
0.00% |
0 / 37 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Rest; |
4 | |
5 | use InvalidArgumentException; |
6 | use MediaWiki\Exception\MWExceptionHandler; |
7 | use MediaWiki\Language\LanguageCode; |
8 | use stdClass; |
9 | use Throwable; |
10 | use Wikimedia\Http\HttpStatus; |
11 | use Wikimedia\Message\ITextFormatter; |
12 | use Wikimedia\Message\MessageValue; |
13 | |
14 | /** |
15 | * Generates standardized response objects. |
16 | */ |
17 | class 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 | } |