Merge "FauxRequest: don’t override getValues()"
[lhc/web/wiklou.git] / includes / Rest / ResponseFactory.php
1 <?php
2
3 namespace MediaWiki\Rest;
4
5 use Exception;
6 use HttpStatus;
7 use InvalidArgumentException;
8 use LanguageCode;
9 use MWExceptionHandler;
10 use stdClass;
11 use Throwable;
12 use Wikimedia\Message\ITextFormatter;
13 use Wikimedia\Message\MessageValue;
14
15 /**
16 * Generates standardized response objects.
17 */
18 class ResponseFactory {
19 const CT_PLAIN = 'text/plain; charset=utf-8';
20 const CT_HTML = 'text/html; charset=utf-8';
21 const CT_JSON = 'application/json';
22
23 /** @var ITextFormatter[] */
24 private $textFormatters;
25
26 /**
27 * @param ITextFormatter[] $textFormatters
28 */
29 public function __construct( $textFormatters ) {
30 $this->textFormatters = $textFormatters;
31 }
32
33 /**
34 * Encode a stdClass object or array to a JSON string
35 *
36 * @param array|stdClass $value
37 * @return string
38 * @throws JsonEncodingException
39 */
40 public function encodeJson( $value ) {
41 $json = json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
42 if ( $json === false ) {
43 throw new JsonEncodingException( json_last_error_msg(), json_last_error() );
44 }
45 return $json;
46 }
47
48 /**
49 * Create an unspecified response. It is the caller's responsibility to set specifics
50 * like response code, content type etc.
51 * @return Response
52 */
53 public function create() {
54 return new Response();
55 }
56
57 /**
58 * Create a successful JSON response.
59 * @param array|stdClass $value JSON value
60 * @param string|null $contentType HTTP content type (should be 'application/json+...')
61 * or null for plain 'application/json'
62 * @return Response
63 */
64 public function createJson( $value, $contentType = null ) {
65 $contentType = $contentType ?? self::CT_JSON;
66 $response = new Response( $this->encodeJson( $value ) );
67 $response->setHeader( 'Content-Type', $contentType );
68 return $response;
69 }
70
71 /**
72 * Create a 204 (No Content) response, used to indicate that an operation which does
73 * not return anything (e.g. a PUT request) was successful.
74 *
75 * Headers are generally interpreted to refer to the target of the operation. E.g. if
76 * this was a PUT request, the caller of this method might want to add an ETag header
77 * describing the created resource.
78 *
79 * @return Response
80 */
81 public function createNoContent() {
82 $response = new Response();
83 $response->setStatus( 204 );
84 return $response;
85 }
86
87 /**
88 * Creates a permanent (301) redirect.
89 * This indicates that the caller of the API should update their indexes and call
90 * the new URL in the future. 301 redirects tend to get cached and are hard to undo.
91 * Client behavior for methods other than GET/HEAD is not well-defined and this type
92 * of response should be avoided in such cases.
93 * @param string $target Redirect target (an absolute URL)
94 * @return Response
95 */
96 public function createPermanentRedirect( $target ) {
97 $response = $this->createRedirectBase( $target );
98 $response->setStatus( 301 );
99 return $response;
100 }
101
102 /**
103 * Creates a temporary (302) redirect.
104 * HTTP 302 was underspecified and has been superseded by 303 (when the redirected request
105 * should be a GET, regardless of what the current request is) and 307 (when the method should
106 * not be changed), but might still be needed for HTTP 1.0 clients or to match legacy behavior.
107 * @param string $target Redirect target (an absolute URL)
108 * @return Response
109 * @see self::createTemporaryRedirect()
110 * @see self::createSeeOther()
111 */
112 public function createLegacyTemporaryRedirect( $target ) {
113 $response = $this->createRedirectBase( $target );
114 $response->setStatus( 302 );
115 return $response;
116 }
117
118 /**
119 * Creates a temporary (307) redirect.
120 * This indicates that the operation the client was trying to perform can temporarily
121 * be achieved by using a different URL. Clients will preserve the request method when
122 * retrying the request with the new URL.
123 * @param string $target Redirect target (an absolute URL)
124 * @return Response
125 */
126 public function createTemporaryRedirect( $target ) {
127 $response = $this->createRedirectBase( $target );
128 $response->setStatus( 307 );
129 return $response;
130 }
131
132 /**
133 * Creates a See Other (303) redirect.
134 * This indicates that the target resource might be of interest to the client, without
135 * necessarily implying that it is the same resource. The client will always use GET
136 * (or HEAD) when following the redirection. Useful for GET-after-POST.
137 * @param string $target Redirect target (an absolute URL)
138 * @return Response
139 */
140 public function createSeeOther( $target ) {
141 $response = $this->createRedirectBase( $target );
142 $response->setStatus( 303 );
143 return $response;
144 }
145
146 /**
147 * Create a 304 (Not Modified) response, used when the client has an up-to-date cached response.
148 *
149 * Per RFC 7232 the response should contain all Cache-Control, Content-Location, Date,
150 * ETag, Expires, and Vary headers that would have been sent with the 200 OK answer
151 * if the requesting client did not have a valid cached response. This is the responsibility
152 * of the caller of this method.
153 *
154 * @return Response
155 */
156 public function createNotModified() {
157 $response = new Response();
158 $response->setStatus( 304 );
159 return $response;
160 }
161
162 /**
163 * Create a HTTP 4xx or 5xx response.
164 * @param int $errorCode HTTP error code
165 * @param array $bodyData An array of data to be included in the JSON response
166 * @return Response
167 * @throws InvalidArgumentException
168 */
169 public function createHttpError( $errorCode, array $bodyData = [] ) {
170 if ( $errorCode < 400 || $errorCode >= 600 ) {
171 throw new InvalidArgumentException( 'error code must be 4xx or 5xx' );
172 }
173 $response = $this->createJson( $bodyData + [
174 'httpCode' => $errorCode,
175 'httpReason' => HttpStatus::getMessage( $errorCode )
176 ] );
177 // TODO add link to error code documentation
178 $response->setStatus( $errorCode );
179 return $response;
180 }
181
182 /**
183 * Create an HTTP 4xx or 5xx response with error message localisation
184 */
185 public function createLocalizedHttpError( $errorCode, MessageValue $messageValue ) {
186 return $this->createHttpError( $errorCode, $this->formatMessage( $messageValue ) );
187 }
188
189 /**
190 * Turn an exception into a JSON error response.
191 * @param Exception|Throwable $exception
192 * @return Response
193 */
194 public function createFromException( $exception ) {
195 if ( $exception instanceof LocalizedHttpException ) {
196 $response = $this->createLocalizedHttpError( $exception->getCode(),
197 $exception->getMessageValue() );
198 } elseif ( $exception instanceof HttpException ) {
199 // FIXME can HttpException represent 2xx or 3xx responses?
200 $response = $this->createHttpError(
201 $exception->getCode(),
202 array_merge(
203 [ 'message' => $exception->getMessage() ],
204 (array)$exception->getErrorData()
205 )
206 );
207 } else {
208 $response = $this->createHttpError( 500, [
209 'message' => 'Error: exception of type ' . get_class( $exception ),
210 'exception' => MWExceptionHandler::getStructuredExceptionData( $exception )
211 ] );
212 // FIXME should we try to do something useful with ILocalizedException?
213 // FIXME should we try to do something useful with common MediaWiki errors like ReadOnlyError?
214 }
215 return $response;
216 }
217
218 /**
219 * Create a JSON response from an arbitrary value.
220 * This is a fallback; it's preferable to use createJson() instead.
221 * @param mixed $value A structure containing only scalars, arrays and stdClass objects
222 * @return Response
223 * @throws InvalidArgumentException When $value cannot be reasonably represented as JSON
224 */
225 public function createFromReturnValue( $value ) {
226 $originalValue = $value;
227 if ( is_scalar( $value ) ) {
228 $data = [ 'value' => $value ];
229 } elseif ( is_array( $value ) || $value instanceof stdClass ) {
230 $data = $value;
231 } else {
232 $type = gettype( $originalValue );
233 if ( $type === 'object' ) {
234 $type = get_class( $originalValue );
235 }
236 throw new InvalidArgumentException( __METHOD__ . ": Invalid return value type $type" );
237 }
238 $response = $this->createJson( $data );
239 return $response;
240 }
241
242 /**
243 * Create a redirect response with type / response code unspecified.
244 * @param string $target Redirect target (an absolute URL)
245 * @return Response
246 */
247 protected function createRedirectBase( $target ) {
248 $response = new Response( $this->getHyperLink( $target ) );
249 $response->setHeader( 'Content-Type', self::CT_HTML );
250 $response->setHeader( 'Location', $target );
251 return $response;
252 }
253
254 /**
255 * Returns a minimal HTML document that links to the given URL, as suggested by
256 * RFC 7231 for 3xx responses.
257 * @param string $url An absolute URL
258 * @return string
259 */
260 protected function getHyperLink( $url ) {
261 $url = htmlspecialchars( $url );
262 return "<!doctype html><title>Redirect</title><a href=\"$url\">$url</a>";
263 }
264
265 public function formatMessage( MessageValue $messageValue ) {
266 if ( !$this->textFormatters ) {
267 // For unit tests
268 return [];
269 }
270 $translations = [];
271 foreach ( $this->textFormatters as $formatter ) {
272 $lang = LanguageCode::bcp47( $formatter->getLangCode() );
273 $messageText = $formatter->format( $messageValue );
274 $translations[$lang] = $messageText;
275 }
276 return [ 'messageTranslations' => $translations ];
277 }
278
279 }