MediaWiki  master
GuzzleHttpRequest.php
Go to the documentation of this file.
1 <?php
21 use GuzzleHttp\Client;
22 use GuzzleHttp\HandlerStack;
23 use GuzzleHttp\MessageFormatter;
24 use GuzzleHttp\Middleware;
25 use GuzzleHttp\Psr7\Request;
27 use Psr\Http\Message\RequestInterface;
28 use Psr\Log\NullLogger;
29 
45  public const SUPPORTS_FILE_POSTS = true;
46 
47  protected $handler = null;
48  protected $sink = null;
50  protected $guzzleOptions = [ 'http_errors' => false ];
51 
61  public function __construct(
62  $url, array $options = [], $caller = __METHOD__, Profiler $profiler = null
63  ) {
64  parent::__construct( $url, $options, $caller, $profiler );
65 
66  if ( isset( $options['handler'] ) ) {
67  $this->handler = $options['handler'];
68  }
69  if ( isset( $options['sink'] ) ) {
70  $this->sink = $options['sink'];
71  }
72  }
73 
94  public function setCallback( $callback ) {
95  $this->sink = null;
96  $this->doSetCallback( $callback );
97  }
98 
109  protected function doSetCallback( $callback ) {
110  if ( !$this->sink ) {
111  parent::doSetCallback( $callback );
112  $this->sink = new MWCallbackStream( $this->callback );
113  }
114  }
115 
121  public function execute() {
122  $this->prepare();
123 
124  if ( !$this->status->isOK() ) {
125  return Status::wrap( $this->status ); // TODO B/C; move this to callers
126  }
127 
128  if ( $this->proxy ) {
129  $this->guzzleOptions['proxy'] = $this->proxy;
130  }
131 
132  $this->guzzleOptions['timeout'] = $this->timeout;
133  $this->guzzleOptions['connect_timeout'] = $this->connectTimeout;
134  $this->guzzleOptions['version'] = '1.1';
135 
136  if ( !$this->followRedirects ) {
137  $this->guzzleOptions['allow_redirects'] = false;
138  } else {
139  $this->guzzleOptions['allow_redirects'] = [
140  'max' => $this->maxRedirects
141  ];
142  }
143 
144  if ( $this->method == 'POST' ) {
146  if ( is_array( $postData ) ) {
147  $this->guzzleOptions['form_params'] = $postData;
148  } else {
149  $this->guzzleOptions['body'] = $postData;
150  // mimic CURLOPT_POST option
151  if ( !isset( $this->reqHeaders['Content-Type'] ) ) {
152  $this->reqHeaders['Content-Type'] = 'application/x-www-form-urlencoded';
153  }
154  }
155 
156  // Suppress 'Expect: 100-continue' header, as some servers
157  // will reject it with a 417 and Curl won't auto retry
158  // with HTTP 1.0 fallback
159  $this->guzzleOptions['expect'] = false;
160  }
161 
162  $stack = HandlerStack::create( $this->handler );
163 
164  // Create Middleware to use cookies from $this->getCookieJar(),
165  // which is in MediaWiki CookieJar format, not in Guzzle-specific CookieJar format.
166  // Note: received cookies (from HTTP response) don't need to be handled here,
167  // they will be added back into the CookieJar by MWHttpRequest::parseCookies().
168  // @phan-suppress-next-line PhanUndeclaredFunctionInCallable
169  $stack->remove( 'cookies' );
170  $mwCookieJar = $this->getCookieJar();
171  $stack->push( Middleware::mapRequest(
172  static function ( RequestInterface $request ) use ( $mwCookieJar ) {
173  $uri = $request->getUri();
174  $cookieHeader = $mwCookieJar->serializeToHttpRequest(
175  $uri->getPath() ?: '/',
176  $uri->getHost()
177  );
178  if ( !$cookieHeader ) {
179  return $request;
180  }
181 
182  return $request->withHeader( 'Cookie', $cookieHeader );
183  }
184  ), 'cookies' );
185 
186  if ( !$this->logger instanceof NullLogger ) {
187  $stack->push( Middleware::log( $this->logger, new MessageFormatter(
188  // TODO {error} will be 'NULL' on success which is unfortunate, but
189  // doesn't seem fixable without a custom formatter. Same for using
190  // PSR-3 variable replacement instead of raw strings.
191  '[{ts}] {method} {uri} HTTP/{version} - {code} {error}'
192  ) ), 'logger' );
193  }
194 
195  $this->guzzleOptions['handler'] = $stack;
196 
197  if ( $this->sink ) {
198  $this->guzzleOptions['sink'] = $this->sink;
199  }
200 
201  if ( $this->caInfo ) {
202  $this->guzzleOptions['verify'] = $this->caInfo;
203  } elseif ( !$this->sslVerifyHost && !$this->sslVerifyCert ) {
204  $this->guzzleOptions['verify'] = false;
205  }
206 
207  $client = new Client( $this->guzzleOptions );
208  $request = new Request( $this->method, $this->url );
209  foreach ( $this->reqHeaders as $name => $value ) {
210  $request = $request->withHeader( $name, $value );
211  }
212 
213  try {
214  $response = $client->send( $request );
215  $this->headerList = $response->getHeaders();
216 
217  $this->respVersion = $response->getProtocolVersion();
218  $this->respStatus = $response->getStatusCode() . ' ' . $response->getReasonPhrase();
219  } catch ( GuzzleHttp\Exception\ConnectException $e ) {
220  // ConnectException is thrown for several reasons besides generic "timeout":
221  // Connection refused
222  // couldn't connect to host
223  // connection attempt failed
224  // Could not resolve IPv4 address for host
225  // Could not resolve IPv6 address for host
226  if ( $this->usingCurl() ) {
227  $handlerContext = $e->getHandlerContext();
228  if ( $handlerContext['errno'] == CURLE_OPERATION_TIMEOUTED ) {
229  $this->status->fatal( 'http-timed-out', $this->url );
230  } else {
231  $this->status->fatal( 'http-curl-error', $handlerContext['error'] );
232  }
233  } else {
234  $this->status->fatal( 'http-request-error' );
235  }
236  } catch ( GuzzleHttp\Exception\RequestException $e ) {
237  if ( $this->usingCurl() ) {
238  $handlerContext = $e->getHandlerContext();
239  $this->status->fatal( 'http-curl-error', $handlerContext['error'] );
240  } else {
241  // Non-ideal, but the only way to identify connection timeout vs other conditions
242  $needle = 'Connection timed out';
243  if ( strpos( $e->getMessage(), $needle ) !== false ) {
244  $this->status->fatal( 'http-timed-out', $this->url );
245  } else {
246  $this->status->fatal( 'http-request-error' );
247  }
248  }
249  } catch ( GuzzleHttp\Exception\GuzzleException $e ) {
250  $this->status->fatal( 'http-internal-error' );
251  }
252 
253  if ( $this->profiler ) {
254  $profileSection = $this->profiler->scopedProfileIn(
255  __METHOD__ . '-' . $this->profileName
256  );
257  }
258 
259  if ( $this->profiler ) {
260  $this->profiler->scopedProfileOut( $profileSection );
261  }
262 
263  $this->parseHeader();
264  $this->setStatus();
265 
266  return Status::wrap( $this->status ); // TODO B/C; move this to callers
267  }
268 
269  protected function prepare() {
270  $this->doSetCallback( $this->callback );
271  parent::prepare();
272  }
273 
277  protected function usingCurl() {
278  return ( $this->handler && is_a( $this->handler, 'GuzzleHttp\Handler\CurlHandler' ) ) ||
279  ( !$this->handler && extension_loaded( 'curl' ) );
280  }
281 
286  protected function parseHeader() {
287  // Failure without (valid) headers gets a response status of zero
288  if ( !$this->status->isOK() ) {
289  $this->respStatus = '0 Error';
290  }
291 
292  foreach ( $this->headerList as $name => $values ) {
293  $this->respHeaders[strtolower( $name )] = $values;
294  }
295 
296  $this->parseCookies();
297  }
298 }
if(!defined('MW_SETUP_CALLBACK'))
Definition: WebStart.php:88
MWHttpRequest implemented using the Guzzle library.
parseHeader()
Guzzle provides headers as an array.
setCallback( $callback)
Set a read callback to accept data read from the HTTP request.
doSetCallback( $callback)
Worker function for setting callbacks.
__construct( $url, array $options=[], $caller=__METHOD__, Profiler $profiler=null)
This wrapper class will call out to curl (if available) or fallback to regular PHP if necessary for h...
getCookieJar()
Returns the cookie jar in use.
setStatus()
Sets HTTPRequest status member to a fatal value with the error message if the returned integer value ...
parseCookies()
Parse the cookies in the response headers and store them in the cookie jar.
int string $timeout
callable $callback
Profiler $profiler
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition: Status.php:58
Profiler base class that defines the interface and some shared functionality.
Definition: Profiler.php:37