Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
59.85% |
82 / 137 |
|
75.00% |
15 / 20 |
CRAP | |
0.00% |
0 / 1 |
ResponseFactory | |
59.85% |
82 / 137 |
|
75.00% |
15 / 20 |
114.26 | |
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 | |
58.33% |
21 / 36 |
|
0.00% |
0 / 1 |
10.54 | |||
createFromReturnValue | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
5 | |||
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 | |||
getResponseComponents | |
0.00% |
0 / 37 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Rest; |
4 | |
5 | use HttpStatus; |
6 | use InvalidArgumentException; |
7 | use LanguageCode; |
8 | use MWExceptionHandler; |
9 | use stdClass; |
10 | use Throwable; |
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 | 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 | } |