Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.00% covered (warning)
78.00%
78 / 100
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
GuzzleHttpRequest
78.00% covered (warning)
78.00%
78 / 100
85.71% covered (warning)
85.71%
6 / 7
51.58
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 setCallback
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 doSetCallback
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 execute
72.84% covered (warning)
72.84%
59 / 81
0.00% covered (danger)
0.00%
0 / 1
35.54
 prepare
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 usingCurl
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 parseHeader
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
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.
7 *
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.
12 *
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
17 *
18 * @file
19 */
20
21use GuzzleHttp\Client;
22use GuzzleHttp\Handler\CurlHandler;
23use GuzzleHttp\HandlerStack;
24use GuzzleHttp\MessageFormatter;
25use GuzzleHttp\Middleware;
26use GuzzleHttp\Psr7\Request;
27use MediaWiki\Status\Status;
28use Psr\Http\Message\RequestInterface;
29use Psr\Log\NullLogger;
30
31/**
32 * MWHttpRequest implemented using the Guzzle library
33 *
34 * @note a new 'sink' option is available as an alternative to callbacks.
35 *   See: http://docs.guzzlephp.org/en/stable/request-options.html#sink)
36 *   The 'callback' option remains available as well.  If both 'sink' and 'callback' are
37 *   specified, 'sink' is used.
38 * @note Callers may set a custom handler via the 'handler' option.
39 *   If this is not set, Guzzle will use curl (if available) or PHP streams (otherwise)
40 * @note Setting either sslVerifyHost or sslVerifyCert will enable both.
41 *   Guzzle does not allow them to be set separately.
42 *
43 * @since 1.33
44 */
45class GuzzleHttpRequest extends MWHttpRequest {
46    public const SUPPORTS_FILE_POSTS = true;
47
48    protected $handler = null;
49    protected $sink = null;
50    /** @var array */
51    protected $guzzleOptions = [ 'http_errors' => false ];
52
53    /**
54     * @internal Use HttpRequestFactory
55     *
56     * @param string $url Url to use. If protocol-relative, will be expanded to an http:// URL
57     * @param array $options (optional) extra params to pass (see HttpRequestFactory::create())
58     * @param string $caller The method making this request, for profiling
59     * @param Profiler|null $profiler An instance of the profiler for profiling, or null
60     * @throws Exception
61     */
62    public function __construct(
63        $url, array $options = [], $caller = __METHOD__, Profiler $profiler = null
64    ) {
65        parent::__construct( $url, $options, $caller, $profiler );
66
67        if ( isset( $options['handler'] ) ) {
68            $this->handler = $options['handler'];
69        }
70        if ( isset( $options['sink'] ) ) {
71            $this->sink = $options['sink'];
72        }
73    }
74
75    /**
76     * Set a read callback to accept data read from the HTTP request.
77     * By default, data is appended to an internal buffer which can be
78     * retrieved through $req->getContent().
79     *
80     * To handle data as it comes in -- especially for large files that
81     * would not fit in memory -- you can instead set your own callback,
82     * in the form function($resource, $buffer) where the first parameter
83     * is the low-level resource being read (implementation specific),
84     * and the second parameter is the data buffer.
85     *
86     * You MUST return the number of bytes handled in the buffer; if fewer
87     * bytes are reported handled than were passed to you, the HTTP fetch
88     * will be aborted.
89     *
90     * This function overrides any 'sink' or 'callback' constructor option.
91     *
92     * @param callable|null $callback
93     * @throws InvalidArgumentException
94     */
95    public function setCallback( $callback ) {
96        $this->sink = null;
97        $this->doSetCallback( $callback );
98    }
99
100    /**
101     * Worker function for setting callbacks.  Calls can originate both internally and externally
102     * via setCallback).  Defaults to the internal read callback if $callback is null.
103     *
104     * If a sink is already specified, this does nothing.  This causes the 'sink' constructor
105     * option to override the 'callback' constructor option.
106     *
107     * @param callable|null $callback
108     * @throws InvalidArgumentException
109     */
110    protected function doSetCallback( $callback ) {
111        if ( !$this->sink ) {
112            parent::doSetCallback( $callback );
113            $this->sink = new MWCallbackStream( $this->callback );
114        }
115    }
116
117    /**
118     * @see MWHttpRequest::execute
119     *
120     * @return Status
121     */
122    public function execute() {
123        $this->prepare();
124
125        if ( !$this->status->isOK() ) {
126            return Status::wrap( $this->status ); // TODO B/C; move this to callers
127        }
128
129        if ( $this->proxy ) {
130            $this->guzzleOptions['proxy'] = $this->proxy;
131        }
132
133        $this->guzzleOptions['timeout'] = $this->timeout;
134        $this->guzzleOptions['connect_timeout'] = $this->connectTimeout;
135        $this->guzzleOptions['version'] = '1.1';
136
137        if ( !$this->followRedirects ) {
138            $this->guzzleOptions['allow_redirects'] = false;
139        } else {
140            $this->guzzleOptions['allow_redirects'] = [
141                'max' => $this->maxRedirects
142            ];
143        }
144
145        if ( $this->method == 'POST' ) {
146            $postData = $this->postData;
147            if ( is_array( $postData ) ) {
148                $this->guzzleOptions['form_params'] = $postData;
149            } else {
150                $this->guzzleOptions['body'] = $postData;
151                // mimic CURLOPT_POST option
152                if ( !isset( $this->reqHeaders['Content-Type'] ) ) {
153                    $this->reqHeaders['Content-Type'] = 'application/x-www-form-urlencoded';
154                }
155            }
156
157            // Suppress 'Expect: 100-continue' header, as some servers
158            // will reject it with a 417 and Curl won't auto retry
159            // with HTTP 1.0 fallback
160            $this->guzzleOptions['expect'] = false;
161        }
162
163        $stack = HandlerStack::create( $this->handler );
164
165        // Create Middleware to use cookies from $this->getCookieJar(),
166        // which is in MediaWiki CookieJar format, not in Guzzle-specific CookieJar format.
167        // Note: received cookies (from HTTP response) don't need to be handled here,
168        // they will be added back into the CookieJar by MWHttpRequest::parseCookies().
169        // @phan-suppress-next-line PhanUndeclaredFunctionInCallable
170        $stack->remove( 'cookies' );
171        $mwCookieJar = $this->getCookieJar();
172        $stack->push( Middleware::mapRequest(
173            static function ( RequestInterface $request ) use ( $mwCookieJar ) {
174                $uri = $request->getUri();
175                $cookieHeader = $mwCookieJar->serializeToHttpRequest(
176                    $uri->getPath() ?: '/',
177                    $uri->getHost()
178                );
179                if ( !$cookieHeader ) {
180                    return $request;
181                }
182
183                return $request->withHeader( 'Cookie', $cookieHeader );
184            }
185        ), 'cookies' );
186
187        if ( !$this->logger instanceof NullLogger ) {
188            $stack->push( Middleware::log( $this->logger, new MessageFormatter(
189                // TODO {error} will be 'NULL' on success which is unfortunate, but
190                //   doesn't seem fixable without a custom formatter. Same for using
191                //   PSR-3 variable replacement instead of raw strings.
192                '[{ts}] {method} {uri} HTTP/{version} - {code} {error}'
193            ) ), 'logger' );
194        }
195
196        $this->guzzleOptions['handler'] = $stack;
197
198        if ( $this->sink ) {
199            $this->guzzleOptions['sink'] = $this->sink;
200        }
201
202        if ( $this->caInfo ) {
203            $this->guzzleOptions['verify'] = $this->caInfo;
204        } elseif ( !$this->sslVerifyHost && !$this->sslVerifyCert ) {
205            $this->guzzleOptions['verify'] = false;
206        }
207
208        $client = new Client( $this->guzzleOptions );
209        $request = new Request( $this->method, $this->url );
210        foreach ( $this->reqHeaders as $name => $value ) {
211            $request = $request->withHeader( $name, $value );
212        }
213
214        try {
215            $response = $client->send( $request );
216            $this->headerList = $response->getHeaders();
217
218            $this->respVersion = $response->getProtocolVersion();
219            $this->respStatus = $response->getStatusCode() . ' ' . $response->getReasonPhrase();
220        } catch ( GuzzleHttp\Exception\ConnectException $e ) {
221            // ConnectException is thrown for several reasons besides generic "timeout":
222            //   Connection refused
223            //   couldn't connect to host
224            //   connection attempt failed
225            //   Could not resolve IPv4 address for host
226            //   Could not resolve IPv6 address for host
227            if ( $this->usingCurl() ) {
228                $handlerContext = $e->getHandlerContext();
229                if ( $handlerContext['errno'] == CURLE_OPERATION_TIMEOUTED ) {
230                    $this->status->fatal( 'http-timed-out', $this->url );
231                } else {
232                    $this->status->fatal( 'http-curl-error', $handlerContext['error'] );
233                }
234            } else {
235                $this->status->fatal( 'http-request-error' );
236            }
237        } catch ( GuzzleHttp\Exception\RequestException $e ) {
238            if ( $this->usingCurl() ) {
239                $handlerContext = $e->getHandlerContext();
240                $this->status->fatal( 'http-curl-error', $handlerContext['error'] );
241            } else {
242                // Non-ideal, but the only way to identify connection timeout vs other conditions
243                $needle = 'Connection timed out';
244                if ( strpos( $e->getMessage(), $needle ) !== false ) {
245                    $this->status->fatal( 'http-timed-out', $this->url );
246                } else {
247                    $this->status->fatal( 'http-request-error' );
248                }
249            }
250        } catch ( GuzzleHttp\Exception\GuzzleException $e ) {
251            $this->status->fatal( 'http-internal-error' );
252        }
253
254        if ( $this->profiler ) {
255            $profileSection = $this->profiler->scopedProfileIn(
256                __METHOD__ . '-' . $this->profileName
257            );
258        }
259
260        if ( $this->profiler ) {
261            $this->profiler->scopedProfileOut( $profileSection );
262        }
263
264        $this->parseHeader();
265        $this->setStatus();
266
267        return Status::wrap( $this->status ); // TODO B/C; move this to callers
268    }
269
270    protected function prepare() {
271        $this->doSetCallback( $this->callback );
272        parent::prepare();
273    }
274
275    protected function usingCurl(): bool {
276        return $this->handler instanceof CurlHandler ||
277            ( !$this->handler && extension_loaded( 'curl' ) );
278    }
279
280    /**
281     * Guzzle provides headers as an array.  Reprocess to match our expectations.  Guzzle will
282     * have already parsed and removed the status line (in EasyHandle::createResponse).
283     */
284    protected function parseHeader() {
285        // Failure without (valid) headers gets a response status of zero
286        if ( !$this->status->isOK() ) {
287            $this->respStatus = '0 Error';
288        }
289
290        foreach ( $this->headerList as $name => $values ) {
291            $this->respHeaders[strtolower( $name )] = $values;
292        }
293
294        $this->parseCookies();
295    }
296}