Localisation updates from https://translatewiki.net.
[mediawiki.git] / includes / http / GuzzleHttpRequest.php
blobf5c343877cf93d13f03b6460b9e8f80eae9ee308
1 <?php
2 /**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
18 * @file
21 use GuzzleHttp\Client;
22 use GuzzleHttp\Handler\CurlHandler;
23 use GuzzleHttp\HandlerStack;
24 use GuzzleHttp\MessageFormatter;
25 use GuzzleHttp\Middleware;
26 use GuzzleHttp\Psr7\Request;
27 use MediaWiki\Status\Status;
28 use Psr\Http\Message\RequestInterface;
29 use Psr\Http\Message\StreamInterface;
30 use Psr\Log\NullLogger;
32 /**
33 * MWHttpRequest implemented using the Guzzle library
35 * @note a new 'sink' option is available as an alternative to callbacks.
36 * See: http://docs.guzzlephp.org/en/stable/request-options.html#sink)
37 * The 'callback' option remains available as well. If both 'sink' and 'callback' are
38 * specified, 'sink' is used.
39 * @note Callers may set a custom handler via the 'handler' option.
40 * If this is not set, Guzzle will use curl (if available) or PHP streams (otherwise)
41 * @note Setting either sslVerifyHost or sslVerifyCert will enable both.
42 * Guzzle does not allow them to be set separately.
44 * @since 1.33
46 class GuzzleHttpRequest extends MWHttpRequest {
47 public const SUPPORTS_FILE_POSTS = true;
49 /** @var callable|null */
50 protected $handler = null;
51 /** @var StreamInterface|null */
52 protected $sink = null;
53 /** @var array */
54 protected $guzzleOptions = [ 'http_errors' => false ];
56 /**
57 * @internal Use HttpRequestFactory
59 * @param string $url Url to use. If protocol-relative, will be expanded to an http:// URL
60 * @param array $options (optional) extra params to pass (see HttpRequestFactory::create())
61 * @param string $caller The method making this request, for profiling @phan-mandatory-param
62 * @param Profiler|null $profiler An instance of the profiler for profiling, or null
63 * @throws Exception
65 public function __construct(
66 $url, array $options = [], $caller = __METHOD__, ?Profiler $profiler = null
67 ) {
68 parent::__construct( $url, $options, $caller, $profiler );
70 if ( isset( $options['handler'] ) ) {
71 $this->handler = $options['handler'];
73 if ( isset( $options['sink'] ) ) {
74 $this->sink = $options['sink'];
78 /**
79 * Set a read callback to accept data read from the HTTP request.
80 * By default, data is appended to an internal buffer which can be
81 * retrieved through $req->getContent().
83 * To handle data as it comes in -- especially for large files that
84 * would not fit in memory -- you can instead set your own callback,
85 * in the form function($resource, $buffer) where the first parameter
86 * is the low-level resource being read (implementation specific),
87 * and the second parameter is the data buffer.
89 * You MUST return the number of bytes handled in the buffer; if fewer
90 * bytes are reported handled than were passed to you, the HTTP fetch
91 * will be aborted.
93 * This function overrides any 'sink' or 'callback' constructor option.
95 * @param callable|null $callback
97 public function setCallback( $callback ) {
98 $this->sink = null;
99 $this->doSetCallback( $callback );
103 * Worker function for setting callbacks. Calls can originate both internally and externally
104 * via setCallback). Defaults to the internal read callback if $callback is null.
106 * If a sink is already specified, this does nothing. This causes the 'sink' constructor
107 * option to override the 'callback' constructor option.
109 * @param callable|null $callback
111 protected function doSetCallback( $callback ) {
112 if ( !$this->sink ) {
113 parent::doSetCallback( $callback );
114 $this->sink = new MWCallbackStream( $this->callback );
119 * @see MWHttpRequest::execute
121 * @return Status
123 public function execute() {
124 $this->prepare();
126 if ( !$this->status->isOK() ) {
127 return Status::wrap( $this->status ); // TODO B/C; move this to callers
130 if ( $this->proxy ) {
131 $this->guzzleOptions['proxy'] = $this->proxy;
134 $this->guzzleOptions['timeout'] = $this->timeout;
135 $this->guzzleOptions['connect_timeout'] = $this->connectTimeout;
136 $this->guzzleOptions['version'] = '1.1';
138 if ( !$this->followRedirects ) {
139 $this->guzzleOptions['allow_redirects'] = false;
140 } else {
141 $this->guzzleOptions['allow_redirects'] = [
142 'max' => $this->maxRedirects
146 if ( $this->method == 'POST' ) {
147 $postData = $this->postData;
148 if ( is_array( $postData ) ) {
149 $this->guzzleOptions['form_params'] = $postData;
150 } else {
151 $this->guzzleOptions['body'] = $postData;
152 // mimic CURLOPT_POST option
153 if ( !isset( $this->reqHeaders['Content-Type'] ) ) {
154 $this->reqHeaders['Content-Type'] = 'application/x-www-form-urlencoded';
158 // Suppress 'Expect: 100-continue' header, as some servers
159 // will reject it with a 417 and Curl won't auto retry
160 // with HTTP 1.0 fallback
161 $this->guzzleOptions['expect'] = false;
164 $stack = HandlerStack::create( $this->handler );
166 // Create Middleware to use cookies from $this->getCookieJar(),
167 // which is in MediaWiki CookieJar format, not in Guzzle-specific CookieJar format.
168 // Note: received cookies (from HTTP response) don't need to be handled here,
169 // they will be added back into the CookieJar by MWHttpRequest::parseCookies().
170 // @phan-suppress-next-line PhanUndeclaredFunctionInCallable
171 $stack->remove( 'cookies' );
172 $mwCookieJar = $this->getCookieJar();
173 $stack->push( Middleware::mapRequest(
174 static function ( RequestInterface $request ) use ( $mwCookieJar ) {
175 $uri = $request->getUri();
176 $cookieHeader = $mwCookieJar->serializeToHttpRequest(
177 $uri->getPath() ?: '/',
178 $uri->getHost()
180 if ( !$cookieHeader ) {
181 return $request;
184 return $request->withHeader( 'Cookie', $cookieHeader );
186 ), 'cookies' );
188 if ( !$this->logger instanceof NullLogger ) {
189 $stack->push( Middleware::log( $this->logger, new MessageFormatter(
190 // TODO {error} will be 'NULL' on success which is unfortunate, but
191 // doesn't seem fixable without a custom formatter. Same for using
192 // PSR-3 variable replacement instead of raw strings.
193 '{method} {uri} HTTP/{version} - {code} {error}'
194 ) ), 'logger' );
197 $this->guzzleOptions['handler'] = $stack;
199 if ( $this->sink ) {
200 $this->guzzleOptions['sink'] = $this->sink;
203 if ( $this->caInfo ) {
204 $this->guzzleOptions['verify'] = $this->caInfo;
205 } elseif ( !$this->sslVerifyHost && !$this->sslVerifyCert ) {
206 $this->guzzleOptions['verify'] = false;
209 $client = new Client( $this->guzzleOptions );
210 $request = new Request( $this->method, $this->url );
211 foreach ( $this->reqHeaders as $name => $value ) {
212 $request = $request->withHeader( $name, $value );
215 try {
216 $response = $client->send( $request );
217 $this->headerList = $response->getHeaders();
219 $this->respVersion = $response->getProtocolVersion();
220 $this->respStatus = $response->getStatusCode() . ' ' . $response->getReasonPhrase();
221 } catch ( GuzzleHttp\Exception\ConnectException $e ) {
222 // ConnectException is thrown for several reasons besides generic "timeout":
223 // Connection refused
224 // couldn't connect to host
225 // connection attempt failed
226 // Could not resolve IPv4 address for host
227 // Could not resolve IPv6 address for host
228 if ( $this->usingCurl() ) {
229 $handlerContext = $e->getHandlerContext();
230 if ( $handlerContext['errno'] == CURLE_OPERATION_TIMEOUTED ) {
231 $this->status->fatal( 'http-timed-out', $this->url );
232 } else {
233 $this->status->fatal( 'http-curl-error', $handlerContext['error'] );
235 } else {
236 $this->status->fatal( 'http-request-error' );
238 } catch ( GuzzleHttp\Exception\RequestException $e ) {
239 if ( $this->usingCurl() ) {
240 $handlerContext = $e->getHandlerContext();
241 $this->status->fatal( 'http-curl-error', $handlerContext['error'] );
242 } else {
243 // Non-ideal, but the only way to identify connection timeout vs other conditions
244 $needle = 'Connection timed out';
245 if ( strpos( $e->getMessage(), $needle ) !== false ) {
246 $this->status->fatal( 'http-timed-out', $this->url );
247 } else {
248 $this->status->fatal( 'http-request-error' );
251 } catch ( GuzzleHttp\Exception\GuzzleException $e ) {
252 $this->status->fatal( 'http-internal-error' );
255 if ( $this->profiler ) {
256 $profileSection = $this->profiler->scopedProfileIn(
257 __METHOD__ . '-' . $this->profileName
261 if ( $this->profiler ) {
262 $this->profiler->scopedProfileOut( $profileSection );
265 $this->parseHeader();
266 $this->setStatus();
268 return Status::wrap( $this->status ); // TODO B/C; move this to callers
271 protected function prepare() {
272 $this->doSetCallback( $this->callback );
273 parent::prepare();
276 protected function usingCurl(): bool {
277 return $this->handler instanceof CurlHandler ||
278 ( !$this->handler && extension_loaded( 'curl' ) );
282 * Guzzle provides headers as an array. Reprocess to match our expectations. Guzzle will
283 * have already parsed and removed the status line (in EasyHandle::createResponse).
285 protected function parseHeader() {
286 // Failure without (valid) headers gets a response status of zero
287 if ( !$this->status->isOK() ) {
288 $this->respStatus = '0 Error';
291 foreach ( $this->headerList as $name => $values ) {
292 $this->respHeaders[strtolower( $name )] = $values;
295 $this->parseCookies();